Initial commit: project-bifrost auth + timeline
This commit is contained in:
commit
1c395c349b
33 changed files with 4993 additions and 0 deletions
29
.env.example
Normal file
29
.env.example
Normal 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
37
.gitignore
vendored
Normal 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
98
CHECKLIST.md
Normal 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
217
INSTALL.md
Normal 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
123
OPERATIONS.md
Normal 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
143
PROJECT.md
Normal 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
167
README.md
Normal 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
56
bin/invite.js
Normal 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
0
data/.gitkeep
Normal file
46
deploy/fenja.service
Normal file
46
deploy/fenja.service
Normal 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
84
deploy/nginx.conf
Normal 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
1294
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
Normal file
22
package.json
Normal 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
59
protected/archive.html
Normal 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 →</button>
|
||||
</div>
|
||||
|
||||
<script src="/archive/archive.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
15
protected/archive.js
Normal file
15
protected/archive.js
Normal 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 = '/';
|
||||
});
|
||||
286
protected/fenja/colors_and_type.css
Normal file
286
protected/fenja/colors_and_type.css
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/* =============================================================
|
||||
Fenja AI — Nordic Editorial Design System
|
||||
"The Digital Archivist"
|
||||
============================================================= */
|
||||
|
||||
/* ────────── Fonts (variable) ────────── */
|
||||
|
||||
/* Manrope — variable font, weight 200–800 */
|
||||
@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); /* 56–88 */
|
||||
--text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 48–72 */
|
||||
--text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 40–56 */
|
||||
--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); }
|
||||
BIN
protected/fenja/fonts/Manrope-VariableFont_wght.ttf
Normal file
BIN
protected/fenja/fonts/Manrope-VariableFont_wght.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
protected/fenja/fonts/Newsreader-VariableFont_opsz,wght.ttf
Normal file
BIN
protected/fenja/fonts/Newsreader-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
862
protected/index.html
Normal file
862
protected/index.html
Normal 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 — 2022–2026</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’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 · Field Notes, No. 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
435
protected/timeline.js
Normal 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 “foundational” 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 “foundation-stone” 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
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
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
2
protected/vendor/d3-geo.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
protected/vendor/topojson-client.min.js
vendored
Normal file
2
protected/vendor/topojson-client.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
246
public/entrance.html
Normal file
246
public/entrance.html
Normal 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">→</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/entrance.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
201
public/entrance.js
Normal file
201
public/entrance.js
Normal 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
151
server.js
Normal 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
114
src/auth.js
Normal 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
119
src/db.js
Normal 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
53
src/mail.js
Normal 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
57
src/middleware.js
Normal 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
72
src/sessions.js
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue