Initial commit: project-bifrost auth + timeline

This commit is contained in:
Arlind Ukshini 2026-04-22 14:39:16 +02:00
commit 1c395c349b
33 changed files with 4993 additions and 0 deletions

29
.env.example Normal file
View file

@ -0,0 +1,29 @@
# ─────────────────────────────────────────────────────────────
# Copy this file to `.env` and fill in real values.
# cp .env.example .env
# Never commit the real .env file. It is in .gitignore.
# ─────────────────────────────────────────────────────────────
# Node listens on this port, bound to 127.0.0.1 only
PORT=3000
# Secret used to HMAC one-time codes before they're stored.
# Generate with: openssl rand -hex 32
# DO NOT change this after go-live — all pending codes will be invalidated.
CODE_PEPPER=replace_me_with_64_random_hex_characters
# SMTP relay settings (your own relay, STARTTLS on 587)
SMTP_HOST=smtp.yourrelay.tld
SMTP_PORT=587
SMTP_USER=fenja@yourdomain.tld
SMTP_PASS=replace_me
# "From" address on outbound mail
MAIL_FROM="Fenja AI <noreply@project-bifrost.fenja.ai>"
# Public origin of the site — used only in log output
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai
# Set to "development" locally to allow http:// cookies.
# In production, omit or set to "production".
NODE_ENV=development

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# ── Node / npm ───────────────────────────────────────────────
node_modules/
npm-debug.log*
*.log
# ── Secrets ──────────────────────────────────────────────────
.env
.env.local
.env.*.local
# ── Runtime state (SQLite + backups) ─────────────────────────
data/*.sqlite
data/*.sqlite-journal
data/*.sqlite-shm
data/*.sqlite-wal
data/backup-*.sqlite
# ── OS junk ──────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ── Editor ───────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
# ── Build / temp ─────────────────────────────────────────────
*.tmp
.cache/
# ── NOTE ─────────────────────────────────────────────────────
# Intentionally committed (not gitignored):
# protected/vendor/ d3 + topojson + countries-110m.json
# protected/fenja/fonts/ Manrope + Newsreader TTFs
# Keeping these in git means: clone → npm install → it just works.

98
CHECKLIST.md Normal file
View file

@ -0,0 +1,98 @@
# Checklist
Run the relevant section **after every change** before considering it done. Items are ordered: if an earlier one fails, stop and fix before testing the rest.
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
- [ ] [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)
If all five pass, the site is up and the gate holds.
---
## B. After changes to auth, sessions, cookies, middleware, or `src/`
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] 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] 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`
## C. After changes to the entrance form, code input, or email
- [ ] Submit a non-invited address → still advances to code screen (enumeration protection intact)
- [ ] 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
## D. After changes to the timeline / protected pages
- [ ] [browser] Timeline loads fully: globe visible, 23 event cards, dot-nav at bottom, fonts render as true italic (not system oblique)
- [ ] [browser] Scroll works smoothly, card reveal animations fire
- [ ] [browser] Dot-nav switches between Timeline / Overview / Archive views
- [ ] DevTools console: no CSP violations, no 404s for fonts/vendor files
- [ ] DevTools network tab: all `/vendor/*` and `/fenja/fonts/*` requests return 200
## E. After changes to CSP, security headers, or Nginx
Do section A with extra attention to:
- [ ] Response headers on `/` include all six: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Content-Security-Policy`, `Strict-Transport-Security`
- [ ] 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)
## F. After dependency or Node.js upgrades
- [ ] `sudo -u fenja npm ci --omit=dev` completes without errors
- [ ] `sudo -u fenja npm audit` reports no high/critical vulnerabilities in production deps
- [ ] Run section A, then section B (full auth flow)
- [ ] Check `node --version` on the VPS is still 20+
## G. After Nginx config changes specifically
- [ ] `sudo nginx -t` before reloading (catches 95% of errors)
- [ ] `ls /etc/nginx/sites-enabled/` contains only `project-bifrost` (no shadow configs)
- [ ] After reload, `curl -I https://project-bifrost.fenja.ai/` includes `X-Powered-By: Express` (proves Nginx is still proxying to Node, not serving static files)
- [ ] Rate-limit zone still exists: `grep -r "limit_req_zone" /etc/nginx/`
## H. After data/DB changes (schema, migrations)
- [ ] Fresh boot creates schema cleanly: stop service, `mv data/fenja.sqlite data/fenja.sqlite.bak`, restart, verify it comes up healthy
- [ ] Restore the backup (`mv` it back) before inviting further users
- [ ] Take a manual backup before deploying: `sudo -u fenja sqlite3 /opt/fenja/data/fenja.sqlite ".backup /opt/fenja/data/pre-change-$(date +%F).sqlite"`
## I. Deploy readiness (before any push to production)
- [ ] Ran locally: `npm install && npm run dev`, walked full flow end-to-end
- [ ] No uncommitted `.env` file in the rsync source
- [ ] No `data/*.sqlite` in the rsync source
- [ ] On the VPS after deploy: section A passes
- [ ] On the VPS after deploy: section B passes
- [ ] Tailed `journalctl -u fenja -f` for 30s while refreshing the site → no errors
---
## Red flags that always mean stop
- `curl -I /` returns 200 **without** `X-Powered-By: Express` → Nginx is serving static, not proxying to Node. Site is broken.
- `curl -I /timeline.js` returns 200 → the auth gate is down. **Do not invite anyone.**
- Any `Set-Cookie: fenja_session=...` header that lacks `HttpOnly` or `Secure` (in prod) → cookie hardening regression.
- `journalctl -u fenja` showing repeated crashes → something's wrong, `systemctl restart fenja` won't fix it.
- The entrance page submits the form as a GET with `?email=` in the URL → JS isn't running (usually CSP blocking a newly-added inline script).
If any of these happen, revert to the last known-good commit and investigate offline.

217
INSTALL.md Normal file
View file

@ -0,0 +1,217 @@
# 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.
Throughout: replace `project-bifrost.fenja.ai` with your actual domain and `user@vps` with your SSH user.
## 0. Prerequisites
- 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
```bash
sudo useradd -r -s /usr/sbin/nologin fenja
sudo mkdir -p /home/fenja /opt/fenja /etc/fenja
sudo chown fenja:fenja /home/fenja /opt/fenja
```
## 2. DNS + TLS
Confirm the subdomain resolves to the VPS:
```bash
dig +short project-bifrost.fenja.ai
curl -s ifconfig.me
```
Both must match. If the domain is behind Cloudflare, set the DNS record to "DNS only" (grey cloud) for this subdomain — Let's Encrypt needs direct access.
Install certbot and issue the cert:
```bash
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
```
Create a minimal placeholder Nginx block so certbot has something to attach to:
```bash
sudo tee /etc/nginx/sites-available/project-bifrost <<'EOF'
server {
listen 80;
server_name project-bifrost.fenja.ai;
root /var/www/html;
}
EOF
sudo ln -sf /etc/nginx/sites-available/project-bifrost /etc/nginx/sites-enabled/project-bifrost
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d project-bifrost.fenja.ai
```
Agree to ToS; say yes to the HTTP → HTTPS redirect.
## 3. Upload the code
On your laptop, from the project folder. Upload to staging, then move with sudo (your SSH user doesn't own `/opt/fenja`):
```bash
rsync -avz --delete \
--exclude node_modules --exclude data --exclude .env --exclude .git \
./ user@vps:/tmp/fenja-upload/
```
On the VPS:
```bash
sudo rsync -a --delete /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
rm -rf /tmp/fenja-upload
```
## 4. Install dependencies
```bash
cd /opt/fenja
sudo -u fenja npm ci --omit=dev
```
Should finish with `added N packages, found 0 vulnerabilities`.
## 5. Create the environment file
```bash
sudo nano /etc/fenja/env
```
Paste (fill in real values):
```
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:
```bash
sudo chown root:fenja /etc/fenja/env
sudo chmod 640 /etc/fenja/env
```
## 6. Systemd unit
```bash
sudo cp /opt/fenja/deploy/fenja.service /etc/systemd/system/fenja.service
sudo nano /etc/systemd/system/fenja.service
```
Change `EnvironmentFile=/opt/fenja/.env` to:
```
EnvironmentFile=/etc/fenja/env
```
Enable and start:
```bash
sudo systemctl daemon-reload
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`.
## 7. Nginx rate-limit zone
Add to `/etc/nginx/nginx.conf` inside the `http { ... }` block (anywhere near the top):
```
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
```
## 8. Replace the Nginx config
The placeholder from step 2 needs to be replaced with the real reverse-proxy config. First, check for conflicting older configs that might shadow the new one:
```bash
ls /etc/nginx/sites-enabled/
sudo grep -rln project-bifrost /etc/nginx/sites-available/
```
If you see any file other than `project-bifrost` (e.g. `project-bifrost.fenja.ai` or `default`), remove them:
```bash
sudo rm -f /etc/nginx/sites-enabled/default
sudo rm -f /etc/nginx/sites-enabled/project-bifrost.fenja.ai
sudo rm -f /etc/nginx/sites-available/project-bifrost.fenja.ai
```
Then install the real config:
```bash
sudo cp /opt/fenja/deploy/nginx.conf /etc/nginx/sites-available/project-bifrost
sudo ln -sf /etc/nginx/sites-available/project-bifrost /etc/nginx/sites-enabled/project-bifrost
sudo nginx -t
sudo systemctl reload nginx
```
## 9. Verify
From your laptop:
```bash
curl -I https://project-bifrost.fenja.ai/ # 200, with X-Frame-Options, CSP, etc.
curl -I https://project-bifrost.fenja.ai/timeline.js # 302 → /
curl -I https://project-bifrost.fenja.ai/vendor/d3-array.min.js # 302 → /
```
All three must behave as expected. The 200 response must include `X-Frame-Options`, `Content-Security-Policy`, and `Strict-Transport-Security` — their presence confirms Nginx is proxying to Node, not serving static files.
## 10. Ops hygiene
Nightly SQLite backup:
```bash
sudo tee /etc/cron.d/fenja-backup <<'EOF'
0 3 * * * fenja sqlite3 /opt/fenja/data/fenja.sqlite ".backup /opt/fenja/data/backup-$(date +\%F).sqlite"
0 4 * * * fenja find /opt/fenja/data -name 'backup-*.sqlite' -mtime +14 -delete
EOF
```
Uptime monitoring: set up an HTTP(S) check against `https://project-bifrost.fenja.ai/` at a 5-minute interval (UptimeRobot free tier works).
## 11. First invite
```bash
sudo -u fenja node /opt/fenja/bin/invite.js add you@yourdomain.com
```
Open `https://project-bifrost.fenja.ai/` in a fresh browser, walk the flow end-to-end.
---
## Gotchas we hit
- **Windows Node 24 + better-sqlite3 11.x**: no prebuilt binaries. Use `better-sqlite3 ^12.2.0`.
- **PowerShell blocks `npm`**: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
- **`useradd -r` with no home**: npm can't write its cache. `mkdir /home/fenja && chown fenja:fenja`.
- **CSP blocking inline `<script>`**: extract to a separate `.js` file and reference with `src="..." defer`.
- **Two Nginx configs for the same domain**: whichever sorts first alphabetically in `sites-enabled/` wins. Keep only one.
- **Certbot placeholder config lingered**: the old one served `/var/www/project-bifrost.fenja.ai` statically and shadowed the real one until removed.

123
OPERATIONS.md Normal file
View file

@ -0,0 +1,123 @@
# Operations
Day-to-day commands for running project-bifrost.
## Managing invites
All commands run on the VPS as the `fenja` user.
```bash
# Add someone
sudo -u fenja node /opt/fenja/bin/invite.js add someone@example.com
# Remove someone (doesn't kill their active session — see below)
sudo -u fenja node /opt/fenja/bin/invite.js remove someone@example.com
# List everyone
sudo -u fenja node /opt/fenja/bin/invite.js list
```
Removing an invite stops a user from requesting *new* codes, but doesn't invalidate their existing session cookie (valid 30 days). To kick them out immediately, also run:
```bash
sudo sqlite3 /opt/fenja/data/fenja.sqlite \
"DELETE FROM sessions WHERE email = 'someone@example.com';"
```
## Service control
```bash
sudo systemctl status fenja # is it running?
sudo systemctl restart fenja # restart (after config/code changes)
sudo journalctl -u fenja -f # live log tail
sudo journalctl -u fenja -n 100 # last 100 lines
```
## Deploying code changes
From your laptop, in WSL, inside the project folder:
```bash
# Push to staging on VPS
rsync -avz --delete \
--exclude node_modules --exclude data --exclude .env --exclude .git \
./ user@project-bifrost.fenja.ai:/tmp/fenja-upload/
# Then on the VPS:
ssh user@project-bifrost.fenja.ai
sudo rsync -a --delete /tmp/fenja-upload/ /opt/fenja/
sudo chown -R fenja:fenja /opt/fenja
rm -rf /tmp/fenja-upload
# If package.json changed:
cd /opt/fenja
sudo -u fenja npm ci --omit=dev
# Restart
sudo systemctl restart fenja
sudo journalctl -u fenja -n 20
```
Confirm `[mail] SMTP relay reachable` and `[bifrost] listening` appear in the logs.
## Editing secrets (SMTP, pepper)
Secrets live in `/etc/fenja/env`, not in the repo.
```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.
## Backups
Nightly cron at `/etc/cron.d/fenja-backup` snapshots the SQLite file to `/opt/fenja/data/backup-YYYY-MM-DD.sqlite`, keeping 14 days.
Manual snapshot:
```bash
sudo -u fenja sqlite3 /opt/fenja/data/fenja.sqlite \
".backup /opt/fenja/data/backup-manual-$(date +%F).sqlite"
```
Pull a backup to your laptop:
```bash
scp user@project-bifrost.fenja.ai:/opt/fenja/data/backup-YYYY-MM-DD.sqlite .
```
## Quick health checks
```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`).
## 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 |
| 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 |
## File locations
```
/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-available/project-bifrost
/etc/nginx/sites-enabled/project-bifrost (symlink)
/etc/letsencrypt/live/project-bifrost.fenja.ai/ TLS certs
/etc/cron.d/fenja-backup
```

143
PROJECT.md Normal file
View file

@ -0,0 +1,143 @@
# 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 → 6-digit code → session cookie. Shown to any logged-out visitor.
2. **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 `fenja` user 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
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`.
4. 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.** `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`).
- **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/`.**
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.
## 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)

167
README.md Normal file
View file

@ -0,0 +1,167 @@
# project-bifrost
Invite-only Fenja AI entrance and archive. Node/Express + SQLite + SMTP,
behind Nginx on a VPS.
## Local development (Windows / VS Code)
```powershell
# 1. Install deps
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>"
# 3. Invite yourself
node bin/invite.js add your@email.com
# 4. Run
npm run dev
# → http://127.0.0.1:3000
# 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:
# should redirect to / (proves gating works)
```
## Deploying to the VPS
This assumes:
- VPS with Nginx already running and TLS for `project-bifrost.fenja.ai`
- Node 20+ installed
- A `fenja` system user owns `/opt/fenja`
### First-time setup
```bash
# On the VPS
sudo useradd -r -s /usr/sbin/nologin fenja
sudo mkdir -p /opt/fenja
sudo chown -R fenja:fenja /opt/fenja
```
### Pushing code from Windows
From the project root, sync with rsync (via WSL) or scp/WinSCP:
```powershell
# Example rsync (requires WSL or rsync on Windows)
rsync -avz --delete `
--exclude node_modules --exclude data --exclude .env --exclude .git `
./ user@vps:/opt/fenja/
```
Then on the VPS:
```bash
cd /opt/fenja
sudo -u fenja npm ci --omit=dev
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):
```bash
sudo -u fenja nano /opt/fenja/.env
sudo chmod 600 /opt/fenja/.env
```
Install the systemd unit and Nginx config:
```bash
sudo cp deploy/fenja.service /etc/systemd/system/fenja.service
sudo systemctl daemon-reload
sudo systemctl enable --now fenja
sudo systemctl status fenja # should be "active (running)"
sudo cp deploy/nginx.conf /etc/nginx/sites-available/project-bifrost
sudo ln -sf /etc/nginx/sites-available/project-bifrost \
/etc/nginx/sites-enabled/project-bifrost
# Also ensure the rate-limit zone is declared — see deploy/nginx.conf for the one-liner
sudo nginx -t
sudo systemctl reload nginx
```
### Verifying from outside
```bash
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/protected/archive.html
# 404 (this URL literally does not exist)
curl -i -X POST https://project-bifrost.fenja.ai/auth/request-code \
-H 'Content-Type: application/json' \
-d '{"email":"nobody@example.com"}'
# 200 + {"ok":true} (no mail is actually sent; endpoint doesn't leak)
```
### Managing invites on the server
```bash
sudo -u fenja node /opt/fenja/bin/invite.js add someone@example.com
sudo -u fenja node /opt/fenja/bin/invite.js list
sudo -u fenja node /opt/fenja/bin/invite.js remove someone@example.com
```
### Backups
Add a cron job:
```
# /etc/cron.d/fenja-backup
0 3 * * * fenja sqlite3 /opt/fenja/data/fenja.sqlite ".backup /opt/fenja/data/backup-$(date +\%F).sqlite"
0 4 * * * fenja find /opt/fenja/data -name 'backup-*.sqlite' -mtime +14 -delete
```
`.backup` is safe on a live SQLite DB. `cp` is not.
## Project layout
```
project-bifrost/
├── package.json
├── server.js # entry point — binds 127.0.0.1:3000
├── .env.example # copy to .env
├── .gitignore
├── bin/
│ └── invite.js # CLI: add / remove / list invites
├── src/
│ ├── db.js # SQLite schema + prepared statements
│ ├── sessions.js # codes, cookies, HMAC
│ ├── mail.js # Nodemailer SMTP transport
│ ├── middleware.js # rateLimit, requireAuth
│ └── auth.js # /auth/* router
├── public/ # served to anyone
│ └── index.html # the Entrance page
├── protected/ # served only with a valid session cookie
│ └── archive.html # placeholder for now; drop the real page here
├── data/ # created on first run — SQLite lives here
└── deploy/
├── nginx.conf
└── fenja.service
```
## 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.
- **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.
- **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.

56
bin/invite.js Normal file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env node
// ─────────────────────────────────────────────────────────────
// bin/invite.js — add / remove / list invites.
//
// Usage:
// npm run invite -- add someone@example.com
// npm run invite -- remove someone@example.com
// npm run invite -- list
//
// Or directly:
// node bin/invite.js add someone@example.com
// ─────────────────────────────────────────────────────────────
import { q } from '../src/db.js';
const [, , cmd, arg] = process.argv;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function help() {
console.log('Usage:');
console.log(' invite add <email>');
console.log(' invite remove <email>');
console.log(' invite list');
process.exit(1);
}
switch (cmd) {
case 'add': {
if (!arg || !EMAIL_RE.test(arg)) help();
const email = arg.trim().toLowerCase();
q.upsertInvite.run(email, Date.now(), 'cli');
console.log(`Invited ${email}`);
break;
}
case 'remove': {
if (!arg || !EMAIL_RE.test(arg)) help();
const email = arg.trim().toLowerCase();
const result = q.deleteInvite.run(email);
console.log(result.changes > 0 ? `Removed ${email}` : `No invite for ${email}`);
break;
}
case 'list': {
const rows = q.listInvites.all();
if (rows.length === 0) {
console.log('(no invites)');
} else {
for (const r of rows) {
const d = new Date(r.invited_at).toISOString().slice(0, 10);
console.log(` ${d} ${r.email}${r.invited_by ? ` (by ${r.invited_by})` : ''}`);
}
console.log(`\n${rows.length} invite${rows.length === 1 ? '' : 's'} total.`);
}
break;
}
default:
help();
}

0
data/.gitkeep Normal file
View file

46
deploy/fenja.service Normal file
View file

@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────
# Systemd unit for project-bifrost.
#
# Install to: /etc/systemd/system/fenja.service
#
# sudo cp deploy/fenja.service /etc/systemd/system/fenja.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now fenja
# sudo systemctl status fenja
# sudo journalctl -u fenja -f # live tail of logs
# ─────────────────────────────────────────────────────────────
[Unit]
Description=Fenja AI (project-bifrost)
After=network.target
[Service]
Type=simple
User=fenja
Group=fenja
WorkingDirectory=/opt/fenja
EnvironmentFile=/opt/fenja/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
# stdout / stderr → journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=fenja
# ─── Hardening ───
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
# Only these paths are writable
ReadWritePaths=/opt/fenja/data
[Install]
WantedBy=multi-user.target

84
deploy/nginx.conf Normal file
View file

@ -0,0 +1,84 @@
# ─────────────────────────────────────────────────────────────
# Nginx config for project-bifrost.fenja.ai
#
# Install to: /etc/nginx/sites-available/project-bifrost
# Symlink: sudo ln -sf /etc/nginx/sites-available/project-bifrost \
# /etc/nginx/sites-enabled/project-bifrost
# Test: sudo nginx -t
# Reload: sudo systemctl reload nginx
#
# Assumes certbot has already created /etc/letsencrypt/live/project-bifrost.fenja.ai/
# ─────────────────────────────────────────────────────────────
# Rate-limiting zones put these in the http {} block of /etc/nginx/nginx.conf
# (or in an /etc/nginx/conf.d/*.conf file). They MUST live at http{} scope,
# not in server{}, so they're shared across workers.
#
# limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
#
# If you've already added it, skip it. If not, either paste that one line
# into /etc/nginx/nginx.conf inside http{}, or drop a file at
# /etc/nginx/conf.d/rate-limits.conf containing just that line.
server {
listen 80;
listen [::]:80;
server_name project-bifrost.fenja.ai;
# Let certbot's renewals reach .well-known/acme-challenge on port 80
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Everything else goes to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name project-bifrost.fenja.ai;
ssl_certificate /etc/letsencrypt/live/project-bifrost.fenja.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/project-bifrost.fenja.ai/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ─── Security headers ───
# HSTS after you're confident the cert-and-redirect loop is solid.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Limit body size we only accept small JSON posts
client_max_body_size 32k;
# Don't leak Nginx version
server_tokens off;
# ─── Extra protection for auth endpoints ───
# Bursts of 10 with a strict 10r/m base rate. Prevents someone from
# script-hammering the verify endpoint.
location /auth/ {
limit_req zone=auth_limit burst=10 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 20s;
}
# ─── Everything else Node ───
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}

1294
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "project-bifrost",
"version": "0.1.0",
"description": "Fenja AI — invite-only entrance, Node/Express, SQLite, SMTP.",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"invite": "node bin/invite.js"
},
"dependencies": {
"better-sqlite3": "^12.2.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"nodemailer": "^8.0.5"
}
}

59
protected/archive.html Normal file
View file

@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Archive — Fenja AI</title>
<style>
:root {
--paper: #faf6ee; --ink: #383831; --ink-soft: #5f5e5e; --walnut: #785f53;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; min-height: 100%;
background: var(--paper); color: var(--ink);
font-family: "Manrope", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
background: radial-gradient(1100px 760px at 22% 42%, #fffcf7 0%, var(--paper) 58%, #f2ecdd 100%);
display: flex; align-items: center; padding: 0 112px;
}
.wrap { max-width: 640px; }
h1 {
font-family: "Newsreader", Georgia, serif;
font-weight: 400; font-size: 56px;
letter-spacing: -0.022em; line-height: 1.06;
margin: 0 0 20px 0;
}
h1 em { font-style: italic; font-weight: 700; }
p {
font-family: "Newsreader", Georgia, serif;
font-style: italic; font-size: 19px;
color: var(--ink-soft); line-height: 1.5;
margin: 0 0 32px 0;
}
.logout {
all: unset;
font-family: "Manrope", system-ui, sans-serif;
font-size: 14px; color: var(--walnut);
cursor: pointer; border-bottom: 1px solid rgba(120, 95, 83, 0.35);
}
.logout:hover { border-bottom-color: var(--walnut); }
@media (max-width: 720px) { body { padding: 80px 28px; } h1 { font-size: 36px; } }
</style>
</head>
<body>
<div class="wrap">
<h1>You are in the <em>archive.</em></h1>
<p>
This is a placeholder. Drop the real archive page here — timeline, overview, the lot — and it will be
served from <code>protected/archive.html</code>, gated by the session cookie.
</p>
<button class="logout" id="logout">Close the archive &rarr;</button>
</div>
<script src="/archive/archive.js" defer></script>
</body>
</html>

15
protected/archive.js Normal file
View file

@ -0,0 +1,15 @@
// ─────────────────────────────────────────────────────────────
// protected/archive.js — client-side behaviour for the archive page.
// Served from /archive/archive.js, gated by requireAuth like the
// rest of the protected directory.
// ─────────────────────────────────────────────────────────────
document.getElementById('logout').addEventListener('click', async () => {
try {
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
} catch (err) {
// Even if the fetch fails, sending them to / will prompt a fresh login
console.warn('logout request failed', err);
}
window.location.href = '/';
});

View file

@ -0,0 +1,286 @@
/* =============================================================
Fenja AI Nordic Editorial Design System
"The Digital Archivist"
============================================================= */
/* ────────── Fonts (variable) ────────── */
/* Manrope — variable font, weight 200800 */
@font-face {
font-family: "Manrope";
font-weight: 200 800;
font-style: normal;
font-display: swap;
src: url("./fonts/Manrope-VariableFont_wght.ttf") format("truetype-variations");
}
/* Newsreader — variable font with optical-size and weight axes */
@font-face {
font-family: "Newsreader";
font-weight: 200 800;
font-style: normal;
font-display: swap;
src: url("./fonts/Newsreader-VariableFont_opsz,wght.ttf") format("truetype-variations");
}
@font-face {
font-family: "Newsreader";
font-weight: 200 800;
font-style: italic;
font-display: swap;
src: url("./fonts/Newsreader-Italic-VariableFont_opsz,wght.ttf") format("truetype-variations");
}
/* ---------- Tokens ----------------------------------------- */
:root {
/* --- Core neutrals (unbleached paper, clay, slate) --- */
--background: #faf6ee; /* base canvas — warm paper */
--surface: #faf6ee;
--surface-container-lowest: #fffcf7; /* most-elevated — unbleached paper, never pure white */
--surface-container-low: #f6f2e8;
--surface-container: #efeadc;
--surface-container-high: #e7e1d0;
--surface-container-highest: #ddd6c3;
--surface-variant: #ddd6c3;
--on-surface: #383831; /* charcoal slate */
--on-surface-variant: #5f5e5e;
--on-surface-muted: #8a887f;
--primary: #5f5e5e;
--on-primary: #fffcf7;
--secondary: #785f53; /* hand-rubbed wood */
--secondary-dim: #6b5348;
--on-secondary: #ffffff;
--secondary-fixed-dim: #9a8679;
--outline: #babab0;
--outline-variant: #babab0; /* used at 15% for ghost borders */
/* --- Archival Pigment accent palette (flat, matte inks) --- */
--pigment-terracotta: #b96b58; /* warnings, critical */
--pigment-copper: #6d8c7c; /* success, growth */
--pigment-ochre: #c29d59; /* cautions, tertiary */
--pigment-indigo: #5a6d83; /* info, neutral data */
--pigment-heather: #8d7a85; /* categorical, supportive */
/* --- Semantic state mappings --- */
--color-success: var(--pigment-copper);
--color-warning: var(--pigment-ochre);
--color-danger: var(--pigment-terracotta);
--color-info: var(--pigment-indigo);
/* --- Type families --- */
--font-serif: "Newsreader", "Source Serif Pro", Georgia, "Times New Roman", serif;
--font-sans: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* --- Type scale (clamped for responsive) --- */
--text-display-xl: clamp(3.5rem, 6vw, 5.5rem); /* 5688 */
--text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 4872 */
--text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 4056 */
--text-headline-lg: 2.25rem; /* 36 */
--text-headline-md: 1.75rem; /* 28 */
--text-headline-sm: 1.375rem; /* 22 */
--text-title-lg: 1.125rem; /* 18 */
--text-title-md: 1rem; /* 16 */
--text-body-lg: 1.0625rem; /* 17 */
--text-body-md: 1rem; /* 16 */
--text-body-sm: 0.875rem; /* 14 */
--text-label-md: 0.8125rem; /* 13 */
--text-label-sm: 0.75rem; /* 12 */
/* Letter-spacing */
--tracking-tight: -0.02em;
--tracking-snug: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.04em;
--tracking-wider: 0.08em;
/* Line-heights */
--leading-tight: 1.1;
--leading-snug: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.6;
--leading-loose: 1.75;
/* --- Spacing scale (editorial, generous) --- */
--space-1: 0.25rem; /* 4 */
--space-2: 0.5rem; /* 8 */
--space-3: 0.75rem; /* 12 */
--space-4: 1rem; /* 16 */
--space-5: 1.5rem; /* 24 */
--space-6: 2rem; /* 32 — list separator default */
--space-7: 2.5rem; /* 40 */
--space-8: 2.75rem; /* 44 — hero-card padding */
--space-10: 4rem; /* 64 */
--space-12: 5rem; /* 80 */
--space-16: 6rem; /* 96 */
--space-20: 7rem; /* 112 — desktop lateral margin */
--space-24: 8rem; /* 128 */
/* --- Radii --- */
--radius-none: 0;
--radius-sm: 0.375rem; /* 6 */
--radius-md: 0.75rem; /* 12 — primary */
--radius-lg: 1.25rem; /* 20 */
--radius-full: 9999px;
/* --- Elevation (atmospheric, warm) --- */
--shadow-none: none;
--shadow-ambient: 0 12px 32px -12px rgba(56, 56, 49, 0.06);
--shadow-float: 0 24px 48px -16px rgba(56, 56, 49, 0.05), 0 4px 12px -4px rgba(56, 56, 49, 0.04);
--shadow-modal: 0 40px 64px -24px rgba(56, 56, 49, 0.08), 0 8px 16px -6px rgba(56, 56, 49, 0.04);
/* --- Ghost border (WCAG fallback only) --- */
--ghost-border-color: rgba(186, 186, 176, 0.15);
--ghost-border: 1px solid var(--ghost-border-color);
/* --- Glass --- */
--glass-blur: blur(16px);
--glass-surface: rgba(255, 252, 247, 0.8);
/* --- Motion --- */
--ease-standard: cubic-bezier(0.2, 0.0, 0, 1);
--ease-entrance: cubic-bezier(0, 0, 0, 1);
--ease-exit: cubic-bezier(0.3, 0, 1, 1);
--duration-fast: 140ms;
--duration-med: 240ms;
--duration-slow: 420ms;
/* --- Layout --- */
--content-max: 72rem; /* 1152 */
--reading-max: 42rem; /* 672 */
}
/* ---------- Base semantic styles --------------------------- */
html {
font-family: var(--font-sans);
color: var(--on-surface);
background: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-size: var(--text-body-md);
line-height: var(--leading-relaxed);
color: var(--on-surface);
background: var(--background);
}
/* Display — serif, tight, left-aligned editorial intent */
.display-xl,
.display-lg,
.display-md {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0 0 var(--space-5) 0;
}
.display-xl { font-size: var(--text-display-xl); }
.display-lg { font-size: var(--text-display-lg); }
.display-md { font-size: var(--text-display-md); }
/* Headlines — serif, authoritative */
h1, .headline-lg,
h2, .headline-md,
h3, .headline-sm {
font-family: var(--font-serif);
font-weight: 400;
color: var(--on-surface);
letter-spacing: var(--tracking-snug);
line-height: var(--leading-snug);
margin: 0 0 var(--space-4) 0;
}
h1, .headline-lg { font-size: var(--text-headline-lg); }
h2, .headline-md { font-size: var(--text-headline-md); }
h3, .headline-sm { font-size: var(--text-headline-sm); }
/* Titles — sans, precise structural labels */
h4, .title-lg,
h5, .title-md {
font-family: var(--font-sans);
font-weight: 600;
color: var(--on-surface);
letter-spacing: var(--tracking-normal);
line-height: var(--leading-snug);
margin: 0 0 var(--space-3) 0;
}
h4, .title-lg { font-size: var(--text-title-lg); }
h5, .title-md { font-size: var(--text-title-md); }
/* Body */
p, .body-md {
font-family: var(--font-sans);
font-weight: 400;
font-size: var(--text-body-md);
line-height: var(--leading-relaxed);
color: var(--on-surface);
margin: 0 0 var(--space-4) 0;
text-wrap: pretty;
}
.body-lg {
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
}
.body-sm {
font-size: var(--text-body-sm);
line-height: var(--leading-normal);
color: var(--on-surface-variant);
}
/* Labels — muted, small caps optional */
.label-md,
.label-sm {
font-family: var(--font-sans);
font-weight: 500;
color: var(--on-surface-variant);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.label-md { font-size: var(--text-label-md); }
.label-sm { font-size: var(--text-label-sm); }
/* Editorial lead — serif italic, subtle */
.lead {
font-family: var(--font-serif);
font-style: italic;
font-size: var(--text-body-lg);
color: var(--on-surface-variant);
line-height: var(--leading-relaxed);
}
/* Inline code / mono */
code, kbd, samp, pre, .mono {
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--on-surface);
}
/* Links — editorial, no underline until hover */
a {
color: var(--secondary);
text-decoration: none;
border-bottom: 1px solid rgba(120, 95, 83, 0.3);
transition: border-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
a:hover {
color: var(--secondary-dim);
border-bottom-color: currentColor;
}
/* Selection — warm, not blue */
::selection {
background: rgba(120, 95, 83, 0.18);
color: var(--on-surface);
}
/* Utility: ghost border fallback */
.ghost-border { border: var(--ghost-border); }
.ghost-border-bottom { border-bottom: var(--ghost-border); }

Binary file not shown.

862
protected/index.html Normal file
View file

@ -0,0 +1,862 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>A Catalog of Sovereignty — 20222026</title>
<link rel="stylesheet" href="fenja/colors_and_type.css" />
<script src="vendor/d3-array.min.js"></script>
<script src="vendor/d3-geo.min.js"></script>
<script src="vendor/topojson-client.min.js"></script>
<style>
:root{
--paper: #faf6ee;
--paper-high: #fffcf7;
--paper-mid: #f4efe2;
--paper-low: #ece5d2;
--ink: #383831;
--ink-soft: #5f5e5e;
--ink-dim: #8a887f;
--copper: #6d8c7c; /* copper green */
--ochre: #c29d59;
--terracotta: #b96b58;
--crimson: #8a3a2f; /* deep crimson */
--ease: cubic-bezier(0.2, 0, 0, 1);
--dur: 240ms;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%;
background: var(--paper);
color: var(--ink);
font-family: "Manrope", system-ui, sans-serif;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
body {
/* Subtle tonal shift across the entire surface — not a gradient on chrome,
just the paper catching light. */
background:
radial-gradient(1200px 800px at 18% 45%, #fffcf7 0%, var(--paper) 55%, #f4efe2 100%);
}
/* ───── Page scaffolding ───── */
.page {
position: fixed; inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 380ms var(--ease);
will-change: opacity;
}
.page.is-active {
opacity: 1;
pointer-events: auto;
}
/* Masthead and folio removed for a cleaner page — no corner chrome. */
/* Page overline title — large, sits lower on the front matter so it reads */
.page-title {
position: absolute;
left: 80px; top: 42vh;
font-family: "Newsreader", Georgia, serif;
font-weight: 400;
font-size: 54px;
letter-spacing: -0.022em;
color: var(--ink);
line-height: 1.08;
z-index: 15;
max-width: 820px;
text-wrap: pretty;
opacity: 1;
transition: opacity 520ms var(--ease), transform 520ms var(--ease);
}
.page-title em {
font-style: italic; font-weight: 700;
}
.page-title + .page-sub {
position: absolute;
left: 80px; top: calc(42vh + 220px);
max-width: 560px;
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 19px;
line-height: 1.5;
color: var(--ink-soft);
z-index: 15;
opacity: 1;
transition: opacity 520ms var(--ease), transform 520ms var(--ease);
}
/* Once the timeline has been advanced, the front matter steps aside */
.page-timeline.is-scrolled .page-title,
.page-timeline.is-scrolled .page-sub {
opacity: 0;
pointer-events: none;
transform: translateY(-12px);
}
/* ───────── Dot-nav ───────── */
.dot-nav-tray {
position: fixed;
left: 0; right: 0; bottom: 0;
height: 110px;
z-index: 35;
pointer-events: none;
background: linear-gradient(to bottom,
rgba(250,246,238,0) 0%,
rgba(250,246,238,0.88) 45%,
rgba(250,246,238,0.98) 100%);
}
.dot-nav {
position: fixed;
bottom: 36px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 44px;
z-index: 40;
}
.dot-btn {
all: unset;
display: flex; flex-direction: column; align-items: center;
gap: 10px;
cursor: pointer;
color: var(--ink-dim);
transition: color var(--dur) var(--ease);
}
.dot-btn:hover { color: var(--ink); }
.dot-btn .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--ink-dim);
transition: background var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.dot-btn.is-active { color: var(--ink); }
.dot-btn.is-active .dot {
background: var(--crimson);
transform: scale(1.15);
}
.dot-btn .label {
font-size: 10.5px;
letter-spacing: 0.24em;
text-transform: uppercase;
font-weight: 500;
}
/* ───────── Globe ghost ───────── */
.globe-wrap {
position: absolute;
/* 15% larger than the original 58vw ≈ 66.7vw.
Shifted ~20% of its width toward the page center: from left:-8%
to roughly left:+5%. */
left: 5%; top: 0;
width: 66.7vw;
height: 100%;
pointer-events: none;
z-index: 1;
opacity: 0.5;
transition: opacity 280ms var(--ease);
/* Mask top and fade the bottom third so the timeline rests on clean paper */
-webkit-mask-image: linear-gradient(to bottom,
transparent 0%, #000 22%, #000 58%, transparent 72%);
mask-image: linear-gradient(to bottom,
transparent 0%, #000 22%, #000 58%, transparent 72%);
}
.globe-wrap svg {
width: 100%; height: 100%;
display: block;
}
/* ───────── Timeline ───────── */
.timeline-viewport {
position: absolute; inset: 0;
z-index: 5;
}
.timeline-track {
position: absolute;
top: 0; left: 0; height: 100%;
will-change: transform;
transform: translate3d(0,0,0);
display: flex; align-items: center;
padding: 0 120px;
--spine-y: 64%;
}
.spine {
position: absolute;
top: var(--spine-y); left: 0;
height: 1px;
width: 100%;
background: linear-gradient(to right,
transparent 0,
rgba(56,56,49,0.22) 60px,
rgba(56,56,49,0.22) calc(100% - 60px),
transparent);
z-index: 2;
}
.year-tick {
position: absolute;
top: var(--spine-y);
transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center;
gap: 10px;
z-index: 3;
color: var(--ink-dim);
}
.year-tick::before {
content: "";
display: block;
width: 1px; height: 28px;
background: rgba(56,56,49,0.28);
}
.year-tick .y {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 22px;
color: var(--ink-soft);
letter-spacing: 0;
font-weight: 400;
}
/* Card */
.evt {
position: absolute;
width: 320px;
padding: 16px 20px 18px;
background: var(--paper-high);
/* Tonal surface shifts instead of 1px borders */
box-shadow:
0 0 0 0.5px rgba(56,56,49,0.05),
0 14px 28px -18px rgba(56,56,49,0.18),
0 2px 6px -3px rgba(56,56,49,0.08);
color: var(--ink);
opacity: 0;
/* Pop-in: small scale + downward lift for a more tactile entrance */
transform: translateY(28px) scale(0.96);
transform-origin: center top;
}
.evt.above {
transform: translateY(-28px) scale(0.96);
transform-origin: center bottom;
}
/* Only animate after first paint — prevents the initial card from
getting stuck at opacity 0 while the transition starts pre-layout. */
.evt.can-animate {
transition:
opacity 640ms var(--ease),
transform 640ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 320ms var(--ease);
}
.evt.is-near {
opacity: 1;
transform: translateY(0) scale(1);
}
.evt.above { bottom: calc(100% - var(--spine-y) + 48px); }
.evt.below { top: calc(var(--spine-y) + 48px); }
/* Connector from card to spine */
.evt::after {
content: "";
position: absolute;
left: 26px;
width: 1px;
background: rgba(56,56,49,0.28);
}
.evt.above::after { top: 100%; height: 40px; }
.evt.below::after { bottom: 100%; height: 40px; }
/* Node on the spine — tiny dot */
.evt .node {
position: absolute;
left: 20px;
width: 13px; height: 13px;
border-radius: 50%;
background: var(--paper-high);
z-index: 1;
}
.evt.above .node { top: calc(100% + 40px - 6px); }
.evt.below .node { bottom: calc(100% + 40px - 6px); }
.evt .node::after {
content: "";
position: absolute;
inset: 3.5px;
border-radius: 50%;
background: var(--ink-soft);
}
.evt[data-accent="copper"] .node::after { background: var(--copper); }
.evt[data-accent="ochre"] .node::after { background: var(--ochre); }
.evt[data-accent="terracotta"] .node::after { background: var(--terracotta); }
.evt[data-accent="crimson"] .node::after { background: var(--crimson); }
.evt .tag-row {
display: flex; gap: 12px; align-items: center;
margin-bottom: 8px;
}
.evt .date {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 13px;
color: var(--ink-soft);
letter-spacing: 0;
}
.evt .kind {
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-dim);
font-weight: 600;
}
.evt[data-accent="copper"] .kind { color: var(--copper); }
.evt[data-accent="ochre"] .kind { color: var(--ochre); }
.evt[data-accent="terracotta"] .kind { color: var(--terracotta); }
.evt[data-accent="crimson"] .kind { color: var(--crimson); }
.evt h3 {
font-family: "Newsreader", Georgia, serif;
font-weight: 400;
font-size: 17px;
line-height: 1.22;
letter-spacing: -0.01em;
color: var(--ink);
margin: 0 0 8px 0;
text-wrap: pretty;
}
.evt h3 em {
font-style: italic;
font-weight: 700;
}
.evt p {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--ink-soft);
text-wrap: pretty;
}
.evt .source {
margin-top: 10px;
font-size: 9.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-dim);
font-weight: 500;
}
/* ───────── Continue button ───────── */
.continue-btn {
all: unset;
position: absolute;
right: 72px;
bottom: 140px;
display: inline-flex;
align-items: baseline;
gap: 22px;
padding: 20px 28px;
background: var(--paper-high);
color: var(--ink);
cursor: pointer;
z-index: 30;
opacity: 0;
transform: translateX(36px);
pointer-events: none;
box-shadow:
0 0 0 0.5px rgba(56,56,49,0.06),
0 18px 32px -18px rgba(56,56,49,0.22),
0 2px 6px -3px rgba(56,56,49,0.08);
transition:
opacity 520ms var(--ease),
transform 520ms var(--ease),
box-shadow var(--dur) var(--ease),
background var(--dur) var(--ease);
}
.continue-btn.is-visible {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
animation: continue-breath 2800ms cubic-bezier(0.2, 0, 0, 1) infinite;
}
@keyframes continue-breath {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(6px); }
}
.continue-btn:hover {
background: #fffbf2;
box-shadow:
0 0 0 0.5px rgba(56,56,49,0.10),
0 24px 40px -20px rgba(56,56,49,0.28),
0 3px 8px -4px rgba(56,56,49,0.10);
}
.continue-btn .c-label {
font-family: "Newsreader", Georgia, serif;
font-size: 20px;
font-weight: 400;
letter-spacing: -0.01em;
color: var(--ink);
line-height: 1;
}
.continue-btn .c-label em {
font-style: italic; font-weight: 700;
}
.continue-btn .c-arrow {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 22px;
color: var(--crimson);
line-height: 1;
transition: transform var(--dur) var(--ease);
}
.continue-btn:hover .c-arrow {
transform: translateX(4px);
}
/* ───────── Overview page ───────── */
/* Globe background behind the overview — same SVG style as the timeline's,
but centered on Europe. It begins at the timeline's size/position so
that when the page is entered, the CSS transition zooms it into place. */
.overview-globe {
position: absolute; inset: 0;
pointer-events: none;
z-index: 1;
overflow: hidden;
/* Soft fade at top + bottom so the paper reads as the surface, not the sphere */
-webkit-mask-image: linear-gradient(to bottom,
transparent 0%, #000 10%, #000 82%, transparent 100%);
mask-image: linear-gradient(to bottom,
transparent 0%, #000 10%, #000 82%, transparent 100%);
}
.overview-globe svg {
position: absolute;
left: 65%; top: 55%;
/* Smaller than before — 92vmax is enough to show Europe at the framing we want,
and it paints fast enough not to block the page fade-in. */
width: 92vmax;
height: 92vmax;
max-width: none;
transform: translate(-50%, -50%) scale(0.78);
transform-origin: 50% 50%;
opacity: 0.22;
transition:
transform 1200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 900ms var(--ease);
}
/* When the overview page becomes active, zoom onto Europe */
.page-overview.is-active .overview-globe svg {
transform: translate(-50%, -50%) scale(1.35);
opacity: 0.42;
}
.overview {
position: absolute; inset: 0;
overflow: auto;
padding: 160px 80px 180px;
scrollbar-width: thin;
scrollbar-color: rgba(56,56,49,0.18) transparent;
z-index: 5;
}
.overview .col-wrap {
max-width: 1280px; margin: 0 auto;
/* Text on the left; globe occupies the right half of the spread. */
display: grid;
grid-template-columns: minmax(420px, 560px) 1fr;
column-gap: 80px;
row-gap: 24px;
align-items: start;
}
.overview h1 {
grid-column: 1;
font-family: "Newsreader", Georgia, serif;
font-weight: 400;
font-size: 56px;
line-height: 1.05;
letter-spacing: -0.025em;
margin: 0 0 18px 0;
text-wrap: balance;
color: var(--ink);
}
.overview h1 em {
font-style: italic; font-weight: 700;
}
.overview .lede {
grid-column: 1;
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 20px;
line-height: 1.5;
color: var(--ink-soft);
max-width: 780px;
margin-bottom: 28px;
}
.overview .rule {
grid-column: 1;
height: 1px;
background: rgba(56,56,49,0.18);
margin: 14px 0 8px 0;
}
.overview p {
grid-column: 1;
font-size: 15px;
line-height: 1.7;
color: var(--ink);
margin: 0 0 14px 0;
text-wrap: pretty;
}
.overview p.drop::first-letter {
font-family: "Newsreader", Georgia, serif;
font-weight: 700;
font-size: 58px;
line-height: 0.9;
float: left;
padding: 4px 10px 0 0;
color: var(--ink);
}
.overview .meta-strip {
grid-column: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 28px 40px;
margin-top: 32px;
padding-top: 24px;
border-top: 0;
background:
linear-gradient(to right, rgba(56,56,49,0.18), rgba(56,56,49,0.18)) top / 100% 1px no-repeat;
}
.overview .meta-strip .cell {
display: flex; flex-direction: column; gap: 8px;
}
.overview .meta-strip .k {
font-size: 10.5px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-dim);
font-weight: 600;
}
.overview .meta-strip .v {
font-family: "Newsreader", Georgia, serif;
font-size: 22px;
letter-spacing: -0.01em;
color: var(--ink);
}
.overview .meta-strip .v em { font-style: italic; font-weight: 700; }
/* ───────── Archive page ───────── */
.archive {
position: absolute; inset: 0;
overflow: auto;
padding: 120px 80px 180px;
}
.archive .inner {
max-width: 1180px; margin: 0 auto;
}
.archive .headline {
font-family: "Newsreader", Georgia, serif;
font-weight: 400;
font-size: 60px;
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 14px 0;
text-wrap: balance;
}
.archive .headline em { font-style: italic; font-weight: 700; }
.archive .sub {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 19px;
color: var(--ink-soft);
margin: 0 0 48px 0;
max-width: 700px;
}
.archive table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.archive thead th {
text-align: left;
padding: 10px 12px;
font-size: 10.5px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-dim);
font-weight: 600;
background:
linear-gradient(to right, rgba(56,56,49,0.28), rgba(56,56,49,0.28))
bottom / 100% 1px no-repeat;
}
.archive tbody tr {
transition: background var(--dur) var(--ease);
background:
linear-gradient(to right, rgba(56,56,49,0.10), rgba(56,56,49,0.10))
bottom / 100% 1px no-repeat;
}
.archive tbody tr:hover {
background:
var(--paper-high)
linear-gradient(to right, rgba(56,56,49,0.10), rgba(56,56,49,0.10))
bottom / 100% 1px no-repeat;
}
.archive tbody td {
padding: 18px 12px;
vertical-align: top;
color: var(--ink);
line-height: 1.45;
}
.archive td.num {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
color: var(--ink-dim);
font-size: 13px;
width: 48px;
}
.archive td.date {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
color: var(--ink-soft);
width: 130px;
white-space: nowrap;
}
.archive td.kind {
width: 150px;
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-weight: 600;
color: var(--ink-dim);
}
.archive tr[data-accent="copper"] td.kind { color: var(--copper); }
.archive tr[data-accent="ochre"] td.kind { color: var(--ochre); }
.archive tr[data-accent="terracotta"] td.kind { color: var(--terracotta); }
.archive tr[data-accent="crimson"] td.kind { color: var(--crimson); }
.archive td.hed {
font-family: "Newsreader", Georgia, serif;
font-size: 16px;
letter-spacing: -0.005em;
color: var(--ink);
max-width: 520px;
}
.archive td.hed em { font-style: italic; font-weight: 700; }
.archive td.src {
color: var(--ink-dim);
font-size: 12px;
font-family: "Newsreader", Georgia, serif;
font-style: italic;
width: 160px;
white-space: nowrap;
}
/* Mark on key rows — tonal, no border */
.archive tr[data-accent] td:first-child {
position: relative;
}
.archive tr[data-accent] td:first-child::before {
content: "";
position: absolute;
left: -10px; top: 50%;
transform: translateY(-50%);
width: 5px; height: 5px;
border-radius: 50%;
background: var(--ink-dim);
}
.archive tr[data-accent="copper"] td:first-child::before { background: var(--copper); }
.archive tr[data-accent="ochre"] td:first-child::before { background: var(--ochre); }
.archive tr[data-accent="terracotta"] td:first-child::before { background: var(--terracotta); }
.archive tr[data-accent="crimson"] td:first-child::before { background: var(--crimson); }
/* Archive footer */
.archive .footer {
margin-top: 72px;
padding-top: 28px;
background:
linear-gradient(to right, rgba(56,56,49,0.18), rgba(56,56,49,0.18))
top / 100% 1px no-repeat;
display: flex; justify-content: space-between;
color: var(--ink-dim);
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-weight: 500;
}
.archive .footer em {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 13px;
color: var(--ink-soft);
letter-spacing: 0;
text-transform: none;
font-weight: 400;
}
/* Short-viewport safety: collapse the page-title block so cards never collide */
@media (max-height: 620px) {
.page-title { font-size: 36px; max-width: 640px; top: 38vh; }
.page-title + .page-sub { font-size: 16px; top: calc(38vh + 160px); }
}
@media (max-height: 500px) {
.page-title { display: none; }
.page-sub { display: none; }
}
.overview::-webkit-scrollbar,
.archive::-webkit-scrollbar { width: 6px; }
.overview::-webkit-scrollbar-thumb,
.archive::-webkit-scrollbar-thumb {
background: rgba(56,56,49,0.18);
border-radius: 3px;
}
/* Folio marks removed for a cleaner page. */
</style>
</head>
<body data-screen-label="01 Timeline">
<!-- ───── Page 1 : TIMELINE ───── -->
<section class="page page-timeline is-active" id="page-timeline" data-screen-label="01 Timeline">
<div class="page-title">From the promise of AI to the loss of <em>sovereignty.</em></div>
<div class="page-sub">Twenty-three headlines, quietly laid across a tinted map. Scroll the wheel — the map turns with you.</div>
<!-- Globe ghost -->
<div class="globe-wrap" id="globe-wrap"></div>
<!-- Continue to the next page -->
<button class="continue-btn" id="continue-btn" type="button">
<span class="c-label">Read the editor&rsquo;s <em>note</em></span>
<span class="c-arrow" aria-hidden="true"></span>
</button>
<div class="timeline-viewport" id="tl-viewport">
<div class="timeline-track" id="tl-track">
<div class="spine" id="spine"></div>
<!-- Year ticks and events injected by JS -->
</div>
</div>
</section>
<!-- ───── Page 2 : OVERVIEW ───── -->
<section class="page page-overview" id="page-overview" data-screen-label="02 Overview">
<!-- Globe background, zoomed onto Europe — animated in when entering -->
<div class="overview-globe" id="overview-globe"></div>
<div class="overview">
<div class="col-wrap">
<h1>Notes on a quiet <em>inheritance.</em></h1>
<div class="lede">
The following pages gather twenty-three headlines published between 2022 and 2026.
Taken together, they describe a slow transfer — of infrastructure, of agency,
of the right to set one's own terms — from the public institutions of Europe
to the private servers of a handful of foreign firms.
</div>
<div class="rule"></div>
<p class="drop">
None of the events in this catalog are, on their own, remarkable. A procurement
decision here; a press release there; a minister quietly conceding, at a
conference in late autumn, that the ministry's new assistant is hosted in
Virginia. Read in sequence, they are something else — a pattern, patient and
unhurried, of institutions agreeing to hold their most sensitive work on
infrastructure they do not own.
</p>
<p>
The timeline opposite is arranged horizontally so the reader can move through
the years at the pace of a slow walk, rather than the pace of a feed. Each
card carries a date, a short headline set in the italic-bold of our
masthead, and a sentence of context. Four muted pigments mark the nature of
the entry: <em>copper green</em> for a step toward sovereignty, <em>ochre</em>
for caution, <em>terracotta</em> for friction, <em>crimson</em> for rupture.
</p>
<p>
The globe drifting behind the timeline is not a chart; it is a weather system.
As the reader scrolls east from North America to Europe, the map turns with
them — a small, analogue gesture, reminding the eye that these events
happened over oceans, on real ground, to real institutions whose names
appear in the right-hand archive.
</p>
<p>
Readers in a hurry may prefer the archive, which lists every entry in
tabular form with its source. Readers with time may prefer to scroll. Either
is a legitimate way to read the catalog; neither, the editors suspect, will
leave the reader unchanged.
</p>
<div class="meta-strip">
<div class="cell">
<div class="k">Entries</div>
<div class="v">Twenty-<em>three</em></div>
</div>
<div class="cell">
<div class="k">Period</div>
<div class="v">2022 <em>2026</em></div>
</div>
<div class="cell">
<div class="k">Compiled</div>
<div class="v">Copenhagen, <em>Spring '26</em></div>
</div>
<div class="cell">
<div class="k">Editor</div>
<div class="v">F. Jørgensen, <em>pro tem.</em></div>
</div>
</div>
</div>
</div>
</section>
<!-- ───── Page 3 : ARCHIVE ───── -->
<section class="page page-archive" id="page-archive" data-screen-label="03 Archive">
<div class="archive">
<div class="inner">
<h1 class="headline">All twenty-three entries, in order of <em>publication.</em></h1>
<p class="sub">
Dates, sources and plate numbers for every card in the catalog. Hover a row to
lift it from the paper.
</p>
<table id="archive-table">
<thead>
<tr>
<th></th>
<th>Date</th>
<th>Register</th>
<th>Headline</th>
<th>Source</th>
</tr>
</thead>
<tbody id="archive-body"><!-- filled by JS --></tbody>
</table>
<div class="footer">
<div>Fenja AI&nbsp;&middot;&nbsp;Field Notes, No.&nbsp;IV</div>
<em>Catalog closed 14 April 2026.</em>
<div>Page III of III</div>
</div>
</div>
</div>
</section>
<!-- Dot-nav tray + nav (shared) -->
<div class="dot-nav-tray"></div>
<nav class="dot-nav">
<button class="dot-btn is-active" data-target="page-timeline">
<span class="dot"></span>
<span class="label">Timeline</span>
</button>
<button class="dot-btn" data-target="page-overview">
<span class="dot"></span>
<span class="label">Overview</span>
</button>
<button class="dot-btn" data-target="page-archive">
<span class="dot"></span>
<span class="label">Archive</span>
</button>
</nav>
<script src="timeline.js" defer></script>
</body>
</html>

