Brand Studio docs
Engineering manualAPI route source

/api/credits

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

Methods: GET. Input signals: none detected. Literal response-status signals: 502.

Source: hosted-app/app/api/credits/route.js. Imports: ../../../lib/account-entitlements.mjs, ../../../lib/admin-access.mjs, ../../../lib/all-rest/alpha-parking.mjs, ../../../lib/all-rest/pull-tier-catalog.mjs, ../../../lib/comprehensive-pull.mjs, ../../../lib/credits-account-view.mjs, ../../../lib/credits.mjs, ../../../lib/ledger-snapshot.mjs, ../../../lib/pricing.mjs, ../../../lib/pull-execution-levels.mjs, ../../../lib/pull-tier.mjs, ../../../lib/sandbox-mode.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 { readCompleteCreditLedger } from "../../../lib/ledger-snapshot.mjs";
// GET /api/credits — the signed-in customer's real credit position.
//
// Exists so the run page can state the cost story with NUMBERS THAT CAME
// FROM THE LEDGER rather than a hardcoded price in the UI. describeCredits
// (lib/credits.mjs) folds the same append-only ledger the authorize step
// checks, so what the customer is shown before a pull and what the gate
// decides are the same computation over the same rows.
//
// Anonymous callers get the same {signup_required} shape the comprehensive
// gate uses, but with HTTP 200 because the price catalog is intentionally
// public. A supplied but invalid token still gets 401 so authedFetch can clear
// a stale local session.
//
// IS_ADMIN RIDES HERE (2026-08-18), and only on the AUTHENTICATED branch.
// The header's "Owner console" link has to appear for one person and for
// nobody else, and the browser cannot decide that — BRAND_STUDIO_ADMIN_EMAILS
// is server-only and stays that way. This route is already fetched on every
// load and already resolves the caller against Supabase, so the flag is
// computed from the email that VERIFIED token carries and travels with the
// balance. See adminResponseFlag: it is absent (not false) for everyone else,
// and it never carries the list.

import { NextResponse } from "next/server";
import { supabaseConfigFromEnv, supabaseRestRequest } from "../../../lib/supabase-rest.mjs";
import { resolveAuthContext, bearerTokenFromHeader } from "../../../lib/comprehensive-pull.mjs";
import { creditCostForPull } from "../../../lib/credits.mjs";
import { accountCreditSummary, unlimitedAccount } from '../../../lib/account-entitlements.mjs';
import { CREDIT_PACKS, CREDIT_PACK_NOTE } from "../../../lib/pricing.mjs";
// THE TIER CATALOG, and why it is answered by THIS route.
//
// The pull-start page already fetches /api/credits before anything else — it
// has to, to know what a report costs — and the tier chips need exactly two
// things: what each tier costs in credits, and what each one actually buys.
// Serving them here means the chips render from ONE payload that the credit
// gate itself is computed from, so the control cannot offer a tier at a price
// the gate would refuse.
//
// The catalog is derived from the collectors own gated unit tables, which is
// why it is imported server-side and serialized: seo-engine.mjs must never
// reach a browser bundle. Built once at module load, because those tables are
// frozen and the derivation is pure.
import { unitsByTier } from "../../../lib/all-rest/pull-tier-catalog.mjs";
import { applyAlphaParkingToTierCatalog, applyAlphaParkingToTierDescriptions } from "../../../lib/all-rest/alpha-parking.mjs";
import { describeTiers } from "../../../lib/pull-tier.mjs";
import { resolveSandboxMode } from "../../../lib/sandbox-mode.mjs";
import { adminResponseFlag } from "../../../lib/admin-access.mjs";
import { executionLevelsFor } from "../../../lib/pull-execution-levels.mjs";
import { checkoutAvailable, checkoutAvailability, welcomeGrant, signupCreditAvailability } from "../../../lib/credits-account-view.mjs";

