/fenjaops: admin-only form to invite non-admin users

- 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>
This commit is contained in:
Arlind Ukshini 2026-04-23 18:07:47 +02:00
parent 107284801b
commit cbfb187d16
9 changed files with 245 additions and 15 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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

View file

@ -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 `<script>` in any HTML** — CSP is strict (`script-src 'self'`). All JS is in separate files.
- **Node binds to `127.0.0.1` only.** Nginx is the single ingress.
- **`/etc/fenja/env` is minimal** (`PORT`, `PUBLIC_ORIGIN`, `NODE_ENV`), owned `root:fenja` mode 640, never in `/opt/fenja/`. Adding secrets back is a security review.
- **No web endpoint can set `is_admin=1` or delete an invite.** The admin page exposes `POST /api/fenjaops/invites` for creating *non-admin* invites only — the handler ignores any `is_admin` field in the body and always stores `0`. Admin promotion (`setInviteAdmin`) and invite deletion (`deleteInvite`) are reachable only via `bin/invite.js` on the VPS. This means a compromised admin session can grow the invite list but cannot escalate anyone (including themselves) or evict existing users.
If a change forces one of these to move, it's not a local change — it's a security review.
@ -148,9 +149,7 @@ One row per click — if a user presses the button multiple times, you get multi
Rough list of things that exist as possibilities, not commitments:
- Admin UI for managing invites without SSH
- Admin UI for the remaining invite operations (remove, rename, admin grant/revoke). Currently only *adding* non-admin invites is possible from the page; everything else stays on `bin/invite.js`.
- Rate-limit headers returned to the client (`Retry-After`) instead of bare 429s
- A proper "resend code" link on the code screen after 30s
- Session list view so users can see/revoke their own active sessions
- Second gated surface (docs? notes? unclear)
- Email templates with proper HTML (currently plain text, which is fine for deliverability)

View file

@ -1,7 +1,8 @@
# project-bifrost
Invite-only Fenja AI entrance and archive. Node/Express + SQLite + SMTP,
behind Nginx on a VPS.
Invite-only Fenja AI entrance and archive. Node/Express + SQLite, behind
Nginx on a VPS. Auth is email-only against an invite list (no SMTP, no
one-time codes).
## Local development (Windows / VS Code)
@ -110,11 +111,18 @@ curl -i -X POST https://project-bifrost.fenja.ai/auth/login \
### Managing invites on the server
```bash
sudo -u fenja node /opt/fenja/bin/invite.js add someone@example.com
sudo -u fenja node /opt/fenja/bin/invite.js add someone@example.com
sudo -u fenja node /opt/fenja/bin/invite.js list
sudo -u fenja node /opt/fenja/bin/invite.js remove someone@example.com
# Admin flag (gates the hidden /fenjaops page). Grant/revoke is CLI-only.
sudo -u fenja node /opt/fenja/bin/invite.js admin add someone@example.com
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
```
Admins can also add *non-admin* invites from the hidden `/fenjaops` page (see OPERATIONS.md). Admin promotion and invite removal stay on the CLI by design — see PROJECT.md §Non-negotiable properties.
### Reading Join-CTA clicks
Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Read it with:
@ -161,6 +169,10 @@ project-bifrost/
│ ├── index.html # the timeline (authed home page)
│ ├── timeline.js # horizontal timeline + dot-nav + globe
│ └── bifrost.js # Overview page scroll scenes
├── admin/ # served at /fenjaops, gated by requireAuth+requireAdmin
│ ├── index.html # stats + invite list + join log + "Invite a user" form
│ ├── admin.css
│ └── admin.js
├── data/ # created on first run — SQLite lives here
└── deploy/
├── nginx.conf

View file