435
protected/timeline.js Normal file
View file

@ -0,0 +1,435 @@
/*
Data 23 events, quietly editorial
*/
const EVENTS = [
// 2022
{ date: '17 Mar 2022', kind: 'Infrastructure', accent: 'ochre',
hed: 'Europe begins buying a future in a language it does not <em>write.</em>',
body: 'Three national ministries sign framework agreements for cloud compute measured in dollars, datacenters located far from home.',
source: 'Le Monde, Archive' },
{ date: '02 Jun 2022', kind: 'Regulation', accent: 'copper',
hed: 'Brussels publishes the first draft of an act it calls quietly <em>historic.</em>',
body: 'Early text of what will become the AI Act is circulated to member states; no one yet uses the word sovereignty in public.',
source: 'EUR-Lex, COM(2022)206' },
{ date: '11 Oct 2022', kind: 'Field Note', accent: 'ochre',
hed: 'A Danish hospital lists twelve assistants, all hosted <em>elsewhere.</em>',
body: 'An internal audit at Rigshospitalet finds twelve pilot tools in use across radiology and oncology; none run on Danish soil.',
source: 'Ugeskrift for Læger' },
// 2023
{ date: '30 Jan 2023', kind: 'Product', accent: 'terracotta',
hed: 'A San Francisco chatbot quietly becomes the continent\u2019s unofficial <em>intern.</em>',
body: 'Ministries, law firms and universities report staff pasting confidential material into a free web tool. Legal teams issue circulars.',
source: 'Financial Times' },
{ date: '22 Apr 2023', kind: 'Rupture', accent: 'crimson',
hed: 'Italy switches off the assistant, then switches it back <em>on.</em>',
body: 'A four-week ban tests the limits of national action against a foreign-hosted model; a truce is brokered behind closed doors.',
source: 'Il Sole 24 Ore' },
{ date: '14 Jul 2023', kind: 'Regulation', accent: 'copper',
hed: 'The Act grows teeth, and so does the debate about who <em>sharpens them.</em>',
body: 'Trilogues yield real penalties for general-purpose models; lobbying intensifies around exemptions for &ldquo;foundational&rdquo; providers.',
source: 'Politico Europe' },
{ date: '06 Sep 2023', kind: 'Infrastructure', accent: 'ochre',
hed: 'A hyperscaler lays its fourth European campus, and calls it an act of <em>friendship.</em>',
body: 'Ground is broken in a small Irish town; the ribbon is cut by two prime ministers and, later, unofficially, the firm\u2019s lawyers.',
source: 'Reuters' },
{ date: '01 Dec 2023', kind: 'Field Note', accent: 'ochre',
hed: 'A European bank tallies its reliance and publishes only the <em>summary.</em>',
body: 'Internal mapping exercise counts 41 AI integrations, 38 of them contingent on a single non-EU cloud provider.',
source: 'Handelsblatt' },
// 2024
{ date: '19 Feb 2024', kind: 'Regulation', accent: 'copper',
hed: 'The Act is adopted, applauded, and then filed somewhere <em>safe.</em>',
body: 'Parliament passes the final text; implementation is deferred to 2026, giving incumbents eighteen months of grace.',
source: 'Official Journal' },
{ date: '08 Apr 2024', kind: 'Infrastructure', accent: 'ochre',
hed: 'A French lab pauses its rollout and asks, in writing, where its data <em>sleeps.</em>',
body: 'CNRS requests residency guarantees for a ministry-wide assistant; the provider replies with a thirty-page legal memorandum.',
source: 'CNRS Bulletin' },
{ date: '21 May 2024', kind: 'Rupture', accent: 'crimson',
hed: 'A foreign court compels disclosure, and a continent learns the meaning of <em>extraterritorial.</em>',
body: 'Under the CLOUD Act, records held on European servers are produced for a foreign proceeding; European data-protection authorities protest.',
source: 'New York Times' },
{ date: '02 Jul 2024', kind: 'Product', accent: 'terracotta',
hed: 'An open-source model is trained in Paris and called, hopefully, a <em>beginning.</em>',
body: 'A French laboratory releases a 22-billion-parameter model under a permissive license; adoption by ministries is briefly debated.',
source: 'Le Figaro' },
{ date: '15 Sep 2024', kind: 'Field Note', accent: 'ochre',
hed: 'A Nordic ministry asks for a sovereign option, and receives a <em>roadmap.</em>',
body: 'Danish procurement office publishes an RFI for on-premise AI platforms; fourteen vendors respond, four of them European.',
source: 'Digitaliseringsstyrelsen' },
{ date: '30 Nov 2024', kind: 'Rupture', accent: 'crimson',
hed: 'A new U.S. administration promises to review, renegotiate and in some cases <em>revoke.</em>',
body: 'Transition teams signal appetite to revisit the transatlantic data framework; European counsel spend December on contingency memos.',
source: 'Bloomberg' },
// 2025
{ date: '22 Jan 2025', kind: 'Field Note', accent: 'ochre',
hed: 'A quiet exodus begins, with no press release and no <em>timetable.</em>',
body: 'Several EU agencies move sensitive workloads off non-EU clouds and into operational hardware hosted by smaller, domestic providers.',
source: 'Politico Europe' },
{ date: '18 Mar 2025', kind: 'Infrastructure', accent: 'copper',
hed: 'Fenja AI is incorporated in Copenhagen, on a Monday, without <em>ceremony.</em>',
body: 'A small team begins work on an on-premise AI platform intended for critical sectors — health, finance, energy, public administration.',
source: 'Erhvervsstyrelsen' },
{ date: '04 May 2025', kind: 'Regulation', accent: 'copper',
hed: 'The Commission funds three &ldquo;foundation-stone&rdquo; initiatives, and Europe exhales <em>carefully.</em>',
body: 'Digital Europe program awards grants to sovereign-infrastructure consortia, including a Nordic group led out of Denmark.',
source: 'DG CNECT' },
{ date: '27 Jul 2025', kind: 'Product', accent: 'terracotta',
hed: 'A regulator issues a fine of nine figures, and a precedent of <em>ten.</em>',
body: 'The Irish Data Protection Commissioner fines a major U.S. provider over transfer mechanisms; the ruling is widely read as pivotal.',
source: 'DPC Ireland' },
{ date: '14 Sep 2025', kind: 'Rupture', accent: 'crimson',
hed: 'A multi-day outage reminds a continent that sovereignty is, among other things, <em>electrical.</em>',
body: 'A regional cloud incident takes portions of public services offline for thirty-one hours; emergency plans are reviewed everywhere.',
source: 'FAZ' },
{ date: '03 Nov 2025', kind: 'Field Note', accent: 'ochre',
hed: 'Procurement teams begin to ask a new question, and in <em>writing.</em>',
body: 'Tender documents across six member states add an explicit residency clause; three require open-source inference and local hosting.',
source: 'TED — Tenders Electronic Daily' },
// 2026
{ date: '12 Jan 2026', kind: 'Product', accent: 'copper',
hed: 'Fenja announces Project Bifrost, named after the bridge that crossed <em>everything.</em>',
body: 'A client-hosted, open-source AI platform designed for regulated European sectors; first deployments announced for Q2.',
source: 'Fenja AI — Field Notes III' },
{ date: '28 Feb 2026', kind: 'Field Note', accent: 'copper',
hed: 'The first hospital signs, and asks that the news be kept <em>small.</em>',
body: 'A regional Danish health authority adopts the platform for radiology triage; the announcement is a single sentence on page nine.',
source: 'Region Hovedstaden' },
{ date: '14 Apr 2026', kind: 'Editorial', accent: 'copper',
hed: 'A catalog is closed, and a second one is quietly <em>begun.</em>',
body: 'The editors mark this issue closed, pending the events of the coming year, which will arrive on their own schedule.',
source: 'Fenja AI — Field Notes IV' },
];
/*
Timeline layout
*/
const CARD_PITCH = 380; // horizontal spacing between card centers
const LEFT_PAD = 520; // extra room so first above-card clears the title/sub
const RIGHT_PAD = 280;
const track = document.getElementById('tl-track');
const viewport = document.getElementById('tl-viewport');
const spine = document.getElementById('spine');
function buildTimeline() {
// Year ticks
const years = [...new Set(EVENTS.map(e => e.date.slice(-4)))].sort();
// Build cards
EVENTS.forEach((e, i) => {
const x = LEFT_PAD + i * CARD_PITCH;
const above = i % 2 === 0;
const card = document.createElement('article');
card.className = 'evt ' + (above ? 'above' : 'below');
card.dataset.accent = e.accent;
card.style.left = (x - 160) + 'px';
card.innerHTML = `
<span class="node"></span>
<div class="tag-row">
<span class="date">${e.date}</span>
<span class="kind">${e.kind}</span>
</div>
<h3>${e.hed}</h3>
<p>${e.body}</p>
<div class="source">${e.source}</div>
`;
track.appendChild(card);
});
// Year ticks
years.forEach(y => {
// place tick at first event of that year
const firstIdx = EVENTS.findIndex(e => e.date.endsWith(y));
const x = LEFT_PAD + firstIdx * CARD_PITCH - CARD_PITCH * 0.42;
const tick = document.createElement('div');
tick.className = 'year-tick';
tick.style.left = x + 'px';
tick.innerHTML = `<span class="y">${y}</span>`;
track.appendChild(tick);
});
// Set track width
const totalW = LEFT_PAD + EVENTS.length * CARD_PITCH + RIGHT_PAD;
track.style.width = totalW + 'px';
}
buildTimeline();
/* Horizontal scroll + inertia — mousewheel-driven */
const state = {
target: 0,
current: 0,
max: 0,
rafPending: false,
};
function recalcMax() {
state.max = Math.max(0, track.offsetWidth - window.innerWidth);
}
recalcMax();
window.addEventListener('resize', recalcMax);
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
function onWheel(e) {
// Prefer vertical delta (mousewheel); allow horizontal from trackpads too.
const dy = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
state.target = clamp(state.target + dy * 1.1, 0, state.max);
e.preventDefault();
kick();
}
function kick() {
if (state.rafPending) return;
state.rafPending = true;
requestAnimationFrame(tick);
}
function tick() {
const diff = state.target - state.current;
state.current += diff * 0.12; // soft inertia
if (Math.abs(diff) < 0.3) {
state.current = state.target;
state.rafPending = false;
} else {
requestAnimationFrame(tick);
}
applyScroll();
}
function applyScroll() {
const vw = window.innerWidth;
// Timeline starts offscreen to the right. At scroll=0 we add one full
// viewport of extra offset so no cards are visible; the hero caption has
// the page to itself. As the reader scrolls, this intro offset decays to
// zero and then the track scrolls normally.
const introTravel = vw;
const introProg = Math.min(1, state.current / introTravel);
const extraOffset = introTravel * (1 - introProg);
track.style.transform = `translate3d(${-state.current + extraOffset}px,0,0)`;
// Front-matter fades once the user has begun scrolling
const pageEl = document.getElementById('page-timeline');
pageEl.classList.toggle('is-scrolled', state.current > 40);
// Progress across the entire catalog (0 → 1)
const p = state.max > 0 ? state.current / state.max : 0;
// Globe opacity — peaks around the 40% mark, where the globe is doing
// its most descriptive work (mid-rotation between the Atlantic and Europe).
// Baseline 0.50, peak 0.78 at p=0.40, then softens back to 0.52 by the end.
const wrapEl = document.getElementById('globe-wrap');
if (wrapEl) {
const peak = 1 - Math.min(1, Math.abs(p - 0.40) / 0.40);
const opacity = 0.50 + peak * 0.28;
wrapEl.style.opacity = opacity.toFixed(3);
}
// Reveal cards (fade + up, one-way once visible).
// NB: account for the intro offset so cards don't arm while offscreen.
document.querySelectorAll('.evt').forEach(el => {
const left = parseFloat(el.style.left);
const onscreen = left - state.current + extraOffset;
if (onscreen < vw + 200 && onscreen > -600) {
el.classList.add('is-near');
}
});
// Continue button — visible once we're near the end and past the front matter
const nearEnd = state.max > 0 && (state.max - state.current) < (vw * 1.1);
const btn = document.getElementById('continue-btn');
if (btn) btn.classList.toggle('is-visible', nearEnd && state.current > 40);
// Globe rotation — from North America toward Europe as we advance.
const rotDeg = 95 + (-10 - 95) * p;
if (globe.setRotation) globe.setRotation(rotDeg);
// Screen label for commenting context
const idx = Math.min(EVENTS.length - 1,
Math.round(state.current / CARD_PITCH));
const slug = '01 Timeline · ' + String(idx + 1).padStart(2, '0') + '/' + EVENTS.length;
pageEl.setAttribute('data-screen-label', slug);
}
/* Keyboard navigation for accessibility */
window.addEventListener('keydown', (e) => {
if (document.getElementById('page-timeline').classList.contains('is-active')) {
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
state.target = clamp(state.target + CARD_PITCH, 0, state.max);
kick();
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
state.target = clamp(state.target - CARD_PITCH, 0, state.max);
kick();
} else if (e.key === 'Home') {
state.target = 0; kick();
} else if (e.key === 'End') {
state.target = state.max; kick();
}
}
});
viewport.addEventListener('wheel', onWheel, { passive: false });
/* Continue button — click routes to the Overview page (same dot-nav path) */
document.getElementById('continue-btn').addEventListener('click', () => {
const overBtn = document.querySelector('.dot-btn[data-target="page-overview"]');
if (overBtn) overBtn.click();
});
/*
Globe orthographic, paper-toned, top/bottom masked by CSS
*/
const WORLD_URL = 'vendor/countries-110m.json';
const globe = { setRotation: null };
/* Kick once so initial cards reveal \u2014 deferred to next frame so layout settles
before transitions can arm. Then enable transitions on all cards. */
requestAnimationFrame(() => {
applyScroll();
requestAnimationFrame(() => {
document.querySelectorAll('.evt').forEach(el => el.classList.add('can-animate'));
});
});
(function initGlobe() {
const wrap = document.getElementById('globe-wrap');
buildGlobe(wrap, { rotateLambda: 95, rotatePhi: -10, setRotationTarget: globe });
// Second globe — sits behind the Overview page, centered on Europe.
// λ = -10° (central longitude over western Europe / Iberia)
// φ = -50° (in d3 this puts 50°N latitude at the SVG center; Germany / N. France)
const overviewWrap = document.getElementById('overview-globe');
if (overviewWrap) {
buildGlobe(overviewWrap, { rotateLambda: -10, rotatePhi: -50 });
}
})();
/* Build a single orthographic globe SVG inside `wrap`.
Options:
rotateLambda : initial longitude center (d3 convention: negate east)
rotatePhi : latitude tilt (d3: negate north latitude)
setRotationTarget : if supplied, exposes a setRotation(deg) on the object
*/
function buildGlobe(wrap, opts) {
const W = 1400, H = 1400;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const INK = '#383831';
const PAPER_LOW = '#ece5d2';
// Ocean
const ocean = document.createElementNS(svg.namespaceURI, 'circle');
ocean.setAttribute('cx', W/2); ocean.setAttribute('cy', H/2);
ocean.setAttribute('r', 560);
ocean.setAttribute('fill', PAPER_LOW);
ocean.setAttribute('opacity', '0.55');
svg.appendChild(ocean);
// defs clip
const defs = document.createElementNS(svg.namespaceURI, 'defs');
const clip = document.createElementNS(svg.namespaceURI, 'clipPath');
const clipId = 'globeClip-' + Math.random().toString(36).slice(2, 8);
clip.setAttribute('id', clipId);
const cc = document.createElementNS(svg.namespaceURI, 'circle');
cc.setAttribute('cx', W/2); cc.setAttribute('cy', H/2); cc.setAttribute('r', 560);
clip.appendChild(cc); defs.appendChild(clip); svg.appendChild(defs);
const g = document.createElementNS(svg.namespaceURI, 'g');
g.setAttribute('clip-path', `url(#${clipId})`);
svg.appendChild(g);
const gratPath = document.createElementNS(svg.namespaceURI, 'path');
gratPath.setAttribute('fill', 'none');
gratPath.setAttribute('stroke', INK);
gratPath.setAttribute('stroke-width', '0.8');
gratPath.setAttribute('opacity', '0.18');
g.appendChild(gratPath);
const countriesPath = document.createElementNS(svg.namespaceURI, 'path');
countriesPath.setAttribute('fill', '#faf6ee');
countriesPath.setAttribute('fill-opacity', '0.85');
countriesPath.setAttribute('stroke', 'none');
g.appendChild(countriesPath);
const bordersPath = document.createElementNS(svg.namespaceURI, 'path');
bordersPath.setAttribute('fill', 'none');
bordersPath.setAttribute('stroke', INK);
bordersPath.setAttribute('stroke-width', '0.8');
bordersPath.setAttribute('stroke-linejoin', 'round');
bordersPath.setAttribute('opacity', '0.55');
g.appendChild(bordersPath);
// Faint outer rim — tonal, not hard
const rim = document.createElementNS(svg.namespaceURI, 'circle');
rim.setAttribute('cx', W/2); rim.setAttribute('cy', H/2); rim.setAttribute('r', 560);
rim.setAttribute('fill', 'none');
rim.setAttribute('stroke', INK);
rim.setAttribute('stroke-width', '1');
rim.setAttribute('opacity', '0.22');
svg.appendChild(rim);
wrap.appendChild(svg);
fetch(WORLD_URL).then(r => r.json()).then(topo => {
const countries = window.topojson.feature(topo, topo.objects.countries);
const borders = window.topojson.mesh(topo, topo.objects.countries, (a,b) => a !== b);
const graticule = window.d3.geoGraticule().step([20, 20]);
const proj = window.d3.geoOrthographic()
.scale(560).translate([W/2, H/2])
.rotate([opts.rotateLambda, opts.rotatePhi ?? -10, 0]).clipAngle(90);
const pathFn = window.d3.geoPath(proj);
function render() {
gratPath.setAttribute('d', pathFn(graticule()) || '');
countriesPath.setAttribute('d', pathFn(countries) || '');
bordersPath.setAttribute('d', pathFn(borders) || '');
}
render();
if (opts.setRotationTarget) {
const phi = opts.rotatePhi ?? -10;
opts.setRotationTarget.setRotation = function(deg) {
proj.rotate([deg, phi, 0]);
render();
};
}
}).catch(err => {
console.warn('globe topology failed to load', err);
});
}
/*
Archive table
*/
(function buildArchive() {
const tbody = document.getElementById('archive-body');
EVENTS.forEach((e, i) => {
const tr = document.createElement('tr');
tr.dataset.accent = e.accent;
tr.innerHTML = `
<td class="num">${String(i + 1).padStart(2, '0')}</td>
<td class="date">${e.date}</td>
<td class="kind">${e.kind}</td>
<td class="hed">${e.hed}</td>
<td class="src">${e.source}</td>
`;
tbody.appendChild(tr);
});
})();
/*
Dot-nav
*/
document.querySelectorAll('.dot-btn').forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.dataset.target;
document.querySelectorAll('.page').forEach(p => {
p.classList.toggle('is-active', p.id === target);
});
document.querySelectorAll('.dot-btn').forEach(b => {
b.classList.toggle('is-active', b === btn);
});
});
});

