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
+
+
+
+
+
+
+ Stats
+
+
Total clicks–
+
Unique users–
+
Invites–
+
Admins–
+
+
+
+
+ Per-user join summary
+
+
+ | Email | Clicks | First click | Last click |
+
+
+
+ No join clicks yet.
+
+
+
+ Invites
+
+
+ | Email | Name | Invited | By | Admin |
+
+
+
+ No invites yet.
+
+
+
+ Raw join log (newest first)
+
+
+ | When | Email | Session |
+
+
+
+ No clicks logged yet.
+
+
+
+
+
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();
+}