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