1
protected/vendor/countries-110m.json vendored Normal file

File diff suppressed because one or more lines are too long

2
protected/vendor/d3-array.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
protected/vendor/d3-geo.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

246
public/entrance.html Normal file
View file

@ -0,0 +1,246 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Fenja AI</title>
<style>
:root {
--paper: #faf6ee;
--paper-sink: #e7e1d0;
--paper-sink-deep: #ddd6c3;
--ink: #383831;
--ink-soft: #5f5e5e;
--ink-dim: #8a887f;
--walnut: #785f53;
--walnut-dim: #6b5348;
--crimson: #8a3a2f;
--ease: cubic-bezier(0.2, 0, 0, 1);
--dur: 240ms;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
min-height: 100%;
background: var(--paper);
color: var(--ink);
font-family: "Manrope", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
background: radial-gradient(1100px 760px at 22% 42%, #fffcf7 0%, var(--paper) 58%, #f2ecdd 100%);
display: flex;
align-items: center;
}
/* ───── Topographic currents ───── */
.currents {
position: fixed;
top: -22vh; right: -18vw;
width: 90vw; height: 130vh;
max-width: 1600px;
pointer-events: none;
z-index: 1;
-webkit-mask-image: radial-gradient(60% 60% at 50% 50%, #000 40%, transparent 82%);
mask-image: radial-gradient(60% 60% at 50% 50%, #000 40%, transparent 82%);
opacity: 0;
transition: opacity 900ms var(--ease);
}
.currents.is-ready { opacity: 1; }
.currents svg { width: 100%; height: 100%; display: block; }
.entrance {
position: relative;
z-index: 10;
width: 100%;
padding: 0 112px;
}
.entrance-inner {
max-width: 560px;
}
/* ───── Steps ───── */
.step { display: none; }
.step.is-active {
display: block;
animation: enter 640ms var(--ease) forwards;
}
@keyframes enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ───── Tagline ───── */
.tagline {
font-family: "Newsreader", Georgia, "Times New Roman", serif;
font-style: italic;
font-weight: 400;
font-size: 34px;
line-height: 1.28;
letter-spacing: -0.012em;
color: var(--ink);
margin: 0 0 40px 0;
text-wrap: pretty;
}
/* ───── Field ───── */
.field { display: block; width: 100%; }
.field-input {
display: block; width: 100%;
height: 56px; padding: 0 18px;
background: var(--paper-sink);
color: var(--ink);
font-family: "Manrope", system-ui, sans-serif;
font-size: 17px; font-weight: 400;
border: 0;
border-radius: 12px 12px 0 0;
outline: 0;
box-shadow: inset 0 -1px 0 0 rgba(56, 56, 49, 0.25);
transition: box-shadow var(--dur) var(--ease), background var(--dur) var(--ease);
}
.field-input::placeholder {
color: var(--ink-dim);
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 17px;
}
.field-input:focus {
background: #eae3d0;
box-shadow: inset 0 -2px 0 0 var(--walnut);
}
.field-input.is-error {
box-shadow: inset 0 -2px 0 0 var(--crimson);
}
.field-input:disabled { opacity: 0.55; cursor: default; }
/* ───── Six-digit code cells ───── */
.code-row {
display: flex; gap: 10px;
margin-bottom: 8px;
}
.code-cell {
width: 54px; height: 64px;
padding: 0; text-align: center;
background: var(--paper-sink);
color: var(--ink);
font-family: "Newsreader", Georgia, serif;
font-weight: 400; font-size: 28px;
border: 0; outline: 0;
border-radius: 10px 10px 0 0;
box-shadow: inset 0 -1px 0 0 rgba(56, 56, 49, 0.25);
transition: box-shadow var(--dur) var(--ease), background var(--dur) var(--ease);
}
.code-cell:focus {
background: #eae3d0;
box-shadow: inset 0 -2px 0 0 var(--walnut);
}
.code-cell.is-filled {
box-shadow: inset 0 -1px 0 0 rgba(56, 56, 49, 0.4);
}
.code-cell.is-error {
box-shadow: inset 0 -2px 0 0 var(--crimson);
}
/* ───── Post-submit acknowledgement ───── */
.ack {
margin-top: 16px;
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 15px;
color: var(--ink-soft);
min-height: 22px;
opacity: 0;
transform: translateY(-4px);
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.ack.is-visible { opacity: 1; transform: translateY(0); }
.ack.is-error { color: var(--crimson); }
/* ───── Quiet secondary action ───── */
.quiet {
margin-top: 28px;
background: transparent; border: 0; padding: 0;
cursor: pointer;
color: var(--ink-soft);
font-family: "Manrope", system-ui, sans-serif;
font-size: 14px;
letter-spacing: 0;
display: inline-flex; align-items: baseline; gap: 8px;
transition: color var(--dur) var(--ease);
}
.quiet:hover { color: var(--ink); }
.quiet .q-arrow {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
color: var(--walnut);
}
@media (max-width: 720px) {
.entrance { padding: 0 28px; }
.tagline { font-size: 26px; margin-bottom: 32px; }
.code-cell { width: 42px; height: 54px; font-size: 22px; }
.code-row { gap: 7px; }
.currents { opacity: 0.5; }
}
</style>
</head>
<body>
<div class="currents" id="currents" aria-hidden="true"></div>
<main class="entrance">
<div class="entrance-inner">
<!-- STEP 1 — EMAIL -->
<section class="step is-active" id="step-email">
<p class="tagline">
Thank you for your commitment and willingness to contribute.
</p>
<form class="field" id="email-form" novalidate>
<input
type="email"
class="field-input"
id="email-input"
name="email"
autocomplete="email"
spellcheck="false"
placeholder="your email"
aria-label="Email address"
required
/>
<div class="ack" id="email-ack" aria-live="polite"></div>
</form>
</section>
<!-- STEP 2 — CODE -->
<section class="step" id="step-code">
<p class="tagline">
A six-digit code is on its way. Enter it below.
</p>
<form class="field" id="code-form" novalidate>
<div class="code-row" id="code-row">
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" aria-label="Digit 1" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 2" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 3" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 4" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 5" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 6" />
</div>
<div class="ack" id="code-ack" aria-live="polite"></div>
<button type="button" class="quiet" id="use-different">
<span>Use a different email</span>
<span class="q-arrow" aria-hidden="true">&rarr;</span>
</button>
</form>
</section>
</div>
</main>
<script src="/entrance.js" defer></script>
</body>
</html>

201
public/entrance.js Normal file
View file

@ -0,0 +1,201 @@
// ─────────────────────────────────────────────────────────────
// public/entrance.js — client-side behaviour for the entrance page.
// Loaded via <script src="/entrance.js" defer></script> so CSP
// can stay at `script-src 'self'` — no inline scripts.
// ─────────────────────────────────────────────────────────────
/* ───── Topographic currents ───── */
(function drawCurrents() {
const wrap = document.getElementById('currents');
if (!wrap) return;
const W = 1400, H = 1400, cx = W * 0.55, cy = H * 0.45;
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const RINGS = 26, BASE_R = 90, STEP = 32, AMP = 26;
for (let i = 0; i < RINGS; i++) {
const r = BASE_R + i * STEP, segs = 260;
const p1 = (i * 0.6) % (Math.PI * 2);
const p2 = (i * 1.3 + 1.2) % (Math.PI * 2);
const a1 = AMP * (0.9 + (i % 5) * 0.08);
const a2 = AMP * 0.35;
let d = '';
for (let s = 0; s <= segs; s++) {
const t = (s / segs) * Math.PI * 2;
const rr = r + a1 * Math.sin(t * 3 + p1 + i * 0.15)
+ a2 * Math.sin(t * 5 + p2 + i * 0.22)
+ AMP * 0.18 * Math.sin(t * 7 + i);
const x = cx + Math.cos(t) * rr;
const y = cy + Math.sin(t) * rr * 0.92;
d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1);
}
d += ' Z';
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', '#383831');
path.setAttribute('stroke-width', '1');
path.setAttribute('stroke-linejoin', 'round');
path.setAttribute('opacity', (i % 3 === 0 ? 0.095 : 0.055).toString());
svg.appendChild(path);
}
wrap.appendChild(svg);
requestAnimationFrame(() => setTimeout(() => wrap.classList.add('is-ready'), 120));
})();
/* ───── Step transitions ───── */
const steps = {
email: document.getElementById('step-email'),
code: document.getElementById('step-code'),
};
function showStep(name) {
Object.entries(steps).forEach(([k, el]) => {
el.classList.toggle('is-active', k === name);
});
}
/* ───── Step 1: email ───── */
const emailForm = document.getElementById('email-form');
const emailInput = document.getElementById('email-input');
const emailAck = document.getElementById('email-ack');
let rememberedEmail = '';
function setAck(el, text, isError) {
el.textContent = text;
el.classList.toggle('is-error', !!isError);
el.classList.toggle('is-visible', !!text);
}
emailInput.addEventListener('input', () => {
emailInput.classList.remove('is-error');
setAck(emailAck, '', false);
});
emailForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = emailInput.value.trim();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
emailInput.classList.add('is-error');
setAck(emailAck, 'Please enter a valid email address.', true);
return;
}
emailInput.disabled = true;
setAck(emailAck, 'Sending\u2026', false);
try {
const res = await fetch('/auth/request-code', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (res.status === 429) {
emailInput.disabled = false;
setAck(emailAck, 'Too many attempts. Try again in a little while.', true);
return;
}
if (!res.ok) {
emailInput.disabled = false;
setAck(emailAck, 'Something went wrong. Try again.', true);
return;
}
} catch (err) {
emailInput.disabled = false;
setAck(emailAck, 'Could not reach the archive. Retry?', true);
return;
}
rememberedEmail = email;
setAck(emailAck, '', false);
showStep('code');
setTimeout(() => codeCells[0].focus(), 300);
});
/* ───── Step 2: code ───── */
const codeCells = Array.from(document.querySelectorAll('.code-cell'));
const codeForm = document.getElementById('code-form');
const codeAck = document.getElementById('code-ack');
function codeValue() { return codeCells.map(c => c.value).join(''); }
codeCells.forEach((cell, i) => {
cell.addEventListener('input', () => {
cell.value = cell.value.replace(/\D/g, '').slice(-1);
cell.classList.toggle('is-filled', !!cell.value);
cell.classList.remove('is-error');
setAck(codeAck, '', false);
if (cell.value && i < codeCells.length - 1) codeCells[i + 1].focus();
if (codeValue().length === 6) submitCode();
});
cell.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !cell.value && i > 0) {
codeCells[i - 1].focus();
codeCells[i - 1].value = '';
codeCells[i - 1].classList.remove('is-filled');
e.preventDefault();
} else if (e.key === 'ArrowLeft' && i > 0) { codeCells[i - 1].focus(); e.preventDefault(); }
else if (e.key === 'ArrowRight' && i < codeCells.length - 1) { codeCells[i + 1].focus(); e.preventDefault(); }
});
cell.addEventListener('paste', (e) => {
const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
if (!text) return;
e.preventDefault();
for (let j = 0; j < codeCells.length; j++) {
codeCells[j].value = text[j] || '';
codeCells[j].classList.toggle('is-filled', !!codeCells[j].value);
}
(codeCells[Math.min(text.length, codeCells.length - 1)] || codeCells[0]).focus();
if (codeValue().length === 6) submitCode();
});
});
codeForm.addEventListener('submit', (e) => { e.preventDefault(); submitCode(); });
let submitting = false;
async function submitCode() {
if (submitting) return;
if (codeValue().length !== 6) return;
submitting = true;
codeCells.forEach(c => c.disabled = true);
setAck(codeAck, 'Reading\u2026', false);
try {
const res = await fetch('/auth/verify-code', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: rememberedEmail, code: codeValue() }),
});
if (res.ok) {
setAck(codeAck, 'Filed. Opening your archive\u2026', false);
setTimeout(() => { window.location.href = '/'; }, 500);
return;
}
codeCells.forEach(c => { c.classList.add('is-error'); c.disabled = false; });
if (res.status === 429) {
setAck(codeAck, 'Too many attempts. Request a new code.', true);
} else {
setAck(codeAck, 'That code doesn\u2019t match. Try again.', true);
}
submitting = false;
} catch (err) {
codeCells.forEach(c => c.disabled = false);
setAck(codeAck, 'Could not reach the archive. Retry?', true);
submitting = false;
}
}
document.getElementById('use-different').addEventListener('click', () => {
codeCells.forEach(c => { c.value = ''; c.classList.remove('is-filled', 'is-error'); c.disabled = false; });
setAck(codeAck, '', false);
submitting = false;
emailInput.disabled = false;
emailInput.value = rememberedEmail;
showStep('email');
setTimeout(() => emailInput.focus(), 300);
});

