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 /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
|
|
|
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel, db, relayEventQueue, relayAccounts } from "@workspace/db";
|
|
|
|
|
import { eq, gte, 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();
|
|
|
|
|
|
|
|
|
|
const ADMIN_SECRET = process.env["ADMIN_SECRET"] ?? "";
|
|
|
|
|
const IS_PROD = process.env["NODE_ENV"] === "production";
|
|
|
|
|
|
|
|
|
|
if (!ADMIN_SECRET) {
|
|
|
|
|
if (IS_PROD) {
|
|
|
|
|
logger.error(
|
|
|
|
|
"ADMIN_SECRET is not set in production — admin relay routes are unprotected. " +
|
|
|
|
|
"Set ADMIN_SECRET in the API server environment immediately.",
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("ADMIN_SECRET not set — admin relay routes accept local-only requests (dev mode)");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Admin auth middleware ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
|
|
|
|
if (ADMIN_SECRET) {
|
|
|
|
|
const authHeader = req.headers["authorization"] ?? "";
|
|
|
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
|
|
|
|
if (token !== ADMIN_SECRET) {
|
|
|
|
|
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) {
|
|
|
|
|
logger.warn("admin-relay: no secret configured, rejecting non-local call", { ip });
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
const [queueCounts, accountCount, approvedToday] = await Promise.all([
|
|
|
|
|
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(
|
|
|
|
|
gte(relayEventQueue.decidedAt, todayStart),
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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/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) => {
|
|
|
|
|
const accounts = await relayAccountService.list();
|
|
|
|
|
res.json({ accounts });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── 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;
|