Brand Studio docs
Engineering manualAPI route source

/api/auth

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

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

Source: hosted-app/app/api/auth/route.js. Imports: ../../../lib/open-beta-signup.mjs, ../../../lib/open-beta.mjs, ../../../lib/public-origin.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

// Sign-in / sign-up for the hosted product — a thin server-side proxy to
// Supabase's own GoTrue endpoints.
//
// WHY A PROXY INSTEAD OF THE SUPABASE CLIENT IN THE BROWSER
// --------------------------------------------------------
// The browser half of this app needs an access token to call
// /api/comprehensive (D18: comprehensive requires an authenticated user).
// The usual way is to ship SUPABASE_URL + the anon key to the client as
// NEXT_PUBLIC_ vars. This deployment's env (hosted-app/docker-compose.yml's
// env_file, and the Vercel project) carries neither — only the server-side
// SUPABASE_URL / SUPABASE_ANON_KEY that supabaseConfigFromEnv already reads.
// Proxying keeps the app runnable on the env that actually exists, with no
// new secrets and no new required configuration.
//
// This adds NO new authority: the anon key is public by design, GoTrue is
// the source of truth for whether a credential is valid, and the token this
// route returns is exactly the token Supabase issued. It does not mint,
// extend, or verify anything itself — resolveAuthContext still hands every
// token back to Supabase on the way in (comprehensive-pull.mjs).
//
// RATE LIMITING is Supabase's, not this route's — a caller who hammers it is
// hammering GoTrue's own limits. That is the same posture the instant route
// documents about platform-level limits, and it is stated here rather than
// left implied.

import { NextResponse } from "next/server";
import { publicOriginFor } from "../../../lib/public-origin.mjs";
import { supabaseAuthActionUrl, supabaseConfigFromEnv } from "../../../lib/supabase-rest.mjs";
import { resolveSandboxMode } from "../../../lib/sandbox-mode.mjs";
import { resolveOpenBeta, signupPathFor } from "../../../lib/open-beta.mjs";
import { grantOpenBetaSignupCredits, openBetaWelcomeNote } from "../../../lib/open-beta-signup.mjs";

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

const clean = (value, max = 400) => String(value ?? "").trim().slice(0, max);
const cleanPassword = (value, max = 400) => String(value ?? "").slice(0, max);

/**
 * CREATE THE ACCOUNT WITHOUT THE EMAIL ROUND-TRIP, honestly.
 *
 * TWO CALLERS NOW, and they are not the same claim. The sandbox (below) skips
 * confirmation because a local walkthrough cannot read a mailbox. The OPEN
 * BETA (BRAND_STUDIO_OPEN_BETA, see lib/open-beta.mjs) skips it because the
 * owner decided friends should be able to test without one — on the live
 * deployment, deliberately, with the consequence stated on the signup screen
 * rather than discovered. They share this function and nothing else: open
 * beta grants credits and says so; the sandbox grants none here and talks
 * about cards not being charged, which on a beta deployment would be a
 * sentence about a checkout that is not there.
 *
 * The original sandbox reasoning, unchanged and still the reason this shape
 * is right:
 *
 * This project has email confirmation ON, which is right: an unconfirmed
 * address is an address that may not belong to whoever typed it. But it means
 * a local walkthrough of the funnel ends at "Confirm the email address, then
 * sign in" with the mail going somewhere nobody is watching — the signup step
 * is the ONE step of the funnel that cannot be tested on the machine it is
 * built on.
 *
 * Where lib/sandbox-mode.mjs allows it, the account is created through
 * Supabase's own ADMIN endpoint with `email_confirm: true`, then signed in
 * with the password that was just chosen. Nothing is faked: it is a real user
 * row with a real confirmation timestamp and a real password grant.
 *
 * WHY ADMIN-CREATE RATHER THAN CREATE-THEN-CONFIRM. The first version of this
 * called the public /auth/v1/signup and then patched the user — which works,
 * and which sends a confirmation email every single time. Four sandbox
 * walkthroughs later Supabase answered `email rate limit exceeded` and the
 * flow it exists to make testable stopped working, having also mailed four
 * strangers' addresses. The admin route sends nothing. A sandbox that spends a
 * shared production quota is not a sandbox.
 *
 * Requires the service-role key. Without it there is no admin API at all, and
 * the honest public-signup path with its `confirmation_required` answer is
 * what runs.
 */
