- 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>
69 lines
6.1 KiB
Markdown
69 lines
6.1 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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. `express.static(public)` — ungated assets
|
|
5. `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.
|
|
|
|
**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 from the page (`POST /api/fenjaops/invites` → stores `is_admin=0`, audit trail records the acting admin's email in `invited_by`). Promotion to admin and removal of invites stay **CLI-only** via `bin/invite.js admin add|remove|list` — a web session compromise cannot escalate the invite list. 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.
|
|
- **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.
|