@ -137,9 +137,74 @@ html, body {
font-size: 13px;
}
/* Invite form */
.invite-form {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(160px, 1fr) auto;
gap: 14px;
align-items: end;
max-width: 900px;
}
.invite-form label {
display: flex;
flex-direction: column;
gap: 6px;
}
.invite-form .lbl {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-dim);
font-weight: 600;
}
.invite-form .opt {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
font-style: italic;
color: var(--ink-dim);
}
.invite-form input {
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 4px;
padding: 9px 11px;
font: inherit;
color: var(--ink);
}
.invite-form input:focus {
outline: none;
border-color: var(--accent);
background: var(--paper);
}
.invite-form button {
background: var(--ink);
color: var(--paper);
border: none;
border-radius: 4px;
padding: 10px 20px;
font: inherit;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
}
.invite-form button:hover:not(:disabled) { background: #000; }
.invite-form button:disabled { opacity: 0.55; cursor: progress; }
.invite-form .form-msg {
grid-column: 1 / -1;
margin: 4px 0 0;
padding: 9px 12px;
border-radius: 4px;
font-size: 13px;
}
.invite-form .form-msg.ok { background: #e4eadf; color: #3a5330; }
.invite-form .form-msg.err { background: #f2dcd6; color: var(--admin); }
@media (max-width: 640px) {
.masthead, .panel { padding-left: 20px; padding-right: 20px; }
.masthead h1 { font-size: 26px; }
.t th, .t td { padding: 8px 10px; }
.t .mono { display: none; }
.invite-form { grid-template-columns: 1fr; }
.invite-form button { width: 100%; }
}

View file

@ -1,7 +1,9 @@
// ─────────────────────────────────────────────────────────────
// admin/admin.js — pulls invites + joins from the gated
// /api/fenjaops endpoints and renders them into the three tables
// in index.html. Read-only: no mutations, no nav links elsewhere.
// /api/fenjaops endpoints and renders them. Also wires up the
// "Invite a new user" form (POST /api/fenjaops/invites), which
// only creates non-admin invites. Promoting to admin stays on
// the CLI (bin/invite.js admin add) by design.
//
// Note: the public URL path is `/fenjaops` (not `/admin`) as a
// small obscurity measure. Internal names stay as "admin".
@ -118,4 +120,66 @@ async function load() {
}
}
const ERROR_COPY = {
invalid_email: 'That email address is not valid.',
invalid_first_name: 'First name is invalid.',
first_name_too_long: 'First name is too long (max 64 characters).',
already_invited: 'That email is already on the invite list.',
};
function setupInviteForm() {
const form = document.getElementById('invite-form');
const msg = document.getElementById('invite-msg');
if (!form || !msg) return;
function show(text, cls) {
msg.textContent = text;
msg.classList.remove('ok', 'err');
msg.classList.add(cls);
msg.hidden = false;
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
msg.hidden = true;
const data = new FormData(form);
const email = String(data.get('email') || '').trim();
const firstName = String(data.get('first_name') || '').trim();
const body = { email };
if (firstName) body.first_name = firstName;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const res = await fetch('/api/fenjaops/invites', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => ({}));
if (res.status === 201) {
show(`Invited ${payload.email}.`, 'ok');
form.reset();
load();
return;
}
if (res.status === 401 || res.status === 404) {
window.location.href = '/';
return;
}
const code = payload.error || 'error';
show(ERROR_COPY[code] || `Error: ${code}`, 'err');
} catch (err) {
console.error('[admin] invite submit', err);
show('Network error — check server logs.', 'err');
} finally {
btn.disabled = false;
}
});
}
load();
setupInviteForm();

View file

@ -10,9 +10,25 @@
<body>
<header class="masthead">
<h1>Fenja AI <span class="dim">— Admin</span></h1>
<p class="meta">Read-only view. Grant/revoke via <code>bin/invite.js admin</code>.</p>
<p class="meta">Invite regular users below. Admin promotion is CLI-only via <code>bin/invite.js admin</code>.</p>
</header>
<section class="panel">
<h2>Invite a new user <span class="dim">— non-admin only</span></h2>
<form class="invite-form" id="invite-form" novalidate>
<label>
<span class="lbl">Email</span>
<input type="email" name="email" autocomplete="off" required />
</label>
<label>
<span class="lbl">First name <span class="opt">(optional)</span></span>
<input type="text" name="first_name" maxlength="64" autocomplete="off" />
</label>
<button type="submit">Send invite</button>
<p class="form-msg" id="invite-msg" hidden></p>
</form>
</section>
<section class="panel">
<h2>Stats</h2>
<div class="stats" id="stats">

View file

@ -98,6 +98,40 @@ app.get('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
res.json(q.listInvites.all());
});
// Create a non-admin invite from the admin UI. Admin promotion is
// intentionally NOT exposed — granting admin stays on the CLI
// (`bin/invite.js admin add`) so that compromising a web session
// cannot escalate the invite list. Audit trail: invited_by is the
// admin's email (not "cli").
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
app.post('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
const { email, first_name } = req.body ?? {};
if (typeof email !== 'string' || !EMAIL_RE.test(email.trim())) {
return res.status(400).json({ error: 'invalid_email' });
}
const normalized = email.trim().toLowerCase();
let firstName = null;
if (first_name != null && first_name !== '') {
if (typeof first_name !== 'string') {
return res.status(400).json({ error: 'invalid_first_name' });
}
const trimmed = first_name.trim();
if (trimmed.length > 64) {
return res.status(400).json({ error: 'first_name_too_long' });
}
firstName = trimmed || null;
}
if (q.getInvite.get(normalized)) {
return res.status(409).json({ error: 'already_invited' });
}
q.upsertInvite.run(normalized, firstName, Date.now(), req.session.email);
return res.status(201).json({ email: normalized, first_name: firstName });
});
app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
res.json({
clicks: q.listJoins.all(),