update docs: minimal env, WSL deploy, join tracking, rsync excludes

- align auth docs with the simplified POST /auth/login flow
- drop CODE_PEPPER / SMTP / MAIL_FROM / mail.js / request-code references
- document the bifrost_joins table and bin/joins.js CLI
- OPERATIONS.md: WSL setup, exclude data/.env/node_modules on promote rsync
- INSTALL.md: 3-value /etc/fenja/env, drop SMTP prereq

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arlind Ukshini 2026-04-23 17:10:08 +02:00
parent a276d07b58
commit 88863183e1
6 changed files with 147 additions and 91 deletions

View file

@ -9,7 +9,7 @@ Notation: run on the VPS unless marked `[local]` or `[browser]`.
## A. After any code change (minimum viable smoke test)
- [ ] `sudo systemctl status fenja``active (running)`
- [ ] `sudo journalctl -u fenja -n 20` shows `[mail] SMTP relay reachable` and `[bifrost] listening`, no red errors
- [ ] `sudo journalctl -u fenja -n 20` shows `[bifrost] listening on 127.0.0.1:3000`, no red errors (there is no `[mail] SMTP relay reachable` line anymore — the mail stack was removed)
- [ ] [local] `curl -I https://project-bifrost.fenja.ai/` → 200, with `X-Frame-Options: DENY` and `Content-Security-Policy` headers
- [ ] [local] `curl -I https://project-bifrost.fenja.ai/timeline.js` → 302, `Location: /`
- [ ] [browser, private window] Open `https://project-bifrost.fenja.ai/` → entrance page renders (not timeline)
@ -22,21 +22,19 @@ If all five pass, the site is up and the gate holds.
Do section A, then:
- [ ] [browser, private] Enter invited email → receive code in inbox (not spam) within 60s
- [ ] [browser] Type code → redirected to `/` showing the timeline (not the entrance)
- [ ] [browser, private] Enter invited email → session cookie issued immediately, welcome step appears (no 6-digit code flow anymore)
- [ ] [browser] Click "Learn more" on the welcome step → redirected to `/timeline` showing the timeline page
- [ ] [browser] Hard-refresh (Ctrl+Shift+R) → stays on the timeline (cookie persists)
- [ ] [browser, new private window] Visit `/` → entrance appears (no cookie leak between sessions)
- [ ] [browser] Click logout → lands on entrance → visiting `/` stays on entrance
- [ ] [browser] Click logout → lands on entrance → visiting `/timeline` redirects to `/`
- [ ] [browser] Visit `/timeline.js` or `/vendor/d3-array.min.js` directly while logged out → redirects to `/`
- [ ] DevTools → Application → Cookies: `fenja_session` shows `HttpOnly ✓`, `Secure ✓`, `SameSite=Lax`
- [ ] DevTools → Application → Cookies: `fenja_session` shows `HttpOnly ✓`, `Secure ✓` (prod only), `SameSite=Lax`
## C. After changes to the entrance form, code input, or email
## C. After changes to the entrance form or login endpoint
- [ ] Submit a non-invited address → still advances to code screen (enumeration protection intact)
- [ ] Submit a non-invited address → inline "not invited" message, no session issued
- [ ] Submit a malformed email (`foo`, `foo@`, empty) → inline error appears, no request sent
- [ ] Type a wrong 6-digit code → "doesn't match" error, cells highlight red, can retry
- [ ] Type 5 wrong codes → get "too many attempts" message; requesting a new code resets the counter
- [ ] Request a code, wait 11 minutes, try to use it → rejected
- [ ] Submit > 30 login attempts from the same IP in an hour → rate-limit response (429)
## D. After changes to the timeline / protected pages
@ -54,7 +52,7 @@ Do section A with extra attention to:
- [ ] CSP contains at minimum: `default-src 'self'`, `script-src 'self'` (no `unsafe-inline` on scripts), `frame-ancestors 'none'`
- [ ] `sudo nginx -t` → "syntax is ok" and "test is successful"
- [ ] [local] Open timeline in a browser → no red CSP violations in DevTools console
- [ ] `curl.exe -I https://project-bifrost.fenja.ai/auth/request-code -X POST` returns quickly (rate-limit zone is functioning)
- [ ] `curl.exe -X POST https://project-bifrost.fenja.ai/auth/login -H 'Content-Type: application/json' -d '{"email":"nobody@example.com"}'` returns quickly (rate-limit zone is functioning; expect 403 `not_invited` on a real email not on the list)
## F. After dependency or Node.js upgrades

