diff --git a/CLAUDE.md b/CLAUDE.md index 5943d01..aacb921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. +**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`. ## Security invariants — do not violate without explicit approval diff --git a/OPERATIONS.md b/OPERATIONS.md index d181574..850963b 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -24,6 +24,30 @@ sudo sqlite3 /opt/fenja/data/fenja.sqlite \ "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 Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Use `bin/joins.js` to read it: diff --git a/PROJECT.md b/PROJECT.md index ba89894..1f5d4c2 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -46,8 +46,12 @@ 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.css +│ └── admin.js ├── 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) ├── deploy/ │ ├── fenja.service systemd unit diff --git a/admin/admin.css b/admin/admin.css new file mode 100644 index 0000000..eccafda --- /dev/null +++ b/admin/admin.css @@ -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; } +} diff --git a/admin/admin.js b/admin/admin.js new file mode 100644 index 0000000..b936887 --- /dev/null +++ b/admin/admin.js @@ -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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[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 ? 'Admin' : ''; + return ` + ${escapeHtml(r.email)} + ${escapeHtml(r.first_name || '')} + ${when} + ${escapeHtml(r.invited_by || '')} + ${admin} + `; + }).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) => ` + + ${escapeHtml(r.email)} + ${r.click_count} + ${iso(r.first_clicked_at)} + ${iso(r.last_clicked_at)} + + `).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) => ` + + ${iso(r.clicked_at)} + ${escapeHtml(r.email)} + ${escapeHtml(shortSession(r.session_id))} + + `).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 = '

Failed to load admin data — check the server logs.

'; + console.error('[admin]', err); + } +} + +load(); diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..005aa34 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,61 @@ + + + + + + + Admin — Fenja AI + + + +
+

Fenja AI — Admin

+

Read-only view. Grant/revoke via bin/invite.js admin.

+
+ +
+

Stats

+
+
Total clicks
+
Unique users
+
Invites
+
Admins
+
+
+ +
+

Per-user join summary

+ + + + + +
EmailClicksFirst clickLast click
+ +
+ +
+

Invites

+ + + + + +
EmailNameInvitedByAdmin
+ +
+ +
+

Raw join log (newest first)

+ + + + + +
WhenEmailSession
+ +
+ + + + diff --git a/bin/invite.js b/bin/invite.js index e9e72d5..14df5ec 100644 --- a/bin/invite.js +++ b/bin/invite.js @@ -1,25 +1,29 @@ #!/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: -// npm run invite -- add someone@example.com [FirstName] -// npm run invite -- remove someone@example.com -// npm run invite -- list -// -// Or directly: -// node bin/invite.js add someone@example.com Erik -// node bin/invite.js add someone@example.com (no name — stored as NULL) +// node bin/invite.js add [FirstName] +// node bin/invite.js remove +// node bin/invite.js list +// node bin/invite.js admin add # grant admin +// node bin/invite.js admin remove # revoke admin +// node bin/invite.js admin list # show all admins // // The first name is optional. When present it's used on the welcome -// screen ("Thanks for your interest, Erik."). When absent the welcome -// screen falls back to anonymous copy ("Thank you for your interest."). -// Re-running `add` on an existing email updates the first name only; -// invited_at and invited_by are preserved. +// screen ("Thank you for your interest, Erik."). When absent the +// welcome screen falls back to anonymous copy. Re-running `add` on +// an existing email updates the first name only; invited_at and +// 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'; -const [, , cmd, emailArg, nameArg] = process.argv; +const [, , cmd, sub, arg3, arg4] = process.argv; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; function help() { @@ -27,11 +31,16 @@ function help() { console.log(' invite add [FirstName]'); console.log(' invite remove '); console.log(' invite list'); + console.log(' invite admin add '); + console.log(' invite admin remove '); + console.log(' invite admin list'); process.exit(1); } switch (cmd) { case 'add': { + const emailArg = sub; + const nameArg = arg3; if (!emailArg || !EMAIL_RE.test(emailArg)) help(); const email = emailArg.trim().toLowerCase(); const firstName = nameArg ? nameArg.trim() : null; @@ -44,6 +53,7 @@ switch (cmd) { break; } case 'remove': { + const emailArg = sub; if (!emailArg || !EMAIL_RE.test(emailArg)) help(); const email = emailArg.trim().toLowerCase(); const result = q.deleteInvite.run(email); @@ -59,12 +69,41 @@ switch (cmd) { const d = new Date(r.invited_at).toISOString().slice(0, 10); const name = r.first_name ? ` [${r.first_name}]` : ''; 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.`); } 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: help(); } diff --git a/server.js b/server.js index dbd6763..2ef0c55 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; 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 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 }); }); +// ─── 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 +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 ─────────────────────────────────────────── // GET / → always the entrance shell. If authed, entrance.js routes // the user to the welcome step client-side. diff --git a/src/db.js b/src/db.js index e0297ef..3e34174 100644 --- a/src/db.js +++ b/src/db.js @@ -79,6 +79,17 @@ if (!inviteCols.some((c) => c.name === 'first_name')) { db.exec(`DROP TABLE IF EXISTS codes`); 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. // The first version of the table used `email` as PRIMARY KEY with // 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 ───────────────────────────────────── export const q = { // 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( `INSERT INTO invites (email, first_name, invited_at, invited_by) VALUES (?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET @@ -114,9 +125,10 @@ export const q = { ), deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'), 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` ), + setInviteAdmin: db.prepare('UPDATE invites SET is_admin = ? WHERE email = ?'), // sessions createSession: db.prepare( diff --git a/src/middleware.js b/src/middleware.js index 7d0f868..90bc958 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -55,3 +55,23 @@ export function requireAuth(req, res, next) { req.session = session; 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 ` (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('

404 — not found

'); + } + return res.status(404).end(); + } + next(); +}