diff --git a/CHECKLIST.md b/CHECKLIST.md index a29ff13..3e5d796 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -78,6 +78,32 @@ Do section A with extra attention to: - [ ] `sudo -u fenja node /opt/fenja/bin/joins.js stats` totals match what you see in `list` - [ ] 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 `email` with 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 list` on 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 has `is_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`, or `deleteInvite` from HTTP — grep `server.js` to 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 diff --git a/CLAUDE.md b/CLAUDE.md index aacb921..0c32555 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Single-process Express app bound to `127.0.0.1:3000`. Nginx is the only public i `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). Read-only view of invites + joins; grant/revoke admin is CLI-only via `bin/invite.js admin add|remove|list`. Internal code keeps the word "admin" (middleware, files, CLI); only the public URL is obscured. +**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`. diff --git a/OPERATIONS.md b/OPERATIONS.md index 850963b..02a2804 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -26,14 +26,28 @@ sudo sqlite3 /opt/fenja/data/fenja.sqlite \ ## Admin web UI (hidden) -There's a small read-only admin page at **`/fenjaops`** — intentionally unlinked from anywhere in the site, and the path is obscure-by-choice so scripted probes of common admin paths (`/admin`, `/wp-admin`, etc.) miss. Access requires: +There's a small admin page at **`/fenjaops`** — intentionally unlinked from anywhere in the site, and the path is obscure-by-choice so scripted probes of common admin paths (`/admin`, `/wp-admin`, etc.) miss. Access requires: 1. A valid session cookie (standard login), **and** 2. The user's invite row has `is_admin = 1`. Anything else — logged-out, logged-in-but-not-admin, scripted probe — gets a plain **404**, same as a missing URL. The existence of `/fenjaops` is not leaked. Internal code uses the word "admin" everywhere (files, middleware, CLI) — only the public URL path is obscured. -Grant / revoke admin **via CLI only** (there is no web mutation): +What the page can and cannot do: + +| Action | Where | +|---------------------------------|-------------------------------| +| View stats / joins / invite list| `/fenjaops` (read) | +| **Invite a new non-admin user** | `/fenjaops` form (`POST /api/fenjaops/invites`) | +| Grant / revoke admin | CLI only (`bin/invite.js admin …`) | +| Remove an invite | CLI only (`bin/invite.js remove …`) | +| Kill a session | SQL (`DELETE FROM sessions WHERE email = …`) | + +The split is deliberate: a web session compromise can grow the invite list with regular users but cannot escalate anyone (including the attacker) to admin or evict existing users. The POST handler on the server ignores any `is_admin` field in the body and always stores `0` — the only path to `is_admin=1` is through the CLI on the VPS. + +Invites created from the form record the acting admin's email in `invited_by` (CLI adds still record `"cli"`), so the log in the invite list shows who added whom. + +Grant / revoke admin stays on the CLI: ```bash # Promote an existing invitee to admin @@ -46,7 +60,7 @@ sudo -u fenja node /opt/fenja/bin/invite.js admin remove someone@example.com sudo -u fenja node /opt/fenja/bin/invite.js admin list ``` -The email must already be in `invites` — admin add doesn't create an invite. The page itself shows: stats, per-user join summary, invite list (with an Admin badge), and the raw click log. Read-only — edits still go through the CLI. +The email must already be in `invites` — admin add doesn't create an invite. Invite it first (via the page or `invite.js add`), then promote via CLI. ## Reading Join-CTA clicks diff --git a/PROJECT.md b/PROJECT.md index 1f5d4c2..0db723c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -46,8 +46,8 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated │ │ ├── colors_and_type.css │ │ └── fonts/ Manrope + Newsreader variable fonts │ └── vendor/ d3-array, d3-geo, topojson-client, countries-110m.json -├── admin/ Hidden admin UI (served at /admin behind requireAuth+requireAdmin) -│ ├── index.html +├── admin/ Hidden admin UI (served at /fenjaops behind requireAuth+requireAdmin) +│ ├── index.html Stats, invite list, join log, + "Invite a new user" form │ ├── admin.css │ └── admin.js ├── bin/ @@ -86,6 +86,7 @@ These things define the security model. Breaking any of them is a regression eve - **No inline `