Files
timmy-tower/artifacts/api-server/src/routes/admin-relay.ts

204 lines
7.6 KiB
TypeScript
Raw Normal View History

task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
/**
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
* admin-relay.ts Admin endpoints for the relay account whitelist + stats.
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
*
* All routes are protected by ADMIN_SECRET env var (Bearer token).
* If ADMIN_SECRET is not set, the routes reject all requests in production
* and accept only from localhost in development.
*
* Routes:
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
* GET /api/admin/relay/stats event counts + account total
* GET /api/admin/relay/accounts list all relay accounts
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
* POST /api/admin/relay/accounts/:pubkey/grant grant access to a pubkey
* POST /api/admin/relay/accounts/:pubkey/revoke revoke access from a pubkey
*/
import { Router, type Request, type Response, type NextFunction } from "express";
import { makeLogger } from "../lib/logger.js";
import { relayAccountService } from "../lib/relay-accounts.js";
task/33: Relay admin panel at /admin/relay (final, all review fixes applied) ## What was built Relay operator dashboard at GET /admin/relay (outside /api prefix). Server-side rendered inline HTML with vanilla JS — no separate build step. Registered in app.ts; absent from routes/index.ts (avoids /api/admin/relay dup). ## Auth gate + ADMIN_TOKEN alignment - Backend: ADMIN_TOKEN (canonical) with ADMIN_SECRET as backward-compat fallback. requireAdmin exported from admin-relay.ts; admin-relay-queue.ts imports it. Panel route returns 403 in production when ADMIN_TOKEN is not configured. - Frontend: prompt reads "Enter the ADMIN_TOKEN". Token verified via stats API probe (401 = bad token). Stored in localStorage; cleared on Log out. ## Stats endpoint (GET /api/admin/relay/stats) - approvedToday: AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight) - liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal; null on failure - Returns: pending, approved, autoApproved, rejected, approvedToday, totalAccounts, liveConnections ## Accounts endpoint fix (blocking review issue #3) GET /api/admin/relay/accounts now LEFT JOINs nostr_identities on pubkey. Returns trustTier (nostr_identities.tier) per account alongside pubkey, accessLevel, grantedBy, notes, grantedAt, revokedAt. Verified: elite accounts show "elite", new accounts show "new". ## Queue endpoint: contentPreview rawEvent content JSON.parsed, sliced to 120 chars; null on parse failure. GET /api/admin/relay/queue?status=pending used by UI. ## Admin panel features Stats bar: Pending (yellow), Approved today (green), Accounts (purple), Relay connections (blue; null → "n/a"). Queue tab: Event ID, Pubkey, Kind, Content preview, Status, Queued, Actions. Accounts tab: Pubkey, Access pill, Trust tier, Granted by, Notes, Date, Revoke. Grant form: pubkey + level + notes; 64-char hex validation client-side. 15s auto-refresh (queue + stats); toast feedback. ## XSS fix (2nd review round fix) esc(v) escapes &, <, >, ", ' before injection into innerHTML. Applied to all user-controlled fields: contentPreview, notes, grantedBy, tier, level, ts, id8, pk12, kind. Onclick uses safeId/safePk (hex-only strip). Stats use textContent (not innerHTML) — no escaping needed. ## TypeScript: 0 errors. Smoke tests: panel HTML ✓, trustTier in accounts ✓ (e.g. "trustTier":"elite"), stats fields ✓, queue ?status=pending ✓.
2026-03-19 20:57:52 +00:00
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel, db, relayEventQueue, relayAccounts, nostrIdentities } from "@workspace/db";
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
import { and, eq, gte, inArray, sql } from "drizzle-orm";
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
const logger = makeLogger("admin-relay");
const router = Router();
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
// ADMIN_TOKEN is the canonical env var; ADMIN_SECRET is the backward-compat alias.
const ADMIN_TOKEN = process.env["ADMIN_TOKEN"] ?? process.env["ADMIN_SECRET"] ?? "";
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
const IS_PROD = process.env["NODE_ENV"] === "production";
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
if (!ADMIN_TOKEN) {
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
if (IS_PROD) {
logger.error(
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
"ADMIN_TOKEN is not set in production — admin relay routes are unprotected. " +
"Set ADMIN_TOKEN in the API server environment immediately.",
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
);
} else {
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
logger.warn("ADMIN_TOKEN not set — admin relay routes accept local-only requests (dev mode)");
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
}
}
// ── Admin auth middleware ──────────────────────────────────────────────────────
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
// Shared by all admin-relay*.ts routes. Token is matched against ADMIN_TOKEN
// (env var), falling back to localhost-only in dev when no token is set.
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (ADMIN_TOKEN) {
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
const authHeader = req.headers["authorization"] ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
if (token !== ADMIN_TOKEN) {
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
res.status(401).json({ error: "Unauthorized" });
return;
}
} else {
const ip = req.ip ?? "";
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
if (!isLocal) {
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
logger.warn("admin-relay: no token configured, rejecting non-local call", { ip });
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
res.status(401).json({ error: "Unauthorized" });
return;
}
}
next();
}
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
// ── GET /admin/relay/stats ────────────────────────────────────────────────────
// Returns event counts by status for today + all-time pending, plus account total.
router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Response) => {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
const STRFRY_URL = process.env["STRFRY_URL"] ?? "http://strfry:7777";
const [queueCounts, accountCount, approvedToday, strfryStats] = await Promise.all([
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
db
.select({ status: relayEventQueue.status, count: sql<number>`count(*)::int` })
.from(relayEventQueue)
.groupBy(relayEventQueue.status),
db.select({ count: sql<number>`count(*)::int` }).from(relayAccounts),
db
.select({ count: sql<number>`count(*)::int` })
.from(relayEventQueue)
.where(
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
and(
inArray(relayEventQueue.status, ["approved", "auto_approved"]),
gte(relayEventQueue.decidedAt, todayStart),
),
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
),
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
// Attempt to fetch live connection count from strfry's /stats endpoint.
// Strfry exposes /stats in some builds; gracefully return null if unavailable.
(async (): Promise<{ connections?: number } | null> => {
try {
const r = await fetch(`${STRFRY_URL}/stats`, {
signal: AbortSignal.timeout(2000),
});
if (!r.ok) return null;
return (await r.json()) as { connections?: number };
} catch {
return null;
}
})(),
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
]);
const statusMap: Record<string, number> = {};
for (const row of queueCounts) {
statusMap[row.status] = row.count;
}
res.json({
pending: statusMap["pending"] ?? 0,
approved: statusMap["approved"] ?? 0,
autoApproved: statusMap["auto_approved"] ?? 0,
rejected: statusMap["rejected"] ?? 0,
approvedToday: approvedToday[0]?.count ?? 0,
totalAccounts: accountCount[0]?.count ?? 0,
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
liveConnections: strfryStats?.connections ?? null,
task/33: Relay admin panel at /api/admin/relay ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct <title> ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓
2026-03-19 20:44:19 +00:00
});
});
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
// ── GET /admin/relay/accounts ─────────────────────────────────────────────────
router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => {
task/33: Relay admin panel at /admin/relay (final, all review fixes applied) ## What was built Relay operator dashboard at GET /admin/relay (outside /api prefix). Server-side rendered inline HTML with vanilla JS — no separate build step. Registered in app.ts; absent from routes/index.ts (avoids /api/admin/relay dup). ## Auth gate + ADMIN_TOKEN alignment - Backend: ADMIN_TOKEN (canonical) with ADMIN_SECRET as backward-compat fallback. requireAdmin exported from admin-relay.ts; admin-relay-queue.ts imports it. Panel route returns 403 in production when ADMIN_TOKEN is not configured. - Frontend: prompt reads "Enter the ADMIN_TOKEN". Token verified via stats API probe (401 = bad token). Stored in localStorage; cleared on Log out. ## Stats endpoint (GET /api/admin/relay/stats) - approvedToday: AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight) - liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal; null on failure - Returns: pending, approved, autoApproved, rejected, approvedToday, totalAccounts, liveConnections ## Accounts endpoint fix (blocking review issue #3) GET /api/admin/relay/accounts now LEFT JOINs nostr_identities on pubkey. Returns trustTier (nostr_identities.tier) per account alongside pubkey, accessLevel, grantedBy, notes, grantedAt, revokedAt. Verified: elite accounts show "elite", new accounts show "new". ## Queue endpoint: contentPreview rawEvent content JSON.parsed, sliced to 120 chars; null on parse failure. GET /api/admin/relay/queue?status=pending used by UI. ## Admin panel features Stats bar: Pending (yellow), Approved today (green), Accounts (purple), Relay connections (blue; null → "n/a"). Queue tab: Event ID, Pubkey, Kind, Content preview, Status, Queued, Actions. Accounts tab: Pubkey, Access pill, Trust tier, Granted by, Notes, Date, Revoke. Grant form: pubkey + level + notes; 64-char hex validation client-side. 15s auto-refresh (queue + stats); toast feedback. ## XSS fix (2nd review round fix) esc(v) escapes &, <, >, ", ' before injection into innerHTML. Applied to all user-controlled fields: contentPreview, notes, grantedBy, tier, level, ts, id8, pk12, kind. Onclick uses safeId/safePk (hex-only strip). Stats use textContent (not innerHTML) — no escaping needed. ## TypeScript: 0 errors. Smoke tests: panel HTML ✓, trustTier in accounts ✓ (e.g. "trustTier":"elite"), stats fields ✓, queue ?status=pending ✓.
2026-03-19 20:57:52 +00:00
// LEFT JOIN nostr_identities to include trustTier per account.
const rows = await db
.select({
pubkey: relayAccounts.pubkey,
accessLevel: relayAccounts.accessLevel,
grantedBy: relayAccounts.grantedBy,
notes: relayAccounts.notes,
grantedAt: relayAccounts.grantedAt,
revokedAt: relayAccounts.revokedAt,
trustTier: nostrIdentities.tier,
})
.from(relayAccounts)
.leftJoin(nostrIdentities, eq(relayAccounts.pubkey, nostrIdentities.pubkey))
.orderBy(relayAccounts.grantedAt);
res.json({ accounts: rows });
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
});
// ── POST /admin/relay/accounts/:pubkey/grant ──────────────────────────────────
router.post(
"/admin/relay/accounts/:pubkey/grant",
requireAdmin,
async (req: Request, res: Response) => {
const { pubkey } = req.params as { pubkey: string };
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
return;
}
const body = req.body as { level?: string; notes?: string };
const level = (body.level ?? "write").toLowerCase() as RelayAccessLevel;
if (!RELAY_ACCESS_LEVELS.includes(level)) {
res.status(400).json({
error: `Invalid access level '${level}'. Must be one of: ${RELAY_ACCESS_LEVELS.join(", ")}`,
});
return;
}
const notes = typeof body.notes === "string" ? body.notes : "admin grant";
await relayAccountService.grant(pubkey, level, notes, "manual");
logger.info("admin granted relay access", {
pubkey: pubkey.slice(0, 8),
level,
notes,
});
res.json({ ok: true, pubkey, accessLevel: level, notes });
},
);
// ── POST /admin/relay/accounts/:pubkey/revoke ─────────────────────────────────
router.post(
"/admin/relay/accounts/:pubkey/revoke",
requireAdmin,
async (req: Request, res: Response) => {
const { pubkey } = req.params as { pubkey: string };
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
return;
}
const body = req.body as { reason?: string };
const reason = typeof body.reason === "string" ? body.reason : "admin revoke";
await relayAccountService.revoke(pubkey, reason);
logger.info("admin revoked relay access", {
pubkey: pubkey.slice(0, 8),
reason,
});
res.json({ ok: true, pubkey, revokedAt: new Date().toISOString(), reason });
},
);
export default router;