Desktop is a GSAP/Lenis/d3 animated experience that doesn't hold up on phones. Rather than retrofitting media queries across 1200+ lines of scroll-trigger code, add a completely isolated static mobile tree: - protected/mobile/index.html — one-page static flow covering the intro, 12 timeline events, hero, 4 capability cards, Bifrost reveal, 3 participation stops, and Join CTA. All copy duplicated from the desktop HTML on purpose — a shared data module would re-couple the two trees. - protected/mobile/mobile.css — paper/ink palette, all m-prefixed, zero cascade overlap with the desktop CSS. - protected/mobile/mobile.js — 60-line client: /auth/me check, /api/bifrost-join POST + panel swap, /auth/logout. No GSAP, no Lenis, no d3. Routing (server.js): - GET /timeline now UA-dispatches via MOBILE_UA_RE. Phone UAs get the mobile page; everything else gets the desktop page. - ?view=mobile and ?view=desktop query overrides take precedence over the UA sniff — for bad guesses or previewing the other version. - Gating is unchanged: protected/mobile/ is inside protected/ so the existing requireAuth + express.static gate covers it. Docs: - CLAUDE.md §routing now lists the UA dispatch as step 4. - PROJECT.md gets a new "Mobile view" section explaining the isolation rules (no shared JS/CSS, content duplicated manually). - CHECKLIST.md gains section H0 with dispatch curl checks, render verification on a phone, and an isolation audit that fails if mobile classes leak into the desktop HTML or vice versa. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 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/
H0. After changes to the mobile view (protected/mobile/*)
Covers protected/mobile/{index.html,mobile.css,mobile.js} and the wantsMobileView() / MOBILE_UA_RE dispatch in server.js.
Dispatch (simulate mobile from a desktop browser by setting a phone UA, or actually load on a phone):
curl -I -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' https://project-bifrost.fenja.ai/timeline -b 'fenja_session=<id>'→ 200 and HTML that contains/mobile/mobile.css(i.e. served the mobile page, not desktop)- Same curl with a desktop UA (the default curl UA is fine) → 200 HTML that does NOT reference
/mobile/mobile.css(served desktop) /timeline?view=mobileon a desktop browser → mobile HTML/timeline?view=desktopon a phone → desktop HTML (falls back gracefully — expected to look awkward but render)- Logged-out GET
/timelinestill redirects to/regardless of UA (requireAuthruns before the UA dispatch)
Mobile page render (on a phone or DevTools device-emulation at 390×844):
- Masthead shows Fenja wordmark on the left, "Log out" button on the right. No horizontal scrollbar at any vertical position
- Intro title + body renders; the second paragraph ("As AI moves into our hospitals...") is crimson
- 12 timeline events render as a vertical list, each with kind + date + headline + body + source. Accent colours correct: Rupture = crimson, Editorial/Regulation = copper, Field Note (2024) = copper, Field Note (2025 CPH bill) = ochre, Product = terracotta
- Hero reads "Fenja AI — Secure & Sovereign, hosted where it belongs." with the italics visible
- Capabilities section renders 4 cards ("1 / 4" through "4 / 4") stacked vertically
- "Project Bifrost" reveal renders as a centered paragraph
- Three stops (Community, Advisory Council, Pilot Projects) render in order; no illustration files are required for the mobile view to be usable
- Join button is tappable and large enough (no 40px-minimum-tap-target issue)
Join CTA behaviour:
- Tap "Join Project Bifrost" → button disables, POST
/api/bifrost-joinreturns 200, CTA panel hides and confirmation panel appears sudo -u fenja node /opt/fenja/bin/joins.js liston the VPS shows a new row with the user's email- Logout button → POST
/auth/logout, redirect to/, next visit to/timelineredirects to/(no session cookie)
Isolation audit (run after any change to desktop CSS/JS to confirm no mobile regression — and vice versa):
grep -r 'class="m-' protected/index.htmlreturns nothing (mobile classes only live inprotected/mobile/)grep -r 'from .*bifrost' protected/mobile/returns nothing (mobile doesn't import desktop JS)- Mobile page
<head>loads exactly two stylesheets:/fenja/colors_and_type.cssand/mobile/mobile.css - Mobile page loads exactly one script:
/mobile/mobile.js— no gsap/lenis/d3 references
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.