Brand Studio docs
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 });
}