View file

@ -22,7 +22,7 @@ node bin/joins.js list # read join-CTA click log
There is no test suite, linter, or build step. Verification is the checklist in `CHECKLIST.md`, primarily by walking the entrance → code → timeline flow in a browser.
For local dev, `.env` must set `CODE_PEPPER` (≥32 chars; `openssl rand -hex 32`) and SMTP credentials — the server hard-exits on boot without a valid pepper. `NODE_ENV=production` toggles the `Secure` cookie flag, so leave it unset locally (HTTP).
For local dev, `.env` needs only `PORT`, `PUBLIC_ORIGIN`, and `NODE_ENV` — see `.env.example`. Auth is email-only against the invite list; there is no 6-digit code flow and no SMTP relay anymore, so no mail/pepper secrets are required. `NODE_ENV=production` toggles the session cookie's `Secure` flag — set it in `/etc/fenja/env` on the VPS; leave it unset (or `development`) locally for HTTP.
## Architecture
@ -36,7 +36,7 @@ Single-process Express app bound to `127.0.0.1:3000`. Nginx is the only public i
4. `express.static(public)` — ungated assets
5. `requireAuth` **then** `express.static(protected)` — gating runs *before* the file is read off disk. Adding a file to `protected/` gates it automatically; adding to `public/` exposes it automatically.
**Auth flow** (see `src/auth.js`): email → 6-digit code (HMAC-SHA256 with `CODE_PEPPER`, 10-min TTL) → `/auth/verify-code` (constant-time compare, 5 wrong guesses nukes the code) → opaque 256-bit session ID stored in SQLite, set as `HttpOnly; Secure; SameSite=Lax` cookie. No JWTs; revocation is a DELETE. `/auth/request-code` always returns 200 regardless of invite status (email-enumeration defense).
**Auth flow** (see `src/auth.js`): email → `POST /auth/login` → the server checks the invite list; on hit it issues an opaque 256-bit session ID stored in SQLite and sets it as an `HttpOnly; Secure; SameSite=Lax` cookie (returns `{ok, firstName}`). On miss it returns `403 {error:"not_invited"}` — email enumeration is acceptable here by design (invite-list-only, preview content). `POST /auth/logout` deletes the session row. `GET /auth/me` returns `{email, firstName}` or 401. No one-time codes, no SMTP, no JWTs; revocation is a DELETE.
**Storage** (`src/db.js`): `better-sqlite3` at `data/fenja.sqlite`, WAL mode, tables `invites` / `sessions` / `rate_limits` / `bifrost_joins`. Prepared statements exported as `q.*`. A `setInterval().unref()` sweeps expired rows every 5 minutes.
@ -50,8 +50,7 @@ These are from `PROJECT.md`. A change that breaks any of them is a security regr
- Protected HTML must never be readable without a valid session cookie — `requireAuth` runs before `express.static(protected)`, don't reorder.
- Session cookie: `HttpOnly`, `Secure` (prod), `SameSite=Lax`, opaque random 256-bit ID.
- Codes hashed with HMAC-SHA256 using `CODE_PEPPER`; comparisons via `crypto.timingSafeEqual`. Never change the pepper on a live server (invalidates pending codes).
- `/auth/request-code` always 200 past the format check (no enumeration).
- `/etc/fenja/env` on the VPS is intentionally minimal — only `PORT`, `PUBLIC_ORIGIN`, `NODE_ENV`. No pepper, no SMTP, no mail-from. The only env value with security impact is `NODE_ENV=production` (enables the `Secure` cookie flag).
- No inline `<script>` in any HTML — CSP is `script-src 'self'`. Put JS in a separate file with `src="..." defer`.
- Node binds to `127.0.0.1` only; Nginx is the single ingress.
- Secrets live in `/etc/fenja/env` on the VPS (not `/opt/fenja/.env`).

View file

