customer-presentation/CLAUDE.md
Arlind Ukshini 2d06f8e513 /fenjaops: add Remove button for non-admin invites
- New endpoint: DELETE /api/fenjaops/invites/:email, behind
  requireAuth + requireAdmin. Guardrails:
    * refuses if the target email is an admin (demote first via
      bin/invite.js admin remove) — preserves the invariant that
      a compromised admin session can't lock everyone out;
    * refuses if the target email equals the caller's own —
      prevents self-inflicted lockouts from the UI;
    * deletes active sessions for the target email so the user
      is kicked out immediately instead of holding their 30-day
      cookie.
- Admin page: Invites table gains an "Action" column. Non-admin,
  non-self rows show a Remove button (quiet ink outline; crimson
  on hover to cue destructive intent). Admin and self rows show
  an em-dash. Click → browser confirm() → DELETE → load() to
  refresh counts + tables.
- admin.js fetches /auth/me alongside the other payloads so
  render can compare each row's email against the viewer's.
- PROJECT.md and CLAUDE.md updated: the "no web deletion"
  invariant is narrowed to "no web deletion of admins or self"
  to reflect the new capability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:23:12 +02:00

6.8 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Companion docs (read these first for non-trivial work)

  • PROJECT.md — architecture, auth flow, non-negotiable security properties, what is/isn't safe to change
  • INSTALL.md — one-time VPS setup
  • OPERATIONS.md — deploy, invite management, backups, troubleshooting
  • CHECKLIST.md — manual test matrix keyed by the area you touched (A through I). After any change, mentally walk the relevant section and call out which items the change plausibly affects.

Common commands

npm install                          # install deps
npm run dev                          # node --watch server.js, binds 127.0.0.1:3000
npm start                            # production start
node bin/invite.js add <email>       # invite (also: remove, list)
node bin/joins.js list               # read join-CTA click log
                                     # (also: summary, for <email>, stats)

There is no test suite, linter, or build step. Verification is the checklist in CHECKLIST.md, primarily by walking the entrance → code → timeline flow in a browser.

For local dev, .env needs only PORT, PUBLIC_ORIGIN, and NODE_ENV — see .env.example. Auth is email-only against the invite list; there is no 6-digit code flow and no SMTP relay anymore, so no mail/pepper secrets are required. NODE_ENV=production toggles the session cookie's Secure flag — set it in /etc/fenja/env on the VPS; leave it unset (or development) locally for HTTP.

Architecture

Single-process Express app bound to 127.0.0.1:3000. Nginx is the only public ingress. ESM only ("type": "module").

Request routing lives in server.js and is deliberately ordered. Understanding this order is the key to the security model:

  1. Security headers + CSP (strict — script-src 'self', no inline scripts)
  2. /auth/* router (public)
  3. GET / dispatches to protected/index.html (timeline) if currentSession(req) is set, else public/entrance.html — the same URL serves different pages depending on cookie state
  4. GET /timeline is UA-dispatched: mobile UAs (see MOBILE_UA_RE in server.js) get protected/mobile/index.html; everyone else gets the animated desktop view at protected/index.html. A ?view=mobile|desktop query override exists for forcing one or the other when the guess is wrong.
  5. express.static(public) — ungated assets
  6. requireAuth then express.static(protected) — gating runs before the file is read off disk. Adding a file to protected/ gates it automatically; adding to public/ exposes it automatically. protected/mobile/ is itself a child of protected/, so the mobile CSS + JS are gated just like the desktop assets.

Auth flow (see src/auth.js): email → POST /auth/login → the server checks the invite list; on hit it issues an opaque 256-bit session ID stored in SQLite and sets it as an HttpOnly; Secure; SameSite=Lax cookie (returns {ok, firstName}). On miss it returns 403 {error:"not_invited"} — email enumeration is acceptable here by design (invite-list-only, preview content). POST /auth/logout deletes the session row. GET /auth/me returns {email, firstName} or 401. No one-time codes, no SMTP, no JWTs; revocation is a DELETE.

Storage (src/db.js): better-sqlite3 at data/fenja.sqlite, WAL mode, tables invites / sessions / rate_limits / bifrost_joins. Prepared statements exported as q.*. A setInterval().unref() sweeps expired rows every 5 minutes.

bifrost_joins logs every click of the final "Join Project Bifrost" CTA — one row per click (auto-increment id, email, clicked_at, session_id). Writes come from POST /api/bifrost-join (behind requireAuth); reads come from bin/joins.js. See OPERATIONS.md for admin usage.

Hidden admin page at /fenjaops (deliberately obscure URL, not /admin) — gated by requireAuth + requireAdmin (is_admin column on invites). Non-admins get a plain 404 so the URL's existence isn't leaked. Files live in admin/ at the repo root (outside public/ and protected/ so only the explicit route reaches them). Admins can create non-admin invites (POST /api/fenjaops/invites, stores is_admin=0, audit trail records the acting admin in invited_by) and remove non-admin invites from the page (DELETE /api/fenjaops/invites/:email; rejects removing admins or oneself, also kills any active sessions for the deleted email). Admin promotion / demotion stays CLI-only (bin/invite.js admin add|remove|list) so a web session compromise cannot escalate or lock everyone out. Internal code keeps the word "admin" (middleware, files, CLI); only the public URL is obscured.

Rate limiting (src/middleware.js) is a SQLite-backed sliding window keyed per-IP — 5 code requests/hour, 20 verify attempts/hour. Nginx adds another layer via a limit_req_zone declared in /etc/nginx/nginx.conf.

Security invariants — do not violate without explicit approval

These are from PROJECT.md. A change that breaks any of them is a security regression even if nothing visibly breaks:

  • Protected HTML must never be readable without a valid session cookie — requireAuth runs before express.static(protected), don't reorder.
  • Session cookie: HttpOnly, Secure (prod), SameSite=Lax, opaque random 256-bit ID.
  • /etc/fenja/env on the VPS is intentionally minimal — only PORT, PUBLIC_ORIGIN, NODE_ENV. No pepper, no SMTP, no mail-from. The only env value with security impact is NODE_ENV=production (enables the Secure cookie flag).
  • No inline <script> in any HTML — CSP is script-src 'self'. Put JS in a separate file with src="..." defer.
  • Node binds to 127.0.0.1 only; Nginx is the single ingress.
  • Secrets live in /etc/fenja/env on the VPS (not /opt/fenja/.env).

Safe-to-change vs. flag-before-touching

  • Safe: content/layout in public/ or protected/, the timeline (protected/timeline.js, data, visuals), copy, fonts/colors in protected/fenja/colors_and_type.css. Adding a new gated page = drop file in protected/ and it's automatically gated. Mobile view: protected/mobile/* is its own static tree with m--prefixed CSS and zero shared JS — edit freely, it can't collide with the desktop cascade.
  • Flag in the change summary: anything in src/, server.js, deploy/, package.json, or .env.example. These touch operational surface area and need a human to redeploy/verify.

Conventions

  • ESM imports only, Node 20+.
  • File headers use the // ─── ... ─── comment banner style. Match it when editing existing files.
  • bin/invite.js and bin/joins.js are the admin CLIs — there is no web UI for either by design. invite.js manages the invite list; joins.js reads the CTA click log.