Brand Studio docs
Engineering manualAPI route source

/api/comprehensive/advance

Exact current route handler: hosted-app/app/api/comprehensive/advance/route.js

Methods: GET, POST. Input signals: JSON body, query parameters. Literal response-status signals: 200, 202.

Source: hosted-app/app/api/comprehensive/advance/route.js. Imports: ../../../../lib/comprehensive-pull-queue-runner.mjs, ../../../../lib/comprehensive-pull-queue.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

import { continueAfterAdvance } from "../../../../lib/queue-continuation.mjs";
// POST/GET /api/comprehensive/advance — the trigger that runs UNATTENDED.
//
// This is the endpoint that finally makes ../route.js's old "there is still no
// cron/queue/worker anywhere in this codebase" note untrue. It advances queued
// comprehensive pulls that nobody is watching: the customer closed the tab, or
// the slice that yielded could not reach anything to hand off to.
//
// THREE CALLERS, ONE BODY OF WORK.
//
//   1. VERCEL CRON — `vercel.json`'s `crons` entry hits GET on this path on its configured
//      schedule (the packaged default is daily, not a minute-scale SLA). Vercel signs cron requests with CRON_SECRET when it is set; we
//      accept that OR our own x-queue-secret, so the same endpoint serves both
//      the platform's scheduler and our own self-invocation.
//   2. SELF-INVOCATION — a slice that yields POSTs here with {pull_id} and the
//      shared secret, so the next slice starts in seconds rather than waiting
//      out the cron interval. Best-effort: it needs the app to know its own
//      public URL (BRAND_STUDIO_PUBLIC_URL or VERCEL_URL).
//   3. A SELF-HOSTED TICKER — instrumentation.js calls the same engine
//      IN-PROCESS on an interval, with no HTTP and no secret, because a docker
//      deployment has no platform scheduler. See that file.
//
// AUTHORISATION. This endpoint runs work on behalf of users using the
// service-role key, so it must not be callable by the public. It requires the
// shared secret (or Vercel's cron signature) and REFUSES OUTRIGHT when no
// secret is configured — an unauthenticated worker endpoint is worse than an
// absent one, and failing closed means a misconfigured deployment loses the
// cron trigger while keeping the poll trigger, rather than exposing a way for
// strangers to spend the account's provider budget.
//
// Capture uses a fenced lease; terminal billing uses the ledger's unique
// spend-per-pull constraint. Concurrent triggers cannot repeat capture or
// debit a pull twice. Billing retries are paced by polling/the worker sweep,
// never by a new capture claim or an immediate self-handoff loop.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// A slice is budgeted at 240s and yields on its own; 300s is the platform
// ceiling that budget sits under.
export const maxDuration = 300;

import { NextResponse, after } from "next/server";
import { supabaseConfigFromEnv } from "../../../../lib/supabase-rest.mjs";
import { queueEnabledFromEnv } from "../../../../lib/comprehensive-pull-queue.mjs";
import {
  advanceComprehensivePullSlice,
  advanceClaimableComprehensivePulls,
  triggerNextSlice
} from "../../../../lib/comprehensive-pull-queue-runner.mjs";

/**
 * Fails CLOSED: with neither secret configured, nothing is authorised. See
 * this file's header on why that is the right default for a worker endpoint.
 */
const authorizeAdvance = (request, env) => {
  const queueSecret = env.BRAND_STUDIO_QUEUE_SECRET || "";
  const cronSecret = env.CRON_SECRET || "";
  if (!queueSecret && !cronSecret) {
    return { ok: false, http_status: 503, reason: "no_queue_secret_configured" };
  }
  const provided = request.headers.get("x-queue-secret") || "";
  if (queueSecret && provided && provided === queueSecret) return { ok: true, via: "queue_secret" };
  const authorization = request.headers.get("authorization") || "";
  if (cronSecret && authorization === `Bearer ${cronSecret}`) return { ok: true, via: "vercel_cron" };
  return { ok: false, http_status: 401, reason: "unauthorized" };
};

const runAdvance = async ({ request, pullId, wait = false }) => {
  const env = process.env;
  if (!queueEnabledFromEnv(env)) {
    return NextResponse.json({ status: "skipped", reason: "queue_disabled" }, { status: 200 });
  }
  const auth = authorizeAdvance(request, env);
  if (!auth.ok) {
    return NextResponse.json({ status: "refused", reason: auth.reason }, { status: auth.http_status });
  }
  const config = supabaseConfigFromEnv(env);
  const log = (line) => console.log(`[pull-queue] ${line}`);

  // A named pull advances capture or reconciles pending terminal billing;
  // an unnamed call sweeps both lanes. The cron takes the second path.
  const advance = pullId
    ? () => advanceComprehensivePullSlice({ pullId, config, env, alphaParking: true, holder: `advance-${auth.via}`, log })
    : () => advanceClaimableComprehensivePulls({ config, env, alphaParking: true, holder: `advance-${auth.via}`, log });

  const work = () => continueAfterAdvance({ work: advance,
    trigger: (id) => triggerNextSlice({ pullId: id, env }),
    onHandoff: (row) => log(`handoff ${row.pull_id}: ${row.triggered ? "accepted" : row.reason || "unavailable"}`)
  });

  // ACCEPT AND DEFER, not accept and block.
  //
  // A slice takes up to four minutes. If this endpoint ran it before
  // responding, the CALLER would be held open for that whole time — and the
  // caller is usually a slice that has just yielded and is trying to hand off,
  // so the outgoing invocation would stay alive burning its own budget waiting
  // for the incoming one. Two invocations alive for one slice's work is the
  // opposite of what slicing is for. (Observed on the first live run: the
  // handoff log did not appear until the NEXT slice had finished, because the
  // trigger was awaiting it.)
  //
  // So the work is scheduled with after() and the trigger gets an immediate
  // 202. Nothing is lost: the outcome is logged, and the only consumer that
  // wants it synchronously is a human debugging, who can ask with ?wait=1.
  if (wait) {
    const result = await work();
    return NextResponse.json({ status: "passed", via: auth.via, waited: true, result }, { status: 200 });
  }
  after(async () => {
    try { await work(); } catch (error) { console.error("[pull-queue] advance failed", error); }
  });
  return NextResponse.json({ status: "accepted", via: auth.via, pull_id: pullId || null }, { status: 202 });
};

export async function POST(request) {
  const body = await request.json().catch(() => ({}));
  return runAdvance({ request, pullId: String(body?.pull_id || ""), wait: Boolean(body?.wait) });
}

/** GET exists for Vercel cron, which issues GET. */
export async function GET(request) {
  const params = new URL(request.url).searchParams;
  return runAdvance({ request, pullId: params.get("pull_id") || "", wait: params.get("wait") === "1" });
}