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