# 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 form. If the email is on the invite list, a session cookie is issued immediately and the welcome step appears. Shown to any logged-out visitor. 2. **Timeline** — an editorial scroll through 12 headlines about digital sovereignty, ending in a pivot to how Fenja AI addresses it. Shown to logged-in users at the same URL; includes a globe, an overview (Project Bifrost scenes), and an archive. 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) - **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: login, logout, me │ ├── db.js SQLite init, schema, prepared statements, cleanup timer │ ├── middleware.js rateLimit() + requireAuth() │ └── sessions.js Cookie issue/clear, opaque session IDs ├── 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 animated desktop timeline (authed home page) │ ├── timeline.js Timeline scroll/globe/archive logic │ ├── bifrost.js Overview page pinned scenes (capabilities, bifrost, meaning, join) │ ├── archive.html Legacy deep-link placeholder │ ├── archive.js Logout button │ ├── mobile/ Minimum-viable mobile view, UA-dispatched from GET /timeline. │ │ ├── index.html Static one-page flow: intro → events → hero → caps → bifrost → join │ │ ├── mobile.css All m-prefixed; zero overlap with the desktop cascade │ │ └── mobile.js Auth check + join POST + logout. No GSAP/Lenis/d3. │ ├── fenja/ │ │ ├── colors_and_type.css │ │ └── fonts/ Manrope + Newsreader variable fonts │ └── vendor/ d3-array, d3-geo, topojson-client, countries-110m.json ├── admin/ Hidden admin UI (served at /fenjaops behind requireAuth+requireAdmin) │ ├── index.html Stats, invite list, join log, + "Invite a new user" form │ ├── admin.css │ └── admin.js ├── bin/ │ ├── invite.js CLI: add/remove/list invites; admin add/remove/list │ └── joins.js CLI: read the Join-CTA click log (list/summary/for/stats) ├── 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 ``` ## Mobile view The animated desktop site is a heavy GSAP / Lenis / d3 experience that does not hold up on phones. Rather than retrofitting the animations, there is a completely separate static mobile tree at `protected/mobile/`. The server inspects the `User-Agent` on `GET /timeline` (see `MOBILE_UA_RE` in `server.js`) and serves `protected/mobile/index.html` to phone UAs, the animated view to everything else. Key properties: - **No shared JS or CSS.** The mobile page loads `/fenja/colors_and_type.css` (fonts only) and its own `mobile.css`. No GSAP, no Lenis, no d3, no ScrollTrigger. Every class is `m-`-prefixed so there is no cascade collision with `protected/index.html`. - **One page, static content.** All 12 timeline events, the 4 capability cards, the Bifrost reveal, the three participation stops, and the Join CTA are rendered as plain HTML. No pinning, no scrubbed animations, no horizontal scroll. - **Same auth + gating.** `protected/mobile/` is inside `protected/`, so the existing `requireAuth` + `express.static(protected)` gate covers the assets automatically. No new route-level gating needed. - **Override.** `/timeline?view=mobile` and `/timeline?view=desktop` force one or the other, in case the UA sniff guesses wrong. The mobile page footer links to `/timeline?view=desktop` so a phone user who wants the full experience can opt in. - **Content stays in sync manually.** The 12-event copy and the capability / bifrost copy is duplicated in both the desktop HTML and the mobile HTML. When rewriting that copy, remember to update both — there is no shared data module on purpose (a shared file would re-couple the two trees). ## How auth works The site is invite-list-only. The invite list IS the authentication factor — a person who knows an invited email can log in as them. The site exposes only marketing/preview content, so this is acceptable by design. If a user needs to be kicked out, remove their invite AND delete their session rows (see OPERATIONS.md). 1. User POSTs `{ email }` to `/auth/login`. Server: - Rate-limits per IP (30/hour) - Validates the email format. If not on the invite list, returns `403 {error: "not_invited"}`. - On success, issues a session row (opaque 256-bit random ID) and sets `HttpOnly; Secure; SameSite=Lax` cookie. Returns `200 {ok: true, firstName}`. 2. Subsequent requests to `/`, `/timeline`, `/vendor/*`, etc. hit `requireAuth` middleware which looks up the session by cookie ID in SQLite. No JWT; revocation is a `DELETE`. 3. `GET /auth/me` returns `{ email, firstName }` for the current session (or 401). The frontend uses it on load to pick which entrance step to show. 4. Logout POSTs to `/auth/logout`, which deletes the session row and clears the cookie. There are **no one-time codes, no pepper, and no SMTP** in this version. The entire mail stack and code-based verification path were removed. ## 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 — this env var must be set in `/etc/fenja/env` on the VPS.) - **No inline `