151
server.js Normal file
View file

@ -0,0 +1,151 @@
// ─────────────────────────────────────────────────────────────
// server.js — entry point.
//
// Binds to 127.0.0.1 only. Nginx reverse-proxies to this port.
// Never expose Node directly to the public internet.
// ─────────────────────────────────────────────────────────────
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import authRouter from './src/auth.js';
import { requireAuth } from './src/middleware.js';
import { currentSession } from './src/sessions.js';
import { initMail } from './src/mail.js';
import './src/db.js'; // side-effect import: opens DB + runs schema
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
// ─── Sanity checks at boot ───────────────────────────────────
if (!process.env.CODE_PEPPER || process.env.CODE_PEPPER.length < 32) {
console.error('FATAL: CODE_PEPPER is missing or too short. Generate with:\n openssl rand -hex 32');
process.exit(1);
}
// ─── Trust Nginx as the single upstream proxy ────────────────
// This makes req.ip reflect X-Forwarded-For (the real client IP)
// instead of 127.0.0.1. Only "loopback" is trusted, so spoofed
// XFF headers from the public internet are ignored.
app.set('trust proxy', 'loopback');
// ─── Body parsers ────────────────────────────────────────────
app.use(express.json({ limit: '4kb' }));
app.use(cookieParser());
// ─── Security headers on every response ──────────────────────
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
// Tight CSP — only our own origin, and inline <style> blocks are allowed
// because the design system ships styles inline inside the artifacts.
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
});
next();
});
// ─── Tiny request log ────────────────────────────────────────
app.use((req, res, next) => {
const started = Date.now();
res.on('finish', () => {
const ms = Date.now() - started;
const line = `${new Date().toISOString()} ${req.ip} ${req.method} ${req.path}${res.statusCode} (${ms}ms)`;
console.log(line);
});
next();
});
// ─── Auth endpoints (public) ─────────────────────────────────
app.use('/auth', authRouter);
// ─── Root dispatch ───────────────────────────────────────────
// GET / → timeline (if authed) | entrance (otherwise)
// GET /entrance → always the entrance (useful for "log in as someone else")
// Other paths fall through to the static handlers below.
app.get('/', (req, res, next) => {
if (currentSession(req)) {
// Authed: serve the timeline directly from /protected/index.html
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
}
// Not authed: serve the entrance
return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
});
// ─── Public static assets (entrance.js, etc.) ────────────────
// Fallthrough so Express can still try the routes below if nothing matches.
app.use(
express.static(path.join(__dirname, 'public'), {
index: false, // don't auto-serve entrance.html at /
extensions: ['html'],
fallthrough: true,
maxAge: 0,
})
);
// ─── GATED — everything in protected/ needs a session cookie ─
// Handles the authed home page assets:
// /timeline.js, /vendor/*, /fenja/colors_and_type.css, /archive.html, etc.
app.use(
requireAuth,
express.static(path.join(__dirname, 'protected'), {
index: false, // we dispatch / ourselves above
extensions: ['html'],
maxAge: 0,
})
);
// ─── 404 fallback ────────────────────────────────────────────
app.use((req, res) => {
if (req.accepts('html')) {
return res.status(404).send('<h1>404 — not found</h1>');
}
res.status(404).end();
});
// ─── Error handler ───────────────────────────────────────────
app.use((err, req, res, _next) => {
console.error('[error]', err);
res.status(500).json({ error: 'internal' });
});
// ─── Start ───────────────────────────────────────────────────
const port = Number(process.env.PORT || 3000);
const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`;
(async () => {
try {
await initMail();
console.log('[mail] SMTP relay reachable');
} catch (err) {
console.error('[mail] SMTP verify failed:', err?.message || err);
console.error(' Fix .env and restart. The app will still boot so you can debug, but /auth/request-code will silently drop mail.');
}
app.listen(port, '127.0.0.1', () => {
console.log(`[bifrost] listening on 127.0.0.1:${port}`);
console.log(`[bifrost] public origin: ${origin}`);
});
})();
// ─── Graceful shutdown ───────────────────────────────────────
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
console.log(`[bifrost] ${sig} received, exiting.`);
process.exit(0);
});
}

114
src/auth.js Normal file
View file

@ -0,0 +1,114 @@
// ─────────────────────────────────────────────────────────────
// src/auth.js — /auth/* endpoints.
//
// POST /auth/request-code { email } → 200 always (no enum leak)
// POST /auth/verify-code { email, code } → 200 on success + Set-Cookie
// POST /auth/logout → 200
// ─────────────────────────────────────────────────────────────
import { Router } from 'express';
import { q } from './db.js';
import { sendCode } from './mail.js';
import {
randomCode,
hashCode,
constantTimeEqual,
issueSession,
clearSession,
CODE_TTL_MS,
} from './sessions.js';
import { rateLimit } from './middleware.js';
const router = Router();
const MAX_VERIFY_ATTEMPTS = 5;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// ─── POST /auth/request-code ─────────────────────────────────
// ALWAYS returns 200 (once past the format check) — we must not
// reveal whether an address is on the invite list.
router.post(
'/request-code',
rateLimit({
key: (req) => `req:${req.ip}`,
max: 5,
windowMs: 60 * 60 * 1000, // 5 requests per IP per hour
}),
async (req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase();
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'invalid_email' });
}
const invited = q.getInvite.get(email);
if (invited) {
const code = randomCode();
q.upsertCode.run(email, hashCode(code), Date.now() + CODE_TTL_MS);
try {
await sendCode(email, code);
} catch (err) {
// Log, but still return 200 to the client. If SMTP is misconfigured
// we want to know *immediately* in the logs, not via users.
console.error('[auth] SMTP send failed for', email, err?.message || err);
}
}
// Uniform response regardless of invite status
return res.status(200).json({ ok: true });
}
);
// ─── POST /auth/verify-code ──────────────────────────────────
router.post(
'/verify-code',
rateLimit({
key: (req) => `verify:${req.ip}`,
max: 20,
windowMs: 60 * 60 * 1000, // 20 verify attempts per IP per hour
}),
(req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase();
const code = String(req.body?.code || '').trim();
if (!EMAIL_RE.test(email) || !/^\d{6}$/.test(code)) {
return res.status(401).json({ error: 'invalid' });
}
const row = q.getCode.get(email, Date.now());
if (!row) {
return res.status(401).json({ error: 'invalid_or_expired' });
}
if (row.attempts >= MAX_VERIFY_ATTEMPTS) {
q.deleteCode.run(email); // force user to request a new code
return res.status(429).json({ error: 'too_many_attempts' });
}
const submitted = hashCode(code);
if (!constantTimeEqual(submitted, row.code_hash)) {
q.incAttempts.run(email);
return res.status(401).json({ error: 'wrong_code' });
}
// Success: single-use — delete the code, issue a session
q.deleteCode.run(email);
issueSession(req, res, email);
return res.status(200).json({ ok: true });
}
);
// ─── POST /auth/logout ───────────────────────────────────────
router.post('/logout', (req, res) => {
clearSession(req, res);
return res.status(200).json({ ok: true });
});
// ─── GET /auth/me ─ convenience for debugging, returns current email or 401
router.get('/me', (req, res) => {
const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null;
if (!s) return res.status(401).end();
return res.json({ email: s.email });
});
export default router;

119
src/db.js Normal file
View file

@ -0,0 +1,119 @@
// ─────────────────────────────────────────────────────────────
// src/db.js — SQLite initialization, schema, prepared queries.
//
// Uses better-sqlite3 (synchronous, in-process). The DB file lives
// at ../data/fenja.sqlite and is created automatically on first run.
// WAL mode is enabled so concurrent readers don't block the writer.
// ─────────────────────────────────────────────────────────────
import Database from 'better-sqlite3';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'fenja.sqlite'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('synchronous = NORMAL'); // safe with WAL, faster
// ─── Schema ──────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS invites (
email TEXT PRIMARY KEY,
invited_at INTEGER NOT NULL,
invited_by TEXT
);
CREATE TABLE IF NOT EXISTS codes (
email TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
issued_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(email);
CREATE INDEX IF NOT EXISTS idx_codes_expires ON codes(expires_at);
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT PRIMARY KEY,
count INTEGER NOT NULL,
window_end INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_limits(window_end);
`);
// ─── Prepared statements ─────────────────────────────────────
export const q = {
// invites
getInvite: db.prepare('SELECT email FROM invites WHERE email = ?'),
upsertInvite: db.prepare(
`INSERT INTO invites (email, invited_at, invited_by) VALUES (?, ?, ?)
ON CONFLICT(email) DO NOTHING`
),
deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'),
listInvites: db.prepare('SELECT email, invited_at, invited_by FROM invites ORDER BY invited_at DESC'),
// codes
upsertCode: db.prepare(
`INSERT INTO codes (email, code_hash, attempts, expires_at) VALUES (?, ?, 0, ?)
ON CONFLICT(email) DO UPDATE SET
code_hash = excluded.code_hash,
attempts = 0,
expires_at = excluded.expires_at`
),
getCode: db.prepare('SELECT code_hash, attempts, expires_at FROM codes WHERE email = ? AND expires_at > ?'),
incAttempts: db.prepare('UPDATE codes SET attempts = attempts + 1 WHERE email = ?'),
deleteCode: db.prepare('DELETE FROM codes WHERE email = ?'),
// sessions
createSession: db.prepare(
`INSERT INTO sessions (id, email, issued_at, expires_at, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?)`
),
getSession: db.prepare('SELECT id, email, issued_at, expires_at FROM sessions WHERE id = ? AND expires_at > ?'),
deleteSession: db.prepare('DELETE FROM sessions WHERE id = ?'),
deleteSessionsForEmail: db.prepare('DELETE FROM sessions WHERE email = ?'),
// rate limits
getRate: db.prepare('SELECT count, window_end FROM rate_limits WHERE key = ?'),
setRate: db.prepare(
`INSERT INTO rate_limits (key, count, window_end) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
count = excluded.count,
window_end = excluded.window_end`
),
// cleanup
cleanup: {
codes: db.prepare('DELETE FROM codes WHERE expires_at < ?'),
sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'),
rates: db.prepare('DELETE FROM rate_limits WHERE window_end < ?'),
},
};
// ─── Periodic cleanup ────────────────────────────────────────
// Every five minutes, prune expired rows. unref() so this timer
// doesn't keep the process alive on shutdown.
const CLEANUP_EVERY_MS = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
q.cleanup.codes.run(now);
q.cleanup.sessions.run(now);
q.cleanup.rates.run(now);
}, CLEANUP_EVERY_MS).unref();
export default db;

