Brand Studio docs
Engineering manualAPI route source

/api/byo-keys

Exact current route handler: hosted-app/app/api/byo-keys/route.js

Methods: DELETE, GET, POST. Input signals: query parameters. Literal response-status signals: 400, 401, 403, 409, 502, 503.

Source: hosted-app/app/api/byo-keys/route.js. Imports: ../../../lib/all-rest/alpha-parking.mjs, ../../../lib/bounded-http.mjs, ../../../lib/byo-keys-store.mjs, ../../../lib/byo-keys.mjs, ../../../lib/byo-mode.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

import { readBoundedJsonRequest } from "../../../lib/bounded-http.mjs";
// /api/byo-keys — the account's own provider credentials.
//
// GET     what is on file (masked), what each provider is for, and whether
//         this account's keys currently cover a whole pull.
// POST    validate one provider's credentials against that provider, and store
//         them only if the call succeeded.
// DELETE  forget one provider.
//
// THE ONE INVARIANT THIS FILE EXISTS TO HOLD: a key value entered here is
// never returned by any of the three verbs. Not on save, not on read, not in
// an error. The response shape carries `last4` and nothing else, and
// tests/byo-keys-account-endpoint.test.mjs greps the raw response body for the
// exact key strings to prove it — the same test the local panel's endpoint has
// had since that feature shipped.
//
// VALIDATE BEFORE STORE, ALWAYS. A key that has not proved it works must not
// reach the database: a stored-but-dead key makes pulls free (the account
// looks covered) and then fails them, which is the worst of both. So the
// cheapest real call the provider has is made first, with the pasted value,
// and a failure returns 400 having written nothing.

import { NextResponse } from "next/server";
import { supabaseConfigFromEnv } from "../../../lib/supabase-rest.mjs";
import { resolveAuthContext, bearerTokenFromHeader } from "../../../lib/comprehensive-pull.mjs";
import { byoProvider, byoProviderCatalog, byoCoversPull, keySourcesPreview } from "../../../lib/byo-keys.mjs";
import { describeStoredKeys, saveProviderKeys, deleteProviderKeys, readAccountKeyEnv } from "../../../lib/byo-keys-store.mjs";
import { resolveByoMode } from "../../../lib/byo-mode.mjs";
import { ALPHA_PARKED_PROVIDERS, applyAlphaParkingToEnv } from "../../../lib/all-rest/alpha-parking.mjs";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const clean = (value, max) => String(value ?? "").trim().slice(0, max);
const parkedProviderIds = new Set(ALPHA_PARKED_PROVIDERS);
const providerIsParked = (id) => parkedProviderIds.has(String(id || "").trim().toLowerCase());
const visibleCatalog = () => byoProviderCatalog().filter((provider) => !providerIsParked(provider.id));
const visibleStored = (stored = []) => stored.filter((row) => !providerIsParked(row.provider));
const visibleKeySources = (sources = {}) => Object.fromEntries(
  Object.entries(sources).filter(([provider]) => !providerIsParked(provider))
);
const alphaCoverage = (accountEnv) => byoCoversPull(
  accountEnv,
  applyAlphaParkingToEnv(process.env),
  { excludedProviders: ALPHA_PARKED_PROVIDERS }
);