@ -1,6 +1,6 @@
# Install
End-to-end setup for a fresh Ubuntu VPS running Nginx, Node, SQLite, and an SMTP relay. Done once per server. Each block is ordered; don't skip.
End-to-end setup for a fresh Ubuntu VPS running Nginx, Node, and SQLite. Done once per server. Each block is ordered; don't skip.
Throughout: replace `project-bifrost.fenja.ai` with your actual domain and `user@vps` with your SSH user.
@ -9,7 +9,6 @@ Throughout: replace `project-bifrost.fenja.ai` with your actual domain and `user
- Ubuntu 22.04+ VPS with sudo
- Nginx installed and running
- An A record pointing the subdomain at the VPS's public IP
- SMTP relay credentials (STARTTLS on port 587) with SPF/DKIM/DMARC set on the sending domain
- Node 20+ installed (`curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - && sudo apt install -y nodejs`)
## 1. Create the service user and directories
@ -69,7 +68,11 @@ rsync -avz --delete \
On the VPS:
```bash
sudo rsync -a --delete /tmp/fenja-upload/ /opt/fenja/
# NOTE: the --exclude list on the promote rsync must include `data`,
# `.env`, and `node_modules` so a later redeploy doesn't wipe them.
sudo rsync -a --delete \
--exclude data --exclude .env --exclude node_modules \
/tmp/fenja-upload/ /opt/fenja/
sudo chown -R fenja:fenja /opt/fenja
sudo -u fenja mkdir -p /opt/fenja/data
sudo chmod 750 /opt/fenja/protected /opt/fenja/data
@ -87,28 +90,21 @@ Should finish with `added N packages, found 0 vulnerabilities`.
## 5. Create the environment file
`/etc/fenja/env` is intentionally minimal — the server only reads three values (see `.env.example`). There are no secrets: auth is email-only against the invite list and the mail stack was removed.
```bash
sudo nano /etc/fenja/env
```
Paste (fill in real values):
Paste:
```
PORT=3000
NODE_ENV=production
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai
# Generate with: openssl rand -hex 32 (64 hex chars)
CODE_PEPPER=...
SMTP_HOST=smtp.yourrelay.tld
SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
MAIL_FROM="Fenja AI <noreply@project-bifrost.fenja.ai>"
```
Lock permissions:
Lock permissions (matches PROJECT.md invariant — root:fenja, mode 640):
```bash
sudo chown root:fenja /etc/fenja/env
@ -136,7 +132,7 @@ sudo systemctl enable --now fenja
sudo journalctl -u fenja -n 20
```
Must show `[mail] SMTP relay reachable` and `[bifrost] listening on 127.0.0.1:3000`.
Must show `[bifrost] listening on 127.0.0.1:3000`. (There is no `[mail] SMTP relay reachable` line — the mail stack was removed in the auth simplification.)
## 7. Nginx rate-limit zone

View file