53
src/mail.js Normal file
View file

@ -0,0 +1,53 @@
// ─────────────────────────────────────────────────────────────
// src/mail.js — SMTP transport + code email.
//
// STARTTLS on 587, per the chosen relay setup.
// transport.verify() is called once at startup from server.js so
// config errors fail loudly at boot, not on the first user's login.
// ─────────────────────────────────────────────────────────────
import nodemailer from 'nodemailer';
let transport;
export function initMail() {
if (!process.env.SMTP_HOST) {
throw new Error('SMTP_HOST is not set. Check your .env file.');
}
transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: false, // STARTTLS on 587
auth:
process.env.SMTP_USER && process.env.SMTP_PASS
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
// Sensible timeouts so a bad relay doesn't hang the login flow
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 15_000,
});
return transport.verify();
}
export async function sendCode(email, code) {
if (!transport) throw new Error('Mail transport not initialized. Call initMail() first.');
const from = process.env.MAIL_FROM || 'Fenja AI <noreply@project-bifrost.fenja.ai>';
await transport.sendMail({
from,
to: email,
subject: `Your Fenja code: ${code}`,
text: [
`Your six-digit code is ${code}`,
``,
`It is valid for ten minutes. If you did not request this, you can safely ignore this message.`,
``,
`— Fenja`,
].join('\n'),
// A plain-text mail has the best deliverability for transactional codes.
// Skip HTML entirely.
});
}

