add hidden /fenjaops admin page (read-only) + is_admin invite flag

- new is_admin column on invites (migration 4) with DEFAULT 0
- requireAdmin middleware returns 404 for non-admins so the route's
  existence isn't leaked; path obscured as /fenjaops (not /admin)
- admin/ dir lives outside public/ and protected/; only reachable via
  the explicit gated mount + /api/fenjaops/{invites,joins} endpoints
- bin/invite.js gains `admin add|remove|list` subcommands
- OPERATIONS.md + CLAUDE.md + PROJECT.md document the hidden URL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arlind Ukshini 2026-04-23 17:29:19 +02:00
parent 88863183e1
commit 107284801b
10 changed files with 478 additions and 18 deletions

View file

@ -42,6 +42,8 @@ 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). 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.
**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`.
## Security invariants — do not violate without explicit approval ## Security invariants — do not violate without explicit approval

View file

@ -24,6 +24,30 @@ sudo sqlite3 /opt/fenja/data/fenja.sqlite \
"DELETE FROM sessions WHERE email = 'someone@example.com';" "DELETE FROM sessions WHERE email = 'someone@example.com';"
``` ```
## 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:
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):
```bash
# Promote an existing invitee to admin
sudo -u fenja node /opt/fenja/bin/invite.js admin add someone@example.com
# Demote
sudo -u fenja node /opt/fenja/bin/invite.js admin remove someone@example.com
# Who's an admin right now?
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.
## Reading Join-CTA clicks ## Reading Join-CTA clicks
Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Use `bin/joins.js` to read it: Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Use `bin/joins.js` to read it:

View file

@ -46,8 +46,12 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
│ │ ├── colors_and_type.css │ │ ├── colors_and_type.css
│ │ └── fonts/ Manrope + Newsreader variable fonts │ │ └── fonts/ Manrope + Newsreader variable fonts
│ └── vendor/ d3-array, d3-geo, topojson-client, countries-110m.json │ └── vendor/ d3-array, d3-geo, topojson-client, countries-110m.json
├── admin/ Hidden admin UI (served at /admin behind requireAuth+requireAdmin)
│ ├── index.html
│ ├── admin.css
│ └── admin.js
├── bin/ ├── bin/
│ ├── invite.js CLI: add/remove/list invites │ ├── invite.js CLI: add/remove/list invites; admin add/remove/list
│ └── joins.js CLI: read the Join-CTA click log (list/summary/for/stats) │ └── joins.js CLI: read the Join-CTA click log (list/summary/for/stats)
├── deploy/ ├── deploy/
│ ├── fenja.service systemd unit │ ├── fenja.service systemd unit

145
admin/admin.css Normal file
View file

@ -0,0 +1,145 @@
/*
admin/admin.css utilitarian read-only admin styles. Shares
Fenja's paper/ink palette with the public site but drops the
editorial serif in favour of system-sans for scannability.
*/
:root {
--paper: #faf6ee;
--paper-2: #f6f2e8;
--ink: #2e2e28;
--ink-soft: #5f5e5e;
--ink-dim: #8a887f;
--line: rgba(46, 46, 40, 0.12);
--accent: #b96b58;
--admin: #8a3a2f;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--paper);
color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
}
.masthead {
padding: 32px 48px 8px;
border-bottom: 1px solid var(--line);
}
.masthead h1 {
margin: 0 0 4px;
font-family: "Newsreader", Georgia, serif;
font-size: 34px;
font-weight: 400;
letter-spacing: -0.015em;
}
.masthead .dim { color: var(--ink-dim); font-style: italic; }
.masthead .meta {
margin: 0;
color: var(--ink-dim);
font-size: 13px;
}
.masthead code {
background: var(--paper-2);
padding: 1px 6px;
border-radius: 3px;
font-size: 12.5px;
}
.panel {
padding: 28px 48px;
border-bottom: 1px solid var(--line);
}
.panel h2 {
margin: 0 0 16px;
font-family: "Newsreader", Georgia, serif;
font-size: 20px;
font-weight: 500;
letter-spacing: -0.005em;
color: var(--ink);
}
.panel h2 .dim { color: var(--ink-dim); font-weight: 400; font-style: italic; }
/* Stats cards */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
max-width: 900px;
}
.stat {
background: var(--paper-2);
padding: 16px 20px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-k {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-dim);
font-weight: 600;
}
.stat-v {
font-family: "Newsreader", Georgia, serif;
font-size: 32px;
font-weight: 500;
color: var(--ink);
line-height: 1;
}
/* Tables */
.t {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.t th,
.t td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.t thead th {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-dim);
font-weight: 600;
border-bottom: 1px solid var(--line);
background: var(--paper-2);
}
.t tbody tr:hover { background: var(--paper-2); }
.t .num { text-align: right; font-variant-numeric: tabular-nums; }
.t .mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 12px; color: var(--ink-soft); }
.t .when { white-space: nowrap; color: var(--ink-soft); font-size: 13px; }
.t .badge {
display: inline-block;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--admin);
font-weight: 700;
}
.empty {
margin: 12px 2px 0;
color: var(--ink-dim);
font-style: italic;
font-size: 13px;
}
@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; }
}

