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