57
src/middleware.js Normal file
View file

@ -0,0 +1,57 @@
// ─────────────────────────────────────────────────────────────
// src/middleware.js — rate limiting + requireAuth.
//
// Rate limiting is per-key (usually per-IP) with a sliding window
// stored in SQLite. Enough to stop scripted abuse without needing
// Redis. For real-deal DDoS protection, put Cloudflare / a WAF in
// front. For a self-hosted invite-only tool, this is plenty.
// ─────────────────────────────────────────────────────────────
import { q } from './db.js';
import { currentSession } from './sessions.js';
/**
* rateLimit({ key, max, windowMs })
* key string OR function(req) string
* max allowed requests per window
* windowMs window size in milliseconds
*
* Returns 429 once the counter crosses max. Counter resets automatically
* at window_end via the cleanup sweep in db.js.
*/
export function rateLimit({ key, max, windowMs }) {
return (req, res, next) => {
const id = typeof key === 'function' ? key(req) : key;
const now = Date.now();
const row = q.getRate.get(id);
if (!row || row.window_end < now) {
q.setRate.run(id, 1, now + windowMs);
return next();
}
if (row.count >= max) {
return res.status(429).end();
}
q.setRate.run(id, row.count + 1, row.window_end);
next();
};
}
/**
* requireAuth gate any route behind a valid session cookie.
*
* - For requests that accept HTML: redirect to "/" (entrance page).
* - For JSON/API requests: return 401.
*
* Sets req.session = { id, email, issued_at, expires_at } on success.
*/
export function requireAuth(req, res, next) {
const session = currentSession(req);
if (!session) {
if (req.accepts('html') && !req.xhr) {
return res.redirect(302, '/');
}
return res.status(401).end();
}
req.session = session;
next();
}

