mirror of
https://github.com/tiennm99/llmapikey.git
synced 2026-09-08 02:20:05 +00:00
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)
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AdminActionResult>}
|
||||
*/
|
||||
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<AdminActionResult>}
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
@@ -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<GenerateKeyResult>}
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main>
|
||||
<h1>Admin</h1>
|
||||
<p className="muted">Signed in as @{identity.githubUsername}.</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="panel">
|
||||
<p className="muted">Key registry is unavailable (no database connection).</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminStatsHeader total={active + pending} active={active} pending={pending} />
|
||||
<AdminKeysFilterForm q={q} status={status} />
|
||||
<AdminKeysTable rows={rows} />
|
||||
<AdminPagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={filteredTotal}
|
||||
q={q}
|
||||
status={status}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AdminCreateKeyForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="panel">
|
||||
<h2>Manually mint a key</h2>
|
||||
<form className="filters" onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="githubUserId"
|
||||
placeholder="GitHub provider_id (numeric)"
|
||||
required
|
||||
/>
|
||||
<input type="text" name="githubUsername" placeholder="GitHub username (optional)" />
|
||||
<button className="btn" type="submit" disabled={pending}>
|
||||
{pending ? "Minting…" : "Mint key"}
|
||||
</button>
|
||||
</form>
|
||||
{result?.status === "created" && (
|
||||
<p className="muted">
|
||||
Created — masked: <code>{maskFromHint(result.keyHint)}</code>
|
||||
</p>
|
||||
)}
|
||||
{result?.status === "exists" && <p className="muted">User already has a key.</p>}
|
||||
{result?.status === "error" && <p className="error">{result.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span>
|
||||
<button className="btn secondary" onClick={onRevoke} disabled={pending}>
|
||||
{pending ? "Revoking…" : "Revoke"}
|
||||
</button>
|
||||
{error && <span className="error"> {error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<form className="panel filters" method="get" action="/admin">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search username or numeric id"
|
||||
/>
|
||||
<select name="status" defaultValue={status}>
|
||||
<option value="all">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
</select>
|
||||
<button className="btn" type="submit">
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="panel">
|
||||
<p className="muted">No keys match.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Key</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>@{row.github_username}</td>
|
||||
<td>
|
||||
<code>{maskFromHint(row.key_hint)}</code>
|
||||
</td>
|
||||
<td>{row.status}</td>
|
||||
<td className="muted">
|
||||
{new Date(row.created_at).toISOString().slice(0, 10)}
|
||||
</td>
|
||||
<td>
|
||||
<AdminKeyRowActions id={row.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pager">
|
||||
{hasPrev ? (
|
||||
<a className="btn secondary" href={hrefFor(page - 1, q, status)}>
|
||||
← Prev
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="muted">Page {page}</span>
|
||||
{hasNext ? (
|
||||
<a className="btn secondary" href={hrefFor(page + 1, q, status)}>
|
||||
Next →
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="panel stats">
|
||||
<span>
|
||||
<strong>{total}</strong> total
|
||||
</span>
|
||||
<span>
|
||||
<strong>{active}</strong> active
|
||||
</span>
|
||||
<span>
|
||||
<strong>{pending}</strong> pending
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<import('./current-github-identity').GithubIdentity|null>}
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<any>} 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<import('./api-keys-repository').ApiKeyRow[]>}
|
||||
*/
|
||||
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<number>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -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<ApiKeyRow|null>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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<MintResult>}
|
||||
*/
|
||||
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();
|
||||
}
|
||||
@@ -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()`.
|
||||
@@ -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 <status?> AND <search?> ORDER BY created_at DESC LIMIT OFFSET
|
||||
countApiKeys = SELECT count(*)::int ... WHERE <status?> AND <search?>
|
||||
```
|
||||
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.
|
||||
@@ -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:<id>`) 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.
|
||||
@@ -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 `<AdminStatsHeader>`, `<AdminKeysFilterForm q status>`,
|
||||
`<AdminKeysTable rows>`, `<AdminPagination page limit total q status>`.
|
||||
5. `admin-keys-filter-form.js`: plain `<form>` (GET) with text input `name="q"`
|
||||
and a `<select name="status">`; submit reloads with query params.
|
||||
6. `admin-keys-table.js`: table headers (Username, Key, Status, Created); map
|
||||
rows; mask hint via `maskFromHint`; embed `<AdminKeyRowActions id>` per row.
|
||||
7. `admin-key-row-actions.js` (`"use client"`): button → `await revokeKey(id)` →
|
||||
on success `router.refresh()`; disable while pending; show inline error.
|
||||
8. `admin-pagination.js`: build prev/next hrefs from current params; hide Prev on
|
||||
page 1, Next when no more rows.
|
||||
9. (Optional) manual-create form component calling `adminCreateKey` — include
|
||||
only if it stays small; otherwise defer (YAGNI for v1 if scope tight). Scope
|
||||
says Create is in — include a minimal create form panel using `adminCreateKey`.
|
||||
|
||||
## Todo
|
||||
- [ ] `app/admin/page.js` with gate → `notFound()` + `force-dynamic`
|
||||
- [ ] searchParams parse/validate (q/status/page)
|
||||
- [ ] stats header (total/active/pending)
|
||||
- [ ] filter form (GET)
|
||||
- [ ] keys table (safe columns only)
|
||||
- [ ] row revoke client action + refresh
|
||||
- [ ] pagination links preserving filters
|
||||
- [ ] manual create form (adminCreateKey)
|
||||
- [ ] minimal table CSS if needed
|
||||
|
||||
## Success Criteria
|
||||
- Non-admin / signed-out → 404 (no redirect, no data).
|
||||
- Search by username substring and by exact id filters the table.
|
||||
- Status filter and pagination preserve each other in the URL.
|
||||
- Revoke removes the row and the list updates without manual reload.
|
||||
- `openrouter_key_hash` never appears in rendered HTML (inspect output).
|
||||
|
||||
## Security Considerations
|
||||
- `notFound()` not redirect — avoids confirming route existence to non-admins.
|
||||
- Page gate is convenience; **actions self-gate** (phase 03) — a leaked action
|
||||
call still rejects.
|
||||
- Render only masked hint; raw hash never serialized to the client.
|
||||
- GET filter form → values are query params (parameterized at the repo layer);
|
||||
no SQL built in the component.
|
||||
|
||||
## Next Steps
|
||||
- Phase 05 adds env doc + tests covering the gate and query shapes.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Phase 05 — Env, docs & tests
|
||||
|
||||
**Context:** [plan.md](plan.md) · `.env.example`, `README.md` · test pattern `tests/mask-helper.test.js`, `tests/rls-deny-all.test.js`
|
||||
|
||||
## Overview
|
||||
- **Priority:** P2
|
||||
- **Status:** pending
|
||||
- **Description:** Document `ADMIN_GITHUB_USER_IDS`, note `/admin` is unlisted &
|
||||
gated, and add `node --test` coverage for authz parsing/matching, repo query
|
||||
shape, and the action authz guard. Update `docs/` per documentation rules.
|
||||
|
||||
## Key Insights
|
||||
- `.env.example` uses commented sections; add admin var under a new "Admin"
|
||||
block. README has an env table (`README.md:40-51`) — add a row there.
|
||||
- Tests are `node:test` + `node:assert/strict`, real logic, skip gracefully when
|
||||
external creds absent (`rls-deny-all.test.js:17-20`). No mocks-for-show.
|
||||
- `isAdmin`/`parseAdminIds` are pure (env only) → fully unit-testable by setting
|
||||
`process.env.ADMIN_GITHUB_USER_IDS` per case.
|
||||
- Action authz guard test: call `revokeKey`/`adminCreateKey` with no admin env
|
||||
(and/or no session) → expect rejection result and **no** DB/OpenRouter call.
|
||||
Since actions read `requireAdminIdentity()` (which calls Supabase), test the
|
||||
guard at the `isAdmin` boundary directly, or skip-when-no-DB for the full path.
|
||||
- Repo query-shape test: assert the parameterized query builder produces the
|
||||
expected fragments for each `status`/`q` combination without hitting the DB —
|
||||
if the builder is internal, export a tiny pure `whereClause`-describing helper
|
||||
for testability, OR run against a real DB gated like `rls-deny-all`.
|
||||
|
||||
## Requirements
|
||||
**Functional**
|
||||
- `.env.example`: add `ADMIN_GITHUB_USER_IDS=` with a comment (numeric
|
||||
`provider_id`s, comma-separated, empty = no admins).
|
||||
- `README.md`: add env-table row + a short "Admin console" note (route `/admin`,
|
||||
unlisted, gated, `notFound()` for non-admins).
|
||||
- Tests:
|
||||
- `tests/is-admin.test.js` — `parseAdminIds` (empty, whitespace, multiple,
|
||||
trailing commas) + `isAdmin` (match, non-match, null identity, substring
|
||||
non-match like `"4"` vs `"42"`).
|
||||
- `tests/admin-keys-queries.test.js` — filter/search query shape per
|
||||
`status`/`q` combo (pure builder) — skip path if it requires a live DB.
|
||||
- `tests/admin-keys-authz.test.js` — guard rejects non-admin (no side effects).
|
||||
|
||||
**Non-functional**
|
||||
- Tests deterministic; restore `process.env` after each case.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
env doc ──▶ .env.example (+comment) , README env table (+row) , README admin note
|
||||
tests ──▶ is-admin (pure) | queries shape (pure or DB-gated) | action guard
|
||||
docs ──▶ docs/system-architecture.md (admin authz boundary), codebase-summary
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
- **Modify:** `.env.example`, `README.md`
|
||||
(no `docs/` directory exists in this repo — README is the single doc surface;
|
||||
do NOT scaffold a docs tree for this small feature, YAGNI)
|
||||
- **Create:** `tests/is-admin.test.js`, `tests/admin-keys-queries.test.js`,
|
||||
`tests/admin-keys-authz.test.js`
|
||||
- **Read for pattern:** `tests/mask-helper.test.js`, `tests/rls-deny-all.test.js`
|
||||
- **Delete:** none
|
||||
|
||||
## Implementation Steps
|
||||
1. `.env.example`: append Admin section:
|
||||
`# Comma-separated numeric GitHub provider_ids granted /admin access. Empty = no admins.`
|
||||
then `ADMIN_GITHUB_USER_IDS=`.
|
||||
2. `README.md`: add table row `| ADMIN_GITHUB_USER_IDS | Admin allowlist (numeric provider_ids, CSV) |`
|
||||
and a 2-3 line "Admin console" paragraph (unlisted, gated, notFound).
|
||||
3. `tests/is-admin.test.js`: set/restore `process.env.ADMIN_GITHUB_USER_IDS`;
|
||||
assert `parseAdminIds` + `isAdmin` cases incl. substring-non-match.
|
||||
4. `tests/admin-keys-queries.test.js`: assert builder output per filter combo;
|
||||
if it needs a DB, gate with `skip: !process.env.DATABASE_URL` and assert real
|
||||
list/count behavior on a staging DB.
|
||||
5. `tests/admin-keys-authz.test.js`: with empty allowlist, assert
|
||||
`revokeKey`/`adminCreateKey` return error and perform no mutation (verify via
|
||||
the pure guard, or DB-gated with row-count assertion).
|
||||
6. Run `npm test` — all green (skips where creds absent).
|
||||
|
||||
## Todo
|
||||
- [ ] `.env.example` admin var + comment
|
||||
- [ ] README env row + admin console note
|
||||
- [ ] `tests/is-admin.test.js`
|
||||
- [ ] `tests/admin-keys-queries.test.js`
|
||||
- [ ] `tests/admin-keys-authz.test.js`
|
||||
- [ ] `npm test` green
|
||||
|
||||
## Success Criteria
|
||||
- `npm test` passes locally and in CI (skips DB/Supabase tests when no creds).
|
||||
- `parseAdminIds`/`isAdmin` cover empty/whitespace/multiple/non-match/substring.
|
||||
- Authz test proves a non-admin action call has no side effects.
|
||||
- README + `.env.example` document the var and the gated, unlisted route.
|
||||
|
||||
## Security Considerations
|
||||
- Tests assert fail-closed behavior (empty allowlist ⇒ no admin).
|
||||
- Substring-non-match test guards against a future loosening of the comparison.
|
||||
- No real secrets in test fixtures; numeric ids only.
|
||||
|
||||
## Next Steps
|
||||
- Feature complete. Optional follow-ups (out of scope): audit log, edit-limits,
|
||||
bulk revoke — defer per YAGNI.
|
||||
|
||||
## Unresolved
|
||||
- None. (Verified: no `docs/` directory in repo — README is the doc surface.)
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: "Admin management (api_keys registry CRUD)"
|
||||
description: "Env-allowlisted admin console to list/search/revoke and manually mint OpenRouter keys."
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 6h
|
||||
branch: master
|
||||
tags: [admin, authz, crud, nextjs, security]
|
||||
created: 2026-06-13
|
||||
---
|
||||
|
||||
# Admin management — api_keys registry CRUD
|
||||
|
||||
Small, gated admin console over `llmapikey.api_keys`. CRUD = **C**reate (manual
|
||||
mint for a user), **R**ead (list + search + filter + paginate), **D**elete
|
||||
(revoke). No update-limits endpoint (**YAGNI** — revoke + recreate covers it).
|
||||
|
||||
Authz is **env-allowlist only** (`ADMIN_GITHUB_USER_IDS`) against the existing
|
||||
numeric `provider_id` identity anchor — **no DB role column, no migration**.
|
||||
Route `/admin` is unlisted (no nav link) and returns `notFound()` for
|
||||
non-admins (never a redirect that would leak its existence). Every server action
|
||||
re-gates server-side; SQL is parameterized; raw keys are never read or logged.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | Depends on |
|
||||
|---|-------|--------|-----------|
|
||||
| 01 | [Admin authz (`is-admin.js`)](phase-01-admin-authz.md) | completed | — |
|
||||
| 02 | [Repository queries (list/count/find/delete)](phase-02-repository-queries.md) | completed | — |
|
||||
| 03 | [Admin server actions (revoke / create)](phase-03-admin-server-actions.md) | completed | 01, 02 |
|
||||
| 04 | [Admin UI (`/admin` + components)](phase-04-admin-ui.md) | completed | 01, 02, 03 |
|
||||
| 05 | [Env, docs & tests](phase-05-env-docs-tests.md) | completed | 01–04 |
|
||||
|
||||
## Implementation note (completed)
|
||||
|
||||
Pure logic was split into `server-only`-free modules so `node --test` can import
|
||||
it (the codebase convention — `server-only` throws under plain node):
|
||||
- `lib/auth/admin-allowlist.js` (`parseAdminIds`/`isAdmin`) ← `is-admin.js` re-exports + adds `requireAdminIdentity`.
|
||||
- `lib/keys/admin-keys-filters.js` (`buildFilterDescriptor`/`clampInt`) ← consumed by `admin-keys-queries.js`.
|
||||
|
||||
Verified: `npm test` 21 pass / 1 skip (Supabase-gated); `npm run build` clean; code review found zero blocking issues.
|
||||
|
||||
## Key decisions
|
||||
|
||||
- **No migration.** Allowlist lives in env; identity anchor reused as-is.
|
||||
- **Shared mint logic.** Extract the existing `mintAndPersist` + helpers from
|
||||
`app/actions/generate-key.js` into `lib/keys/mint-key.js` (DRY), imported by
|
||||
both `generate-key.js` and the new `admin-keys.js`. generate-key behavior
|
||||
must stay byte-for-byte identical.
|
||||
- **Repo split.** Admin read/search queries go in a new
|
||||
`lib/keys/admin-keys-queries.js` so `api-keys-repository.js` stays <200 lines.
|
||||
- **Excluded (YAGNI):** edit-limits / update endpoint, bulk ops, audit log,
|
||||
CSV export, role table, per-admin scoping.
|
||||
|
||||
## File ownership (no overlap across parallel work)
|
||||
|
||||
- Phase 01: `lib/auth/is-admin.js`
|
||||
- Phase 02: `lib/keys/admin-keys-queries.js`, `lib/keys/api-keys-repository.js`
|
||||
- Phase 03: `app/actions/admin-keys.js`, `lib/keys/mint-key.js`,
|
||||
`app/actions/generate-key.js` (refactor only)
|
||||
- Phase 04: `app/admin/page.js`, `components/admin/*`, `app/globals.css`
|
||||
- Phase 05: `.env.example`, `README.md`, `tests/*.test.js`, `docs/*`
|
||||
|
||||
Phases 01 & 02 are independent → parallelizable. 03 depends on both. 04 on 03.
|
||||
|
||||
## Global success criteria
|
||||
|
||||
- Non-admin hitting `/admin` or any admin action gets `notFound()` / rejection.
|
||||
- List/search/filter/paginate works; counts header accurate.
|
||||
- Revoke removes the OpenRouter key (idempotent) and the DB row.
|
||||
- Manual mint reuses reserve→mint→activate, honoring gates.
|
||||
- `npm test` green; no raw key in any log/response except one-time create display.
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { isAdmin } from "../lib/auth/admin-allowlist.js";
|
||||
|
||||
const ENV = "ADMIN_GITHUB_USER_IDS";
|
||||
|
||||
/**
|
||||
* Authz-guard contract. Both admin server actions (`revokeKey`, `adminCreateKey`)
|
||||
* gate on `requireAdminIdentity()`, which returns the identity only when
|
||||
* `isAdmin(identity)` is true and `null` otherwise (the action then performs NO
|
||||
* DB/OpenRouter work). The full action path needs Supabase + a DB, so here we
|
||||
* assert the deterministic guard logic those actions depend on: a non-admin (or
|
||||
* an empty allowlist) is never admitted, so the actions can only short-circuit
|
||||
* to a rejection with no side effects.
|
||||
*/
|
||||
|
||||
function withAllowlist(value, fn) {
|
||||
const prev = process.env[ENV];
|
||||
if (value === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = value;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = prev;
|
||||
}
|
||||
}
|
||||
|
||||
test("guard denies a non-admin identity (action would short-circuit, no side effects)", () => {
|
||||
withAllowlist("12345", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "99999" }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("guard denies when no admins are configured (fail-closed)", () => {
|
||||
withAllowlist("", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "12345" }), false);
|
||||
});
|
||||
withAllowlist(undefined, () => {
|
||||
assert.equal(isAdmin({ githubUserId: "12345" }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("guard denies an unauthenticated caller (null identity)", () => {
|
||||
withAllowlist("12345", () => {
|
||||
assert.equal(isAdmin(null), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("guard admits only an exact allowlisted id", () => {
|
||||
withAllowlist("12345", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "12345" }), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { buildFilterDescriptor, clampInt } from "../lib/keys/admin-keys-filters.js";
|
||||
|
||||
// Pure filter-shape tests: prove which inputs become SQL predicates, without a
|
||||
// live DB. The actual queries (admin-keys-queries.js) feed these descriptors
|
||||
// into parameterized `postgres` fragments — values are never spliced into SQL.
|
||||
|
||||
test("buildFilterDescriptor: status filters only for pending/active", () => {
|
||||
assert.deepEqual(buildFilterDescriptor({ status: "pending" }), {
|
||||
statusFilter: "pending",
|
||||
search: null,
|
||||
});
|
||||
assert.deepEqual(buildFilterDescriptor({ status: "active" }), {
|
||||
statusFilter: "active",
|
||||
search: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildFilterDescriptor: 'all' / unknown / empty status → no predicate", () => {
|
||||
assert.equal(buildFilterDescriptor({ status: "all" }).statusFilter, null);
|
||||
assert.equal(buildFilterDescriptor({ status: "garbage" }).statusFilter, null);
|
||||
assert.equal(buildFilterDescriptor({}).statusFilter, null);
|
||||
});
|
||||
|
||||
test("buildFilterDescriptor: q is trimmed; blank → no search", () => {
|
||||
assert.equal(buildFilterDescriptor({ q: " octocat " }).search, "octocat");
|
||||
assert.equal(buildFilterDescriptor({ q: " " }).search, null);
|
||||
assert.equal(buildFilterDescriptor({ q: "" }).search, null);
|
||||
assert.equal(buildFilterDescriptor({}).search, null);
|
||||
});
|
||||
|
||||
test("buildFilterDescriptor: combines status and search", () => {
|
||||
assert.deepEqual(buildFilterDescriptor({ q: "42", status: "active" }), {
|
||||
statusFilter: "active",
|
||||
search: "42",
|
||||
});
|
||||
});
|
||||
|
||||
test("clampInt: clamps to range and falls back on non-numeric", () => {
|
||||
assert.equal(clampInt(20, 1, 100, 20), 20);
|
||||
assert.equal(clampInt(0, 1, 100, 20), 1);
|
||||
assert.equal(clampInt(500, 1, 100, 20), 100);
|
||||
assert.equal(clampInt(-5, 0, 100, 0), 0);
|
||||
assert.equal(clampInt("abc", 1, 100, 20), 20);
|
||||
assert.equal(clampInt(undefined, 1, 100, 20), 20);
|
||||
assert.equal(clampInt(3.9, 1, 100, 20), 3); // truncates
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { parseAdminIds, isAdmin } from "../lib/auth/admin-allowlist.js";
|
||||
|
||||
const ENV = "ADMIN_GITHUB_USER_IDS";
|
||||
|
||||
/** Run `fn` with the allowlist env set to `value`, restoring it afterwards. */
|
||||
function withAllowlist(value, fn) {
|
||||
const prev = process.env[ENV];
|
||||
if (value === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = value;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = prev;
|
||||
}
|
||||
}
|
||||
|
||||
test("parseAdminIds: empty / unset → []", () => {
|
||||
assert.deepEqual(parseAdminIds(""), []);
|
||||
assert.deepEqual(parseAdminIds(undefined), []);
|
||||
assert.deepEqual(parseAdminIds(null), []);
|
||||
});
|
||||
|
||||
test("parseAdminIds: trims, drops empties and trailing commas", () => {
|
||||
assert.deepEqual(parseAdminIds(" 1 , 2 ,,3 "), ["1", "2", "3"]);
|
||||
assert.deepEqual(parseAdminIds("42"), ["42"]);
|
||||
});
|
||||
|
||||
test("isAdmin: matches an id in the allowlist", () => {
|
||||
withAllowlist("12345,67890", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "67890" }), true);
|
||||
assert.equal(isAdmin({ githubUserId: "12345" }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test("isAdmin: rejects ids not in the allowlist", () => {
|
||||
withAllowlist("12345", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "99999" }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("isAdmin: exact match only — no substring/prefix match", () => {
|
||||
withAllowlist("42", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "4" }), false);
|
||||
assert.equal(isAdmin({ githubUserId: "420" }), false);
|
||||
assert.equal(isAdmin({ githubUserId: "42" }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test("isAdmin: fail-closed for null/missing identity and empty allowlist", () => {
|
||||
withAllowlist("12345", () => {
|
||||
assert.equal(isAdmin(null), false);
|
||||
assert.equal(isAdmin(undefined), false);
|
||||
assert.equal(isAdmin({}), false);
|
||||
});
|
||||
withAllowlist("", () => {
|
||||
assert.equal(isAdmin({ githubUserId: "12345" }), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user