/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>
This commit is contained in:
Arlind Ukshini 2026-04-24 11:23:12 +02:00
parent 06fb151550
commit 2d06f8e513
6 changed files with 130 additions and 4 deletions

View file

@ -43,7 +43,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. `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. **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`. **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

@ -103,7 +103,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. - **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. - **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. - **`/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. - **No web endpoint can set `is_admin=1`, and no web endpoint can delete or demote an admin.** The admin page exposes `POST /api/fenjaops/invites` for creating *non-admin* invites (the handler ignores any `is_admin` field in the body and always stores `0`), and `DELETE /api/fenjaops/invites/:email` for removing *non-admin* invites (the handler refuses if `is_admin=1` or if the target email equals the caller's own email). Admin promotion (`setInviteAdmin`) and admin demotion / self-deletion are reachable only via `bin/invite.js` on the VPS. This means a compromised admin session can grow or prune the non-admin invite list, but it cannot escalate anyone to admin, demote an existing admin, or lock all other admins out.
If a change forces one of these to move, it's not a local change — it's a security review. If a change forces one of these to move, it's not a local change — it's a security review.

View file

@ -129,6 +129,36 @@ html, body {
color: var(--admin); color: var(--admin);
font-weight: 700; font-weight: 700;
} }
.t .dim { color: var(--ink-dim); }
/* Row action buttons (Remove, ). Quiet by default, crimson on hover
so the destructive intent reads clearly before the click. */
.row-action {
all: unset;
cursor: pointer;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: 3px;
background: transparent;
color: var(--ink-soft);
box-shadow: inset 0 0 0 1px var(--line);
transition:
background 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.row-action:hover {
color: #fff;
background: var(--admin);
box-shadow: inset 0 0 0 1px var(--admin);
}
.row-action:disabled {
opacity: 0.55;
cursor: progress;
}
.empty { .empty {
margin: 12px 2px 0; margin: 12px 2px 0;

View file

@ -28,6 +28,12 @@ function escapeHtml(s) {
}[c])); }[c]));
} }
// Email of the currently-authenticated admin, set by load() from
// /auth/me. Used to suppress the "Remove" button on the viewer's
// own row so they can't accidentally delete themselves from the UI
// (the server also rejects this, but hiding the control is clearer).
let currentAdminEmail = null;
function renderInvites(rows) { function renderInvites(rows) {
const tbody = document.querySelector('#t-invites tbody'); const tbody = document.querySelector('#t-invites tbody');
const empty = document.getElementById('empty-invites'); const empty = document.getElementById('empty-invites');
@ -40,16 +46,69 @@ function renderInvites(rows) {
tbody.innerHTML = rows.map((r) => { tbody.innerHTML = rows.map((r) => {
const when = new Date(r.invited_at).toISOString().slice(0, 10); const when = new Date(r.invited_at).toISOString().slice(0, 10);
const admin = r.is_admin ? '<span class="badge">Admin</span>' : ''; const admin = r.is_admin ? '<span class="badge">Admin</span>' : '';
// Action cell: "Remove" button for non-admin rows that aren't
// the viewer's own. Admin rows require a CLI demote first (see
// the server's cannot_remove_admin guard) — the column shows
// a dash instead. Self row: also a dash.
let action = '<span class="dim">—</span>';
if (!r.is_admin && r.email !== currentAdminEmail) {
action = `<button type="button" class="row-action row-action--delete"
data-email="${escapeHtml(r.email)}">Remove</button>`;
}
return `<tr> return `<tr>
<td>${escapeHtml(r.email)}</td> <td>${escapeHtml(r.email)}</td>
<td>${escapeHtml(r.first_name || '')}</td> <td>${escapeHtml(r.first_name || '')}</td>
<td class="when">${when}</td> <td class="when">${when}</td>
<td>${escapeHtml(r.invited_by || '')}</td> <td>${escapeHtml(r.invited_by || '')}</td>
<td class="num">${admin}</td> <td class="num">${admin}</td>
<td class="num">${action}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
// Event delegation — one listener on the table, dispatched to the
// clicked row-action button. Avoids re-binding every re-render.
(function wireInviteActions() {
const table = document.getElementById('t-invites');
if (!table) return;
table.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.row-action--delete');
if (!btn) return;
const email = btn.dataset.email;
if (!email) return;
if (!window.confirm(`Remove invite for ${email}?\n\nThis also kicks them out of any active session.`)) return;
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Removing…';
try {
const res = await fetch('/api/fenjaops/invites/' + encodeURIComponent(email), {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.status === 200) {
load(); // refresh the whole board — simplest + shows new counts
return;
}
const payload = await res.json().catch(() => ({}));
const msg = {
invalid_email: 'That email is invalid.',
cannot_remove_self: 'You cannot remove your own admin account.',
cannot_remove_admin:'Demote this admin via the CLI first (bin/invite.js admin remove).',
not_found: 'That invite is already gone.',
}[payload.error] || `Remove failed (HTTP ${res.status}).`;
alert(msg);
btn.disabled = false;
btn.textContent = original;
} catch (err) {
console.error('[admin] remove invite', err);
alert('Network error — see server logs.');
btn.disabled = false;
btn.textContent = original;
}
});
})();
function renderSummary(rows) { function renderSummary(rows) {
const tbody = document.querySelector('#t-summary tbody'); const tbody = document.querySelector('#t-summary tbody');
const empty = document.getElementById('empty-summary'); const empty = document.getElementById('empty-summary');
@ -89,9 +148,10 @@ function renderClicks(rows) {
async function load() { async function load() {
try { try {
const [invitesRes, joinsRes] = await Promise.all([ const [invitesRes, joinsRes, meRes] = await Promise.all([
fetch('/api/fenjaops/invites', { credentials: 'same-origin' }), fetch('/api/fenjaops/invites', { credentials: 'same-origin' }),
fetch('/api/fenjaops/joins', { credentials: 'same-origin' }), fetch('/api/fenjaops/joins', { credentials: 'same-origin' }),
fetch('/auth/me', { credentials: 'same-origin' }),
]); ]);
if (!invitesRes.ok || !joinsRes.ok) { if (!invitesRes.ok || !joinsRes.ok) {
@ -101,6 +161,15 @@ async function load() {
return; return;
} }
// Capture the viewer's own email so renderInvites() can suppress
// the delete button on their own row. Non-fatal if this fetch
// fails — worst case the user sees a Remove button on their own
// row and the server's cannot_remove_self guard catches the click.
if (meRes.ok) {
const me = await meRes.json().catch(() => ({}));
currentAdminEmail = (me.email || '').toLowerCase() || null;
}
const invites = await invitesRes.json(); const invites = await invitesRes.json();
const joins = await joinsRes.json(); const joins = await joinsRes.json();

View file

@ -53,7 +53,7 @@
<h2>Invites</h2> <h2>Invites</h2>
<table class="t" id="t-invites"> <table class="t" id="t-invites">
<thead><tr> <thead><tr>
<th>Email</th><th>Name</th><th>Invited</th><th>By</th><th class="num">Admin</th> <th>Email</th><th>Name</th><th>Invited</th><th>By</th><th class="num">Admin</th><th class="num">Action</th>
</tr></thead> </tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>

View file

@ -132,6 +132,33 @@ app.post('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
return res.status(201).json({ email: normalized, first_name: firstName }); return res.status(201).json({ email: normalized, first_name: firstName });
}); });
// Remove a non-admin invite from the admin UI. Guardrails:
// (1) cannot remove an admin row — demote via the CLI first,
// so a compromised admin session can't lock everyone out;
// (2) cannot remove yourself — prevents self-inflicted lockouts.
// Deleting the invite also deletes any active sessions for that
// email so the user is kicked out immediately instead of holding
// their 30-day cookie.
app.delete('/api/fenjaops/invites/:email', requireAuth, requireAdmin, (req, res) => {
const raw = decodeURIComponent(req.params.email || '').trim().toLowerCase();
if (!raw || !EMAIL_RE.test(raw)) {
return res.status(400).json({ error: 'invalid_email' });
}
if (raw === req.session.email) {
return res.status(400).json({ error: 'cannot_remove_self' });
}
const invite = q.getInvite.get(raw);
if (!invite) {
return res.status(404).json({ error: 'not_found' });
}
if (invite.is_admin) {
return res.status(403).json({ error: 'cannot_remove_admin' });
}
q.deleteInvite.run(raw);
q.deleteSessionsForEmail.run(raw);
return res.status(200).json({ email: raw, deleted: true });
});
app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => { app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
res.json({ res.json({
clicks: q.listJoins.all(), clicks: q.listJoins.all(),