@ -60,41 +60,102 @@ sudo journalctl -u fenja -n 100 # last 100 lines
## Deploying code changes
From your laptop, in WSL, inside the project folder:
The project lives on a Windows filesystem; deploys run from **WSL** (Ubuntu under Windows 11) because `rsync` and `ssh` come with it and behave identically to Linux. Never deploy from PowerShell directly — Windows path semantics + line endings cause subtle breakage on the VPS.
### One-time WSL setup
```bash
# Push to staging on VPS
# Inside WSL (Ubuntu):
sudo apt update && sudo apt install -y rsync openssh-client
# Copy your SSH key into WSL's ~/.ssh (if it isn't already there).
# The Windows OpenSSH key lives at C:\Users\<you>\.ssh\ — WSL sees this as:
mkdir -p ~/.ssh
cp /mnt/c/Users/Arlin/.ssh/id_ed25519 ~/.ssh/
cp /mnt/c/Users/Arlin/.ssh/id_ed25519.pub ~/.ssh/
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
# Sanity check: you should land on the VPS without being prompted for a password.
ssh user@project-bifrost.fenja.ai "hostname"
```
> **File permissions gotcha**: SSH will refuse a key that's group-readable. If it prompts for a password despite a copied key, re-run `chmod 600 ~/.ssh/id_ed25519`.
### Every deploy
> **Destructive-rsync warning**: both rsync steps use `--delete`. Every `--delete` invocation below is paired with `--exclude data --exclude .env --exclude node_modules`; do not omit any of those excludes. The consequence of forgetting them is that `/opt/fenja/data` (SQLite DB + nightly backups) and/or `/opt/fenja/node_modules` gets wiped — the service fails to boot until you recreate them and re-run `npm ci`. Has happened once — April 2026.
```bash
# 1. Open WSL and cd into the repo via its WSL path. The Windows project
# folder `C:\Users\Arlin\01 DEVELOPMENT\fenja-bifrost` is visible as:
cd "/mnt/c/Users/Arlin/01 DEVELOPMENT/fenja-bifrost"
# 2. Push the tree to a staging dir on the VPS. Notes dirs / secrets /
# build artefacts are excluded so rsync --delete doesn't nuke things
# on the server that aren't in your checkout.
rsync -avz --delete \
--exclude node_modules --exclude data --exclude .env --exclude .git \
--exclude 'CHANGES*.md' --exclude 'MERGE_NOTES.md' --exclude 'AUTH_SIMPLIFICATION_NOTES.md' \
./ user@project-bifrost.fenja.ai:/tmp/fenja-upload/
# Then on the VPS:
# 3. SSH in and promote the upload.
# CRITICAL: the exclude list on the --delete rsync must include
# `data`, `.env`, AND `node_modules` — all three are intentionally
# absent from the upload, and without these excludes, --delete
# wipes the server-side copies. Incident log: April 2026 (data),
# April 2026 (node_modules, same deploy).
ssh user@project-bifrost.fenja.ai
sudo rsync -a --delete /tmp/fenja-upload/ /opt/fenja/
sudo rsync -a --delete \
--exclude data --exclude .env --exclude node_modules \
/tmp/fenja-upload/ /opt/fenja/
sudo chown -R fenja:fenja /opt/fenja
rm -rf /tmp/fenja-upload
# If package.json changed:
# 4. If package.json changed (new dep or version bump):
cd /opt/fenja
sudo -u fenja npm ci --omit=dev
# Restart
# 5. Take a pre-change DB snapshot if the deploy includes a schema change
# (new/renamed column, new table — check git diff of src/db.js first):
sudo -u fenja sqlite3 /opt/fenja/data/fenja.sqlite \
".backup /opt/fenja/data/pre-change-$(date +%F).sqlite"
# 6. Restart and watch the first ~20 log lines for a clean boot
sudo systemctl restart fenja
sudo journalctl -u fenja -n 20
```
Confirm `[mail] SMTP relay reachable` and `[bifrost] listening` appear in the logs.
Confirm `[bifrost] listening on 127.0.0.1:3000` appears in the logs. (There is no longer an `[mail] SMTP relay reachable` line — the mail stack was removed in the auth simplification.)
## Editing secrets (SMTP, pepper)
### WSL-specific pitfalls
Secrets live in `/etc/fenja/env`, not in the repo.
| Symptom | Cause | Fix |
|---|---|---|
| `rsync: command not found` | WSL image lacks rsync | `sudo apt install rsync` |
| `Permission denied (publickey)` | SSH key missing in WSL, or wrong perms | Copy from `/mnt/c/Users/<you>/.ssh/` and `chmod 600` |
| `CRLF will be replaced by LF` warnings from git / scripts failing on server with `bad interpreter: ^M` | Windows line endings snuck in | In the repo: `git config core.autocrlf input`, then re-save the file. Shell scripts in `bin/` must be LF. |
| Very slow rsync | Running rsync against files on `/mnt/c/` is slower than a native WSL filesystem; acceptable for small deploys, painful for big ones | Fine for this repo (< 50MB). For large trees, clone into `~/repos/` inside WSL instead. |
| `ssh: Could not resolve hostname` | Corporate VPN or DNS quirks | Confirm with `ssh -v`; may need to switch network. |
## Editing env config
`/etc/fenja/env` is intentionally minimal — only `PORT`, `PUBLIC_ORIGIN`, and `NODE_ENV=production`. There are **no secrets** (no pepper, no SMTP, no mail-from): auth is email-only against the invite list, and the mail stack was removed.
```bash
sudo nano /etc/fenja/env
sudo systemctl restart fenja
```
**Never change `CODE_PEPPER` after go-live** — it invalidates every pending code. Not catastrophic (users re-request within 10 min), but avoid unless rotating for security.
Expected contents:
```
PORT=3000
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai
NODE_ENV=production
```
If you ever add a real secret back (e.g. an analytics token), match the invariant in PROJECT.md: root:fenja, mode 640, never in the repo.
## Backups
@ -115,24 +176,32 @@ scp user@project-bifrost.fenja.ai:/opt/fenja/data/backup-YYYY-MM-DD.sqlite .
## Quick health checks
From **WSL** (bash) on your laptop:
```bash
curl -I https://project-bifrost.fenja.ai/ # expect 200
curl -I https://project-bifrost.fenja.ai/timeline.js # expect 302 → /
curl -I https://project-bifrost.fenja.ai/api/bifrost-join -X POST # expect 401 (auth-gated)
```
Or from **PowerShell** — use `curl.exe` (the bare `curl` alias is PowerShell's own `Invoke-WebRequest` with a different flag grammar):
```powershell
# From your laptop
curl.exe -I https://project-bifrost.fenja.ai/ # expect 200
curl.exe -I https://project-bifrost.fenja.ai/timeline.js # expect 302 → /
```
If either fails, check Nginx (`sudo systemctl status nginx`) and Node (`sudo systemctl status fenja`).
If either fails, check Nginx (`sudo systemctl status nginx`) and Node (`sudo systemctl status fenja`) on the VPS.
## Troubleshooting
| Symptom | First thing to check |
|---|---|
| Users don't get codes | `journalctl -u fenja -n 50` for SMTP errors |
| Codes arrive in spam | SPF/DKIM/DMARC records on the sending domain |
| Invited user can't log in | Confirm the email is actually on the invite list: `node bin/invite.js list`. Email is matched lowercase-trimmed. |
| 502 Bad Gateway | Node crashed — `systemctl status fenja` then `journalctl` |
| 504 Gateway Timeout | Node running but hung — `systemctl restart fenja` |
| Nginx config change broke something | `sudo nginx -t` will tell you exactly what |
| Can't log in with the right code | Clock drift between your machine and the VPS, or pepper mismatch |
| Session cookie shipped without `Secure` | `NODE_ENV=production` missing from `/etc/fenja/env`; add it and restart |
## File locations

View file

@ -10,7 +10,7 @@ Invite-only frontdoor and editorial timeline for Fenja AI. Self-hosted on a sing
Two surfaces on one domain (`project-bifrost.fenja.ai`):
1. **Entrance** — email → 6-digit code → session cookie. Shown to any logged-out visitor.
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.
@ -19,7 +19,6 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
- **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`)
@ -31,11 +30,10 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
.
├── server.js Entry. Binds 127.0.0.1:3000. Routing + CSP + logging.
├── src/
│ ├── auth.js /auth/* endpoints: request-code, verify-code, logout, me
│ ├── auth.js /auth/* endpoints: login, 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
│ └── sessions.js Cookie issue/clear, opaque session IDs
├── public/ Served to anyone (ungated)
│ ├── entrance.html The login page
│ └── entrance.js Two-step form behaviour
@ -63,30 +61,27 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
## 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`.
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.)
- **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`).
- **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.
- **Secrets live in `/etc/fenja/env` (mode 640, root:fenja), never in `/opt/fenja/`.**
- **`/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.
If a change forces one of these to move, it's not a local change — it's a security review.

