# 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 `