From 616f1339894fd74703b6b05f7a4c6925b5e36ea2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 13 Jun 2026 21:16:57 +0700 Subject: [PATCH] feat: add gated admin console for api_keys registry (list/search/filter/revoke/mint) - env-allowlist authz via ADMIN_GITHUB_USER_IDS on numeric provider_id (no migration) - server-side re-gated revoke + manual-mint actions - parameterized search/filter/paginate queries - shared mint-key extraction (DRY) from generate-key - notFound() for non-admins (404 never leaks route existence) - 3 unit-test suites (authz/queries/integration) --- .env.example | 6 + README.md | 7 + app/actions/admin-keys.js | 102 +++++++++++++++ app/actions/generate-key.js | 63 +-------- app/admin/page.js | 77 +++++++++++ app/globals.css | 55 ++++++++ components/admin/admin-create-key-form.js | 58 +++++++++ components/admin/admin-key-row-actions.js | 42 ++++++ components/admin/admin-keys-filter-form.js | 27 ++++ components/admin/admin-keys-table.js | 51 ++++++++ components/admin/admin-pagination.js | 47 +++++++ components/admin/admin-stats-header.js | 22 ++++ lib/auth/admin-allowlist.js | 36 ++++++ lib/auth/is-admin.js | 27 ++++ lib/keys/admin-keys-filters.js | 37 ++++++ lib/keys/admin-keys-queries.js | 73 +++++++++++ lib/keys/api-keys-repository.js | 24 ++++ lib/keys/mint-key.js | 79 ++++++++++++ .../phase-01-admin-authz.md | 79 ++++++++++++ .../phase-02-repository-queries.md | 105 +++++++++++++++ .../phase-03-admin-server-actions.md | 113 ++++++++++++++++ .../phase-04-admin-ui.md | 121 ++++++++++++++++++ .../phase-05-env-docs-tests.md | 101 +++++++++++++++ .../260613-2033-admin-management-crud/plan.md | 72 +++++++++++ tests/admin-keys-authz.test.js | 55 ++++++++ tests/admin-keys-queries.test.js | 49 +++++++ tests/is-admin.test.js | 62 +++++++++ 27 files changed, 1528 insertions(+), 62 deletions(-) create mode 100644 app/actions/admin-keys.js create mode 100644 app/admin/page.js create mode 100644 components/admin/admin-create-key-form.js create mode 100644 components/admin/admin-key-row-actions.js create mode 100644 components/admin/admin-keys-filter-form.js create mode 100644 components/admin/admin-keys-table.js create mode 100644 components/admin/admin-pagination.js create mode 100644 components/admin/admin-stats-header.js create mode 100644 lib/auth/admin-allowlist.js create mode 100644 lib/auth/is-admin.js create mode 100644 lib/keys/admin-keys-filters.js create mode 100644 lib/keys/admin-keys-queries.js create mode 100644 lib/keys/mint-key.js create mode 100644 plans/260613-2033-admin-management-crud/phase-01-admin-authz.md create mode 100644 plans/260613-2033-admin-management-crud/phase-02-repository-queries.md create mode 100644 plans/260613-2033-admin-management-crud/phase-03-admin-server-actions.md create mode 100644 plans/260613-2033-admin-management-crud/phase-04-admin-ui.md create mode 100644 plans/260613-2033-admin-management-crud/phase-05-env-docs-tests.md create mode 100644 plans/260613-2033-admin-management-crud/plan.md create mode 100644 tests/admin-keys-authz.test.js create mode 100644 tests/admin-keys-queries.test.js create mode 100644 tests/is-admin.test.js diff --git a/.env.example b/.env.example index 936cba4..efc33ca 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ MAX_TOTAL_KEYS=500 KEY_DAILY_LIMIT_USD=10 # Key lifetime in days (sets expires_at on mint). KEY_EXPIRY_DAYS=90 + +# ---- Admin (server-only) ---- +# Comma-separated numeric GitHub provider_ids granted access to /admin. +# These are the immutable numeric ids (provider_id), NOT GitHub logins. +# Empty/unset = no admins (fail-closed); /admin returns 404 for everyone. +ADMIN_GITHUB_USER_IDS= diff --git a/README.md b/README.md index 59c9c59..65de3bf 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed before minting, so concurrent double-submits yield exactly one OpenRouter key. - **Schema isolation:** `llmapikey` is NOT added to PostgREST exposed schemas; RLS is deny-all as defense in depth. +- **Admin console:** route `/admin` (unlisted — no nav link) lists, searches, + filters, revokes, and manually mints keys. Access is gated by the + `ADMIN_GITHUB_USER_IDS` allowlist against the same numeric `provider_id` + anchor; non-admins get `notFound()` (a 404, never a redirect that would leak + the route's existence). Every admin server action re-checks the allowlist + server-side, so the page gate is defense-in-depth only. ## Setup @@ -49,6 +55,7 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed | `MAX_TOTAL_KEYS` | Kill-switch: stop minting past N active keys | | `KEY_DAILY_LIMIT_USD` | Per-key daily cap sent to OpenRouter | | `KEY_EXPIRY_DAYS` | Key lifetime (sets `expires_at`) | + | `ADMIN_GITHUB_USER_IDS` | Admin allowlist — numeric GitHub `provider_id`s, CSV (server-only) | 3. **Database** — apply the migration to a **staging branch first**, then prod (Supabase SQL editor or `psql "$DATABASE_URL" -f ...`): ```bash diff --git a/app/actions/admin-keys.js b/app/actions/admin-keys.js new file mode 100644 index 0000000..fc79ddf --- /dev/null +++ b/app/actions/admin-keys.js @@ -0,0 +1,102 @@ +"use server"; + +import "server-only"; + +import { requireAdminIdentity } from "@/lib/auth/is-admin"; +import * as repo from "@/lib/keys/api-keys-repository"; +import { mintAndPersist, numEnv } from "@/lib/keys/mint-key"; +import { deleteKey } from "@/lib/openrouter/provisioning-client"; + +/** + * Admin actions over the `api_keys` registry. EVERY action re-gates with + * `requireAdminIdentity()` server-side — the `/admin` page gate is + * defense-in-depth only; server actions are independently invocable. + * + * @typedef {Object} AdminActionResult + * @property {"revoked"|"created"|"exists"|"error"} status + * @property {string} [keyHint] last-4 hint (create only). Raw key is NEVER returned here. + * @property {string} [message] human-friendly info/error + */ + +/** + * Revoke a key: delete the upstream OpenRouter key (idempotent), then the DB row. + * Pending rows (null hash) skip the upstream call and just remove the row. If the + * upstream delete fails for a non-404 reason, the DB row is kept so the key isn't + * silently orphaned (reconcile-keys.js will report it). + * + * @param {string} id api_keys row id + * @returns {Promise} + */ +export async function revokeKey(id) { + if (!(await requireAdminIdentity())) { + return { status: "error", message: "Not authorized." }; + } + + const row = await repo.findById(id); + if (!row) return { status: "error", message: "Key not found." }; + + if (row.openrouter_key_hash) { + try { + await deleteKey(row.openrouter_key_hash); // idempotent on 404 + } catch { + return { status: "error", message: "Could not revoke upstream key. Try again." }; + } + } + + await repo.deleteById(id); + return { status: "revoked" }; +} + +/** + * Manually mint a key for an arbitrary GitHub user (admin override). Reuses the + * shared reserve→mint→activate flow and honors PROVISIONING_ENABLED and the + * MAX_TOTAL_KEYS ceiling, exactly like the self-serve path. + * + * @param {{ githubUserId: string, githubUsername: string }} params + * @returns {Promise} + */ +export async function adminCreateKey({ githubUserId, githubUsername } = {}) { + if (!(await requireAdminIdentity())) { + return { status: "error", message: "Not authorized." }; + } + + const userId = String(githubUserId ?? "").trim(); + if (!/^\d+$/.test(userId)) { + return { status: "error", message: "githubUserId must be numeric (GitHub provider_id)." }; + } + const username = String(githubUsername ?? "").trim() || userId; + + // Idempotency: an existing active key is never re-minted. + const existing = await repo.findByGithubUserId(userId); + if (existing && existing.status === "active") { + return { status: "exists", keyHint: existing.key_hint, message: "User already has a key." }; + } + + if (process.env.PROVISIONING_ENABLED !== "true") { + return { status: "error", message: "Key giveaway is not live yet. Check back soon." }; + } + + const maxKeys = numEnv("MAX_TOTAL_KEYS", 0); + if (maxKeys > 0 && (await repo.countLiveKeys()) >= maxKeys) { + return { status: "error", message: "Key limit reached. Cannot mint more keys." }; + } + + const reservedId = await repo.reserve(userId, username); + if (!reservedId) { + // A pending row exists (in-flight or a prior active resolved above). + return { status: "error", message: "A key request for this user is already in progress." }; + } + + // Authoritative ceiling re-check now that our own pending row is counted. + if (maxKeys > 0 && (await repo.countLiveKeys()) > maxKeys) { + await repo.deletePending(reservedId); + return { status: "error", message: "Key limit reached. Cannot mint more keys." }; + } + + const result = await mintAndPersist(reservedId, userId); + if (result.status !== "created") { + return { status: "error", message: result.message }; + } + // Surface only the masked hint to the admin UI — never the raw key here. + return { status: "created", keyHint: result.keyHint }; +} diff --git a/app/actions/generate-key.js b/app/actions/generate-key.js index ebc2d8c..08d6376 100644 --- a/app/actions/generate-key.js +++ b/app/actions/generate-key.js @@ -3,9 +3,8 @@ import "server-only"; import { getCurrentGithubIdentity } from "@/lib/auth/current-github-identity"; -import { last4 } from "@/lib/keys/key-format"; import * as repo from "@/lib/keys/api-keys-repository"; -import { createKey, deleteKey } from "@/lib/openrouter/provisioning-client"; +import { mintAndPersist, numEnv } from "@/lib/keys/mint-key"; /** * @typedef {Object} GenerateKeyResult @@ -101,56 +100,6 @@ async function resolveConflict(identity) { }; } -/** - * Mint then persist, compensating on either failure. - * - * @param {string} reservedId - * @param {string} githubUserId - * @returns {Promise} - */ -async function mintAndPersist(reservedId, githubUserId) { - let mint; - try { - mint = await createKey({ - name: `llmapikey:${githubUserId}`, // opaque numeric id — no PII into OpenRouter logs - limitUsd: numEnv("KEY_DAILY_LIMIT_USD", 10), - resetPeriod: "daily", - includeByok: true, - expiresAt: expiryIso(numEnv("KEY_EXPIRY_DAYS", 90)), - }); - } catch { - await repo.deletePending(reservedId); // free the reservation; no orphan key exists - return { status: "error", message: "Could not create your key. Please try again." }; - } - - try { - await repo.activate(reservedId, { hash: mint.hash, hint: last4(mint.key) }); - } catch { - try { - await deleteKey(mint.hash); // avoid an orphaned billable key - } catch { - // best-effort; reconcile-keys.js will surface any leak - } - await repo.deletePending(reservedId); - return { status: "error", message: "Could not save your key. Please try again." }; - } - - // Raw key returned for one-time display. Never logged or re-persisted. - return { status: "created", rawKey: mint.key, keyHint: last4(mint.key) }; -} - -/** - * Parse a numeric env var, falling back if missing or malformed. - * - * @param {string} name - * @param {number} fallback - * @returns {number} - */ -function numEnv(name, fallback) { - const n = Number(process.env[name]); - return Number.isFinite(n) && n >= 0 ? n : fallback; -} - /** * @param {string|Date} createdAt * @param {number} maxAgeMs @@ -160,13 +109,3 @@ function isStale(createdAt, maxAgeMs) { const t = new Date(createdAt).getTime(); return Number.isFinite(t) && Date.now() - t > maxAgeMs; } - -/** - * ISO timestamp `days` in the future for the key's expires_at. - * - * @param {number} days - * @returns {string} - */ -function expiryIso(days) { - return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); -} diff --git a/app/admin/page.js b/app/admin/page.js new file mode 100644 index 0000000..c257d2b --- /dev/null +++ b/app/admin/page.js @@ -0,0 +1,77 @@ +import { notFound } from "next/navigation"; + +import { requireAdminIdentity } from "@/lib/auth/is-admin"; +import { listApiKeys, countApiKeys } from "@/lib/keys/admin-keys-queries"; +import { AdminStatsHeader } from "@/components/admin/admin-stats-header"; +import { AdminKeysFilterForm } from "@/components/admin/admin-keys-filter-form"; +import { AdminKeysTable } from "@/components/admin/admin-keys-table"; +import { AdminPagination } from "@/components/admin/admin-pagination"; +import { AdminCreateKeyForm } from "@/components/admin/admin-create-key-form"; + +// Reads the session per request — never prerender. +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 20; +const STATUSES = ["all", "pending", "active"]; + +/** + * Admin console. Gated by `requireAdminIdentity()`; non-admins (and signed-out + * users) get `notFound()` — NOT a redirect, so the route's existence is never + * confirmed to a probing non-admin. + */ +export default async function AdminPage({ searchParams }) { + const identity = await requireAdminIdentity(); + if (!identity) notFound(); + + const sp = (await searchParams) ?? {}; + const q = typeof sp.q === "string" ? sp.q : ""; + const status = STATUSES.includes(sp.status) ? sp.status : "all"; + const page = Math.max(1, Number.parseInt(sp.page, 10) || 1); + const offset = (page - 1) * PAGE_SIZE; + + let rows = []; + let filteredTotal = 0; + let active = 0; + let pending = 0; + let dbError = false; + try { + [rows, filteredTotal, active, pending] = await Promise.all([ + listApiKeys({ q, status, limit: PAGE_SIZE, offset }), + countApiKeys({ q, status }), + countApiKeys({ status: "active" }), + countApiKeys({ status: "pending" }), + ]); + } catch { + // DB unreachable (e.g. local without DATABASE_URL) — show an empty state + // rather than crashing, mirroring the dashboard's tolerance. + dbError = true; + } + + return ( +
+