View file

@ -11,11 +11,8 @@ npm install
# 2. Configure
copy .env.example .env
# Edit .env:
# CODE_PEPPER — generate with: openssl rand -hex 32
# (or any 64-char hex string; use the same one in prod)
# SMTP_* — your relay credentials
# MAIL_FROM — e.g. "Fenja AI <noreply@project-bifrost.fenja.ai>"
# .env is minimal — PORT, PUBLIC_ORIGIN, NODE_ENV only. Leave
# NODE_ENV=development locally so cookies work over HTTP.
# 3. Invite yourself
node bin/invite.js add your@email.com
@ -26,9 +23,9 @@ npm run dev
# 5. Walk the flow:
# a) Open http://127.0.0.1:3000
# b) Enter your invited email → receive code by mail
# c) Type code → lands on /archive
# d) Hit http://127.0.0.1:3000/archive directly in a private window:
# b) Enter your invited email → session cookie issued, welcome step renders
# c) Click "Learn more" → lands on /timeline (the authed home page)
# d) Hit http://127.0.0.1:3000/timeline directly in a private window:
# should redirect to / (proves gating works)
```
@ -68,11 +65,12 @@ sudo chmod 750 /opt/fenja/protected
sudo chmod 750 /opt/fenja/data
```
Create `/opt/fenja/.env` in place (do NOT rsync it — keep secrets off your laptop):
Create `/etc/fenja/env` (kept out of `/opt/fenja/` so it can't be rsynced over). The file is minimal — see `.env.example` for the three values (`PORT`, `PUBLIC_ORIGIN`, `NODE_ENV=production`):
```bash
sudo -u fenja nano /opt/fenja/.env
sudo chmod 600 /opt/fenja/.env
sudo nano /etc/fenja/env
sudo chown root:fenja /etc/fenja/env
sudo chmod 640 /etc/fenja/env
```
Install the systemd unit and Nginx config:
@ -97,16 +95,16 @@ sudo systemctl reload nginx
curl -I https://project-bifrost.fenja.ai/
# 200 + HTML (the entrance)
curl -I https://project-bifrost.fenja.ai/archive
# 302 → /
curl -I https://project-bifrost.fenja.ai/timeline
# 302 → / (gate holds without a session cookie)
curl -I https://project-bifrost.fenja.ai/protected/archive.html
curl -I https://project-bifrost.fenja.ai/protected/index.html
# 404 (this URL literally does not exist)
curl -i -X POST https://project-bifrost.fenja.ai/auth/request-code \
curl -i -X POST https://project-bifrost.fenja.ai/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"nobody@example.com"}'
# 200 + {"ok":true} (no mail is actually sent; endpoint doesn't leak)
# 403 + {"error":"not_invited"} (invite-list-only; enumeration is acceptable by design)
```
### Managing invites on the server
@ -153,14 +151,16 @@ project-bifrost/
│ └── joins.js # CLI: read the Join-CTA click log
├── src/
│ ├── db.js # SQLite schema + prepared statements
│ ├── sessions.js # codes, cookies, HMAC
│ ├── mail.js # Nodemailer SMTP transport
│ ├── sessions.js # opaque 256-bit session cookies
│ ├── middleware.js # rateLimit, requireAuth
│ └── auth.js # /auth/* router
│ └── auth.js # /auth/login, /auth/logout, /auth/me
├── public/ # served to anyone
│ └── index.html # the Entrance page
│ ├── entrance.html # the Entrance page (email form → welcome)
│ └── entrance.js # entrance form behaviour
├── protected/ # served only with a valid session cookie
│ └── archive.html # placeholder for now; drop the real page here
│ ├── index.html # the timeline (authed home page)
│ ├── timeline.js # horizontal timeline + dot-nav + globe
│ └── bifrost.js # Overview page scroll scenes
├── data/ # created on first run — SQLite lives here
└── deploy/
├── nginx.conf
@ -169,11 +169,10 @@ project-bifrost/
## Security model at a glance
- **Static HTML is never served from a public directory for `/archive`.** Node checks the session cookie *before* reading the file off disk. No cookie → 302 → / (the file bytes never leave the server).
- **The session cookie is `HttpOnly`, `Secure`, `SameSite=Lax`.** JavaScript on the page cannot read or steal it.
- **Static HTML is never served from a public directory for gated pages.** Node checks the session cookie *before* reading the file off disk. No cookie → 302 → / (the file bytes never leave the server).
- **The session cookie is `HttpOnly`, `Secure`, `SameSite=Lax`.** JavaScript on the page cannot read or steal it. `Secure` is conditional on `NODE_ENV=production`.
- **Sessions are opaque 256-bit random IDs** stored in SQLite. Revoking is a DELETE.
- **One-time codes are HMAC'd with a server-side pepper** (`CODE_PEPPER`) before storage. A DB leak alone cannot reveal codes.
- **Rate limits on everything.** 5 code requests per IP per hour, 20 verify attempts per IP per hour, 5 wrong guesses per code before it's nuked.
- **`/auth/request-code` always returns 200** — attackers cannot probe the invite list by email-enumeration.
- **Auth is invite-list-only.** `POST /auth/login` with an email on the list issues a session; an email not on the list returns `403 {error:"not_invited"}`. There are no one-time codes, no pepper, no SMTP — the invite list *is* the auth factor. Enumeration is acceptable by design (preview content).
- **Rate limit**: 30 login attempts per IP per hour.
- **Node binds to `127.0.0.1` only.** Nginx is the single ingress; there is no public Node port.
- **Strict CSP** blocks inline scripts and foreign origins from the gated pages.