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:
parent
88863183e1
commit
107284801b
10 changed files with 478 additions and 18 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
145
admin/admin.css
Normal 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
121
admin/admin.js
Normal 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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[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
61
admin/index.html
Normal 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>
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
server.js
34
server.js
|
|
@ -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.
|
||||||
|
|
|
||||||
16
src/db.js
16
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 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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue