- POST /api/fenjaops/invites on server.js (requireAuth+requireAdmin). Ignores any is_admin field in the body — always stores 0. Records the acting admin's email in invited_by so the audit trail shows who added whom (CLI adds still record "cli"). - admin/index.html: new "Invite a new user" form panel at the top (email + optional first name). - admin/admin.js: wires the form submit to the POST, shows inline success/error, refreshes the tables on success. - admin/admin.css: form styling matching the existing paper/ink palette; mobile stacks. - Docs: CLAUDE.md, PROJECT.md, OPERATIONS.md, CHECKLIST.md, README.md all updated. New non-negotiable property in PROJECT.md: no web endpoint can set is_admin=1 or delete an invite — promotion + removal stay on bin/invite.js. New CHECKLIST.md section H2 covers the page's gating, the invite form, and an escalation-path audit. Admin promotion and invite deletion remain CLI-only so a compromised admin session cannot escalate or evict. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.6 KiB
8.6 KiB
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 20shows[bifrost] listening on 127.0.0.1:3000, no red errors (there is no[mail] SMTP relay reachableline anymore — the mail stack was removed)- [local]
curl -I https://project-bifrost.fenja.ai/→ 200, withX-Frame-Options: DENYandContent-Security-Policyheaders - [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 → session cookie issued immediately, welcome step appears (no 6-digit code flow anymore)
- [browser] Click "Learn more" on the welcome step → redirected to
/timelineshowing the timeline page - [browser] Hard-refresh (Ctrl+Shift+R) → stays on the timeline (cookie persists)
- [browser, new private window] Visit
/→ entrance appears (no cookie leak between sessions) - [browser] Click logout → lands on entrance → visiting
/timelineredirects to/ - [browser] Visit
/timeline.jsor/vendor/d3-array.min.jsdirectly while logged out → redirects to/ - DevTools → Application → Cookies:
fenja_sessionshowsHttpOnly ✓,Secure ✓(prod only),SameSite=Lax
C. After changes to the entrance form or login endpoint
- Submit a non-invited address → inline "not invited" message, no session issued
- Submit a malformed email (
foo,foo@, empty) → inline error appears, no request sent - Submit > 30 login attempts from the same IP in an hour → rate-limit response (429)
D. After changes to the timeline / protected pages
- [browser] Timeline loads fully: globe visible, 12 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'(nounsafe-inlineon 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 -X POST https://project-bifrost.fenja.ai/auth/login -H 'Content-Type: application/json' -d '{"email":"nobody@example.com"}'returns quickly (rate-limit zone is functioning; expect 403not_invitedon a real email not on the list)
F. After dependency or Node.js upgrades
sudo -u fenja npm ci --omit=devcompletes without errorssudo -u fenja npm auditreports no high/critical vulnerabilities in production deps- Run section A, then section B (full auth flow)
- Check
node --versionon the VPS is still 20+
G. After Nginx config changes specifically
sudo nginx -tbefore reloading (catches 95% of errors)ls /etc/nginx/sites-enabled/contains onlyproject-bifrost(no shadow configs)- After reload,
curl -I https://project-bifrost.fenja.ai/includesX-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/
H1. After changes to the Join-CTA tracking (bifrost_joins)
- [browser, logged in] Click the final "Join Project Bifrost" CTA → confirmation panel renders (staggered checkmarks appear)
- DevTools network tab:
POST /api/bifrost-joinreturns 200 with{clicked_at: <ms>} sudo -u fenja node /opt/fenja/bin/joins.js listshows a new row with your email and a current timestamp- Click the CTA a second time (refresh the page first): a second row appears — the log is per-click, not per-user
sudo -u fenja node /opt/fenja/bin/joins.js summarygroups by email with correctclick_countsudo -u fenja node /opt/fenja/bin/joins.js statstotals match what you see inlist- Logged out:
curl -X POST https://project-bifrost.fenja.ai/api/bifrost-join→ 401 (auth gate holds)
H2. After changes to the hidden admin page (/fenjaops)
Covers admin/, the /api/fenjaops/* endpoints, and requireAdmin.
Access / gating:
- [browser, logged out] GET
/fenjaops→ 302 →/(not a leaky 404-that-sets-no-cookie) - [browser, logged in as non-admin] GET
/fenjaops→ plain 404 page (not a redirect, not a blank 403 — the existence of the URL must not be leaked) - [browser, logged in as admin] GET
/fenjaops→ page renders with stats, invite list (Admin badges present), join summary, raw click log, and the "Invite a new user" form at the top
Invite form — happy path:
- Fill
emailwith a brand-new address, leave first name blank → click "Send invite" → green "Invited …" confirmation; form clears - The invite list below refreshes and shows the new row with no Admin badge and
invited_by= your admin email (not"cli") sudo -u fenja node /opt/fenja/bin/invite.js liston the VPS shows the same row
Invite form — edge cases:
- Submit an email already on the list → red "already on the invite list" message; no duplicate row added
- Submit a malformed email (
foo,foo@, empty) → red "not valid" message; no row added - Submit a first name >64 chars → red "too long" message; no row added
- DevTools network: the request is
POST /api/fenjaops/invites,Content-Type: application/json, 201 on success / 400 / 409 on failure
Escalation-path audit (do this after any change that touches server.js around /fenjaops or src/middleware.js):
curl -i -X POST https://project-bifrost.fenja.ai/api/fenjaops/invites -H 'Content-Type: application/json' -d '{"email":"foo@example.com","is_admin":1}'while logged in as admin → 201, then confirm on the VPS that the new row hasis_admin = 0(the body field must be ignored server-side)- Same POST while logged out → 401 or redirect; no row created
- Same POST while logged in as non-admin → 404 from
requireAdmin; no row created - There is no endpoint on the server that accepts
is_admin,setInviteAdmin, ordeleteInvitefrom HTTP — grepserver.jsto confirm the only write is the invite-creation POST
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 (
mvit 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
.envfile in the rsync source - No
data/*.sqlitein the rsync source - On the VPS after deploy: section A passes
- On the VPS after deploy: section B passes
- Tailed
journalctl -u fenja -ffor 30s while refreshing the site → no errors
Red flags that always mean stop
curl -I /returns 200 withoutX-Powered-By: Express→ Nginx is serving static, not proxying to Node. Site is broken.curl -I /timeline.jsreturns 200 → the auth gate is down. Do not invite anyone.- Any
Set-Cookie: fenja_session=...header that lacksHttpOnlyorSecure(in prod) → cookie hardening regression. journalctl -u fenjashowing repeated crashes → something's wrong,systemctl restart fenjawon'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.