Engineering manualAPI route source
/api/account/api-keys
Exact current route handler: hosted-app/app/api/account/api-keys/route.js
Methods: DELETE, GET, POST. Input signals: JSON body, query parameters. Literal response-status signals: 400, 401, 403, 503.
Source: hosted-app/app/api/account/api-keys/route.js. Imports: ../../../../lib/api-keys-store.mjs, ../../../../lib/api-keys.mjs, ../../../../lib/comprehensive-pull.mjs, ../../../../lib/supabase-rest.mjs, next/server. Exact source is shown for inspection; its presence does not prove authentication, validation or live behavior.
All API routes · API and execution contract
// /api/account/api-keys — the account's own service-account credentials.
//
// GET every key on file, in the safe display shape (name, scopes, last4,
// created_at, last_used_at, revoked_at) — never the hash.
// POST mint a new key. `{ name, scopes: ["run_pulls", "read_reports"] }`.
// The plaintext is returned in THIS ONE RESPONSE and never again.
// DELETE revoke one key, by id (`?id=...`).
//
// SIGNED-IN ONLY, AND A REAL SESSION — not an API key. Managing an account's
// programmatic identity (minting or revoking the credentials a script or
// agent holds) is a decision only the human who owns the account should be
// able to make from a machine credential; a leaked key that could mint its
// own siblings, or revoke the one being used to catch it doing so, would be a
// key that could outlive every attempt to shut it down. See the gate() below.
//
// THE ONE INVARIANT THIS FILE EXISTS TO HOLD, same shape as
// hosted-app/app/api/byo-keys/route.js's: a key's plaintext value is present
// in exactly one response — the POST that just created it — and every other
// verb, and every error, carries only the safe shape from
// describeApiKeyRow().
import { NextResponse } from "next/server";
import { supabaseConfigFromEnv } from "../../../../lib/supabase-rest.mjs";
import { resolveAuthContext, bearerTokenFromHeader } from "../../../../lib/comprehensive-pull.mjs";
import { API_KEY_SCOPES, cleanKeyName } from "../../../../lib/api-keys.mjs";
import { listApiKeys, createApiKey, revokeApiKey } from "../../../../lib/api-keys-store.mjs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const gate = async (request) => {
const config = supabaseConfigFromEnv(process.env);
const token = bearerTokenFromHeader(request.headers.get("authorization"));
const authContext = await resolveAuthContext({ token, ...config });
if (!authContext.authenticated) {
return { refusal: NextResponse.json({ status: "refused", reason: "signup_required" }, { status: 401 }) };
}
// AN API KEY MAY NOT MANAGE API KEYS. See this file's header.
if (authContext.apiKey) {
return {
refusal: NextResponse.json({
status: "refused",
reason: "session_required",
detail: "API keys cannot create, list or revoke API keys — sign in with your account to manage them."
}, { status: 403 })
};
}
if (!config.supabaseServiceRoleKey) {
return {
refusal: NextResponse.json({
status: "refused",
reason: "api_keys_not_configured",
detail: "SUPABASE_SERVICE_ROLE_KEY is not set. This table has RLS on with no client policies, so nothing can read or write it without the service role."
}, { status: 503 })
};
}
return { authContext, config };
};
export async function GET(request) {
const gated = await gate(request);
if (gated.refusal) return gated.refusal;
const keys = await listApiKeys({ userId: gated.authContext.user.id, config: gated.config });
return NextResponse.json({ status: "passed", scopes: API_KEY_SCOPES, keys });
}
export async function POST(request) {
const gated = await gate(request);
if (gated.refusal) return gated.refusal;
const body = await request.json().catch(() => ({}));
const created = await createApiKey({
userId: gated.authContext.user.id,
name: cleanKeyName(body?.name),
scopes: body?.scopes,
config: gated.config
});
if (created.status !== "passed") {
return NextResponse.json({
status: "refused",
reason: created.reason,
detail: created.reason === "no_valid_scopes"
? `Choose at least one of: ${API_KEY_SCOPES.join(", ")}.`
: undefined
}, { status: created.reason === "no_valid_scopes" ? 400 : 502 });
}
return NextResponse.json({
status: "passed",
// SHOWN ONCE. No other response this route ever sends — not GET, not a
// later POST, not this same POST's own retry — carries this field again.
key: created.key,
api_key: created.row,
note: "Save this key now. It will not be shown again — only the last four characters are kept for display."
});
}
export async function DELETE(request) {
const gated = await gate(request);
if (gated.refusal) return gated.refusal;
const keyId = String(new URL(request.url).searchParams.get("id") || "").trim();
if (!keyId) return NextResponse.json({ status: "refused", reason: "missing_id" }, { status: 400 });
const revoked = await revokeApiKey({ userId: gated.authContext.user.id, keyId, config: gated.config });
if (revoked.status !== "passed") {
return NextResponse.json(
{ status: "failed", reason: revoked.reason },
{ status: revoked.reason === "not_found_or_already_revoked" ? 404 : 502 }
);
}
return NextResponse.json({ status: "passed", api_key: revoked.row });
}