143 lines
7.6 KiB
Markdown
143 lines
7.6 KiB
Markdown
# project-bifrost
|
|
|
|
Invite-only frontdoor and editorial timeline for Fenja AI. Self-hosted on a single VPS.
|
|
|
|
> **New here (human or AI)?** Read this file top to bottom before making changes. It is the shortest path to understanding what exists and why.
|
|
|
|
---
|
|
|
|
## What it is
|
|
|
|
Two surfaces on one domain (`project-bifrost.fenja.ai`):
|
|
|
|
1. **Entrance** — email → 6-digit code → session cookie. Shown to any logged-out visitor.
|
|
2. **Timeline** — an editorial scroll through 23 headlines about digital sovereignty, with a globe, archive, and overview. Shown to logged-in users at the same URL.
|
|
|
|
The root URL `/` is context-aware: unauthenticated → entrance, authenticated → timeline.
|
|
|
|
## Stack
|
|
|
|
- **Runtime** — Node 20+, Express 4
|
|
- **Storage** — SQLite via `better-sqlite3 12.x` (single file on disk, WAL mode)
|
|
- **Mail** — Nodemailer, STARTTLS on 587, own relay
|
|
- **Web server** — Nginx reverse-proxying to `127.0.0.1:3000`
|
|
- **TLS** — Let's Encrypt via certbot
|
|
- **Process supervisor** — systemd (`fenja.service`)
|
|
- **Runs as** — unprivileged `fenja` user on the VPS
|
|
|
|
## Repo layout
|
|
|
|
```
|
|
.
|
|
├── server.js Entry. Binds 127.0.0.1:3000. Routing + CSP + logging.
|
|
├── src/
|
|
│ ├── auth.js /auth/* endpoints: request-code, verify-code, logout, me
|
|
│ ├── db.js SQLite init, schema, prepared statements, cleanup timer
|
|
│ ├── mail.js Nodemailer transport + sendCode()
|
|
│ ├── middleware.js rateLimit() + requireAuth()
|
|
│ └── sessions.js Code generation, HMAC, cookie issue/clear
|
|
├── public/ Served to anyone (ungated)
|
|
│ ├── entrance.html The login page
|
|
│ └── entrance.js Two-step form behaviour
|
|
├── protected/ Served only with valid session cookie
|
|
│ ├── index.html The timeline (authed home page)
|
|
│ ├── timeline.js Timeline scroll/globe/archive logic
|
|
│ ├── archive.html Legacy deep-link placeholder
|
|
│ ├── archive.js Logout button
|
|
│ ├── fenja/
|
|
│ │ ├── colors_and_type.css
|
|
│ │ └── fonts/ Manrope + Newsreader variable fonts
|
|
│ └── vendor/ d3-array, d3-geo, topojson-client, countries-110m.json
|
|
├── bin/
|
|
│ └── invite.js CLI: add/remove/list invites
|
|
├── deploy/
|
|
│ ├── fenja.service systemd unit
|
|
│ └── nginx.conf Nginx server block
|
|
├── data/ SQLite lives here (gitignored)
|
|
├── PROJECT.md This file
|
|
├── OPERATIONS.md Day-to-day ops: invites, deploys, backups
|
|
├── INSTALL.md One-time server setup
|
|
└── CHECKLIST.md Manual test checklist after any change
|
|
```
|
|
|
|
## How auth works
|
|
|
|
1. User POSTs email to `/auth/request-code`. Server:
|
|
- Rate-limits per IP (5/hour)
|
|
- Checks invite list. If invited, generates a 6-digit code, HMACs it with `CODE_PEPPER`, stores the hash with 10-min TTL, sends the code via SMTP.
|
|
- **Always returns 200** regardless of invite status (prevents email enumeration).
|
|
2. User POSTs `{ email, code }` to `/auth/verify-code`. Server:
|
|
- Rate-limits per IP (20/hour) and per-code (5 wrong guesses before deletion)
|
|
- Compares HMAC in constant time
|
|
- On success: deletes the code, creates a server-side session row, sets an `HttpOnly; Secure; SameSite=Lax` cookie with opaque 256-bit random ID
|
|
3. Subsequent requests to `/`, `/timeline.js`, `/vendor/*`, etc. hit `requireAuth` middleware which looks up the session by cookie ID in SQLite. No JWT; revocation is a `DELETE`.
|
|
4. Logout POSTs to `/auth/logout`, which deletes the session row and clears the cookie.
|
|
|
|
## Non-negotiable properties
|
|
|
|
These things define the security model. Breaking any of them is a regression even if tests pass.
|
|
|
|
- **Protected HTML is never accessible without a valid session cookie.** `requireAuth` runs *before* `express.static`; the file is never read off disk for unauthenticated requests.
|
|
- **The session cookie is always `HttpOnly`, `Secure`, `SameSite=Lax`.** (`Secure` is conditional on `NODE_ENV=production` to allow local dev over HTTP.)
|
|
- **Codes are HMAC-SHA256 with a server-side pepper** stored in `/etc/fenja/env`, never in the repo.
|
|
- **The pepper never changes after go-live** unless deliberately rotating (invalidates all pending codes).
|
|
- **`/auth/request-code` returns 200 for every email**, invited or not. Never reveal who's on the list.
|
|
- **Code comparisons are constant-time** (`crypto.timingSafeEqual`).
|
|
- **No inline `<script>` in any HTML** — CSP is strict (`script-src 'self'`). All JS is in separate files.
|
|
- **Node binds to `127.0.0.1` only.** Nginx is the single ingress.
|
|
- **Secrets live in `/etc/fenja/env` (mode 640, root:fenja), never in `/opt/fenja/`.**
|
|
|
|
If a change forces one of these to move, it's not a local change — it's a security review.
|
|
|
|
## Things that are easy to change
|
|
|
|
Everything above the auth layer is flexible:
|
|
|
|
- Content of any page in `public/` or `protected/` (copy, layout, visuals)
|
|
- Adding new protected pages (drop file in `protected/`, it's automatically gated)
|
|
- Adding new public pages (drop file in `public/`, it's automatically available)
|
|
- Tweaking the timeline (data, accents, animations)
|
|
- Fonts, colors, type scale (`protected/fenja/colors_and_type.css`)
|
|
- Error copy, confirmation copy, empty states
|
|
- Adding new `/auth/*` endpoints for e.g. invite management (but keep the invariants above)
|
|
|
|
## What's where at runtime (on the VPS)
|
|
|
|
```
|
|
/opt/fenja/ code (owned by fenja:fenja)
|
|
/opt/fenja/data/ SQLite + nightly backups
|
|
/etc/fenja/env secrets (root:fenja, 640)
|
|
/etc/systemd/system/fenja.service
|
|
/etc/nginx/sites-enabled/project-bifrost
|
|
/etc/letsencrypt/live/project-bifrost.fenja.ai/
|
|
/etc/cron.d/fenja-backup
|
|
```
|
|
|
|
## For AI agents working on this project
|
|
|
|
**Start by reading this file, then the specific file(s) you're being asked to modify. Do not skim.**
|
|
|
|
Good rules of thumb:
|
|
|
|
- **Content/layout/visual changes** — almost always local to one file in `public/` or `protected/`. Safe to iterate freely.
|
|
- **Adding a new page** — drop it in the right directory, done. Gating is automatic via the directory.
|
|
- **Changes touching `src/`, `server.js`, `deploy/`, or session/cookie behaviour** — read the "Non-negotiable properties" above first. If your change would violate any of them, stop and ask the human.
|
|
- **Changes to `package.json`, `deploy/`, `.env.example`** — operational surface area. Flag explicitly in the change summary and have the human redeploy.
|
|
- **Never commit secrets, `.env` files, `data/*.sqlite`, or anything the `.gitignore` excludes.**
|
|
- **When finished**, walk through `CHECKLIST.md` mentally: which items does this change plausibly affect? If any, say so in the summary so the human knows what to spot-check.
|
|
- **If unsure whether a change is safe, ask.** The codebase is small; the cost of a clarifying question is tiny compared to the cost of a silent regression.
|
|
|
|
## Current status
|
|
|
|
Live at `https://project-bifrost.fenja.ai/`. Backups and uptime monitoring configured. Production running, invites being handled manually via SSH.
|
|
|
|
## Things not yet done
|
|
|
|
Rough list of things that exist as possibilities, not commitments:
|
|
|
|
- Admin UI for managing invites without SSH
|
|
- Rate-limit headers returned to the client (`Retry-After`) instead of bare 429s
|
|
- A proper "resend code" link on the code screen after 30s
|
|
- Session list view so users can see/revoke their own active sessions
|
|
- Second gated surface (docs? notes? unclear)
|
|
- Email templates with proper HTML (currently plain text, which is fine for deliverability)
|