121
admin/admin.js Normal file
View file

@ -0,0 +1,121 @@
// ─────────────────────────────────────────────────────────────
// 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.
//
// Note: the public URL path is `/fenjaops` (not `/admin`) as a
// small obscurity measure. Internal names stay as "admin".
// ─────────────────────────────────────────────────────────────
function iso(ms) {
return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + 'Z';
}
function shortSession(id) {
if (!id) return '';
return id.slice(0, 10) + '…';
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[c]));
}
function renderInvites(rows) {
const tbody = document.querySelector('#t-invites tbody');
const empty = document.getElementById('empty-invites');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => {
const when = new Date(r.invited_at).toISOString().slice(0, 10);
const admin = r.is_admin ? '<span class="badge">Admin</span>' : '';
return `<tr>
<td>${escapeHtml(r.email)}</td>
<td>${escapeHtml(r.first_name || '')}</td>
<td class="when">${when}</td>
<td>${escapeHtml(r.invited_by || '')}</td>
<td class="num">${admin}</td>
</tr>`;
}).join('');
}
function renderSummary(rows) {
const tbody = document.querySelector('#t-summary tbody');
const empty = document.getElementById('empty-summary');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => `
<tr>
<td>${escapeHtml(r.email)}</td>
<td class="num">${r.click_count}</td>
<td class="when">${iso(r.first_clicked_at)}</td>
<td class="when">${iso(r.last_clicked_at)}</td>
</tr>
`).join('');
}
function renderClicks(rows) {
const tbody = document.querySelector('#t-clicks tbody');
const empty = document.getElementById('empty-clicks');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => `
<tr>
<td class="when">${iso(r.clicked_at)}</td>
<td>${escapeHtml(r.email)}</td>
<td class="mono">${escapeHtml(shortSession(r.session_id))}</td>
</tr>
`).join('');
}
async function load() {
try {
const [invitesRes, joinsRes] = await Promise.all([
fetch('/api/fenjaops/invites', { credentials: 'same-origin' }),
fetch('/api/fenjaops/joins', { credentials: 'same-origin' }),
]);
if (!invitesRes.ok || !joinsRes.ok) {
// Session expired or admin flag revoked while the page was open —
// bounce to the front page rather than leaving stale tables.
window.location.href = '/';
return;
}
const invites = await invitesRes.json();
const joins = await joinsRes.json();
document.getElementById('stat-clicks').textContent = joins.total_clicks;
document.getElementById('stat-unique').textContent = joins.unique_users;
document.getElementById('stat-invites').textContent = invites.length;
document.getElementById('stat-admins').textContent = invites.filter((r) => r.is_admin).length;
renderInvites(invites);
renderSummary(joins.summary);
renderClicks(joins.clicks);
} catch (err) {
// Network failure — show a soft error rather than a blank page.
const stats = document.getElementById('stats');
stats.innerHTML = '<p style="color:var(--admin)">Failed to load admin data — check the server logs.</p>';
console.error('[admin]', err);
}
}
load();