const adminCreateAndSignIn = async ({ supabaseUrl, supabaseAnonKey, serviceRoleKey, email, password }) => {
  if (!serviceRoleKey) return null;
  const created = await fetch(`${supabaseUrl}/auth/v1/admin/users`, {
    method: "POST",
    headers: {
      apikey: serviceRoleKey,
      authorization: `Bearer ${serviceRoleKey}`,
      "content-type": "application/json"
    },
    body: JSON.stringify({ email, password, email_confirm: true })
  });
  const body = await created.json().catch(() => ({}));
  if (!created.ok) {
    // An address that already exists is not a sandbox failure — it is the
    // ordinary "you already have an account" case, and the caller falls
    // through to the real signup so Supabase writes that message, not us.
    return { conflict: created.status === 422 || created.status === 409, reason: body?.msg || body?.message || "" };
  }
  const granted = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
    method: "POST",
    headers: { apikey: supabaseAnonKey, "content-type": "application/json" },
    body: JSON.stringify({ email, password })
  });
  const session = await granted.json().catch(() => ({}));
  if (!granted.ok || !session?.access_token) return null;
  return { session, userId: body?.id || session.user?.id || null };
};

export async function POST(request) {
  const { supabaseUrl, supabaseAnonKey, supabaseServiceRoleKey } = supabaseConfigFromEnv(process.env);
  const sandbox = resolveSandboxMode(process.env);
  const openBeta = resolveOpenBeta(process.env);
  if (!supabaseUrl || !supabaseAnonKey) {
    return NextResponse.json({ status: "refused", reason: "supabase_not_configured" }, { status: 503 });
  }
  const body = await request.json().catch(() => ({}));
  const authCallbackUrl = new URL("/auth/callback", publicOriginFor(request));
  authCallbackUrl.searchParams.set("next", "/");
  const action = body.action === "signup" ? "signup"
    : body.action === "recover" ? "recover"
    : body.action === "resend_confirmation" ? "resend_confirmation"
    : body.action === "update_password" ? "update_password"
    : body.action === "verify_session" ? "verify_session"
    : body.action === "refresh" ? "refresh"
    : body.action === "signout" ? "signout"
    : "signin";
  const email = clean(body.email);
  const password = cleanPassword(body.password);

  if (action === "verify_session") {
    const accessToken = clean(body.access_token, 4096);
    if (!accessToken) {
      return NextResponse.json({ status: "refused", reason: "missing_access_token" }, { status: 400 });
    }
    const verified = await fetch(`${supabaseUrl}/auth/v1/user`, {
      headers: { apikey: supabaseAnonKey, authorization: `Bearer ${accessToken}` }
    });
    const user = await verified.json().catch(() => ({}));
    if (!verified.ok || !user?.id || !user?.email) {
      return NextResponse.json(
        { status: "refused", reason: "invalid_or_expired_auth_link" },
        { status: 401, headers: { "cache-control": "no-store" } }
      );
    }
    return NextResponse.json({
      status: "passed",
      user: { id: user.id, email: user.email }
    }, { headers: { "cache-control": "no-store" } });
  }

  if (action === "signout") {
    const accessToken = clean(body.access_token, 4096);
    if (!accessToken) {
      return NextResponse.json({ status: "refused", reason: "missing_access_token" }, { status: 400 });
    }
    const signedOut = await fetch(`${supabaseUrl}/auth/v1/logout?scope=local`, {
      method: "POST",
      headers: { apikey: supabaseAnonKey, authorization: `Bearer ${accessToken}` }
    });
    if (!signedOut.ok && signedOut.status !== 401) {
      return NextResponse.json({ status: "refused", reason: `logout_http_${signedOut.status}` }, { status: signedOut.status });
    }
    return NextResponse.json({ status: "signed_out" });
  }

  if (action === "resend_confirmation") {
    if (!email) {
      return NextResponse.json({ status: "refused", reason: "missing_email" }, { status: 400 });
    }
    const resentUrl = supabaseAuthActionUrl({
      supabaseUrl,
      action: "resend",
      redirectTo: authCallbackUrl.toString()
    });
    const resent = await fetch(resentUrl, {
      method: "POST",
      headers: { apikey: supabaseAnonKey, "content-type": "application/json" },
      body: JSON.stringify({ type: "signup", email })
    });
    const resentPayload = await resent.json().catch(() => ({}));
    if (!resent.ok) {
      return NextResponse.json({
        status: "refused",
        reason: resentPayload?.error_description || resentPayload?.msg || resentPayload?.message || `auth_http_${resent.status}`
      }, { status: resent.status });
    }
    return NextResponse.json({
      status: "confirmation_sent",
      reason: "A new confirmation link is on its way. Check your inbox."
    });
  }

  if (action === "update_password") {
    const accessToken = clean(body.access_token, 4096);
    if (!accessToken || password.length < 8) {
      return NextResponse.json({ status: "refused", reason: "missing_or_short_password" }, { status: 400 });
    }
    const updated = await fetch(`${supabaseUrl}/auth/v1/user`, {
      method: "PUT",
      headers: {
        apikey: supabaseAnonKey,
        authorization: `Bearer ${accessToken}`,
        "content-type": "application/json"
      },
      body: JSON.stringify({ password })
    });
    const updatedPayload = await updated.json().catch(() => ({}));
    if (!updated.ok) {
      return NextResponse.json({
        status: "refused",
        reason: updatedPayload?.error_description || updatedPayload?.msg || updatedPayload?.message || `auth_http_${updated.status}`
      }, { status: updated.status });
    }
    return NextResponse.json({ status: "password_updated", reason: "Password updated. You can continue to Brand Studio." });
  }

  // SESSION REFRESH (owner-reported, 2026-08-18): access tokens live ~an
  // hour, and until this action existed the client had no way to renew one —
  // every session silently died and the UI mistranslated the resulting 401s
  // ("no credits", a teaser on the customer's own report). GoTrue rotates the
  // refresh token on every use, so the client must store the NEW pair from
  // this response; replaying the old one is refused by Supabase, which is
  // the correct anti-replay behavior and is passed through as session_expired.
  if (action === "refresh") {
    const refreshToken = clean(body.refresh_token);
    if (!refreshToken) {
      return NextResponse.json({ status: "refused", reason: "missing_refresh_token" }, { status: 400 });
    }
    const renewed = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
      method: "POST",
      headers: { apikey: supabaseAnonKey, "content-type": "application/json" },
      body: JSON.stringify({ refresh_token: refreshToken })
    });
    const renewedPayload = await renewed.json().catch(() => ({}));
    if (!renewed.ok || !renewedPayload?.access_token) {
      return NextResponse.json({ status: "refused", reason: "session_expired" }, { status: 401 });
    }
    return NextResponse.json({
      status: "passed",
      access_token: renewedPayload.access_token,
      refresh_token: renewedPayload.refresh_token ?? null,
      expires_at: renewedPayload.expires_at ?? null,
      user: { id: renewedPayload.user?.id || null, email: renewedPayload.user?.email || null }
    });
  }

  // PASSWORD RECOVERY. The sign-in form had no "forgot password" at all
  // (review M8), which for a product that gates a paid action behind an
  // account is a dead end: a customer who mistypes a password at signup can
  // never reach the thing they bought.
  //
  // Supabase's /auth/v1/recover sends the reset email. It answers 200 for an
  // address it has never seen as well as one it has, on purpose — telling a
  // stranger whether an email is registered is an account-enumeration oracle —
  // and this route passes that behaviour through rather than "improving" it
  // into a leak. The customer-facing message says what was done, not what was
  // found.
  if (action === "recover") {
    if (!email) {
      return NextResponse.json({ status: "refused", reason: "missing_credentials" }, { status: 400 });
    }
    const recoveryUrl = supabaseAuthActionUrl({
      supabaseUrl,
      action: "recover",
      redirectTo: authCallbackUrl.toString()
    });
    const recovery = await fetch(recoveryUrl, {
      method: "POST",
      headers: { apikey: supabaseAnonKey, "content-type": "application/json" },
      body: JSON.stringify({ email })
    });
    if (!recovery.ok) {
      const failure = await recovery.json().catch(() => ({}));
      return NextResponse.json({
        status: "refused",
        reason: failure?.error_description || failure?.msg || failure?.message || `auth_http_${recovery.status}`
      }, { status: recovery.status });
    }
    return NextResponse.json({
      status: "recovery_sent",
      reason: "If that address has an account, a reset link is on its way. Check your inbox."
    });
  }

  if (!email || !password) {
    return NextResponse.json({ status: "refused", reason: "missing_credentials" }, { status: 400 });
  }

  // SANDBOX SIGNUP runs INSTEAD OF the public one, not after it — see
  // adminCreateAndSignIn's header. Calling /auth/v1/signup first and then
  // patching the user sends a confirmation email on every attempt and burns
  // the project's shared email quota; the admin route sends nothing.
  //
  // A conflict (the address already has an account) falls through to the real
  // signup below on purpose, so Supabase writes that message rather than this
  // route inventing one.
  //
  // OPEN BETA takes the SAME branch, for a different reason, and says a
  // different thing about it. The flag is checked alongside the sandbox
  // rather than after the public signup below, because the point of both is
  // that /auth/v1/signup — and its confirmation email — never runs.
  //
  // WHEN THE FLAG IS OFF THIS BRANCH IS BYTE-FOR-BYTE WHAT IT WAS: the
  // condition falls back to `sandbox.enabled`, the response carries only the
  // sandbox fields, and nothing is granted. tests/hosted-app-open-beta.test.mjs
  // pins that, because "we did not change the old path" is a claim, and a
  // claim about an authentication path is worth a test.
  const signupPath = signupPathFor({
    sandbox: sandbox.enabled,
    openBeta: openBeta.enabled,
    serviceRoleKey: Boolean(supabaseServiceRoleKey)
  });
  if (action === "signup" && signupPath.path === "admin_create") {
    const created = await adminCreateAndSignIn({ supabaseUrl, supabaseAnonKey, serviceRoleKey: supabaseServiceRoleKey, email, password });
    if (created?.session) {
      const userId = created.session.user?.id || created.userId;

      // THE GRANT. Once per account, ever — enforced by a unique index, not
      // by this code (see lib/open-beta-signup.mjs). A failure here does not
      // fail the signup: the account and the session are already real, and
      // trading a working account for a broken one to protest three credits
      // would be the wrong way round.
      const grant = signupPath.grant
        ? await grantOpenBetaSignupCredits({
          userId,
          config: { supabaseUrl, supabaseAnonKey, supabaseServiceRoleKey },
          env: process.env
        })
        : { granted: 0, status: "skipped" };

      return NextResponse.json({
        status: "passed",
        access_token: created.session.access_token,
        refresh_token: created.session.refresh_token ?? null,
        expires_at: created.session.expires_at ?? null,
        user: { id: userId, email: created.session.user?.email || email },
        // OPEN BETA's own fields. Separate from the sandbox's rather than
        // reusing them: `sandbox_confirmation_skipped` means "this is not the
        // real product", which on a beta deployment is false — it IS the real
        // product, temporarily free. A client that showed the sandbox banner
        // here would be telling a customer their account is a simulation.
        ...(signupPath.banner === "open_beta" ? {
          open_beta: true,
          credits_granted: grant.granted,
          // "granted" the first time, "already_granted" on a retry that the
          // index refused. Both are honest and neither is an error; the UI
          // only celebrates the first.
          grant_status: grant.status,
          open_beta_note: openBetaWelcomeNote(grant)
        } : {}),
        // The UI renders this as a banner and a toast. A skipped confirmation
        // that nobody is told about is a difference between environments that
        // gets discovered in production.
        //
        // Suppressed when open beta is on, because then the confirmation was
        // not skipped "in a sandbox" — it was skipped on the live product, on
        // purpose, and open_beta_note is the sentence that says so.
        ...(signupPath.banner === "sandbox" ? {
          sandbox_confirmation_skipped: true,
          sandbox_note: "Email confirmation was skipped — this is a sandbox. On the live product you'd click a link in your inbox first."
        } : {})
      });
    }
  }

  const path = action === "signup"
    ? `/auth/v1/signup?redirect_to=${encodeURIComponent(authCallbackUrl.toString())}`
    : "/auth/v1/token?grant_type=password";
  const response = await fetch(`${supabaseUrl}${path}`, {
    method: "POST",
    headers: { apikey: supabaseAnonKey, "content-type": "application/json" },
    body: JSON.stringify({ email, password })
  });
  const payload = await response.json().catch(() => ({}));

  if (!response.ok) {
    // Supabase's own message is passed through rather than replaced with a
    // friendlier invention — "Invalid login credentials" and "User already
    // registered" are different facts and the customer needs the real one.
    return NextResponse.json({
      status: "refused",
      reason: payload?.error_description || payload?.msg || payload?.message || `auth_http_${response.status}`
    }, { status: response.status });
  }

  // A signup on a project with email confirmation ON returns a user but NO
  // session. Saying "you're signed in" there would be a lie the very next
  // request would expose, so the honest state is named instead.
  if (!payload?.access_token) {
    return NextResponse.json({
      status: "confirmation_required",
      reason: "Account created. Confirm the email address, then sign in.",
      user_id: payload?.id || payload?.user?.id || null
    }, { status: 200 });
  }

  return NextResponse.json({
    status: "passed",
    access_token: payload.access_token,
    refresh_token: payload.refresh_token ?? null,
    expires_at: payload.expires_at ?? null,
    user: { id: payload.user?.id || null, email: payload.user?.email || email }
  });
}