7.6 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):
- Entrance — email → 6-digit code → session cookie. Shown to any logged-out visitor.
- 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
fenjauser 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
- 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).
- 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=Laxcookie with opaque 256-bit random ID
- Subsequent requests to
/,/timeline.js,/vendor/*, etc. hitrequireAuthmiddleware which looks up the session by cookie ID in SQLite. No JWT; revocation is aDELETE. - 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.
requireAuthruns beforeexpress.static; the file is never read off disk for unauthenticated requests. - The session cookie is always
HttpOnly,Secure,SameSite=Lax. (Secureis conditional onNODE_ENV=productionto 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-codereturns 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.1only. 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/orprotected/(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/orprotected/. 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,
.envfiles,data/*.sqlite, or anything the.gitignoreexcludes. - When finished, walk through
CHECKLIST.mdmentally: 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)