/api/credits/topup
Exact current route handler: hosted-app/app/api/credits/topup/route.js
Methods: POST. Input signals: none detected. Literal response-status signals: 400, 502, 503.
Source: hosted-app/app/api/credits/topup/route.js. Imports: ../../../../lib/all-rest/alpha-parking.mjs, ../../../../lib/all-rest/pull-tier-catalog.mjs, ../../../../lib/bounded-http.mjs, ../../../../lib/comprehensive-pull.mjs, ../../../../lib/credits.mjs, ../../../../lib/ledger-snapshot.mjs, ../../../../lib/pricing.mjs, ../../../../lib/pull-tier.mjs, ../../../../lib/sandbox-mode.mjs, ../../../../lib/stripe-checkout-attempts.mjs, ../../../../lib/stripe-credit-checkout.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 { readBoundedJsonRequest } from "../../../../lib/bounded-http.mjs";
import { creditBillingSessionRefusal } from "../../../../lib/stripe-credit-checkout.mjs";
import { startCreditCheckout } from "../../../../lib/stripe-checkout-attempts.mjs";
import { readCompleteCreditLedger } from "../../../../lib/ledger-snapshot.mjs";
// POST /api/credits/topup — local sandbox grant or hosted Stripe Checkout.
//
// Explicit local sandbox grants remain here. Other environments delegate to
// Stripe Checkout and grant nothing until a verified paid webhook arrives.
//
// The free grant branch is available only where lib/sandbox-mode.mjs permits
// explicit development opt-in. Production delegates to Stripe and never uses
// the sandbox grant branch, even with a sandbox flag set.
//
// WHAT IT WRITES. One `topup` row in brand_studio_credit_ledger, with the
// pack's credit count and a reason that names itself as a sandbox grant. The
// row is written with the SERVICE ROLE key for the same reason the spend row
// is (comprehensive-pull-handler.mjs L449): RLS reserves ledger writes to
// service_role precisely so a customer cannot mint their own credits. The
// deployment therefore refuses this route without that key rather than
// appearing to work and failing at the database.
//
// THE PACK IS LOOKED UP, NEVER ACCEPTED. The body names a pack id; the server
// reads the credits and the dollars out of lib/pricing.mjs. A client that can
// post an amount is a client that can post 10,000, and "it's only the sandbox"
// is exactly how that ships to production one day.
import { NextResponse } from "next/server";
import { supabaseConfigFromEnv, supabaseRestRequest } from "../../../../lib/supabase-rest.mjs";
import { resolveAuthContext, bearerTokenFromHeader } from "../../../../lib/comprehensive-pull.mjs";
import { describeCredits, creditCostForPull, LEDGER_ACTOR_SYSTEM } from "../../../../lib/credits.mjs";
import { packById, CREDIT_PACKS, CREDIT_PACK_NOTE } from "../../../../lib/pricing.mjs";
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";
const PULL_TIER_CATALOG = applyAlphaParkingToTierDescriptions(
describeTiers(applyAlphaParkingToTierCatalog(unitsByTier(), { env: process.env }))
);
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request) {
const sandbox = resolveSandboxMode(process.env);
const config = supabaseConfigFromEnv(process.env);
const token = bearerTokenFromHeader(request.headers.get("authorization"));
const authContext = await resolveAuthContext({ token, ...config });
// Auth first, so a signed-out caller on a production deployment is told to
// sign in rather than told about a sandbox that would not have served them
// anyway.
const refusal = creditBillingSessionRefusal(authContext);
if (refusal) {
const { http, ...body } = refusal;
return NextResponse.json({ ...body, cost_per_pull: creditCostForPull({ route: "hosted", depth: "standard" }) },
{ status: http, headers: { "cache-control": "no-store" } });
}
if (!sandbox.enabled) {
const body = await readBoundedJsonRequest(request, 4096).catch(() => ({}));
const result = await startCreditCheckout({ config, userId: authContext.user.id,
packId: body?.pack, attemptId: body?.attempt_id, afterAttemptId: body?.after_attempt_id });
const { http, ...response } = result;
return NextResponse.json(response, { status: http, headers: { "cache-control": "no-store" } });
}
if (!config.supabaseServiceRoleKey) {
// Same posture as the pull gate: refuse up front rather than accept a
// purchase the database will reject at the last step.
return NextResponse.json({
status: "refused",
reason: "billing_not_configured",
detail: "SUPABASE_SERVICE_ROLE_KEY is not set, so a credit grant could not be written."
}, { status: 503 });
}
const body = await readBoundedJsonRequest(request, 4096).catch(() => ({}));
const pack = packById(body?.pack);
if (!pack) {
return NextResponse.json({
status: "refused",
reason: "unknown_pack",
detail: `Choose one of: ${CREDIT_PACKS.map((entry) => entry.id).join(", ")}.`
}, { status: 400 });
}
const insert = await supabaseRestRequest({
...config,
pathName: "brand_studio_credit_ledger",
method: "POST",
body: [{
user_id: authContext.user.id,
kind: "topup",
amount: pack.credits,
// The row says what it is, forever. A sandbox grant that reads like a
// purchase in the ledger is a reconciliation bug waiting to happen.
reason: `sandbox checkout — ${pack.label} (${pack.credits} credit${pack.credits === 1 ? "" : "s"}, $${pack.price_usd} not charged)`,
// WHO. A sandbox checkout is self-serve — a customer pressed a button
// that spends no money — so the actor is the machine, not a named
// human. `admin:<email>` is reserved for the /admin top-up, which is
// the only path where a person grants credits to an account that is
// not their own. See the actors block in lib/credits.mjs.
actor: LEDGER_ACTOR_SYSTEM
}],
prefer: "return=representation"
});
if (insert.status !== "passed") {
return NextResponse.json({
status: "failed",
reason: `topup_write_failed: ${insert.reason || "unknown"}`
}, { status: 502 });
}
// Read the balance back from the ledger rather than adding the pack to a
// number the client already had. The balance is a fold over rows; anything
// else is a second implementation of it that can drift.
const ledgerResponse = await readCompleteCreditLedger({ config, userId: authContext.user.id, authToken: authContext.token });
if (ledgerResponse.status !== "passed") return NextResponse.json({ status: "failed", reason: "grant_recorded_balance_unavailable", granted: pack.credits, retry_topup: false }, { status: 502 });
const entries = Array.isArray(ledgerResponse.body) ? ledgerResponse.body : [];
// The SAME SHAPE /api/credits answers with, deliberately. The client keeps
// one credits object; a purchase response that omitted `packs` replaced that
// object with a partial one and the price list vanished until the next page
// load — the checkout dialog reopened with no packs in it. A response that
// is "the new state" has to actually be the whole state.
return NextResponse.json({
status: "passed",
email: authContext.user.email || "",
sandbox: true,
sandbox_detail: sandbox.detail,
packs: CREDIT_PACKS.map((entry) => ({ ...entry })),
// `tiers` and `pack_note` ride here for the SAME reason `packs` does, and
// the reason is in the comment above: the client keeps one credits object
// and this response replaces it. A purchase that answered without the tier
// catalog would blank the tier chips until the next page load — the exact
// failure that made the checkout dialog reopen with no packs in it.
pack_note: CREDIT_PACK_NOTE,
tiers: PULL_TIER_CATALOG,
granted: pack.credits,
pack: { id: pack.id, label: pack.label, credits: pack.credits, price_usd: pack.price_usd },
cost_per_pull: creditCostForPull({ route: "hosted", depth: "standard" }),
...describeCredits({ entries })
});
}