Engineering manualAPI route source
/api/interview-prefill
Exact current route handler: hosted-app/app/api/interview-prefill/route.js
Methods: POST. Input signals: none detected. Literal response-status signals: 400, 401, 429.
Source: hosted-app/app/api/interview-prefill/route.js. Imports: ../../../lib/bounded-http.mjs, ../../../lib/comprehensive-pull.mjs, ../../../lib/local/interview-prefill-ai.mjs, ../../../lib/model-usage-ledger.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";
// POST /api/interview-prefill — the confirm step's answer suggestions.
//
// The sibling of /api/brand-check, on exactly the same terms, for the other
// half of the same screen. That route asks a model to DOUBT a row the customer
// is looking at; this one asks it to OFFER candidate answers for the boxes the
// customer has not filled in yet. Both run before a credit moves, both are
// billed to us, and neither can change a single thing without a human tapping.
//
// "throw cost out the window ... I want the user to be given as many options
// as possible." — the owner, upgrade proposal `gateway_interview_prefills`
//
// FREE, AND THEREFORE BOUNDED. Three bounds, copied from brand-check rather
// than re-decided, because the abuse shape is identical:
// - SIGNED IN ONLY. Anonymous callers get 401. The free instant card is the
// public tier; this is not it.
// - ONE BUCKET PER USER PER HOUR, not per IP: the cost follows the account.
// - IT FETCHES NOTHING. Not one byte of network beyond the single model call.
// The evidence — title, description, headline, navigation, the homepage's
// own words — arrives in the request body, assembled by
// buildConfirmationPrefill from the FREE instant card the client already
// holds (user-confirmations.mjs's aiEvidenceFromCard). If this route ever
// starts fetching, the property that makes the confirm screen worth having
// (it opens at $0.00 and adds no wait) is gone.
//
// THERE IS NO ERROR BRANCH FOR THE CLIENT. Absent key, timeout, non-200,
// malformed JSON, nothing quotable, an unreadable page — every one of them
// answers `{status: "ok", fields: []}`. The dialog treats "no candidates"
// identically whether the cause was "the model said nothing" or "the model was
// never asked", which is what makes the no-key deployment byte-identical to
// today's screen instead of a screen with a broken feature on it.
//
// The one thing this route deliberately does NOT do is write anything down. A
// suggestion is not a finding, it has no place in a stored pull, and a
// suggestion the customer ignored is not evidence about their brand.
import { NextResponse } from "next/server";
import { supabaseConfigFromEnv } from "../../../lib/supabase-rest.mjs";
import { bearerTokenFromHeader, resolveAuthContext } from "../../../lib/comprehensive-pull.mjs";
import { modelUsageAllowed, modelUsageEntry, recordModelUsage } from "../../../lib/model-usage-ledger.mjs";
import {
INTERVIEW_PREFILL_SCHEMA_VERSION,
createInterviewPrefillEngine,
evidenceIsReadable,
normalizePrefillEvidence
} from "../../../lib/local/interview-prefill-ai.mjs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// TWELVE AN HOUR, PER ACCOUNT — the same number, for the same reason, as
// /api/brand-check's CHECKS_PER_HOUR. A confirm screen is opened once or twice
// per pull, so twelve is generous for a person and useless for a script. Same
// per-instance caveat the instant route documents: serverless instances are
// ephemeral, so this is a speed bump and production must also carry a
// platform-level limit.
const PREFILLS_PER_HOUR = 12;
const WINDOW_MS = 3600000;
const buckets = new Map();
const allowRequest = (key) => {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now - bucket.startedAt >= WINDOW_MS) {
buckets.set(key, { startedAt: now, used: 1 });
if (buckets.size > 5000) buckets.clear();
return true;
}
if (bucket.used >= PREFILLS_PER_HOUR) return false;
bucket.used += 1;
return true;
};
/** Exported for the test: the bucket is per-user and resets on the hour. */
export const __resetInterviewPrefillBuckets = () => buckets.clear();
/**
* The evidence, out of whatever the caller posted.
*
* `ai_evidence` is the block buildConfirmationPrefill assembles; `evidence` is
* accepted too so a caller that holds the block on its own (a test, the CLI)
* does not have to fake a prefill around it. Everything is re-sanitized by
* normalizePrefillEvidence — this arrives over HTTP and is not trusted.
*/
export const evidenceFromBody = (body = null) =>
normalizePrefillEvidence(body?.ai_evidence || body?.evidence || body?.prefill?.ai_evidence || {});
/** The empty answer, in the one shape this route ever returns. */
const emptyAnswer = (startedMs, reason = "") =>
NextResponse.json({
status: "ok",
schema_version: INTERVIEW_PREFILL_SCHEMA_VERSION,
prefill_status: "skipped",
...(reason ? { reason } : {}),
fields: [],
elapsed_ms: Date.now() - startedMs
});
export async function POST(request) {
const startedMs = Date.now();
const { supabaseUrl, supabaseAnonKey } = supabaseConfigFromEnv(process.env);
const auth = await resolveAuthContext({
token: bearerTokenFromHeader(request.headers.get("authorization")),
supabaseUrl,
supabaseAnonKey
});
// The free PRE-PULL read is still an account-only read. No credit is checked
// and none is charged. The model call's provider cost is recorded as a
// zero-credit usage row (model-usage-ledger.mjs); no balance moves.
if (!auth.authenticated) {
return NextResponse.json(
{ status: "refused", reason: "signup_required", detail: auth.reason || "authentication required" },
{ status: 401 }
);
}
if (!allowRequest(auth.user.id)) {
return NextResponse.json(
{ status: "rate_limited", reason: "too_many_prefills", detail: `at most ${PREFILLS_PER_HOUR} suggestion reads per hour` },
{ status: 429 }
);
}
if (process.env.BRAND_STUDIO_ASSISTED_PREFLIGHT !== "on") {
return NextResponse.json({ status: "ok", prefill_status: "skipped", fields: [], decisions: [], reason: "assisted_preflight_not_enabled", charged: false });
}
let body;
try { body = await readBoundedJsonRequest(request); }
catch (error) { return NextResponse.json({ status: "refused", reason: error.code || "invalid_json" }, { status: error.httpStatus || 400 }); }
if (!body || typeof body !== "object") {
return NextResponse.json({ status: "blocked", reason: "missing_body" }, { status: 400 });
}
const evidence = evidenceFromBody(body);
// Nothing readable arrived — a bot-walled or unreadable homepage. The screen
// already renders that honestly (ConfirmBrandDialog's rule 4); it does not
// also need a model to be asked about an empty page.
if (!evidenceIsReadable(evidence)) return emptyAnswer(startedMs, "no_evidence");
// Today's model-call caps, checked before the call (model-usage-ledger.mjs).
const cap = await modelUsageAllowed({ userId: auth.user.id });
if (!cap.ok) return emptyAnswer(startedMs, cap.reason);
const engine = createInterviewPrefillEngine({ env: process.env });
const result = await engine.suggest({ evidence });
// The call's real cost, written down (zero credits; see model-usage-ledger.mjs).
await recordModelUsage({ entry: modelUsageEntry({ userId: auth.user.id, line: "interview_prefill", cost: result.cost }) });
// Candidates go out whatever the status: an absent or failed model produces
// an empty list, and the client is written to draw nothing for it. There is
// no error branch for the caller to handle.
return NextResponse.json({
status: "ok",
schema_version: INTERVIEW_PREFILL_SCHEMA_VERSION,
prefill_status: result.status,
...(result.reason ? { reason: result.reason } : {}),
fields: result.fields,
elapsed_ms: Date.now() - startedMs
});
}