Engineering manualAPI route source
/api/instant
Exact current route handler: hosted-app/app/api/instant/route.js
Methods: GET. Input signals: query parameters. Literal response-status signals: 429.
Source: hosted-app/app/api/instant/route.js. Imports: ../../../lib/instant-read.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
// Hosted instant brand card — the free, public, UNLIMITED tier (D18).
//
// Deterministic homepage extraction, no AI, no browser, no filesystem writes
// (owner decisions 2026-08-05/06): stateless by construction so it deploys on
// serverless. Tap-to-swap overrides are client-side session state only; the
// local run-dir sidecar does not exist here. Comprehensive capture now ships
// at POST/GET /api/comprehensive (D18: authenticated, all-REST, START+POLL) —
// this route is unaffected and stays public and unlimited per that decision.
//
// SAME EXTRACTOR AS THE PANEL, since 2026-08-15.
//
// This route used to call a lighter second implementation (buildInstantCard),
// and the owner ran the hosted card on ign.com and got, in his words, garbage:
// no logo at all, three chrome-y "observed accents" instead of IGN's red, the
// whole SEO title ("VIDEO GAME NEWS, REVIEWS, AND WALKTHROUGHS — IGN") printed
// as the brand's name, and two "fonts" called VAR(--FONTFAMILYNOVAR) and
// VAR(--FONTFAMILYVAR). The panel's card for the same URL had the real
// ignimgs logo, the brand hex, and the name "IGN". Two extractors meant two
// answers to one question, and the customer-facing one was the worse of them.
// There is now ONE (src/local/instant-card-rich.mjs, byte-copied into
// hosted-app/lib/local/ and enforced by tests/hosted-app-copy-drift.test.mjs).
//
// The round-4 guards are unchanged and, on the redirect path, stronger:
// - assertPublicHttpTarget (DNS-resolving, denies loopback/link-local/
// metadata) still runs on the URL the customer typed, before any fetch;
// - it now ALSO runs on every redirect hop and every stylesheet the page
// declares, because the rich extractor takes a `validateHop` callback and
// this route passes the resolving check rather than the syntactic one;
// - the bot-wall gate still refuses to present an interstitial as a brand,
// and now sees the same inputs the panel's gate sees (real body text and
// the document's own HTTP status), so a 403 challenge page is caught here
// the way it is caught there.
//
// ANSWERS (releases 047 and 050):
// 200 status "ok" (a card), or "blocked_by_site" (a bot check, not a card)
// 424 status "failed": the homepage could not be read. reason is timeout,
// unreachable, connection_dropped, too_many_redirects, redirect_blocked,
// http_NNN or empty_homepage, with a message sentence, retry, and
// Retry-After when a retry can help
// 500 status "failed", reason internal_error: our fault, logged, retry false
// 400 / 422 an address that cannot be read; 429 rate limited
//
// LOGOS (release 051): a brand.logos row is a link, or, when the site draws
// its header logo inline, a sanitized "data:image/svg+xml;base64," image of
// at most 6,000 bytes marked inline_svg: true. not_from_site marks a borrowed
// public-library mark, as before.
import { NextResponse } from "next/server";
import { readInstantBrand, INSTANT_READ_BUDGET_MS } from "../../../lib/instant-read.mjs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Minimal per-instance token bucket. Serverless instances are ephemeral, so
// this is a speed bump, not the abuse posture: production must ALSO enforce
// platform-level rate limits.
const BUCKET_CAPACITY = 10;
const REFILL_PER_MS = 10 / 60000; // 10 requests per minute
const buckets = new Map();
const allowRequest = (key) => {
const now = Date.now();
const bucket = buckets.get(key) || { tokens: BUCKET_CAPACITY, at: now };
bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + (now - bucket.at) * REFILL_PER_MS);
bucket.at = now;
if (bucket.tokens < 1) { buckets.set(key, bucket); return false; }
bucket.tokens -= 1;
buckets.set(key, bucket);
if (buckets.size > 5000) buckets.clear();
return true;
};
// The extraction itself lives in lib/instant-read.mjs, shared with the saved
// brand path (POST /api/brands/basic) so a saved read is the same read.
export async function GET(request) {
const clientKey = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anonymous";
if (!allowRequest(clientKey)) {
return NextResponse.json({ status: "rate_limited", reason: "too_many_requests" }, { status: 429 });
}
const url = new URL(request.url).searchParams.get("url") || "";
// The browser waits 30 s for this answer: the read stops at 27 s (075).
const result = await readInstantBrand({ url, env: process.env, budgetMs: INSTANT_READ_BUDGET_MS });
return NextResponse.json(result.body, { status: result.http_status, ...(result.headers ? { headers: result.headers } : {}) });
}