const PULL_TIER_CATALOG = applyAlphaParkingToTierDescriptions(
  describeTiers(applyAlphaParkingToTierCatalog(unitsByTier(), { env: process.env }))
);

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

export async function GET(request) {
  const config = supabaseConfigFromEnv(process.env);
  const sandbox = resolveSandboxMode(process.env);
  const token = bearerTokenFromHeader(request.headers.get("authorization"));
  const authContext = await resolveAuthContext({ token, ...config });
  // The PRICE LIST is public. It is answered on the signed-out branch too,
  // because the landing page states what a report costs in dollars before
  // anyone is asked for an email (review RB2), and a price rendered from a
  // literal in JSX is a price that can disagree with the one charged.
  const packs = CREDIT_PACKS.map((pack) => ({ ...pack }));
  if (!authContext.authenticated) {
    return NextResponse.json({
      status: "refused",
      reason: "signup_required",
      checkout_available: false,
      checkout_availability: checkoutAvailability(),
      welcome_grant: null,
      // The price is knowable WITHOUT an account — a signed-out visitor
      // deserves to know what a pull costs before being asked to sign up.
      cost_per_pull: creditCostForPull({ route: "hosted", depth: "standard" }),
      packs,
      pack_note: CREDIT_PACK_NOTE,
      // The TIERS are public for the same reason the price list is: the
      // landing page states what a report costs before anyone is asked for an
      // email, and "which report" is now half of that sentence.
      tiers: PULL_TIER_CATALOG,
      // `tiers` is retained for stored-pull compatibility. These are the
      // source-backed execution recipes shown to new customers; unlike the
      // legacy catalog they state exactly what is selected, what is
      // conditional, and what remains parked.
      execution_levels: executionLevelsFor(),
      sandbox: sandbox.enabled,
      sandbox_detail: sandbox.detail,
      // OPEN BETA is answered on the SIGNED-OUT branch too, and that is the
      // whole reason it is here rather than on a route of its own: the signup
      // screen has to state "free while we're in beta, 3 reports on us" to a
      // visitor who by definition has no account yet. This endpoint is
      // already the one the page fetches before anyone signs in (for the
      // price list), so the beta copy travels with the prices it replaces.
      // Nothing secret is disclosed — it is a description of the signup the
      // visitor is about to perform.
      open_beta: signupCreditAvailability({ config })
    }, { status: token ? 401 : 200 });
  }

  const ledgerResponse = await readCompleteCreditLedger({ config, userId: authContext.user.id, authToken: authContext.token });
  if (ledgerResponse.status !== "passed") {
    return NextResponse.json({ status: "failed", reason: `ledger_lookup_failed: ${ledgerResponse.reason}` }, { status: 502 });
  }
  const entries = Array.isArray(ledgerResponse.body) ? ledgerResponse.body : [];

  return NextResponse.json({
    status: "passed",
    email: authContext.user.email || "",
    checkout_available: checkoutAvailable({ userId: authContext.user.id }),
    checkout_availability: checkoutAvailability({ userId: authContext.user.id }),
    welcome_grant: welcomeGrant(entries, authContext.user.id),
    cost_per_pull: unlimitedAccount(authContext.user.id) ? 0 : creditCostForPull({ route: "hosted", depth: "standard" }),
    packs,
    pack_note: CREDIT_PACK_NOTE,
    tiers: unlimitedAccount(authContext.user.id) ? PULL_TIER_CATALOG.map(row => ({ ...row, credits: 0 })) : PULL_TIER_CATALOG,
    execution_levels: executionLevelsFor(),
    sandbox: sandbox.enabled,
    sandbox_detail: sandbox.detail,
    open_beta: signupCreditAvailability({ config }),
    // Spread, so a non-admin's response has no such field at all.
    ...adminResponseFlag(authContext.user.email, process.env),
    ...accountCreditSummary({ entries, userId: authContext.user.id })
  });
}