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:
parent
a276d07b58
commit
88863183e1
6 changed files with 147 additions and 91 deletions
20
CHECKLIST.md
20
CHECKLIST.md
|
|
@ -9,7 +9,7 @@ Notation: run on the VPS unless marked `[local]` or `[browser]`.
|
||||||
## A. After any code change (minimum viable smoke test)
|
## A. After any code change (minimum viable smoke test)
|
||||||
|
|
||||||
- [ ] `sudo systemctl status fenja` → `active (running)`
|
- [ ] `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/` → 200, with `X-Frame-Options: DENY` and `Content-Security-Policy` headers
|
||||||
- [ ] [local] `curl -I https://project-bifrost.fenja.ai/timeline.js` → 302, `Location: /`
|
- [ ] [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)
|
- [ ] [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:
|
Do section A, then:
|
||||||
|
|
||||||
- [ ] [browser, private] Enter invited email → receive code in inbox (not spam) within 60s
|
- [ ] [browser, private] Enter invited email → session cookie issued immediately, welcome step appears (no 6-digit code flow anymore)
|
||||||
- [ ] [browser] Type code → redirected to `/` showing the timeline (not the entrance)
|
- [ ] [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] Hard-refresh (Ctrl+Shift+R) → stays on the timeline (cookie persists)
|
||||||
- [ ] [browser, new private window] Visit `/` → entrance appears (no cookie leak between sessions)
|
- [ ] [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 `/`
|
- [ ] [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
|
- [ ] 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
|
- [ ] Submit > 30 login attempts from the same IP in an hour → rate-limit response (429)
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## D. After changes to the timeline / protected pages
|
## 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'`
|
- [ ] 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"
|
- [ ] `sudo nginx -t` → "syntax is ok" and "test is successful"
|
||||||
- [ ] [local] Open timeline in a browser → no red CSP violations in DevTools console
|
- [ ] [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
|
## F. After dependency or Node.js upgrades
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## 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
|
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.
|
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.
|
**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.
|
- 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.
|
- 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).
|
- `/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).
|
||||||
- `/auth/request-code` always 200 past the format check (no enumeration).
|
|
||||||
- No inline `<script>` in any HTML — CSP is `script-src 'self'`. Put JS in a separate file with `src="..." defer`.
|
- 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.
|
- 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`).
|
- Secrets live in `/etc/fenja/env` on the VPS (not `/opt/fenja/.env`).
|
||||||
|
|
|
||||||
26
INSTALL.md
26
INSTALL.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Install
|
# 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.
|
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
|
- Ubuntu 22.04+ VPS with sudo
|
||||||
- Nginx installed and running
|
- Nginx installed and running
|
||||||
- An A record pointing the subdomain at the VPS's public IP
|
- 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`)
|
- 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
|
## 1. Create the service user and directories
|
||||||
|
|
@ -69,7 +68,11 @@ rsync -avz --delete \
|
||||||
On the VPS:
|
On the VPS:
|
||||||
|
|
||||||
```bash
|
```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 chown -R fenja:fenja /opt/fenja
|
||||||
sudo -u fenja mkdir -p /opt/fenja/data
|
sudo -u fenja mkdir -p /opt/fenja/data
|
||||||
sudo chmod 750 /opt/fenja/protected /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
|
## 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
|
```bash
|
||||||
sudo nano /etc/fenja/env
|
sudo nano /etc/fenja/env
|
||||||
```
|
```
|
||||||
|
|
||||||
Paste (fill in real values):
|
Paste:
|
||||||
|
|
||||||
```
|
```
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai
|
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
|
```bash
|
||||||
sudo chown root:fenja /etc/fenja/env
|
sudo chown root:fenja /etc/fenja/env
|
||||||
|
|
@ -136,7 +132,7 @@ sudo systemctl enable --now fenja
|
||||||
sudo journalctl -u fenja -n 20
|
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
|
## 7. Nginx rate-limit zone
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,41 +60,102 @@ sudo journalctl -u fenja -n 100 # last 100 lines
|
||||||
|
|
||||||
## Deploying code changes
|
## 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
|
```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 \
|
rsync -avz --delete \
|
||||||
--exclude node_modules --exclude data --exclude .env --exclude .git \
|
--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/
|
./ 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
|
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
|
sudo chown -R fenja:fenja /opt/fenja
|
||||||
rm -rf /tmp/fenja-upload
|
rm -rf /tmp/fenja-upload
|
||||||
|
|
||||||
# If package.json changed:
|
# 4. If package.json changed (new dep or version bump):
|
||||||
cd /opt/fenja
|
cd /opt/fenja
|
||||||
sudo -u fenja npm ci --omit=dev
|
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 systemctl restart fenja
|
||||||
sudo journalctl -u fenja -n 20
|
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
|
```bash
|
||||||
sudo nano /etc/fenja/env
|
sudo nano /etc/fenja/env
|
||||||
sudo systemctl restart fenja
|
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
|
## Backups
|
||||||
|
|
||||||
|
|
@ -115,24 +176,32 @@ scp user@project-bifrost.fenja.ai:/opt/fenja/data/backup-YYYY-MM-DD.sqlite .
|
||||||
|
|
||||||
## Quick health checks
|
## 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
|
```powershell
|
||||||
# From your laptop
|
|
||||||
curl.exe -I https://project-bifrost.fenja.ai/ # expect 200
|
curl.exe -I https://project-bifrost.fenja.ai/ # expect 200
|
||||||
curl.exe -I https://project-bifrost.fenja.ai/timeline.js # expect 302 → /
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
| Symptom | First thing to check |
|
| Symptom | First thing to check |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Users don't get codes | `journalctl -u fenja -n 50` for SMTP errors |
|
| 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. |
|
||||||
| Codes arrive in spam | SPF/DKIM/DMARC records on the sending domain |
|
|
||||||
| 502 Bad Gateway | Node crashed — `systemctl status fenja` then `journalctl` |
|
| 502 Bad Gateway | Node crashed — `systemctl status fenja` then `journalctl` |
|
||||||
| 504 Gateway Timeout | Node running but hung — `systemctl restart fenja` |
|
| 504 Gateway Timeout | Node running but hung — `systemctl restart fenja` |
|
||||||
| Nginx config change broke something | `sudo nginx -t` will tell you exactly what |
|
| 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
|
## File locations
|
||||||
|
|
||||||
|
|
|
||||||
35
PROJECT.md
35
PROJECT.md
|
|
@ -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`):
|
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.
|
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.
|
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
|
- **Runtime** — Node 20+, Express 4
|
||||||
- **Storage** — SQLite via `better-sqlite3 12.x` (single file on disk, WAL mode)
|
- **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`
|
- **Web server** — Nginx reverse-proxying to `127.0.0.1:3000`
|
||||||
- **TLS** — Let's Encrypt via certbot
|
- **TLS** — Let's Encrypt via certbot
|
||||||
- **Process supervisor** — systemd (`fenja.service`)
|
- **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.
|
├── server.js Entry. Binds 127.0.0.1:3000. Routing + CSP + logging.
|
||||||
├── src/
|
├── 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
|
│ ├── db.js SQLite init, schema, prepared statements, cleanup timer
|
||||||
│ ├── mail.js Nodemailer transport + sendCode()
|
|
||||||
│ ├── middleware.js rateLimit() + requireAuth()
|
│ ├── 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)
|
├── public/ Served to anyone (ungated)
|
||||||
│ ├── entrance.html The login page
|
│ ├── entrance.html The login page
|
||||||
│ └── entrance.js Two-step form behaviour
|
│ └── entrance.js Two-step form behaviour
|
||||||
|
|
@ -63,30 +61,27 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
|
||||||
|
|
||||||
## How auth works
|
## How auth works
|
||||||
|
|
||||||
1. User POSTs email to `/auth/request-code`. Server:
|
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).
|
||||||
- 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.
|
1. User POSTs `{ email }` to `/auth/login`. Server:
|
||||||
- **Always returns 200** regardless of invite status (prevents email enumeration).
|
- Rate-limits per IP (30/hour)
|
||||||
2. User POSTs `{ email, code }` to `/auth/verify-code`. Server:
|
- Validates the email format. If not on the invite list, returns `403 {error: "not_invited"}`.
|
||||||
- Rate-limits per IP (20/hour) and per-code (5 wrong guesses before deletion)
|
- On success, issues a session row (opaque 256-bit random ID) and sets `HttpOnly; Secure; SameSite=Lax` cookie. Returns `200 {ok: true, firstName}`.
|
||||||
- Compares HMAC in constant time
|
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`.
|
||||||
- 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. `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.
|
||||||
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`.
|
|
||||||
4. Logout POSTs to `/auth/logout`, which deletes the session row and clears the cookie.
|
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
|
## Non-negotiable properties
|
||||||
|
|
||||||
These things define the security model. Breaking any of them is a regression even if tests pass.
|
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.
|
- **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.)
|
- **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.)
|
||||||
- **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`).
|
|
||||||
- **No inline `<script>` in any HTML** — CSP is strict (`script-src 'self'`). All JS is in separate files.
|
- **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.
|
- **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.
|
If a change forces one of these to move, it's not a local change — it's a security review.
|
||||||
|
|
||||||
|
|
|
||||||
51
README.md
51
README.md
|
|
@ -11,11 +11,8 @@ npm install
|
||||||
|
|
||||||
# 2. Configure
|
# 2. Configure
|
||||||
copy .env.example .env
|
copy .env.example .env
|
||||||
# Edit .env:
|
# .env is minimal — PORT, PUBLIC_ORIGIN, NODE_ENV only. Leave
|
||||||
# CODE_PEPPER — generate with: openssl rand -hex 32
|
# NODE_ENV=development locally so cookies work over HTTP.
|
||||||
# (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>"
|
|
||||||
|
|
||||||
# 3. Invite yourself
|
# 3. Invite yourself
|
||||||
node bin/invite.js add your@email.com
|
node bin/invite.js add your@email.com
|
||||||
|
|
@ -26,9 +23,9 @@ npm run dev
|
||||||
|
|
||||||
# 5. Walk the flow:
|
# 5. Walk the flow:
|
||||||
# a) Open http://127.0.0.1:3000
|
# a) Open http://127.0.0.1:3000
|
||||||
# b) Enter your invited email → receive code by mail
|
# b) Enter your invited email → session cookie issued, welcome step renders
|
||||||
# c) Type code → lands on /archive
|
# c) Click "Learn more" → lands on /timeline (the authed home page)
|
||||||
# d) Hit http://127.0.0.1:3000/archive directly in a private window:
|
# d) Hit http://127.0.0.1:3000/timeline directly in a private window:
|
||||||
# should redirect to / (proves gating works)
|
# should redirect to / (proves gating works)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -68,11 +65,12 @@ sudo chmod 750 /opt/fenja/protected
|
||||||
sudo chmod 750 /opt/fenja/data
|
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
|
```bash
|
||||||
sudo -u fenja nano /opt/fenja/.env
|
sudo nano /etc/fenja/env
|
||||||
sudo chmod 600 /opt/fenja/.env
|
sudo chown root:fenja /etc/fenja/env
|
||||||
|
sudo chmod 640 /etc/fenja/env
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the systemd unit and Nginx config:
|
Install the systemd unit and Nginx config:
|
||||||
|
|
@ -97,16 +95,16 @@ sudo systemctl reload nginx
|
||||||
curl -I https://project-bifrost.fenja.ai/
|
curl -I https://project-bifrost.fenja.ai/
|
||||||
# 200 + HTML (the entrance)
|
# 200 + HTML (the entrance)
|
||||||
|
|
||||||
curl -I https://project-bifrost.fenja.ai/archive
|
curl -I https://project-bifrost.fenja.ai/timeline
|
||||||
# 302 → /
|
# 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)
|
# 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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"email":"nobody@example.com"}'
|
-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
|
### Managing invites on the server
|
||||||
|
|
@ -153,14 +151,16 @@ project-bifrost/
|
||||||
│ └── joins.js # CLI: read the Join-CTA click log
|
│ └── joins.js # CLI: read the Join-CTA click log
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── db.js # SQLite schema + prepared statements
|
│ ├── db.js # SQLite schema + prepared statements
|
||||||
│ ├── sessions.js # codes, cookies, HMAC
|
│ ├── sessions.js # opaque 256-bit session cookies
|
||||||
│ ├── mail.js # Nodemailer SMTP transport
|
|
||||||
│ ├── middleware.js # rateLimit, requireAuth
|
│ ├── middleware.js # rateLimit, requireAuth
|
||||||
│ └── auth.js # /auth/* router
|
│ └── auth.js # /auth/login, /auth/logout, /auth/me
|
||||||
├── public/ # served to anyone
|
├── 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
|
├── 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
|
├── data/ # created on first run — SQLite lives here
|
||||||
└── deploy/
|
└── deploy/
|
||||||
├── nginx.conf
|
├── nginx.conf
|
||||||
|
|
@ -169,11 +169,10 @@ project-bifrost/
|
||||||
|
|
||||||
## Security model at a glance
|
## 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).
|
- **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.
|
- **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.
|
- **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.
|
- **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 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.
|
- **Rate limit**: 30 login attempts per IP per hour.
|
||||||
- **`/auth/request-code` always returns 200** — attackers cannot probe the invite list by email-enumeration.
|
|
||||||
- **Node binds to `127.0.0.1` only.** Nginx is the single ingress; there is no public Node port.
|
- **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.
|
- **Strict CSP** blocks inline scripts and foreign origins from the gated pages.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue