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

11 KiB

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)