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)))
});
}