Commit Graph

4 Commits

Author SHA1 Message Date
alexpaynex
ca8cbee179 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
alexpaynex
ac3493fc69 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
alexpaynex
c168081c7e 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
alexpaynex
94613019fc 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