61
admin/index.html Normal file
View file

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Admin — Fenja AI</title>
<link rel="stylesheet" href="/fenjaops/admin.css" />
</head>
<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>
</header>
<section class="panel">
<h2>Stats</h2>
<div class="stats" id="stats">
<div class="stat"><span class="stat-k">Total clicks</span><span class="stat-v" id="stat-clicks"></span></div>
<div class="stat"><span class="stat-k">Unique users</span><span class="stat-v" id="stat-unique"></span></div>
<div class="stat"><span class="stat-k">Invites</span><span class="stat-v" id="stat-invites"></span></div>
<div class="stat"><span class="stat-k">Admins</span><span class="stat-v" id="stat-admins"></span></div>
</div>
</section>
<section class="panel">
<h2>Per-user join summary</h2>
<table class="t" id="t-summary">
<thead><tr>
<th>Email</th><th class="num">Clicks</th><th>First click</th><th>Last click</th>
</tr></thead>
<tbody></tbody>
</table>
<p class="empty" id="empty-summary" hidden>No join clicks yet.</p>
</section>
<section class="panel">
<h2>Invites</h2>
<table class="t" id="t-invites">
<thead><tr>
<th>Email</th><th>Name</th><th>Invited</th><th>By</th><th class="num">Admin</th>
</tr></thead>
<tbody></tbody>
</table>
<p class="empty" id="empty-invites" hidden>No invites yet.</p>
</section>
<section class="panel">
<h2>Raw join log <span class="dim">(newest first)</span></h2>
<table class="t" id="t-clicks">
<thead><tr>
<th>When</th><th>Email</th><th class="mono">Session</th>
</tr></thead>
<tbody></tbody>
</table>
<p class="empty" id="empty-clicks" hidden>No clicks logged yet.</p>
</section>
<script src="/fenjaops/admin.js" defer></script>
</body>
</html>

View file

