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
This commit is contained in:
129
artifacts/api-server/src/routes/admin-relay.ts
Normal file
129
artifacts/api-server/src/routes/admin-relay.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* admin-relay.ts — Admin endpoints for the relay account whitelist.
|
||||
*
|
||||
* 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:
|
||||
* GET /api/admin/relay/accounts — list all relay accounts
|
||||
* 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";
|
||||
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel } from "@workspace/db";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
Reference in New Issue
Block a user