72
src/sessions.js Normal file
View file

@ -0,0 +1,72 @@
// ─────────────────────────────────────────────────────────────
// src/sessions.js — one-time codes + session cookies.
//
// Codes: 6 digits, HMAC-SHA256 with a server-side pepper.
// Sessions: opaque 256-bit random IDs stored in SQLite, not JWTs.
// Revoking a session is a DELETE; no signing keys to rotate.
// ─────────────────────────────────────────────────────────────
import crypto from 'node:crypto';
import { q } from './db.js';
export const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export const COOKIE_NAME = 'fenja_session';
// ─── Crypto helpers ──────────────────────────────────────────
export function randomCode() {
// randomInt is cryptographically secure on Node 20+
return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
}
export function randomSessionId() {
return crypto.randomBytes(32).toString('hex'); // 64 hex chars
}
export function hashCode(code) {
const pepper = process.env.CODE_PEPPER;
if (!pepper || pepper.length < 32) {
throw new Error('CODE_PEPPER is missing or too short. Generate with: openssl rand -hex 32');
}
return crypto.createHmac('sha256', pepper).update(code).digest('hex');
}
export function constantTimeEqual(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
// ─── Session lifecycle ───────────────────────────────────────
export function issueSession(req, res, email) {
const id = randomSessionId();
const now = Date.now();
q.createSession.run(
id,
email,
now,
now + SESSION_TTL_MS,
req.ip || null,
req.get('user-agent')?.slice(0, 500) || null
);
res.cookie(COOKIE_NAME, id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS,
});
}
export function clearSession(req, res) {
const id = req.cookies?.[COOKIE_NAME];
if (id) q.deleteSession.run(id);
res.clearCookie(COOKIE_NAME, { path: '/' });
}
export function currentSession(req) {
const id = req.cookies?.[COOKIE_NAME];
if (!id) return null;
return q.getSession.get(id, Date.now()) || null;
}