Brand Studio docs
Engineering manualAPI route source

/api/admin

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

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

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

// GET /api/admin?view=overview|accounts|pulls|providers — the owner's reads.
//
// ONE ROUTE, FOUR VIEWS, ONE GATE. Four separate endpoints would be four
// separate places for the gate to be written, and the fourth one is where it
// gets forgotten. The gate runs BEFORE the view is even looked at, so an
// unknown `view` from a stranger and a known one both answer 404 — the
// parameter cannot be used to probe what this surface can do.
//
// THE PROVIDERS VIEW IS THE ONE THAT LEAVES THE BUILDING. The other three read
// our own database; this one reaches four suppliers with the deployment's own
// credentials. Two consequences, both deliberate:
//   1. It sits behind the SAME gate, unchanged — a stranger who could reach it
//      could make us call our providers on demand.
//   2. NO CREDENTIAL CROSSES THE WIRE BACK. The response carries balances,
//      statuses, reasons and the last four characters of the key that answered
//      — never a key, never the admin list, never a base URL with auth in it.
//
// Everything here is a thin wrapper. What is fetched and how it is folded is
// lib/admin-api.mjs and lib/provider-balances.mjs; who is allowed is
// lib/admin-access.mjs; who our suppliers ARE is lib/local/provider-registry.mjs.

import { NextResponse } from "next/server";
import { supabaseConfigFromEnv } from "../../../lib/supabase-rest.mjs";
import { bearerTokenFromHeader } from "../../../lib/comprehensive-pull.mjs";
import { adminPageFromQuery } from "../../../lib/admin-access.mjs";
import {
  resolveAdminContext,
  readAdminOverview,
  readAdminAccounts,
  readAdminPulls,
  readAdminProviders,
  readAdminPlan,
  recordPlanDecision
} from "../../../lib/admin-api.mjs";

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

export async function GET(request) {
  const config = supabaseConfigFromEnv(process.env);
  const token = bearerTokenFromHeader(request.headers.get("authorization"));
  const admin = await resolveAdminContext({ token, config, env: process.env });
  if (!admin.ok) return NextResponse.json(admin.body, { status: admin.http_status });

  const url = new URL(request.url);
  const view = url.searchParams.get("view") || "overview";
  const page = adminPageFromQuery(url.searchParams.get("page"));

  if (view === "accounts") {
    const result = await readAdminAccounts({ config, page });
    return NextResponse.json({ ...result.body, viewer: admin.email }, { status: result.http_status });
  }
  if (view === "pulls") {
    const result = await readAdminPulls({ config, page });
    return NextResponse.json({ ...result.body, viewer: admin.email }, { status: result.http_status });
  }
  if (view === "providers") {
    // `config` added 2026-08-19 for the "spend by model / by use" fold —
    // service-role, one small Supabase scan, same posture as every other
    // view on this route (see readAdminProviders's own header).
    const result = await readAdminProviders({ env: process.env, config });
    return NextResponse.json({ ...result.body, viewer: admin.email }, { status: result.http_status });
  }
  if (view === "plan") {
    const result = await readAdminPlan({ config });
    return NextResponse.json({ ...result.body, viewer: admin.email }, { status: result.http_status });
  }
  if (view !== "overview") {
    // A view this route does not have, asked for by someone who has already
    // proved they are the owner. Named honestly rather than silently served
    // the overview, so a typo in a bookmark is visible instead of looking
    // like the dashboard has quietly changed.
    return NextResponse.json({ status: "refused", reason: "unknown_view", detail: "Use overview, accounts, pulls, providers, or plan." }, { status: 400 });
  }

  const result = await readAdminOverview({ config });
  return NextResponse.json({ ...result.body, viewer: admin.email }, { status: result.http_status });
}

// POST /api/admin?view=plan — the owner's decision on ONE proposal.
//
// SAME GATE, SAME 404. A write is placed here rather than in its own route for
// the reason the reads are: the gate is written once. The refusal for a stranger
// is identical to the read path's — 404, not 403 — so the existence of an
// approval surface is not confirmed to anyone who cannot use it.
//
// THE DECISION IS ALL THAT HAPPENS. No work is scheduled, no flag is flipped, no
// provider is called, and no pull's cost changes. That is the contract the owner
// asked for: see it all, approve it first, and only then start the plan.
//
// The ACTOR is taken from `admin.email` — the address the gate verified against
// Supabase's own /auth/v1/user — and never from the request body, so the audit
// trail records who it was rather than who the client said it was.
export async function POST(request) {
  const config = supabaseConfigFromEnv(process.env);
  const token = bearerTokenFromHeader(request.headers.get("authorization"));
  const admin = await resolveAdminContext({ token, config, env: process.env });
  if (!admin.ok) return NextResponse.json(admin.body, { status: admin.http_status });

  const url = new URL(request.url);
  if ((url.searchParams.get("view") || "") !== "plan") {
    return NextResponse.json(
      { status: "refused", reason: "unknown_view", detail: "The only writable view is plan." },
      { status: 400 }
    );
  }

  const payload = await request.json().catch(() => null);
  if (!payload || typeof payload !== "object") {
    return NextResponse.json({ status: "refused", reason: "invalid_body" }, { status: 400 });
  }

  const result = await recordPlanDecision({
    adminEmail: admin.email,
    proposalId: payload.proposal_id,
    status: payload.status,
    config
  });
  return NextResponse.json(result.body, { status: result.http_status });
}