Engineering manualAPI route source
/api/comprehensive
Exact current route handler: hosted-app/app/api/comprehensive/route.js
Methods: GET, POST. Input signals: query parameters. Literal response-status signals: 202, 422.
Source: hosted-app/app/api/comprehensive/route.js. Imports: ../../../lib/bounded-http.mjs, ../../../lib/comprehensive-pull-handler.mjs, ../../../lib/comprehensive-pull-queue-runner.mjs, ../../../lib/comprehensive-pull-queue.mjs, ../../../lib/local/search-query-plan.mjs, ../../../lib/local/url-safety.mjs, ../../../lib/queue-continuation.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
// Hosted comprehensive pull — the paid, authenticated, all-REST tier (D18).
//
// D18 ("public gets the INSTANT pull; COMPREHENSIVE requires login/signup")
// supersedes the earlier "comprehensive tier is coming-soon" posture in
// app/api/instant/route.js. Route 2 from deploy/HOSTING.md: every leg served
// by a REST provider (Firecrawl / Serper+keyless-ladder / ScrapeCreators+
// Apify), so it runs on this Node serverless function with no browser and no
// yt-dlp — see hosted-app/lib/all-rest/all-rest-orchestrator.mjs.
//
// START + POLL, not one long request:
// POST returns {status:"running", pull_id, run_id, lane} once auth, credits,
// and the brand/pull rows are settled — see
// hosted-app/lib/comprehensive-pull-handler.mjs's
// authorizeAndReserveComprehensivePull for exactly what that checks and in
// what order (auth BEFORE credits BEFORE any row exists).
// GET ?pull_id=... polls current status from Supabase.
//
// ============================ THE 300s WALL ============================
//
// THE LIMITATION THAT USED TO BE HONEST AND IS NOW CLOSED.
//
// Vercel functions are request-scoped. The capture is scheduled with
// next/server's after(), which is the supported way to continue work past the
// response — but the platform still kills the invocation at maxDuration
// (300s), and a capture that had not finished writing the pull row by then was
// left `running` forever until the reaper found it. Two real 30-page pulls
// died at ~301s exactly this way.
//
// Raising maxDuration is not the fix, because 300s IS the ceiling on this
// plan, and because "the work happens to fit" is not an architecture. THE FIX
// IS THAT THE PULL ROW IS NOW A RESUMABLE JOB.
//
// - The whole request (urls, queries, social profiles, page/social budgets)
// is written onto the row at reservation time, so any later invocation can
// reconstruct what was asked for.
// - The capture is cut into STAGES along its real dependency graph. A slice
// runs as many stages as fit in a conservative budget (240s), persists
// every settled leg, and yields.
// - The seo leg — the measured long pole, a serial list of SERP calls —
// chunks INSIDE itself and checkpoints per chunk, so even one leg can
// cross a slice boundary without losing calls already paid for.
// - Three independent triggers pick a yielded job back up (the poll below,
// the cron/ticker on ./advance, and a slice's own self-invocation), all
// gated by one fencing-token compare-and-swap so that exactly one of them
// ever advances a given job at a given moment.
// - Billing happens once, at true completion, through the one CAS-guarded
// finalizer both lanes share.
//
// See hosted-app/lib/comprehensive-pull-queue.mjs for the four invariants this
// rests on, and ./advance/route.js for the trigger that runs unattended.
//
// The DIRECT lane (BRAND_STUDIO_PULL_QUEUE=off) is the pre-queue behaviour,
// kept as a fallback: one after(), the whole capture, and therefore the
// shallow depth that measurably fits inside 300s. The UI labels which lane a
// pull ran on rather than implying the same depth in both.
//
// ============================== THE REAPER ==============================
//
// Still here, and now able to tell the two failures apart. A `running` row is
// reaped to a terminal `failed` state — never `partial`, never `passed`, and
// per debitForPull a `failed` pull is NEVER charged — when it has stopped
// ADVANCING, which for a sliced job means "no checkpoint progress for longer
// than the idle threshold" and for a direct-lane pull still means "created
// longer ago than the threshold". Measuring a multi-slice job from created_at
// would reap every job the queue exists for; see stalenessForRunningPull.
//
// It fires lazily on the customer's own GET poll below, and on demand via
// POST /api/comprehensive/sweep. Neither is a cron — ./advance is.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// 300s: the platform ceiling on this plan, confirmed against Vercel's duration
// docs for Hobby-with-Fluid. Unlike before the queue, this number is no longer
// load-bearing for correctness — a slice is BUDGETED at 240s and yields on its
// own, so an invocation killed at the wall costs at most the stage that was
// in flight, and the job resumes from its last checkpoint. The number bounds
// how much work one slice can do, not whether the pull can finish.
export const maxDuration = 300;
import { NextResponse, after } from "next/server";
import { supabaseConfigFromEnv } from "../../../lib/supabase-rest.mjs";
import { assertPublicHttpTarget } from "../../../lib/local/url-safety.mjs";
import { searchQueriesFor } from "../../../lib/local/search-query-plan.mjs";
import { readBoundedJsonRequest } from '../../../lib/bounded-http.mjs';
import {
authorizeAndReserveComprehensivePull,
runCaptureAndFinalizeComprehensivePull,
readComprehensivePullStatus,
bearerTokenFromHeader,
DEFAULT_STALE_RUNNING_MS
} from "../../../lib/comprehensive-pull-handler.mjs";
import {
queueEnabledFromEnv,
QUEUE_PAGE_BUDGET,
QUEUE_SCREENSHOT_BUDGET,
QUEUE_SOCIAL_LIMIT,
DIRECT_PAGE_BUDGET,
DIRECT_SOCIAL_LIMIT
} from "../../../lib/comprehensive-pull-queue.mjs";
import {
advanceComprehensivePullSlice,
triggerNextSlice
} from "../../../lib/comprehensive-pull-queue-runner.mjs";
import { sliceLoopDecision } from "../../../lib/queue-continuation.mjs";
/**
* Run slices until the job completes or this invocation runs out of room.
*
* One invocation can hold several slices back to back when the earlier ones
* were cheap (the static stage costs no network at all), which is why this
* loops rather than running exactly one. It stops as soon as a slice yields
* for budget reasons — that yield IS the signal that this invocation is done —
* and asks the next invocation to continue. Every slice's budget counts from
* invocationStartMs, the start of this request (081), so a second slice gets
* only what the request has left of the slice budget.
*/
const driveSlices = async ({ pullId, config, env, invocationStartMs = Date.now() }) => {
const startedMs = invocationStartMs;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const result = await advanceComprehensivePullSlice({
pullId,
config,
env,
invocationStartMs,
alphaParking: true,
holder: `invocation-${process.pid}`,
log: (line) => console.log(`[pull-queue] ${line}`)
});
// Stop when someone else holds it, it is done, or billing is paced by
// owner polls and the worker sweep. A slice that yielded because it ran
// out of budget has used this invocation's room too; hand off rather than
// starting another here. A planned_ yield also hands off (080), as
// /advance does, so a pull keeps moving after the tab closes.
//
// finalize_unconfirmed_retryable ALSO hands off: the runner already made
// three in-place finalize attempts before saying it, so looping here
// would only stack three-attempt batches against the same outage until
// the 200-second guard (and every 3-second poll would start another
// driver doing the same). One bounded batch per invocation; the poll,
// lease expiry, and the sweep pace the rest.
const decision = sliceLoopDecision(result, { elapsedMs: Date.now() - startedMs });
if (decision === "stop") return result;
if (decision === "handoff") {
// eslint-disable-next-line no-await-in-loop
const trigger = await triggerNextSlice({ pullId, env });
console.log(`[pull-queue] handoff pull=${pullId} triggered=${trigger.triggered} ${trigger.reason || ""}`);
return result;
}
}
};
export async function POST(request) {
const invocationStartMs = Date.now();
const token = bearerTokenFromHeader(request.headers.get("authorization"));
let body;
try { body = await readBoundedJsonRequest(request, 131072); }
catch (error) { return NextResponse.json({status:'refused',reason:error.message}, {status:error.httpStatus||400}); }
const config = supabaseConfigFromEnv(process.env);
const queueLane = queueEnabledFromEnv(process.env);
// URL safety runs BEFORE auth and credits, because a refusal here costs the
// caller nothing and creates no row. Fourteen collectors are about to be
// pointed at this address; on a hosted deployment, one of them reaching
// 169.254.169.254 is a credential-exfiltration path, not a bad pull.
const target = await assertPublicHttpTarget(body?.url || body?.source_url || "");
if (!target.ok) {
return NextResponse.json({
status: "blocked",
reason: target.reason,
message: target.message
}, { status: 422 });
}
// Depth is decided HERE, by the lane, not by the client.
//
// Before the queue this was a client constant the server merely clamped —
// seam #10 of the pre-build risk map. Now the server owns it: the queue lane
// runs the full spec (30 pages / 8 shots / 50 posts) because it can outlive
// a request, and the direct lane runs the measured-to-fit depth because it
// cannot. A client asking for more than its lane allows gets its lane's
// ceiling, and the response says which lane it is on.
const pageBudget = queueLane
? Math.min(QUEUE_PAGE_BUDGET, Math.max(1, Number(body.page_budget) || QUEUE_PAGE_BUDGET))
: Math.min(DIRECT_PAGE_BUDGET, Math.max(1, Number(body.page_budget) || DIRECT_PAGE_BUDGET));
const socialLimit = queueLane
? Math.min(QUEUE_SOCIAL_LIMIT, Math.max(1, Number(body.social_limit) || QUEUE_SOCIAL_LIMIT))
: Math.min(DIRECT_SOCIAL_LIMIT, Math.max(1, Number(body.social_limit) || DIRECT_SOCIAL_LIMIT));
// Auth + credits + row creation happen HERE, synchronously, before the
// response is sent — an anonymous or out-of-credit caller never reaches the
// line below that schedules capture work (requirement 2/3). D18's gate is
// untouched by the queue: authenticated + credits BEFORE any capture, and a
// pull that produces nothing is still never charged.
const reservation = await authorizeAndReserveComprehensivePull({
token,
body: {
...body,
page_budget: pageBudget,
social_limit: socialLimit,
screenshot_budget: queueLane ? QUEUE_SCREENSHOT_BUDGET : undefined
},
config,
queueLane,
requireExecutionPlan: true,
alphaParking: true
});
if (!reservation.ok) {
return NextResponse.json(reservation.body, { status: reservation.http_status });
}
// AN IDEMPOTENT REPLAY. authorizeAndReserveComprehensivePull found (or
// raced onto, via the 23505 fallback) a pull already carrying this
// idempotency key — nothing new was reserved, and NOTHING IS SCHEDULED
// here. 200, the same pull_id, never a second capture and never a second
// charge. See that function's IDEMPOTENCY block for the full reasoning.
if (reservation.replay) {
return NextResponse.json(reservation.body, { status: reservation.http_status || 200 });
}
after(async () => {
try {
if (reservation.queued) {
// The queue lane. This invocation runs the FIRST slice (and any cheap
// ones after it) and then hands off. If it is killed mid-slice, the
// checkpoint from the last completed stage survives and the next
// trigger resumes from there — which is the entire point.
await driveSlices({ pullId: reservation.pullId, config, env: process.env, invocationStartMs });
return;
}
await runCaptureAndFinalizeComprehensivePull({
pullId: reservation.pullId,
runId: reservation.runId,
userId: reservation.userId,
sourceUrl: reservation.sourceUrl,
domain: reservation.domain,
pullProfileId: reservation.pullProfileId,
urls: Array.isArray(body.urls) ? body.urls : [],
// Default to the deterministic brand-identity plan (query-manifest
// spec §8.1) so a bare {url} request still exercises the search leg —
// quoted name + domain, official-site, reviews, and the bare domain
// as ONE entry rather than the whole plan. `reservation.queries` is
// the CAPPED caller list (MAX_SEARCH_QUERIES, refusal receipt stored
// on the row) — the raw body is never read here, so an uncapped list
// cannot reach the paid leg through this lane.
queries: reservation.queries.length
? reservation.queries
: searchQueriesFor({ brandName: reservation.userConfirmations?.brand_name || "", domain: reservation.domain }),
socialProfiles: Array.isArray(body.social_profiles) ? body.social_profiles : [],
// Canonicalized and persisted by reservation; never re-derived from
// the raw confirmation payload in the after() process.
groundedIndustry: reservation.groundedIndustry,
// The VALIDATED confirmations, handed back by the reservation rather
// than re-read from the raw body — one sanitizer, one answer. Null when
// the caller sent none, and this lane then behaves exactly as it did
// before the confirmation step existed.
userConfirmations: reservation.userConfirmations || null,
// The reservation-stored plan — the capture reconciles against the
// exact document the row already carries (adversarial-review F7).
queryManifest: reservation.queryManifest || null,
pageBudget,
socialLimit,
// THE TIER THE RESERVATION PRICED. Taken from the reservation rather
// than from `body.tier` for the same reason userConfirmations is: the
// reservation normalized and gated it, and re-reading the raw body
// here would be a second dialect of the same decision — one that could
// run a capture at a depth the customer was not charged for.
pullTier: reservation.pullTier,
// Whose keys, and therefore whose bill. Both from the reservation.
byoKeys: reservation.byoKeys,
accountUnlimited: reservation.accountUnlimited,
accountKeyEnv: reservation.accountKeyEnv,
alphaParking: true,
config
});
} catch (error) {
// runCaptureAndFinalizeComprehensivePull already catches a capture
// error and writes a `failed` row itself; this is the last-resort
// backstop for a failure in the finalize plumbing (e.g. Supabase
// unreachable). On the queue lane a throw here is survivable in a way it
// never was before: the row keeps its checkpoint, so the next trigger
// continues rather than starting over.
console.error("comprehensive pull finalize failed", reservation.pullId, error);
}
});
return NextResponse.json(reservation.body, { status: 202 });
}
export async function GET(request) {
const invocationStartMs = Date.now();
const token = bearerTokenFromHeader(request.headers.get("authorization"));
const pullId = new URL(request.url).searchParams.get("pull_id") || "";
const config = supabaseConfigFromEnv(process.env);
// reapStaleAfterMs turns on the lazy reap-on-poll path (see this file's
// header): a `running` row that has stopped ADVANCING is moved to `failed`
// before the response is built, so polling a dead pull is itself the fix
// rather than reporting "running" forever. A sliced job that is merely
// between slices is NOT stale and is not touched.
const result = await readComprehensivePullStatus({ pullId, token, config, reapStaleAfterMs: DEFAULT_STALE_RUNNING_MS });
// THE POLL IS ALSO A TRIGGER.
//
// A customer watching the loading screen is, for free, the most reliable
// continuation signal the system has — it needs no cron, no self-URL, and no
// shared secret, and it works identically on Vercel, on docker and on a
// laptop. Running pulls advance capture; terminal pulls with a durable
// pending-billing marker reconcile only the debit. No provider work repeats.
//
// The cost of doing this on EVERY poll is one row read: the claim's
// fencing-token compare-and-swap refuses immediately when another writer
// holds the lease, which during an active slice is almost always. The
// benefit is that a job whose previous slice yielded resumes within one poll
// interval instead of waiting up to a minute for the cron. That trade is
// deliberate — it is cheap to ask and expensive to be stuck.
const ownerBillingRetry = result.body?.billing_retryable === true
&& !result.body?.public_example && !result.body?.viewed_as_admin;
if (result.http_status === 200 && (result.body?.status === "running" || ownerBillingRetry) && queueEnabledFromEnv(process.env)) {
after(async () => {
try {
await driveSlices({ pullId, config, env: process.env, invocationStartMs });
} catch (error) {
console.error("[pull-queue] poll-advance failed", pullId, error);
}
});
}
return NextResponse.json(result.body, { status: result.http_status });
}