@ -1,25 +1,29 @@
#!/usr/bin/env node #!/usr/bin/env node
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// bin/invite.js — add / remove / list invites. // bin/invite.js — add / remove / list invites, plus grant/revoke
// admin on existing invites.
// //
// Usage: // Usage:
// npm run invite -- add someone@example.com [FirstName] // node bin/invite.js add <email> [FirstName]
// npm run invite -- remove someone@example.com // node bin/invite.js remove <email>
// npm run invite -- list // node bin/invite.js list
// // node bin/invite.js admin add <email> # grant admin
// Or directly: // node bin/invite.js admin remove <email> # revoke admin
// node bin/invite.js add someone@example.com Erik // node bin/invite.js admin list # show all admins
// node bin/invite.js add someone@example.com (no name — stored as NULL)
// //
// The first name is optional. When present it's used on the welcome // The first name is optional. When present it's used on the welcome
// screen ("Thanks for your interest, Erik."). When absent the welcome // screen ("Thank you for your interest, Erik."). When absent the
// screen falls back to anonymous copy ("Thank you for your interest."). // welcome screen falls back to anonymous copy. Re-running `add` on
// Re-running `add` on an existing email updates the first name only; // an existing email updates the first name only; invited_at and
// invited_at and invited_by are preserved. // invited_by are preserved.
//
// Admin flag gates the hidden /admin surface (see server.js). Admin
// grant/revoke operates on existing invite rows only — a non-invited
// email must be invited first, then promoted.
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
import { q } from '../src/db.js'; import { q } from '../src/db.js';
const [, , cmd, emailArg, nameArg] = process.argv; const [, , cmd, sub, arg3, arg4] = process.argv;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function help() { function help() {
@ -27,11 +31,16 @@ function help() {
console.log(' invite add <email> [FirstName]'); console.log(' invite add <email> [FirstName]');
console.log(' invite remove <email>'); console.log(' invite remove <email>');
console.log(' invite list'); console.log(' invite list');
console.log(' invite admin add <email>');
console.log(' invite admin remove <email>');
console.log(' invite admin list');
process.exit(1); process.exit(1);
} }
switch (cmd) { switch (cmd) {
case 'add': { case 'add': {
const emailArg = sub;
const nameArg = arg3;
if (!emailArg || !EMAIL_RE.test(emailArg)) help(); if (!emailArg || !EMAIL_RE.test(emailArg)) help();
const email = emailArg.trim().toLowerCase(); const email = emailArg.trim().toLowerCase();
const firstName = nameArg ? nameArg.trim() : null; const firstName = nameArg ? nameArg.trim() : null;
@ -44,6 +53,7 @@ switch (cmd) {
break; break;
} }
case 'remove': { case 'remove': {
const emailArg = sub;
if (!emailArg || !EMAIL_RE.test(emailArg)) help(); if (!emailArg || !EMAIL_RE.test(emailArg)) help();
const email = emailArg.trim().toLowerCase(); const email = emailArg.trim().toLowerCase();
const result = q.deleteInvite.run(email); const result = q.deleteInvite.run(email);
@ -59,12 +69,41 @@ switch (cmd) {
const d = new Date(r.invited_at).toISOString().slice(0, 10); const d = new Date(r.invited_at).toISOString().slice(0, 10);
const name = r.first_name ? ` [${r.first_name}]` : ''; const name = r.first_name ? ` [${r.first_name}]` : '';
const by = r.invited_by ? ` (by ${r.invited_by})` : ''; const by = r.invited_by ? ` (by ${r.invited_by})` : '';
console.log(` ${d} ${r.email}${name}${by}`); const admin = r.is_admin ? ' *ADMIN*' : '';
console.log(` ${d} ${r.email}${name}${by}${admin}`);
} }
console.log(`\n${rows.length} invite${rows.length === 1 ? '' : 's'} total.`); console.log(`\n${rows.length} invite${rows.length === 1 ? '' : 's'} total.`);
} }
break; break;
} }
case 'admin': {
if (sub === 'add' || sub === 'remove') {
const emailArg = arg3;
if (!emailArg || !EMAIL_RE.test(emailArg)) help();
const email = emailArg.trim().toLowerCase();
const invite = q.getInvite.get(email);
if (!invite) {
console.log(`No invite for ${email} — invite them first with: invite add ${email}`);
process.exit(1);
}
q.setInviteAdmin.run(sub === 'add' ? 1 : 0, email);
console.log(sub === 'add' ? `Granted admin to ${email}` : `Revoked admin from ${email}`);
} else if (sub === 'list') {
const rows = q.listInvites.all().filter((r) => r.is_admin);
if (rows.length === 0) {
console.log('(no admins)');
} else {
for (const r of rows) {
const name = r.first_name ? ` [${r.first_name}]` : '';
console.log(` ${r.email}${name}`);
}
console.log(`\n${rows.length} admin${rows.length === 1 ? '' : 's'} total.`);
}
} else {
help();
}
break;
}
default: default:
help(); help();
} }

View file

@ -11,7 +11,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import authRouter from './src/auth.js'; import authRouter from './src/auth.js';
import { requireAuth } from './src/middleware.js'; import { requireAuth, requireAdmin } from './src/middleware.js';
import { q } from './src/db.js'; // also side-effect: opens DB + runs schema import { q } from './src/db.js'; // also side-effect: opens DB + runs schema
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -75,6 +75,38 @@ app.post('/api/bifrost-join', requireAuth, (req, res) => {
res.json({ clicked_at: clickedAt }); res.json({ clicked_at: clickedAt });
}); });
// ─── Admin surface (gated, unlisted) ─────────────────────────
// Accessible only to invite rows with `is_admin=1`. Non-admins —
// authed or not — get a plain 404 from requireAdmin so the route's
// existence is not leaked. Admin files live in ./admin/ (outside
// both public/ and protected/) so they can only be reached through
// these explicit routes.
//
// The public URL path is deliberately obscure — `/fenjaops` rather
// than `/admin` — so scripted probes of common admin paths miss.
// Internal names (files, middleware, CLI subcommand) stay as "admin"
// since the obscurity is a URL concern only, not an identity one.
// Grant admin via:
// node bin/invite.js admin add <email>
app.use('/fenjaops',
requireAuth,
requireAdmin,
express.static(path.join(__dirname, 'admin'), { index: 'index.html', maxAge: 0 })
);
app.get('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
res.json(q.listInvites.all());
});
app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
res.json({
clicks: q.listJoins.all(),
summary: q.summariseJoins.all(),
total_clicks: q.countJoins.get().n,
unique_users: q.countUniqueJoiners.get().n,
});
});
// ─── Root dispatch ─────────────────────────────────────────── // ─── Root dispatch ───────────────────────────────────────────
// GET / → always the entrance shell. If authed, entrance.js routes // GET / → always the entrance shell. If authed, entrance.js routes
// the user to the welcome step client-side. // the user to the welcome step client-side.

