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