/**
 * Auth first, then the feature flag — the same ordering /api/credits/topup
 * uses, and for the same reason: a signed-out caller on a deployment without
 * BYO should be told to sign in, not told about a feature that would not have
 * served them anyway.
 */
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 }) };
  }
  // Credential administration is intentionally session-only, including the
  // catalog/listing. read_reports and run_pulls never imply credential access.
  if (authContext.apiKey || !authContext.token) {
    return { refusal: NextResponse.json({ status: "refused", reason: "session_required_for_credentials" }, { status: 403 }) };
  }
  const mode = resolveByoMode(process.env);
  if (!mode.enabled) {
    // 403 with the reason in plain English rather than a 404: "not here" and
    // "not yet" are different facts, and `source: no_secret` in particular is
    // an OPERATOR's problem the operator can only fix if it is said out loud.
    return {
      refusal: NextResponse.json({
        status: "refused",
        reason: "byo_not_available",
        detail: mode.detail,
        source: mode.source
      }, { status: 403 })
    };
  }
  if (!config.supabaseServiceRoleKey) {
    return {
      refusal: NextResponse.json({
        status: "refused",
        reason: "byo_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, mode };
};

export async function GET(request) {
  const gated = await gate(request);
  if (gated.refusal) return gated.refusal;
  const { authContext, config, mode } = gated;

  const stored = await describeStoredKeys({ userId: authContext.user.id, config });
  // WHETHER THE DISCOUNT APPLIES, computed from the DECRYPTED set rather than
  // from the masked one. A row whose ciphertext no longer decrypts (a rotated
  // server secret) is stored but unusable, and telling the customer their
  // pulls are free when the capture will fall back to our keys and charge them
  // is the one wrong answer this page can give.
  const accountEnv = await readAccountKeyEnv({ userId: authContext.user.id, config, env: process.env });
  const coverage = alphaCoverage(accountEnv);

  return NextResponse.json({
    status: "passed",
    byo: { enabled: mode.enabled, detail: mode.detail },
    providers: visibleCatalog(),
    stored: visibleStored(stored),
    coverage,
    // The precedence preview — which key each provider WOULD use on the next
    // pull. Same {provider, source, last4} shape the local panel answers with,
    // and it calls no provider.
    key_sources: visibleKeySources(keySourcesPreview(accountEnv, applyAlphaParkingToEnv(process.env)))
  });
}

export async function POST(request) {
  const gated = await gate(request);
  if (gated.refusal) return gated.refusal;
  const { authContext, config } = gated;

  let body;
  try { body = await readBoundedJsonRequest(request); }
  catch (error) { return NextResponse.json({ status: "refused", reason: error.code || "invalid_json" }, { status: error.httpStatus || 400 }); }
  const requestedProvider = clean(body?.provider, 40).toLowerCase();
  if (providerIsParked(requestedProvider)) {
    return NextResponse.json({
      status: "refused",
      reason: "alpha_parked:provider_not_available",
      provider: requestedProvider,
      charged: false
    }, { status: 409 });
  }
  const definition = byoProvider(body?.provider);
  if (!definition) {
    return NextResponse.json({
      status: "refused",
      reason: "unknown_provider",
      detail: `Choose one of: ${visibleCatalog().map((entry) => entry.id).join(", ")}.`
    }, { status: 400 });
  }

  // Only this provider's own declared fields are read off the body. A key the
  // catalog does not name cannot be smuggled into the store by naming it in
  // JSON — the same reasoning packById uses for pack ids.
  const values = {};
  for (const key of definition.envKeys) {
    const value = clean(body?.values?.[key], 400);
    if (!value) {
      return NextResponse.json({ status: "refused", reason: "missing_field", field: key }, { status: 400 });
    }
    values[key] = value;
  }

  // THE REAL CALL, BEFORE ANYTHING IS WRITTEN.
  const verdict = await definition.validate(values, process.env);
  if (!verdict.valid) {
    return NextResponse.json({
      status: verdict.status === "needs_attention" ? "needs_attention" : "refused",
      reason: verdict.status === "needs_attention" ? verdict.reason : "validation_failed",
      // The provider's own refusal, named. Never the key, and never a guess
      // about which of two DataForSEO fields was wrong — the provider does not
      // tell us and inventing an answer would send the customer to the wrong
      // field.
      detail: verdict.detail || verdict.reason || "the provider refused this credential",
      provider: definition.id
    }, { status: verdict.status === "needs_attention" ? 409 : 400 });
  }

  const saved = await saveProviderKeys({
    userId: authContext.user.id,
    provider: definition.id,
    values,
    config,
    env: process.env
  });
  if (saved.status !== "passed") {
    return NextResponse.json({ status: "failed", reason: saved.reason }, { status: 502 });
  }

  // The WHOLE new state, the same way /api/credits/topup answers with the
  // whole credits object — a response that is "the new state" has to actually
  // be the whole state, or the client's one object is replaced by a partial.
  const accountEnv = await readAccountKeyEnv({ userId: authContext.user.id, config, env: process.env });
  return NextResponse.json({
    status: "passed",
    provider: definition.id,
    stored: visibleStored(await describeStoredKeys({ userId: authContext.user.id, config })),
    coverage: alphaCoverage(accountEnv),
    key_sources: visibleKeySources(keySourcesPreview(accountEnv, applyAlphaParkingToEnv(process.env)))
  });
}

export async function DELETE(request) {
  const gated = await gate(request);
  if (gated.refusal) return gated.refusal;
  const { authContext, config } = gated;

  const url = new URL(request.url);
  const provider = clean(url.searchParams.get("provider"), 40);
  const removed = await deleteProviderKeys({ userId: authContext.user.id, provider, config });
  if (removed.status !== "passed") {
    return NextResponse.json({ status: "failed", reason: removed.reason }, { status: removed.reason === "unknown_provider" ? 400 : 502 });
  }

  const accountEnv = await readAccountKeyEnv({ userId: authContext.user.id, config, env: process.env });
  return NextResponse.json({
    status: "passed",
    provider,
    stored: visibleStored(await describeStoredKeys({ userId: authContext.user.id, config })),
    coverage: alphaCoverage(accountEnv),
    key_sources: visibleKeySources(keySourcesPreview(accountEnv, applyAlphaParkingToEnv(process.env)))
  });
}