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:
@@ -10,3 +10,4 @@ export * from "./timmy-config";
|
||||
export * from "./free-tier-grants";
|
||||
export * from "./timmy-nostr-events";
|
||||
export * from "./nostr-trust-vouches";
|
||||
export * from "./relay-accounts";
|
||||
|
||||
36
lib/db/src/schema/relay-accounts.ts
Normal file
36
lib/db/src/schema/relay-accounts.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { nostrIdentities } from "./nostr-identities";
|
||||
|
||||
// ── Access level type ─────────────────────────────────────────────────────────
|
||||
|
||||
export const RELAY_ACCESS_LEVELS = ["none", "read", "write"] as const;
|
||||
export type RelayAccessLevel = (typeof RELAY_ACCESS_LEVELS)[number];
|
||||
|
||||
// ── relay_accounts ────────────────────────────────────────────────────────────
|
||||
// One row per Nostr pubkey that has been explicitly registered with the relay.
|
||||
// Absence = "none" (default deny). FK to nostr_identities ensures we always
|
||||
// have a trust record alongside the relay record.
|
||||
|
||||
export const relayAccounts = pgTable("relay_accounts", {
|
||||
pubkey: text("pubkey")
|
||||
.primaryKey()
|
||||
.references(() => nostrIdentities.pubkey, { onDelete: "cascade" }),
|
||||
|
||||
accessLevel: text("access_level")
|
||||
.$type<RelayAccessLevel>()
|
||||
.notNull()
|
||||
.default("none"),
|
||||
|
||||
// "manual" = operator-granted regardless of trust tier
|
||||
// "auto-tier" = promoted automatically by TrustService
|
||||
grantedBy: text("granted_by").notNull().default("manual"),
|
||||
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
// Set when access is revoked; null means currently active.
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
|
||||
notes: text("notes"),
|
||||
});
|
||||
|
||||
export type RelayAccount = typeof relayAccounts.$inferSelect;
|
||||
Reference in New Issue
Block a user