customer-presentation/PROJECT.md
Arlind Ukshini 72590b08bc add mobile view at protected/mobile/ (UA-dispatched)
Desktop is a GSAP/Lenis/d3 animated experience that doesn't hold up
on phones. Rather than retrofitting media queries across 1200+ lines
of scroll-trigger code, add a completely isolated static mobile tree:

- protected/mobile/index.html — one-page static flow covering the
  intro, 12 timeline events, hero, 4 capability cards, Bifrost
  reveal, 3 participation stops, and Join CTA. All copy duplicated
  from the desktop HTML on purpose — a shared data module would
  re-couple the two trees.
- protected/mobile/mobile.css — paper/ink palette, all m-prefixed,
  zero cascade overlap with the desktop CSS.
- protected/mobile/mobile.js — 60-line client: /auth/me check,
  /api/bifrost-join POST + panel swap, /auth/logout. No GSAP, no
  Lenis, no d3.

Routing (server.js):
- GET /timeline now UA-dispatches via MOBILE_UA_RE. Phone UAs get
  the mobile page; everything else gets the desktop page.
- ?view=mobile and ?view=desktop query overrides take precedence
  over the UA sniff — for bad guesses or previewing the other
  version.
- Gating is unchanged: protected/mobile/ is inside protected/ so
  the existing requireAuth + express.static gate covers it.

Docs:
- CLAUDE.md §routing now lists the UA dispatch as step 4.
- PROJECT.md gets a new "Mobile view" section explaining the
  isolation rules (no shared JS/CSS, content duplicated manually).
- CHECKLIST.md gains section H0 with dispatch curl checks, render
  verification on a phone, and an isolation audit that fails if
  mobile classes leak into the desktop HTML or vice versa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:03:13 +02:00

172 lines
11 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 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 `<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.
- **`/etc/fenja/env` is minimal** (`PORT`, `PUBLIC_ORIGIN`, `NODE_ENV`), owned `root:fenja` mode 640, never in `/opt/fenja/`. Adding secrets back is a security review.
- **No web endpoint can set `is_admin=1` or delete an invite.** The admin page exposes `POST /api/fenjaops/invites` for creating *non-admin* invites only — the handler ignores any `is_admin` field in the body and always stores `0`. Admin promotion (`setInviteAdmin`) and invite deletion (`deleteInvite`) are reachable only via `bin/invite.js` on the VPS. This means a compromised admin session can grow the invite list but cannot escalate anyone (including themselves) or evict existing users.
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.
## Tracking: Join-CTA clicks
The final "Join Project Bifrost" CTA records every press into `bifrost_joins`. Schema:
```
bifrost_joins(id INTEGER PK AUTOINCREMENT,
email TEXT NOT NULL,
clicked_at INTEGER NOT NULL,
session_id TEXT)
```
One row per click — if a user presses the button multiple times, you get multiple rows, so per-user history is preserved. Writes happen via `POST /api/bifrost-join` (behind `requireAuth`, reads `req.session.email`). Reads happen via `bin/joins.js` (see OPERATIONS.md).
## Things not yet done
Rough list of things that exist as possibilities, not commitments:
- Admin UI for the remaining invite operations (remove, rename, admin grant/revoke). Currently only *adding* non-admin invites is possible from the page; everything else stays on `bin/invite.js`.
- Rate-limit headers returned to the client (`Retry-After`) instead of bare 429s
- Session list view so users can see/revoke their own active sessions
- Second gated surface (docs? notes? unclear)