Admin

+

Signed in as @{identity.githubUsername}.

+ + {dbError ? ( +
+

Key registry is unavailable (no database connection).

+
+ ) : ( + <> + + + + + + )} + + +
+ ); +} diff --git a/app/globals.css b/app/globals.css index d1c898b..3107b26 100644 --- a/app/globals.css +++ b/app/globals.css @@ -144,3 +144,58 @@ pre { color: var(--danger); font-weight: 600; } + +/* Admin console */ +.stats { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.filters { + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +.filters input, +.filters select { + background: #0a0c10; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 0.5rem 0.6rem; + font-size: 0.9rem; +} + +.filters input[type="text"] { + flex: 1; + min-width: 220px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.table th, +.table td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); +} + +.table th { + color: var(--muted); + font-weight: 600; +} + +.pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 1rem 0; +} diff --git a/components/admin/admin-create-key-form.js b/components/admin/admin-create-key-form.js new file mode 100644 index 0000000..3b4026c --- /dev/null +++ b/components/admin/admin-create-key-form.js @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { adminCreateKey } from "@/app/actions/admin-keys"; +import { maskFromHint } from "@/lib/keys/key-format"; + +/** + * Manual mint form (admin override). Submits the numeric GitHub provider_id and + * optional username to the server action; the raw key is never returned here, so + * only a masked hint is shown on success. + */ +export function AdminCreateKeyForm() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [result, setResult] = useState(/** @type {any} */ (null)); + + function onSubmit(e) { + e.preventDefault(); + const form = e.currentTarget; + const githubUserId = form.githubUserId.value; + const githubUsername = form.githubUsername.value; + startTransition(async () => { + const res = await adminCreateKey({ githubUserId, githubUsername }); + setResult(res); + if (res.status === "created" || res.status === "exists") { + form.reset(); + router.refresh(); + } + }); + } + + return ( +
+

Manually mint a key

+
+ + + +
+ {result?.status === "created" && ( +

+ Created — masked: {maskFromHint(result.keyHint)} +

+ )} + {result?.status === "exists" &&

User already has a key.

} + {result?.status === "error" &&

{result.message}

} +
+ ); +} diff --git a/components/admin/admin-key-row-actions.js b/components/admin/admin-key-row-actions.js new file mode 100644 index 0000000..c59dfd4 --- /dev/null +++ b/components/admin/admin-key-row-actions.js @@ -0,0 +1,42 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { revokeKey } from "@/app/actions/admin-keys"; + +/** + * Per-row revoke control. Calls the server action (which independently re-gates), + * then refreshes the server component so the deleted row disappears. + * + * @param {{ id: string }} props + */ +export function AdminKeyRowActions({ id }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(""); + + function onRevoke() { + if (!window.confirm("Revoke this key? Deletes the OpenRouter key and the record.")) { + return; + } + setError(""); + startTransition(async () => { + const res = await revokeKey(id); + if (res.status === "revoked") { + router.refresh(); + } else { + setError(res.message || "Failed to revoke."); + } + }); + } + + return ( + + + {error && {error}} + + ); +} diff --git a/components/admin/admin-keys-filter-form.js b/components/admin/admin-keys-filter-form.js new file mode 100644 index 0000000..28ae3eb --- /dev/null +++ b/components/admin/admin-keys-filter-form.js @@ -0,0 +1,27 @@ +/** + * Search + status filter. A native GET form → values become `?q=&status=` query + * params (no client JS). Omitting a `page` field means a new filter resets to + * page 1. The repo layer binds these as parameters; no SQL is built here. + * + * @param {{ q: string, status: string }} props + */ +export function AdminKeysFilterForm({ q, status }) { + return ( +
+ + + +
+ ); +} diff --git a/components/admin/admin-keys-table.js b/components/admin/admin-keys-table.js new file mode 100644 index 0000000..86654c9 --- /dev/null +++ b/components/admin/admin-keys-table.js @@ -0,0 +1,51 @@ +import { maskFromHint } from "@/lib/keys/key-format"; +import { AdminKeyRowActions } from "./admin-key-row-actions"; + +/** + * Renders only safe columns: username, masked key hint, status, created date. + * The `openrouter_key_hash` is NEVER rendered or serialized to the client. + * + * @param {{ rows: import('@/lib/keys/api-keys-repository').ApiKeyRow[] }} props + */ +export function AdminKeysTable({ rows }) { + if (!rows.length) { + return ( +
+

No keys match.

+
+ ); + } + + return ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
UsernameKeyStatusCreated +
@{row.github_username} + {maskFromHint(row.key_hint)} + {row.status} + {new Date(row.created_at).toISOString().slice(0, 10)} + + +
+
+ ); +} diff --git a/components/admin/admin-pagination.js b/components/admin/admin-pagination.js new file mode 100644 index 0000000..2241bed --- /dev/null +++ b/components/admin/admin-pagination.js @@ -0,0 +1,47 @@ +/** + * Build an `/admin` href preserving the active filter and setting the page. + * + * @param {number} page + * @param {string} q + * @param {string} status + * @returns {string} + */ +function hrefFor(page, q, status) { + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (status && status !== "all") params.set("status", status); + params.set("page", String(page)); + return `/admin?${params.toString()}`; +} + +/** + * Prev/Next links. Prev hidden on page 1; Next hidden when the current page is + * the last. Renders nothing when there's only a single page. + * + * @param {{ page: number, pageSize: number, total: number, q: string, status: string }} props + */ +export function AdminPagination({ page, pageSize, total, q, status }) { + const hasPrev = page > 1; + const hasNext = page * pageSize < total; + if (!hasPrev && !hasNext) return null; + + return ( +
+ {hasPrev ? ( + + ← Prev + + ) : ( + + )} + Page {page} + {hasNext ? ( + + Next → + + ) : ( + + )} +
+ ); +} diff --git a/components/admin/admin-stats-header.js b/components/admin/admin-stats-header.js new file mode 100644 index 0000000..02338b1 --- /dev/null +++ b/components/admin/admin-stats-header.js @@ -0,0 +1,22 @@ +/** + * Registry summary header. `total`/`active`/`pending` are global counts (not + * scoped to the current search) so the header stays a stable overview while the + * table below reflects the active filter. + * + * @param {{ total: number, active: number, pending: number }} props + */ +export function AdminStatsHeader({ total, active, pending }) { + return ( +
+ + {total} total + + + {active} active + + + {pending} pending + +
+ ); +} diff --git a/lib/auth/admin-allowlist.js b/lib/auth/admin-allowlist.js new file mode 100644 index 0000000..1837b2e --- /dev/null +++ b/lib/auth/admin-allowlist.js @@ -0,0 +1,36 @@ +/** + * Pure admin-allowlist logic. No `server-only` guard and no network I/O — only + * an env read — so it is unit-testable under plain node, mirroring + * `lib/keys/key-format.js`. The session-resolving gate (`requireAdminIdentity`) + * lives in `lib/auth/is-admin.js`, which adds the `server-only` boundary. + * + * Authz is keyed on the numeric, immutable GitHub `provider_id` — the same + * identity anchor used for key ownership — never the mutable login. + */ + +/** + * Parse the comma-separated allowlist env value into trimmed, non-empty ids. + * + * @param {string|undefined|null} raw e.g. "12345, 67890" + * @returns {string[]} numeric provider_id strings; `[]` when empty/unset. + */ +export function parseAdminIds(raw) { + return String(raw ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * True iff the identity's numeric GitHub id is in `ADMIN_GITHUB_USER_IDS`. + * Compared as exact strings — never a substring/prefix match, so "4" can never + * match "42". Empty/unset env ⇒ zero admins (fail-closed). + * + * @param {{ githubUserId?: string }|null|undefined} identity + * @returns {boolean} + */ +export function isAdmin(identity) { + const id = identity?.githubUserId; + if (!id) return false; + return parseAdminIds(process.env.ADMIN_GITHUB_USER_IDS).includes(id); +} diff --git a/lib/auth/is-admin.js b/lib/auth/is-admin.js new file mode 100644 index 0000000..ac104e0 --- /dev/null +++ b/lib/auth/is-admin.js @@ -0,0 +1,27 @@ +import "server-only"; + +import { getCurrentGithubIdentity } from "@/lib/auth/current-github-identity"; +import { isAdmin, parseAdminIds } from "@/lib/auth/admin-allowlist"; + +// Re-export the pure helpers so server code has a single import surface while +// the unit tests import them from `admin-allowlist.js` (no `server-only` guard). +export { isAdmin, parseAdminIds }; + +/** + * Resolve the current GitHub identity and gate it in one call — the single gate + * used by the `/admin` page and every admin server action. + * + * `getCurrentGithubIdentity()` returns `null` when unauthenticated and can throw + * on malformed session metadata; both map to "not admin". + * + * @returns {Promise} + * the identity when admin, else `null` (caller maps to `notFound()`/rejection). + */ +export async function requireAdminIdentity() { + try { + const identity = await getCurrentGithubIdentity(); + return identity && isAdmin(identity) ? identity : null; + } catch { + return null; + } +} diff --git a/lib/keys/admin-keys-filters.js b/lib/keys/admin-keys-filters.js new file mode 100644 index 0000000..05c38f7 --- /dev/null +++ b/lib/keys/admin-keys-filters.js @@ -0,0 +1,37 @@ +/** + * Pure filter/pagination helpers for the admin key queries. No `server-only` + * guard and no I/O, so they're unit-testable under plain node (mirroring + * `key-format.js`). The SQL-composing queries that consume them live in + * `admin-keys-queries.js` behind the `server-only` boundary. + */ + +/** + * Reduce raw filter input to a normalized, validated descriptor. + * + * - `status` only filters for the known values `pending`/`active`; anything else + * (including `all`/empty) ⇒ no status predicate. + * - `q` is trimmed; blank ⇒ no search predicate. + * + * @param {{ q?: string, status?: string }} [filters] + * @returns {{ statusFilter: 'pending'|'active'|null, search: string|null }} + */ +export function buildFilterDescriptor({ q, status } = {}) { + const statusFilter = status === "pending" || status === "active" ? status : null; + const term = typeof q === "string" ? q.trim() : ""; + return { statusFilter, search: term || null }; +} + +/** + * Clamp a value to an integer within [min, max], falling back when non-numeric. + * + * @param {unknown} value + * @param {number} min + * @param {number} max + * @param {number} fallback + * @returns {number} + */ +export function clampInt(value, min, max, fallback) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(Math.max(Math.trunc(n), min), max); +} diff --git a/lib/keys/admin-keys-queries.js b/lib/keys/admin-keys-queries.js new file mode 100644 index 0000000..c905dce --- /dev/null +++ b/lib/keys/admin-keys-queries.js @@ -0,0 +1,73 @@ +import "server-only"; + +import { getSql } from "@/lib/db/postgres-client"; +import { buildFilterDescriptor, clampInt } from "@/lib/keys/admin-keys-filters"; + +/** + * Admin read/search/paginate queries over `llmapikey.api_keys`. Kept separate + * from `api-keys-repository.js` so that file stays small and focused. The pure + * filter helpers live in `admin-keys-filters.js` (no `server-only`) for testing. + * + * Rows are the `ApiKeyRow` shape declared in `api-keys-repository.js`. All user + * input flows as bound parameters via the `postgres` tagged template — there is + * NEVER any string concatenation of `q`/`status` into SQL. + */ + +/** + * Build the parameterized WHERE fragment shared by list + count (so the + * paginator header can never disagree with the rows it labels). Composes + * `postgres` fragments — every value stays a bound parameter. + * + * @param {import('postgres').Sql} sql + * @param {{ q?: string, status?: string }} filters + * @returns {import('postgres').PendingQuery} a `where ...` fragment, or empty. + */ +function buildWhere(sql, filters) { + const { statusFilter, search } = buildFilterDescriptor(filters); + const conds = []; + if (statusFilter) conds.push(sql`status = ${statusFilter}`); + if (search) { + conds.push( + sql`(github_username ilike ${"%" + search + "%"} or github_user_id = ${search})`, + ); + } + let where = sql``; + conds.forEach((cond, i) => { + where = i === 0 ? sql`where ${cond}` : sql`${where} and ${cond}`; + }); + return where; +} + +/** + * List keys matching the filters, newest first, paginated. `limit` is clamped to + * [1, 100] and `offset` to ≥ 0 to bound resource use from `?page` abuse. + * + * @param {{ q?: string, status?: string, limit?: number, offset?: number }} [params] + * @returns {Promise} + */ +export async function listApiKeys({ q, status, limit, offset } = {}) { + const sql = getSql(); + const lim = clampInt(limit, 1, 100, 20); + const off = clampInt(offset, 0, Number.MAX_SAFE_INTEGER, 0); + const where = buildWhere(sql, { q, status }); + const rows = await sql` + select * from llmapikey.api_keys + ${where} + order by created_at desc + limit ${lim} offset ${off}`; + return rows; +} + +/** + * Count keys matching the filters — identical predicate to `listApiKeys`. + * + * @param {{ q?: string, status?: string }} [params] + * @returns {Promise} + */ +export async function countApiKeys({ q, status } = {}) { + const sql = getSql(); + const where = buildWhere(sql, { q, status }); + const rows = await sql` + select count(*)::int as n from llmapikey.api_keys ${where}`; + return rows[0].n; +} diff --git a/lib/keys/api-keys-repository.js b/lib/keys/api-keys-repository.js index fe246c0..8b0eedf 100644 --- a/lib/keys/api-keys-repository.js +++ b/lib/keys/api-keys-repository.js @@ -69,6 +69,30 @@ export async function findByGithubUserId(githubUserId) { return rows.length ? rows[0] : null; } +/** + * Fetch a single key row by its primary key (admin revoke flow). + * + * @param {string} id + * @returns {Promise} + */ +export async function findById(id) { + const sql = getSql(); + const rows = await sql` + select * from llmapikey.api_keys where id = ${id} limit 1`; + return rows.length ? rows[0] : null; +} + +/** + * Delete a key row by id (admin revoke, after the upstream key is deleted). + * + * @param {string} id + * @returns {Promise} + */ +export async function deleteById(id) { + const sql = getSql(); + await sql`delete from llmapikey.api_keys where id = ${id}`; +} + /** * Count live keys (active + in-flight pending) — basis for the MAX_TOTAL_KEYS * Sybil kill-switch. Pending rows are counted so concurrent reservations cannot diff --git a/lib/keys/mint-key.js b/lib/keys/mint-key.js new file mode 100644 index 0000000..414b4a4 --- /dev/null +++ b/lib/keys/mint-key.js @@ -0,0 +1,79 @@ +import "server-only"; + +import { last4 } from "@/lib/keys/key-format"; +import * as repo from "@/lib/keys/api-keys-repository"; +import { createKey, deleteKey } from "@/lib/openrouter/provisioning-client"; + +/** + * Shared mint-and-persist logic for a reserved key row. Used by both the + * self-serve `generateKey()` action and the admin `adminCreateKey()` action so + * the reserve→mint→activate→compensate flow lives in exactly one place (DRY). + * + * @typedef {Object} MintResult + * @property {"created"|"error"} status + * @property {string} [rawKey] present only when status === "created" (shown once) + * @property {string} [keyHint] last-4 hint for masked display + * @property {string} [message] human-friendly error + */ + +/** + * Mint then persist against an already-reserved row, compensating on either + * failure so a crashed mint never leaves an orphaned billable key or a stuck + * pending row. + * + * @param {string} reservedId + * @param {string} githubUserId + * @returns {Promise} + */ +export async function mintAndPersist(reservedId, githubUserId) { + let mint; + try { + mint = await createKey({ + name: `llmapikey:${githubUserId}`, // opaque numeric id — no PII into OpenRouter logs + limitUsd: numEnv("KEY_DAILY_LIMIT_USD", 10), + resetPeriod: "daily", + includeByok: true, + expiresAt: expiryIso(numEnv("KEY_EXPIRY_DAYS", 90)), + }); + } catch { + await repo.deletePending(reservedId); // free the reservation; no orphan key exists + return { status: "error", message: "Could not create your key. Please try again." }; + } + + try { + await repo.activate(reservedId, { hash: mint.hash, hint: last4(mint.key) }); + } catch { + try { + await deleteKey(mint.hash); // avoid an orphaned billable key + } catch { + // best-effort; reconcile-keys.js will surface any leak + } + await repo.deletePending(reservedId); + return { status: "error", message: "Could not save your key. Please try again." }; + } + + // Raw key returned for one-time display. Never logged or re-persisted. + return { status: "created", rawKey: mint.key, keyHint: last4(mint.key) }; +} + +/** + * Parse a numeric env var, falling back if missing or malformed. + * + * @param {string} name + * @param {number} fallback + * @returns {number} + */ +export function numEnv(name, fallback) { + const n = Number(process.env[name]); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +/** + * ISO timestamp `days` in the future for the key's expires_at. + * + * @param {number} days + * @returns {string} + */ +export function expiryIso(days) { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} diff --git a/plans/260613-2033-admin-management-crud/phase-01-admin-authz.md b/plans/260613-2033-admin-management-crud/phase-01-admin-authz.md new file mode 100644 index 0000000..33cdf6c --- /dev/null +++ b/plans/260613-2033-admin-management-crud/phase-01-admin-authz.md @@ -0,0 +1,79 @@ +# Phase 01 — Admin authz (`lib/auth/is-admin.js`) + +**Context:** [plan.md](plan.md) · identity anchor `lib/auth/current-github-identity.js:24-45` + +## Overview +- **Priority:** P1 (blocks 03, 04) +- **Status:** pending +- **Description:** Env-allowlist admin check over the existing numeric + `provider_id` identity. No DB role, no migration. + +## Key Insights +- Identity already exposes `githubUserId` (numeric string, regex-asserted at + `current-github-identity.js:40`). Reuse it verbatim — do NOT re-parse metadata. +- Allowlist is operator-controlled env → safe trust boundary. Compare as strings + after trimming; both sides are numeric `provider_id`s. +- `getCurrentGithubIdentity()` returns `null` (unauthenticated) and can **throw** + (malformed metadata). The combined helper must treat both as "not admin". + +## Requirements +**Functional** +- `isAdmin(identity)` → boolean. True iff `identity?.githubUserId` is in the + parsed `ADMIN_GITHUB_USER_IDS` allowlist. +- `parseAdminIds(raw)` (exported for tests) → `string[]`, comma-split, trimmed, + empties dropped. Empty/undefined env → `[]` (no admins). +- `requireAdminIdentity()` → resolves identity + gates in one call; returns the + identity if admin, else `null` (caller maps `null` → `notFound()` / rejection). + +**Non-functional** +- File <200 lines (will be ~40). Pure logic except the resolve helper. +- No logging of the allowlist or identity. + +## Architecture +Data flow: +``` +env ADMIN_GITHUB_USER_IDS ──parseAdminIds──▶ string[] +identity.githubUserId ─────includes?───────▶ boolean (isAdmin) +requireAdminIdentity(): getCurrentGithubIdentity() ──try/catch──▶ identity|null + └─ isAdmin? ─▶ identity | null +``` +- `isAdmin` is pure (env read only) → unit-testable without a session. +- `requireAdminIdentity` is the single gate used by pages and actions. + +## Related Code Files +- **Create:** `lib/auth/is-admin.js` +- **Read for pattern:** `lib/auth/current-github-identity.js` +- **Modify:** none +- **Delete:** none + +## Implementation Steps +1. Create `lib/auth/is-admin.js` with `import "server-only";` and import + `getCurrentGithubIdentity`. +2. `export function parseAdminIds(raw)` — `String(raw ?? "").split(",").map(s => s.trim()).filter(Boolean)`. +3. `export function isAdmin(identity)` — guard `identity?.githubUserId`; return + `parseAdminIds(process.env.ADMIN_GITHUB_USER_IDS).includes(identity.githubUserId)`. +4. `export async function requireAdminIdentity()` — `try { const id = await getCurrentGithubIdentity(); return id && isAdmin(id) ? id : null; } catch { return null; }`. +5. JSDoc each export (`@param`, `@returns`); note allowlist values are numeric + `provider_id`s, not logins. + +## Todo +- [ ] Create `lib/auth/is-admin.js` +- [ ] `parseAdminIds` (trim/empty/multi) +- [ ] `isAdmin` (null-safe) +- [ ] `requireAdminIdentity` (catch throw → null) +- [ ] JSDoc + `server-only` + +## Success Criteria +- `parseAdminIds("")` → `[]`; `parseAdminIds(" 1 , 2 ,,3 ")` → `["1","2","3"]`. +- `isAdmin({githubUserId:"42"})` true when env contains `42`, false otherwise, + false for `null`/missing id. +- `requireAdminIdentity()` returns `null` when identity throws or is non-admin. + +## Security Considerations +- Allowlist compared as exact strings — no substring/`startsWith` (would let + `"4"` match `"42"`). +- Empty env = zero admins (fail-closed), never "allow all". +- Never log identity or allowlist contents. + +## Next Steps +- Phase 03 actions and Phase 04 page both call `requireAdminIdentity()`. diff --git a/plans/260613-2033-admin-management-crud/phase-02-repository-queries.md b/plans/260613-2033-admin-management-crud/phase-02-repository-queries.md new file mode 100644 index 0000000..6d9d721 --- /dev/null +++ b/plans/260613-2033-admin-management-crud/phase-02-repository-queries.md @@ -0,0 +1,105 @@ +# Phase 02 — Repository queries (list / count / find / delete) + +**Context:** [plan.md](plan.md) · existing repo `lib/keys/api-keys-repository.js` · `getSql()` `lib/db/postgres-client.js` + +## Overview +- **Priority:** P1 (blocks 03, 04) +- **Status:** pending +- **Description:** Add admin read/search/paginate + find/delete queries. Keep + `api-keys-repository.js` <200 lines by putting search/admin queries in a new + `lib/keys/admin-keys-queries.js`; add only `findById`/`deleteById` to the + existing repo (they're generic key-by-id ops, fit the repo's existing role). + +## Key Insights +- `api-keys-repository.js` is 85 lines; adding 4 functions would push it near the + limit → split. **`findById` + `deleteById`** stay in `api-keys-repository.js` + (sibling to `findByGithubUserId`/`deletePending`). **`listApiKeys` + + `countApiKeys`** (filter/search composition) go in `admin-keys-queries.js`. +- The `postgres` tagged template (`sql\`...\``) parameterizes interpolated + values — **never** string-concatenate user input. Dynamic WHERE built by + composing `sql` fragments, not by splicing strings. +- Columns (migration `0001`): `id, github_user_id, github_username, + openrouter_key_hash, key_hint, status, created_at`. **No `expires_at` / + `updated_at`** — do not select columns that don't exist. +- `status` filter domain: `all | pending | active`. `all` ⇒ omit the status + predicate (do not filter to a 3rd value). +- `q` searches `github_username` via `ILIKE '%q%'` AND exact-matches + `github_user_id` (numeric id paste) → `OR`. Bound `limit` to avoid huge pages. + +## Requirements +**Functional** +- `listApiKeys({ q, status, limit, offset })` → `ApiKeyRow[]`, `order by + created_at desc`, `limit`/`offset` applied. +- `countApiKeys({ q, status })` → `number` (`count(*)::int`), same filters as list. +- `findById(id)` → `ApiKeyRow|null`. +- `deleteById(id)` → `void`. + +**Non-functional** +- Both files <200 lines. Parameterized queries only. No logging of rows. + +## Architecture +Shared filter composition (in `admin-keys-queries.js`): +``` +buildFilters({ q, status }) → { whereStatus, whereSearch } as sql fragments +listApiKeys = SELECT * ... WHERE AND ORDER BY created_at DESC LIMIT OFFSET +countApiKeys = SELECT count(*)::int ... WHERE AND +``` +Compose with the `postgres` lib's fragment support so list and count share one +predicate builder (DRY) — both must apply identical filters or the paginator +header lies. + +Predicate building (parameterized, no interpolation): +- status: `status === 'all'` → no predicate; else `sql`status = ${status}``. +- search: when `q` non-empty → `sql`(github_username ilike ${'%' + q + '%'} or github_user_id = ${q})``. + Note `'%' + q + '%'` is a **bound parameter value**, not spliced SQL. +- Combine present predicates with `AND`; if none, `WHERE true` (or omit clause). + +## Related Code Files +- **Create:** `lib/keys/admin-keys-queries.js` (listApiKeys, countApiKeys, shared filter builder) +- **Modify:** `lib/keys/api-keys-repository.js` (add `findById`, `deleteById`) +- **Read for pattern:** existing `findByGithubUserId`/`countLiveKeys` (`api-keys-repository.js:65-85`) +- **Delete:** none + +## Implementation Steps +1. In `api-keys-repository.js` add `findById(id)`: `select * ... where id = ${id} limit 1` → row|null. +2. Add `deleteById(id)`: `delete from llmapikey.api_keys where id = ${id}`. +3. Create `admin-keys-queries.js` with `import "server-only";` + `getSql`. +4. Implement an internal `whereClause({ q, status })` returning a composed `sql` + fragment using the lib's fragment API (e.g. `sql\`where \${cond}\``), guarding + empty `q` and `status === 'all'`. +5. `listApiKeys` — clamp `limit` to `[1, 100]`, `offset` to `>= 0`; build query + using the shared clause; `order by created_at desc limit ${limit} offset ${offset}`. +6. `countApiKeys` — same clause; `select count(*)::int as n`; return `rows[0].n`. +7. JSDoc with `@typedef` reuse note (rows are `ApiKeyRow` from the repo). + +## Todo +- [ ] `findById` / `deleteById` in repo +- [ ] `admin-keys-queries.js` scaffold + `server-only` +- [ ] Shared `whereClause` (status + search, parameterized) +- [ ] `listApiKeys` with clamped limit/offset, desc order +- [ ] `countApiKeys` reusing the clause +- [ ] Verify both files <200 lines + +## Success Criteria +- list + count return consistent filtered sets for every `status` value. +- `q` matching a username substring and `q` matching an exact id both return rows. +- No string interpolation of `q`/`status`/`id` into SQL (review the diff). +- `findById('nope')` → `null`; `deleteById` removes exactly one row. + +## Security Considerations +- **Parameterized only** — SQL-injection safe; `q` flows as a bound value. +- `limit` clamp prevents resource-exhaustion via `?page` abuse. +- Functions return full rows incl. `openrouter_key_hash`; callers must never log + or send the hash to the client (UI selects only safe columns to render). + +## Next Steps +- Phase 03 `revokeKey` uses `findById`/`deleteById`; Phase 04 page uses + `listApiKeys`/`countApiKeys`. + +## Notes (verified) +- `postgres@^3.4.5` (porsager) supports embedding `sql` fragments inside a parent + `sql\`...\`` template and dynamic-condition composition — fragment approach is + valid. `prepare: false` (pooler) does not affect parameterization. +- If composing fragments proves awkward, the safe fallback is a small set of + explicit query branches (status present/absent × q present/absent), each a + static template with bound params — still zero interpolation. diff --git a/plans/260613-2033-admin-management-crud/phase-03-admin-server-actions.md b/plans/260613-2033-admin-management-crud/phase-03-admin-server-actions.md new file mode 100644 index 0000000..1e2efdc --- /dev/null +++ b/plans/260613-2033-admin-management-crud/phase-03-admin-server-actions.md @@ -0,0 +1,113 @@ +# Phase 03 — Admin server actions (revoke / manual create) + +**Context:** [plan.md](plan.md) · [phase-01](phase-01-admin-authz.md) · [phase-02](phase-02-repository-queries.md) · pattern `app/actions/generate-key.js` + +## Overview +- **Priority:** P1 (blocks 04) +- **Status:** pending +- **Description:** `app/actions/admin-keys.js` (`"use server"` + `server-only`) + with `revokeKey(id)` and `adminCreateKey({ githubUserId, githubUsername })`. + Both **re-gate with `requireAdminIdentity()` server-side** — never trust the + page gate alone. Refactor shared mint logic into `lib/keys/mint-key.js` (DRY). + +## Key Insights +- `mintAndPersist` + `numEnv`/`expiryIso` currently live private in + `generate-key.js:111-172`. Extract to `lib/keys/mint-key.js` and import from + both files so admin mint and self-serve mint share one code path. generate-key + output/flow must remain identical (regression risk — see tests). +- The full reserve→ceiling-recheck→mint flow in `generateKey()` is **per-user + idempotent** and keyed on the signed-in identity. For admin create we mint for + an **arbitrary** target user → reuse `reserve`/`countLiveKeys`/`mintAndPersist` + but with the admin-supplied `{ githubUserId, githubUsername }`, not the + admin's own identity. +- `deleteKey(hash)` is idempotent on 404 (`provisioning-client.js:63-71`) → safe + to call before `deleteById`; a missing upstream key still cleans the DB row. +- Pending rows have `openrouter_key_hash = null` → revoke must skip `deleteKey` + when hash is absent and just delete the row. + +## Requirements +**Functional** +- `revokeKey(id)`: + 1. `requireAdminIdentity()`; `null` → `{ status: "error", message: "Not authorized." }`. + 2. `findById(id)`; missing → `{ status: "error", message: "Key not found." }`. + 3. if `openrouter_key_hash` → `deleteKey(hash)` (idempotent). + 4. `deleteById(id)`. + 5. return `{ status: "revoked" }`. +- `adminCreateKey({ githubUserId, githubUsername })`: + 1. re-gate (as above). + 2. validate `githubUserId` matches `/^\d+$/`; else `{ status: "error", message: "githubUserId must be numeric." }`. + 3. coerce `githubUsername` to a non-empty string (fallback to the id if blank). + 4. existing active key for that id → `{ status: "exists" }`. + 5. `PROVISIONING_ENABLED !== "true"` → gated error (same copy as generate-key). + 6. `MAX_TOTAL_KEYS` ceiling (pre + post-reserve authoritative re-check). + 7. reserve → mint → activate via shared `mint-key.js`. + 8. return `{ status: "created", keyHint }` — **do not** return `rawKey` to the + admin UI list view (see security); creation result surfaces hint only. + +**Non-functional** +- File <200 lines. Return shapes mirror existing `GenerateKeyResult` style. + +## Architecture +``` +admin-keys.js ──requireAdminIdentity()──▶ gate (both actions, first line) +revokeKey → findById → [deleteKey?] → deleteById +adminCreateKey → validate id → reserve → ceiling re-check → mintAndPersist (shared) + +lib/keys/mint-key.js (extracted) + mintAndPersist(reservedId, githubUserId) → CreateKeyResult flow + compensation + numEnv / expiryIso (moved here; generate-key imports them) +``` +Refactor sequence (must keep generate-key green): +1. Create `mint-key.js`, move `mintAndPersist`, `numEnv`, `expiryIso` verbatim. +2. In `generate-key.js`, delete the moved fns, import from `mint-key.js`. +3. Verify `generateKey()` behavior unchanged (existing provisioning-client test + + new mint test). + +## Related Code Files +- **Create:** `app/actions/admin-keys.js`, `lib/keys/mint-key.js` +- **Modify:** `app/actions/generate-key.js` (extract shared fns, import them) +- **Read for pattern:** `app/actions/generate-key.js`, `lib/keys/api-keys-repository.js` +- **Delete:** none + +## Implementation Steps +1. Create `lib/keys/mint-key.js` (`server-only`); move `mintAndPersist`, + `numEnv`, `expiryIso`; export them. Keep `STALE_PENDING_MS` in generate-key + (only its conflict path uses it). +2. Update `generate-key.js` imports; remove the now-moved private fns; confirm no + other references. +3. Create `app/actions/admin-keys.js` with `"use server"` + `server-only`, + import `requireAdminIdentity`, repo (`findById`/`deleteById`/`reserve`/ + `findByGithubUserId`/`countLiveKeys`/`deletePending`), `deleteKey`, + `mintAndPersist`, `numEnv`. +4. Implement `revokeKey(id)` per Requirements; wrap `deleteKey` failure path to + surface `{ status:"error" }` (do not delete the DB row if upstream delete + throws non-404 — leaves a recoverable state, reconcile script reports it). +5. Implement `adminCreateKey(...)` reusing the generate-key ceiling logic + (pre-`>=` then post-reserve `>` re-check) against the target id. +6. JSDoc + result typedefs; never log `rawKey`/`hash`. + +## Todo +- [ ] Extract `mint-key.js`; rewire `generate-key.js` +- [ ] `revokeKey` with admin gate + idempotent deleteKey + null-hash skip +- [ ] `adminCreateKey` with gate + numeric validation + ceiling + shared mint +- [ ] No `rawKey` returned to admin list view +- [ ] generate-key regression check + +## Success Criteria +- Non-admin calling either action → rejection, no DB/OpenRouter side effects. +- Revoking an active key calls `deleteKey` then removes the row; revoking a + pending row (null hash) just removes the row. +- `adminCreateKey` rejects non-numeric id; honors `PROVISIONING_ENABLED` and + `MAX_TOTAL_KEYS`; second create for same id → `exists`. +- `generateKey()` behaves identically after the extraction. + +## Security Considerations +- **Server-side re-gate on every action** — the page gate is defense-in-depth + only; actions are independently invocable. +- Raw key never returned to the admin table flow; `hash` never logged. +- Numeric-id validation prevents minting against a spoofed/garbage identity and + keeps the OpenRouter key `name` (`llmapikey:`) PII-free. +- `deleteKey` before `deleteById` ordering avoids orphaned billable upstream keys. + +## Next Steps +- Phase 04 wires these actions into the table revoke button and a create form. diff --git a/plans/260613-2033-admin-management-crud/phase-04-admin-ui.md b/plans/260613-2033-admin-management-crud/phase-04-admin-ui.md new file mode 100644 index 0000000..89ddfa1 --- /dev/null +++ b/plans/260613-2033-admin-management-crud/phase-04-admin-ui.md @@ -0,0 +1,121 @@ +# Phase 04 — Admin UI (`/admin` page + components) + +**Context:** [plan.md](plan.md) · [phase-03](phase-03-admin-server-actions.md) · pattern `app/dashboard/page.js`, `components/generate-key-panel.js` + +## Overview +- **Priority:** P2 +- **Status:** pending +- **Description:** `app/admin/page.js` server component (`force-dynamic`): + resolve+gate → `notFound()` for non-admins → render stats header, search/status + filter form, keys table with revoke, prev/next pagination. Small components + under `components/admin/`. + +## Key Insights +- Mirror `dashboard/page.js`: `export const dynamic = "force-dynamic"` (reads + session per request, never prerender). +- Gate with `requireAdminIdentity()`; on `null` call `notFound()` (Next + `next/navigation`) — renders 404, **does not redirect** (a redirect to /login + would confirm the route exists to a probing non-admin). +- `searchParams` is async in Next 15 server components → `await searchParams` + (or accept the promise) before reading `q`/`status`/`page`. +- The table must render only safe columns: `github_username`, + `maskFromHint(key_hint)`, `status`, `created_at`. **Never** render or embed + `openrouter_key_hash`. +- Revoke is a mutation → must be a client component form invoking the server + action, then `router.refresh()` (or `revalidatePath('/admin')` in the action) + so the list reflects the deletion. + +## Requirements +**Functional** +- Parse `searchParams`: `q` (string, default ""), `status` (`all|pending|active`, + default `all`, validated/clamped), `page` (int ≥ 1, default 1). +- `limit = 20`; `offset = (page-1)*limit`. +- Concurrently `listApiKeys({q,status,limit,offset})` and `countApiKeys({q,status})`. +- Stats header: total (count of current filter) + active + pending. Active/pending + counts via two `countApiKeys` calls (`status:'active'`, `status:'pending'`) — + ignoring `q` for the global stats, or scoped; **decision: global (ignore q)** + so the header is a stable registry summary, table is the filtered view. +- Filter form: GET form (`method` defaults to GET) → `?q=&status=` query params + (no JS needed; native form submit). Preserve current values. +- Table: one row per key; revoke button per row. +- Pagination: Prev (page>1) / Next (offset+limit < total) as links preserving + `q`/`status`. +- DB unreachable (no `DATABASE_URL` locally) → catch and show an empty-state + panel (mirror dashboard's try/catch tolerance), not a crash. + +**Non-functional** +- Each component file <200 lines (all small). Reuse `panel`/`muted`/`btn`/`error` + classes; add minimal table CSS to `globals.css` only if needed. + +## Architecture +``` +/admin (server) components/admin/ + requireAdminIdentity() admin-keys-filter-form.js (server, GET form) + └ null → notFound() admin-keys-table.js (server, maps rows) + parse searchParams └ admin-key-row-actions.js (client: revoke form) + list + count (Promise.all) admin-stats-header.js (server, counts) + render stats/form/table/pager admin-pagination.js (server, prev/next links) +``` +Data flow: searchParams → validated query → repo (phase 02) → rows → table. +Mutation flow: row button → `admin-key-row-actions` (client) → `revokeKey(id)` +action → `router.refresh()`. + +## Related Code Files +- **Create:** `app/admin/page.js`, `components/admin/admin-stats-header.js`, + `components/admin/admin-keys-filter-form.js`, + `components/admin/admin-keys-table.js`, + `components/admin/admin-key-row-actions.js` (client), + `components/admin/admin-pagination.js` +- **Modify:** `app/globals.css` (minimal `.table`/`th`/`td` rules — only if needed) +- **Read for pattern:** `app/dashboard/page.js`, `components/generate-key-panel.js` +- **Delete:** none + +## Implementation Steps +1. `app/admin/page.js`: `force-dynamic`; `const identity = await + requireAdminIdentity(); if (!identity) notFound();`. +2. `const sp = await searchParams;` parse+validate `q/status/page` (helper to + clamp status to the allowed set, page to ≥1). +3. `Promise.all([listApiKeys, countApiKeys, countActive, countPending])` inside + try/catch; on error render empty-state panel. +4. Render ``, ``, + ``, ``. +5. `admin-keys-filter-form.js`: plain `
` (GET) with text input `name="q"` + and a `