View file

@ -79,6 +79,17 @@ if (!inviteCols.some((c) => c.name === 'first_name')) {
db.exec(`DROP TABLE IF EXISTS codes`); db.exec(`DROP TABLE IF EXISTS codes`);
db.exec(`DROP INDEX IF EXISTS idx_codes_expires`); db.exec(`DROP INDEX IF EXISTS idx_codes_expires`);
// Migration 4: is_admin column on invites. Gates access to the hidden
// /admin surface (see server.js + src/middleware.js). Stored on the
// invite row itself rather than a separate table — the list of
// authorised admins is derived from the invite list, and demoting is
// a one-column flip rather than a cross-table delete. Defaults to 0;
// toggle via bin/invite.js admin add|remove.
const inviteColsV2 = db.prepare(`PRAGMA table_info(invites)`).all();
if (!inviteColsV2.some((c) => c.name === 'is_admin')) {
db.exec(`ALTER TABLE invites ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
}
// Migration 3: bifrost_joins schema expanded to one-row-per-click. // Migration 3: bifrost_joins schema expanded to one-row-per-click.
// The first version of the table used `email` as PRIMARY KEY with // The first version of the table used `email` as PRIMARY KEY with
// INSERT OR IGNORE — only the first click was recorded per user. // INSERT OR IGNORE — only the first click was recorded per user.
@ -106,7 +117,7 @@ if (joinCols.length > 0 && !joinCols.some((c) => c.name === 'id')) {
// ─── Prepared statements ───────────────────────────────────── // ─── Prepared statements ─────────────────────────────────────
export const q = { export const q = {
// invites // invites
getInvite: db.prepare('SELECT email, first_name FROM invites WHERE email = ?'), getInvite: db.prepare('SELECT email, first_name, is_admin FROM invites WHERE email = ?'),
upsertInvite: db.prepare( upsertInvite: db.prepare(
`INSERT INTO invites (email, first_name, invited_at, invited_by) VALUES (?, ?, ?, ?) `INSERT INTO invites (email, first_name, invited_at, invited_by) VALUES (?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET ON CONFLICT(email) DO UPDATE SET
@ -114,9 +125,10 @@ export const q = {
), ),
deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'), deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'),
listInvites: db.prepare( listInvites: db.prepare(
`SELECT email, first_name, invited_at, invited_by `SELECT email, first_name, invited_at, invited_by, is_admin
FROM invites ORDER BY invited_at DESC` FROM invites ORDER BY invited_at DESC`
), ),
setInviteAdmin: db.prepare('UPDATE invites SET is_admin = ? WHERE email = ?'),
// sessions // sessions
createSession: db.prepare( createSession: db.prepare(

View file

@ -55,3 +55,23 @@ export function requireAuth(req, res, next) {
req.session = session; req.session = session;
next(); next();
} }
/**
* requireAdmin gate any route behind an `is_admin` invite row.
*
* Must be chained AFTER requireAuth (reads req.session.email). For
* logged-in-but-not-admin users this returns a plain 404 so the
* admin surface is indistinguishable from any other missing URL
* the existence of /admin is not leaked to non-admins. Grant/revoke
* via `bin/invite.js admin add|remove <email>` (CLI only).
*/
export function requireAdmin(req, res, next) {
const invite = q.getInvite.get(req.session?.email);
if (!invite || !invite.is_admin) {
if (req.accepts('html') && !req.xhr) {
return res.status(404).send('<h1>404 — not found</h1>');
}
return res.status(404).end();
}
next();
}