Brand Studio docs
Engineering manualAPI route source

/api/brand-check

Exact current route handler: hosted-app/app/api/brand-check/route.js

Methods: POST. Input signals: none detected. Literal response-status signals: 400, 401, 429.

Source: hosted-app/app/api/brand-check/route.js. Imports: ../../../lib/bounded-http.mjs, ../../../lib/comprehensive-pull.mjs, ../../../lib/local/brand-adjudicator.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/brand-check — the adjudicator, asked during the QUESTION step.
//
// This is the second half of the owner's ask. The first half shipped as the
// confirm-your-brand screen (ec9aa5c): before a credit moves, a human is shown
// what the pull worked out and invited to fix it. What the screen could not do
// was DOUBT itself — it printed every prefilled row in the same confident
// black, including a row that any reader of the candidate's own words would
// throw out immediately.
//
//   "i think its safe to say some ai will help to determine if certain things
//    are accurate when compared to a brand spec of the url, the logo, etc to
//    match with ai understanding etc in the question and refinement steps"
//
// So: one batched model call, on the free side of the paywall, whose entire
// output is a caution chip and a quoted reason next to a row the human is
// already looking at. It cannot remove a row, it cannot block the run, and it
// cannot bless a row either — see src/local/brand-adjudicator.mjs's rule 1.
//
// FREE, AND THEREFORE BOUNDED. This runs before the credit is spent, so it is
// billed to us, not to the customer. Three bounds keep that honest:
//   - 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 OpenAI
//     call. Candidate text must arrive in the request.
//
// WHAT THIS ROUTE CANNOT DO, said plainly. A confirm-step prefill is read off
// the free instant card, and that card's social rows are LINKS SCRAPED FROM A
// HOMEPAGE — a platform, a URL and a handle, with no bio, no channel
// description, no about text. There is therefore usually nothing for a verdict
// to quote, and a verdict that cannot quote is "cannot_tell", which draws no
// chip (rule 2). That is the correct outcome and not a bug to be worked around
// by fetching profiles here: fetching them is what the PAID pull does, with
// the customer's consent and on the customer's credit.
//
// The route consequently adjudicates whatever candidate text the CALLER
// supplies — `prefill.socials[].description` when a client already holds one,
// or an explicit `candidates` array from any caller that does (the capture
// path holds ScrapeCreators profile payloads and is the real source of these).
// Given none, it answers `{ verdicts: [] }` in a millisecond and the dialog
// draws exactly what it drew before this route existed.

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 {
  ADJUDICATOR_LIMITS,
  ADJUDICATOR_SUBJECTS,
  BRAND_ADJUDICATOR_SCHEMA_VERSION,
  createBrandAdjudicator
} from "../../../lib/local/brand-adjudicator.mjs";

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

// TWELVE CHECKS AN HOUR, PER ACCOUNT. 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 CHECKS_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 >= CHECKS_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 __resetBrandCheckBuckets = () => buckets.clear();

const clean = (value, max = 400) => String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max);

/**
 * Candidates out of a posted prefill, plus any the caller passed explicitly.
 *
 * A prefill social row only becomes a candidate when it carries text of its
 * own — see this file's header for why that is usually false and why that is
 * correct.
 */
export const candidatesFromPrefill = ({ prefill = null, candidates = [] } = {}) => {
  const out = [];
  for (const row of Array.isArray(prefill?.socials) ? prefill.socials : []) {
    const text = clean(row?.description || row?.bio || row?.about || row?.title, ADJUDICATOR_LIMITS.candidate_text_chars);
    if (!text) continue;
    const platform = clean(row?.platform, 40);
    const handle = clean(row?.handle, 120);
    out.push({
      subject_type: ADJUDICATOR_SUBJECTS.SOCIAL_PROFILE,
      subject_key: platform && handle ? `${platform}:${handle}` : clean(row?.url, 200),
      label: clean(row?.url, 200),
      text
    });
  }
  for (const row of Array.isArray(candidates) ? candidates : []) {
    const text = clean(row?.text, ADJUDICATOR_LIMITS.candidate_text_chars);
    const key = clean(row?.subject_key, 200);
    if (!text || !key) continue;
    out.push({
      subject_type: clean(row?.subject_type, 40) || ADJUDICATOR_SUBJECTS.SOCIAL_PROFILE,
      subject_key: key,
      label: clean(row?.label, 200),
      text
    });
  }
  return out.slice(0, ADJUDICATOR_LIMITS.candidates);
};

/**
 * The brand spec the candidates are judged against, built from what the
 * customer is looking at on the screen. This is the confirm step, so the
 * customer's own typed answers ARE the spec — there is no capture yet.
 */
export const anchorFromPrefill = ({ prefill = null, url = "" } = {}) => ({
  brand_name: clean(prefill?.brand_name, 80),
  domain: clean(prefill?.domain || url, 200),
  site_title_phrase: clean(prefill?.search_phrase, 160),
  business_category: clean(prefill?.business_type, 120),
  city: clean(prefill?.location, 80),
  site_linked_profiles: (Array.isArray(prefill?.socials) ? prefill.socials : [])
    .map((row) => ({ url: clean(row?.url, 200), handle: clean(row?.handle, 120) }))
    .filter((row) => row.url || row.handle)
});

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_checks", detail: `at most ${CHECKS_PER_HOUR} brand checks per hour` },
      { status: 429 }
    );
  }

  if (process.env.BRAND_STUDIO_ASSISTED_PREFLIGHT !== "on") {
    return NextResponse.json({ status: "ok", adjudicator_status: "skipped", verdicts: [], checked: 0, 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 candidates = candidatesFromPrefill({ prefill: body.prefill, candidates: body.candidates });
  if (!candidates.length) {
    // Nothing quotable arrived. Answering "no verdicts" is the honest empty
    // result, and the dialog draws no chips — which is what it did before.
    return NextResponse.json({
      status: "ok",
      schema_version: BRAND_ADJUDICATOR_SCHEMA_VERSION,
      verdicts: [],
      checked: 0,
      elapsed_ms: Date.now() - startedMs
    });
  }

  // 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 NextResponse.json({ status: "ok", schema_version: BRAND_ADJUDICATOR_SCHEMA_VERSION, adjudicator_status: "skipped", verdicts: [], checked: 0, reason: cap.reason, charged: false, elapsed_ms: Date.now() - startedMs });
  const adjudicator = createBrandAdjudicator({ env: process.env });
  const result = await adjudicator.adjudicate({
    anchor: anchorFromPrefill({ prefill: body.prefill, url: body.url }),
    candidates
  });
  // The call's real cost, written down (zero credits; see model-usage-ledger.mjs).
  await recordModelUsage({ entry: modelUsageEntry({ userId: auth.user.id, line: "brand_adjudication", cost: result.cost }) });

  // Verdicts go out whatever the status: an absent or failed model produces a
  // full set of inert "cannot_tell" rows, and the client is written to draw
  // nothing for them. There is no error branch for the caller to handle.
  return NextResponse.json({
    status: "ok",
    schema_version: BRAND_ADJUDICATOR_SCHEMA_VERSION,
    adjudicator_status: result.status,
    verdicts: result.verdicts,
    checked: candidates.length,
    elapsed_ms: Date.now() - startedMs
  });
}