diff --git a/.env.example b/.env.example
index da8dd4d..0665de5 100644
--- a/.env.example
+++ b/.env.example
@@ -3,11 +3,24 @@ ARCADE_API_KEY=
ANTHROPIC_API_KEY=
# --- Auth (Better Auth BFF + OIDC). Leave unset to keep typed-in user ids. ---
+
+# Sign-in without the IdP: signed in by default, sign-out works, no OIDC client to
+# register. Wins over the OIDC_* settings below, and refuses to engage on the
+# production deployment (VERCEL_ENV, else NODE_ENV). Runs as DEV_AUTH_USER, else
+# ARCADE_USER_ID. On Vercel this belongs on the Preview environment only: a preview
+# hostname cannot be registered as an OIDC redirect URI, so real login there is a
+# dead end.
+# DEV_AUTH=true
+# DEV_AUTH_USER=you@example.com
+
# openssl rand -hex 32
BETTER_AUTH_SECRET=
-# Public origin of this app (redirects + cookies). Under `pnpm dev`, portless
-# injects PORTLESS_URL; set this only when that is wrong.
+# Public origin of this app — the host in the redirect_uri the IdP is handed, so it
+# has to be the origin you browse. Under `pnpm dev`, portless injects PORTLESS_URL;
+# on Vercel it is derived from VERCEL_ENV / VERCEL_BRANCH_URL / VERCEL_URL. Set this
+# only when both are wrong, and per environment — a value pinned to production makes
+# every preview redirect there.
# BETTER_AUTH_URL=https://returntypes.localhost
# Register an OIDC client on Arcade's IdP (Ory / Coordinator). Redirect URI:
diff --git a/CLAUDE.md b/CLAUDE.md
index 0dec75c..8d88d33 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -154,6 +154,51 @@ compiles it, so there is no build step.
- `pnpm --filter @repo/api smoke` drives every route in one pass against the live
API. Same key requirement; it is a script, not a test.
+## Auth (`packages/api`)
+
+A Better Auth BFF (`src/auth.ts`) over the same Ory IdP as Arcade: the browser holds
+only an httpOnly session cookie, the OIDC tokens stay in the database, and identity
+for tool runs is `user.accountId` — the OIDC `sub`, which is the Arcade account.
+
+`authMode()` is the single thing to branch on, and there are three answers:
+
+- `oidc` — `BETTER_AUTH_SECRET` + `OIDC_CLIENT_ID` + `OIDC_DISCOVERY_URL` are set.
+- `dev` — `DEV_AUTH=true`. **Signed in by default with no IdP** (`src/dev-auth.ts`):
+ it answers `/api/auth/*` itself in Better Auth's own shapes, so `authClient` and
+ everything that reads a session need no dev branch. Signing out is a cookie and
+ signed-in is its absence, so there is no session store — nothing to migrate, no
+ rows in the auth tables, and no `BETTER_AUTH_URL` to get right. It runs as
+ `DEV_AUTH_USER`, defaulting to `ARCADE_USER_ID` so it matches the id `smoke.ts`
+ and the fixtures use. It wins over `oidc`, because a machine with credentials in
+ `.env` is exactly the one that wants to skip the dance, and it refuses on the
+ production **deployment** — `VERCEL_ENV` when set, else `NODE_ENV`, because Vercel
+ builds previews with `NODE_ENV=production` too and a preview is the case this
+ exists for. Nothing there authenticates anybody, so that check is the whole
+ security model.
+- `off` — neither. Login is unavailable and a run uses the `userId` in the request
+ body, which is what the UI's "Run as" field is.
+
+`AUTH_REQUIRED=true` refuses runs without a session in any mode. Put `DEV_AUTH=true`
+in the **workspace-root `.env`**, not the shell: turbo runs tasks in strict env mode,
+so an ambient variable never reaches the dev server.
+
+`authBaseURL()` is the host in the `redirect_uri` the IdP is handed, so getting it
+wrong does not fail loudly — the IdP rejects an unregistered URI, or the browser is
+sent to a machine that isn't there, and login just never completes. It reads
+`BETTER_AUTH_URL`, then `PORTLESS_URL`, then Vercel's own env, and only then
+localhost. Consequences worth knowing:
+
+- **An OIDC client registers exact redirect URIs**, so every origin that serves login
+ needs `{origin}/api/auth/oauth2/callback/oidc` registered on the Ory client. That is
+ why previews get `DEV_AUTH=true` instead: `VERCEL_URL` is per-deployment, so no
+ preview URL can be registered ahead of time.
+- Previews therefore prefer `VERCEL_BRANCH_URL`, which is stable per branch — the one
+ preview host where registering a URI would actually hold. `src/auth.test.ts` pins
+ that precedence.
+- Set `BETTER_AUTH_URL` **per environment** if at all. One pinned to the production
+ domain makes every preview send the browser to production, which then owns the
+ cookie — `.vercel.app` is on the public suffix list, so no cookie can span both.
+
## Scripts (`packages/api`)
Users write TypeScript against the catalog's types, validate it without running it,
diff --git a/apps/frontend/src/hooks/api.ts b/apps/frontend/src/hooks/api.ts
index 239b436..d327a83 100644
--- a/apps/frontend/src/hooks/api.ts
+++ b/apps/frontend/src/hooks/api.ts
@@ -31,8 +31,13 @@ export const authKeys = {
me: () => [...authKeys.all, "me"],
}
+/**
+ * `url` is not `.url()`: a real IdP returns an absolute authorize URL, but dev
+ * sign-in (`DEV_AUTH=true`) returns the same-origin path to land on, since the
+ * server behind the portless proxy cannot know its own public origin.
+ */
const OAuthSignInSchema = z.object({
- url: z.string().url().optional(),
+ url: z.string().min(1).optional(),
redirect: z.boolean().optional(),
})
@@ -165,12 +170,12 @@ export function useMe(options?: ClientQueryOptions) {
})
}
-/** Whether the BFF has OIDC env wired up (`useMe` → `configured`). */
-export function useAuthConfigured() {
+/** Which login the BFF serves: `oidc`, `dev`, `off` (`useMe` → `mode`). */
+export function useAuthMode() {
const me = useMe()
return {
...me,
- data: me.data?.configured ?? null,
+ data: me.data?.mode ?? null,
}
}
diff --git a/apps/frontend/src/routes/login.tsx b/apps/frontend/src/routes/login.tsx
index 9faee05..5ef824b 100644
--- a/apps/frontend/src/routes/login.tsx
+++ b/apps/frontend/src/routes/login.tsx
@@ -2,11 +2,15 @@
* Sign in via Arcade's IdP (Ory / Coordinator OIDC). Only useful once
* BETTER_AUTH_SECRET + OIDC_* are set on the server; otherwise the BFF returns
* 503 and this page says so.
+ *
+ * `DEV_AUTH=true` makes the same button a local sign-in with no IdP at all, which
+ * is why this page reads `mode` rather than a boolean — the flow is identical, the
+ * thing on the other end is not.
*/
import { createFileRoute, Link } from "@tanstack/react-router"
import { useEffect } from "react"
import { Button } from "@/components/ui/button"
-import { useAuthConfigured, useSignInOidc } from "@/hooks/api"
+import { useAuthMode, useSignInOidc } from "@/hooks/api"
import { authClient } from "@/lib/auth-client"
export const Route = createFileRoute("/login")({
@@ -15,7 +19,7 @@ export const Route = createFileRoute("/login")({
function LoginPage() {
const { data: session, isPending } = authClient.useSession()
- const { data: configured = null } = useAuthConfigured()
+ const { data: mode = null } = useAuthMode()
const signIn = useSignInOidc()
useEffect(() => {
@@ -35,13 +39,22 @@ function LoginPage() {
- {configured === false ? (
+ {mode === "off" ? (
Auth is not configured in this server process. Put{" "}
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,{" "}
OIDC_DISCOVERY_URL, and BETTER_AUTH_SECRET{" "}
- in the workspace .env, then restart pnpm dev{" "}
- (env is only read at startup).
+ in the workspace .env — or just{" "}
+ DEV_AUTH=true to skip the IdP locally — then restart{" "}
+ pnpm dev (env is only read at startup).
+
+ ) : null}
+
+ {mode === "dev" ? (
+
+ DEV_AUTH=true: this signs you straight in as{" "}
+ DEV_AUTH_USER (defaulting to ARCADE_USER_ID)
+ with no IdP involved. Non-production only.
) : null}
@@ -49,11 +62,15 @@ function LoginPage() {
Checking session…
) : (
)}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 6f778a6..28b2e6f 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -5,12 +5,7 @@ import { createMiddleware } from "hono/factory"
import { describeRoute, resolver, validator } from "hono-openapi"
import { z } from "zod"
import { agentHandler } from "./agent"
-import {
- getAuth,
- getSessionUser,
- isAuthConfigured,
- resolveRunUserId,
-} from "./auth"
+import { authMode, getAuth, getSessionUser, resolveRunUserId } from "./auth"
import {
authorizationFor,
type Catalog,
@@ -19,6 +14,7 @@ import {
} from "./catalog"
import { generateTypes } from "./codegen"
import { db } from "./db"
+import { devAuthHandler } from "./dev-auth"
import { revalidateAll, runScript, upsertScript } from "./execute"
import { mcpHandler } from "./mcp"
import { openApiDocument } from "./openapi"
@@ -108,19 +104,20 @@ export const routes = new Hono()
operationId: "getMe",
summary: "Current session",
description:
- "Whether OIDC auth is configured on this process, and the signed-in user if any. " +
- "Always 200 — `configured: false` means login is disabled; `user: null` means signed out.",
+ "Which login this process serves, and the signed-in user if any. Always 200 — " +
+ '`mode: "off"` means login is disabled; `user: null` means signed out.',
tags: ["auth"],
responses: {
- 200: json(MeResponseSchema, "Auth config and optional session user."),
+ 200: json(MeResponseSchema, "Auth mode and optional session user."),
},
}),
async (c) => {
- if (!isAuthConfigured()) {
- return c.json({ configured: false, user: null }, 200)
- }
- const user = await getSessionUser(c.req.raw.headers)
- return c.json({ configured: true, user }, 200)
+ const mode = authMode()
+ if (mode === "off") return c.json({ mode, user: null }, 200)
+ return c.json(
+ { mode, user: await getSessionUser(c.req.raw.headers) },
+ 200
+ )
}
)
.post(
@@ -608,13 +605,16 @@ export const app = new Hono()
// is set — see ./auth. Mounted on `app`, not `routes`, so it stays out of the
// OpenAPI / MCP surface the same way /chat and /mcp do.
.all("/api/auth/*", (c) => {
+ // `DEV_AUTH=true` answers these paths itself, in the same shapes, so nothing
+ // downstream — including the browser client — knows the difference.
+ if (authMode() === "dev") return devAuthHandler(c.req.raw)
const auth = getAuth()
if (!auth) {
return c.json(
{
error: "auth_not_configured",
message:
- "Set BETTER_AUTH_SECRET, OIDC_CLIENT_ID, and OIDC_DISCOVERY_URL to enable login.",
+ "Set BETTER_AUTH_SECRET, OIDC_CLIENT_ID, and OIDC_DISCOVERY_URL to enable login, or DEV_AUTH=true to be signed in locally without an IdP.",
},
503
)
@@ -651,7 +651,7 @@ const document = openApiDocument(app, {
"`POST /api/seed` populates it; the read endpoints query it.",
},
tags: [
- { name: "auth", description: "Session and OIDC configuration." },
+ { name: "auth", description: "Session and login configuration." },
{ name: "seed", description: "Populate the mirror from the Arcade API." },
{ name: "tools", description: "Read the mirrored catalog." },
{
diff --git a/packages/api/src/auth.test.ts b/packages/api/src/auth.test.ts
new file mode 100644
index 0000000..2bd589a
--- /dev/null
+++ b/packages/api/src/auth.test.ts
@@ -0,0 +1,74 @@
+/**
+ * `authBaseURL()` is the host in the `redirect_uri` the IdP is handed, which makes it
+ * the difference between login working and login looping. Nothing here reaches the
+ * network, so like ./dev-auth.test.ts this suite needs no API key.
+ */
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import { authBaseURL } from "./auth"
+
+const MANAGED = [
+ "BETTER_AUTH_URL",
+ "PORTLESS_URL",
+ "VERCEL_ENV",
+ "VERCEL_URL",
+ "VERCEL_BRANCH_URL",
+ "VERCEL_PROJECT_PRODUCTION_URL",
+] as const
+
+let saved: Partial> = {}
+
+beforeEach(() => {
+ saved = Object.fromEntries(MANAGED.map((key) => [key, process.env[key]]))
+ for (const key of MANAGED) delete process.env[key]
+})
+
+afterEach(() => {
+ for (const key of MANAGED) {
+ const value = saved[key]
+ if (value === undefined) delete process.env[key]
+ else process.env[key] = value
+ }
+})
+
+describe("authBaseURL", () => {
+ it("prefers an explicit BETTER_AUTH_URL over anything it could infer", () => {
+ process.env.BETTER_AUTH_URL = "https://scripts.example.com"
+ process.env.PORTLESS_URL = "https://returntypes.localhost"
+ process.env.VERCEL_URL = "deployment.vercel.app"
+
+ expect(authBaseURL()).toBe("https://scripts.example.com")
+ })
+
+ it("uses the portless origin under `pnpm dev`", () => {
+ process.env.PORTLESS_URL = "https://thimphu.returntypes.localhost"
+
+ expect(authBaseURL()).toBe("https://thimphu.returntypes.localhost")
+ })
+
+ it("names the production domain on the production deployment", () => {
+ process.env.VERCEL_ENV = "production"
+ process.env.VERCEL_PROJECT_PRODUCTION_URL = "abilities.vercel.app"
+ process.env.VERCEL_URL = "abilities-abc123.vercel.app"
+
+ expect(authBaseURL()).toBe("https://abilities.vercel.app")
+ })
+
+ it("prefers the branch URL on a preview, since a per-deployment host is unregisterable", () => {
+ process.env.VERCEL_ENV = "preview"
+ process.env.VERCEL_BRANCH_URL = "abilities-git-thimphu.vercel.app"
+ process.env.VERCEL_URL = "abilities-abc123.vercel.app"
+
+ expect(authBaseURL()).toBe("https://abilities-git-thimphu.vercel.app")
+ })
+
+ it("falls back to the deployment's own URL when there is no branch URL", () => {
+ process.env.VERCEL_ENV = "preview"
+ process.env.VERCEL_URL = "abilities-abc123.vercel.app"
+
+ expect(authBaseURL()).toBe("https://abilities-abc123.vercel.app")
+ })
+
+ it("only reaches localhost when nothing has said otherwise", () => {
+ expect(authBaseURL()).toBe("http://localhost:3000")
+ })
+})
diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts
index 73b8deb..b69bf27 100644
--- a/packages/api/src/auth.ts
+++ b/packages/api/src/auth.ts
@@ -7,7 +7,8 @@
*
* Auth is optional until OIDC env is set — the rest of the API keeps working with
* a typed-in Arcade user id. Set `AUTH_REQUIRED=true` to refuse unauthenticated
- * runs once login works.
+ * runs once login works. `DEV_AUTH=true` is the third mode: a real session shape
+ * with no IdP, so the signed-in paths work locally — see ./dev-auth.
*/
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
@@ -15,6 +16,7 @@ import { genericOAuth } from "better-auth/plugins"
import { z } from "zod"
import * as authSchema from "./auth-schema"
import { db } from "./db"
+import { devAuthEnabled, devSessionUser } from "./dev-auth"
const OIDC_PROVIDER_ID = "oidc"
@@ -56,19 +58,57 @@ export function isAuthConfigured(): boolean {
)
}
+export type AuthMode = "oidc" | "dev" | "off"
+
+/**
+ * Which login this process serves, and the one thing routes should branch on.
+ *
+ * `dev` beats `oidc` deliberately: the reason to set `DEV_AUTH=true` is to skip the
+ * IdP round trip, and a machine that has OIDC credentials in `.env` is exactly the
+ * machine that wants to skip it.
+ */
+export function authMode(): AuthMode {
+ if (devAuthEnabled()) return "dev"
+ return isAuthConfigured() ? "oidc" : "off"
+}
+
/**
- * Public origin Better Auth issues redirects for. Under `pnpm dev`, portless
- * injects `PORTLESS_URL` (e.g. https://returntypes.localhost); override with
- * `BETTER_AUTH_URL` when that is wrong (preview hosts, standalone API).
+ * Public origin Better Auth issues redirects for — and, more to the point, the host
+ * in the `redirect_uri` it sends the IdP. Under `pnpm dev`, portless injects
+ * `PORTLESS_URL` (e.g. https://returntypes.localhost); `BETTER_AUTH_URL` overrides
+ * everything, for a host none of this guesses right.
+ *
+ * The `localhost:3000` default is a *last* resort rather than the fallback for
+ * anything deployed. A deployment that guesses localhost does not fail loudly: it
+ * sends the IdP a plausible `redirect_uri`, the IdP rejects it as unregistered or
+ * bounces the browser to a machine that isn't there, and login simply never
+ * completes. So a Vercel deployment names itself from Vercel's own env instead.
*/
export function authBaseURL(): string {
return (
process.env.BETTER_AUTH_URL ??
process.env.PORTLESS_URL ??
+ vercelOrigin() ??
"http://localhost:3000"
)
}
+/**
+ * This deployment's own public origin, or null off Vercel.
+ *
+ * Previews prefer `VERCEL_BRANCH_URL` over `VERCEL_URL`: the latter changes with
+ * every deployment, and an OIDC client registers exact redirect URIs, so a
+ * per-deployment host is one nobody can ever register. The branch URL is stable, so
+ * it is the only preview host where real login could be made to work.
+ */
+function vercelOrigin(): string | null {
+ const host =
+ process.env.VERCEL_ENV === "production"
+ ? (process.env.VERCEL_PROJECT_PRODUCTION_URL ?? process.env.VERCEL_URL)
+ : (process.env.VERCEL_BRANCH_URL ?? process.env.VERCEL_URL)
+ return host ? `https://${host}` : null
+}
+
function trustedOrigins(): string[] {
const fromEnv = (process.env.CORS_ORIGIN ?? "")
.split(",")
@@ -173,6 +213,7 @@ export function arcadeUserId(user: {
export async function getSessionUser(
headers: Headers
): Promise {
+ if (devAuthEnabled()) return devSessionUser(headers)
const auth = getAuth()
if (!auth) return null
const session = await auth.api.getSession({ headers })
diff --git a/packages/api/src/dev-auth.test.ts b/packages/api/src/dev-auth.test.ts
new file mode 100644
index 0000000..8a18880
--- /dev/null
+++ b/packages/api/src/dev-auth.test.ts
@@ -0,0 +1,166 @@
+/**
+ * Dev sign-in is the one auth path with nothing upstream of it, so unlike the rest
+ * of the suite this file needs no API key and no catalog — it drives the real routes
+ * and reads the real cookies.
+ *
+ * Every case sets its own env: `authMode()` and friends read `process.env` per call
+ * precisely so that a machine with OIDC credentials in `.env` behaves the same here.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import app from "./app"
+
+const MANAGED = [
+ "DEV_AUTH",
+ "DEV_AUTH_USER",
+ "NODE_ENV",
+ "VERCEL_ENV",
+ "BETTER_AUTH_SECRET",
+ "OIDC_CLIENT_ID",
+ "OIDC_DISCOVERY_URL",
+] as const
+
+const DEV_USER = "dev@example.invalid"
+
+let saved: Partial> = {}
+
+beforeEach(() => {
+ saved = Object.fromEntries(MANAGED.map((key) => [key, process.env[key]]))
+ for (const key of MANAGED) delete process.env[key]
+ process.env.DEV_AUTH = "true"
+ process.env.DEV_AUTH_USER = DEV_USER
+})
+
+afterEach(() => {
+ for (const key of MANAGED) {
+ const value = saved[key]
+ if (value === undefined) delete process.env[key]
+ else process.env[key] = value
+ }
+})
+
+const getSession = (cookie?: string) =>
+ app.request("/api/auth/get-session", {
+ headers: cookie ? { cookie } : {},
+ })
+
+/** The `Set-Cookie` a response sends back, as a browser would send it next time. */
+const cookieFrom = (response: Response) => {
+ const header = response.headers.get("set-cookie") ?? ""
+ return header.split(";")[0] ?? ""
+}
+
+describe("dev sign-in", () => {
+ it("reports the mode and a session with no request cookies at all", async () => {
+ const response = await app.request("/api/me")
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({
+ mode: "dev",
+ user: {
+ id: `dev_${DEV_USER}`,
+ accountId: DEV_USER,
+ email: DEV_USER,
+ name: "Dev user",
+ image: null,
+ },
+ })
+ })
+
+ it("answers get-session in Better Auth's shape, so the browser client is unchanged", async () => {
+ const body = await (await getSession()).json()
+
+ // What `authClient.useSession()` reads, and what the rail mirrors into the
+ // run-as atom.
+ expect(body).toMatchObject({
+ session: { userId: `dev_${DEV_USER}` },
+ user: { accountId: DEV_USER, emailVerified: true },
+ })
+ })
+
+ it("signs out with a cookie, and back in by clearing it", async () => {
+ const signOut = await app.request("/api/auth/sign-out", { method: "POST" })
+ expect(await signOut.json()).toEqual({ success: true })
+
+ const cookie = cookieFrom(signOut)
+ expect(cookie).toBe("returntypes.dev_signed_out=1")
+ expect(await (await getSession(cookie)).json()).toBeNull()
+ expect(
+ await (await app.request("/api/me", { headers: { cookie } })).json()
+ ).toEqual({ mode: "dev", user: null })
+
+ const signIn = await app.request("/api/auth/sign-in/oauth2", {
+ method: "POST",
+ headers: { "content-type": "application/json", cookie },
+ body: JSON.stringify({ providerId: "oidc", callbackURL: "/scripts" }),
+ })
+
+ expect(await signIn.json()).toEqual({ redirect: true, url: "/scripts" })
+ // Cleared: the browser drops it, and an empty value reads as absent regardless.
+ expect(signIn.headers.get("set-cookie")).toContain("Max-Age=0")
+ expect(await (await getSession(cookieFrom(signIn))).json()).not.toBeNull()
+ })
+
+ it("will not redirect off-origin after sign-in", async () => {
+ for (const callbackURL of ["https://evil.example/", "//evil.example/"]) {
+ const response = await app.request("/api/auth/sign-in/oauth2", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ callbackURL }),
+ })
+ expect(await response.json()).toEqual({ redirect: true, url: "/" })
+ }
+ })
+
+ it("does not pretend to serve the rest of Better Auth", async () => {
+ const response = await app.request("/api/auth/oauth2/callback/oidc")
+
+ expect(response.status).toBe(404)
+ expect(await response.json()).toMatchObject({
+ error: "dev_auth_unsupported",
+ })
+ })
+
+ it("refuses in production, where nothing would be authenticating anybody", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
+ process.env.NODE_ENV = "production"
+
+ expect(await (await app.request("/api/me")).json()).toEqual({
+ mode: "off",
+ user: null,
+ })
+ // No OIDC env either, so login is simply unavailable — not faked.
+ expect((await getSession()).status).toBe(503)
+ warn.mockRestore()
+ })
+
+ it("still engages on a Vercel preview, which builds as production", async () => {
+ // The case dev auth exists for: NODE_ENV says production, the deployment is not.
+ process.env.NODE_ENV = "production"
+ process.env.VERCEL_ENV = "preview"
+
+ expect(await (await app.request("/api/me")).json()).toMatchObject({
+ mode: "dev",
+ user: { accountId: DEV_USER },
+ })
+ })
+
+ it("refuses on the production deployment even when NODE_ENV is unset", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
+ process.env.VERCEL_ENV = "production"
+
+ expect(await (await app.request("/api/me")).json()).toEqual({
+ mode: "off",
+ user: null,
+ })
+ warn.mockRestore()
+ })
+
+ it("is off when DEV_AUTH is not set", async () => {
+ delete process.env.DEV_AUTH
+
+ expect(await (await app.request("/api/me")).json()).toEqual({
+ mode: "off",
+ user: null,
+ })
+ })
+})
diff --git a/packages/api/src/dev-auth.ts b/packages/api/src/dev-auth.ts
new file mode 100644
index 0000000..1cb828b
--- /dev/null
+++ b/packages/api/src/dev-auth.ts
@@ -0,0 +1,199 @@
+/**
+ * Dev sign-in: a session with no IdP behind it.
+ *
+ * `DEV_AUTH=true` hands `/api/auth/*` to this module instead of Better Auth, so
+ * everything downstream of a session — the rail's session chip, `GET /api/me`,
+ * `resolveRunUserId`, `AUTH_REQUIRED` — is exercisable without registering an OIDC
+ * client on Arcade's IdP. The paths and payloads are Better Auth's, which is the
+ * point: the browser client needs no dev branch, and neither does anything that
+ * reads a session.
+ *
+ * Signed in is the default and signing out is a cookie, so there is no session
+ * store — nothing to migrate, nothing a restart loses, no rows in the auth tables,
+ * and no dependency on `BETTER_AUTH_URL` being right for this host.
+ *
+ * Nothing here authenticates anybody, so the production refusal below is the entire
+ * security model. Both it and `DEV_AUTH` are read per call rather than at module
+ * load, because the three processes that host this app pick up the workspace `.env`
+ * at different times.
+ *
+ * A Vercel preview is the case this exists for. Its hostname changes per deployment
+ * and an OIDC client registers exact redirect URIs, so no preview URL can be
+ * pre-registered with the IdP — real login there is a dead end, while the deployment
+ * is still not production.
+ */
+import type { SessionUser } from "./auth"
+
+/** Present means signed out. Absent — the default — means signed in. */
+const SIGNED_OUT_COOKIE = "returntypes.dev_signed_out"
+
+/**
+ * Fixed rather than derived from the clock: the browser re-polls `get-session` and
+ * compares the payload by value, so timestamps that moved would churn the atom.
+ */
+const ISSUED_AT = new Date(0).toISOString()
+const EXPIRES_AT = new Date("2100-01-01T00:00:00.000Z").toISOString()
+
+let warnedInProduction = false
+
+export function devAuthEnabled(): boolean {
+ if (process.env.DEV_AUTH !== "true") return false
+ if (isProductionDeployment()) {
+ if (!warnedInProduction) {
+ warnedInProduction = true
+ console.warn(
+ "DEV_AUTH=true ignored: this is the production deployment. Set OIDC_CLIENT_ID / OIDC_DISCOVERY_URL / BETTER_AUTH_SECRET for real login."
+ )
+ }
+ return false
+ }
+ return true
+}
+
+/**
+ * The production *deployment*, which is not the same thing as a production build.
+ * Vercel sets `NODE_ENV=production` for preview deployments too, so `NODE_ENV` alone
+ * would refuse a dev session exactly where one is wanted. `VERCEL_ENV` distinguishes
+ * them (`production` / `preview` / `development`) and wins wherever it exists.
+ */
+function isProductionDeployment(): boolean {
+ if (process.env.VERCEL_ENV) return process.env.VERCEL_ENV === "production"
+ return process.env.NODE_ENV === "production"
+}
+
+/**
+ * The Arcade end user a dev session runs as. Defaults to `ARCADE_USER_ID` — the
+ * same id `smoke.ts` and the test fixtures resolve — so turning dev auth on does
+ * not quietly move which account tools execute as.
+ */
+export function devUserId(): string {
+ return (
+ process.env.DEV_AUTH_USER ??
+ process.env.ARCADE_USER_ID ??
+ "anirudh@arcade.dev"
+ )
+}
+
+/**
+ * The session user, or null when signed out.
+ *
+ * Guarded again here rather than trusting the caller: this is the function that
+ * decides somebody is signed in, and it must be impossible for it to say yes in
+ * production.
+ */
+export function devSessionUser(headers: Headers): SessionUser | null {
+ if (!devAuthEnabled()) return null
+ if (readCookie(headers, SIGNED_OUT_COOKIE)) return null
+ const id = devUserId()
+ return {
+ id: `dev_${id}`,
+ // Under real auth this is the OIDC `sub`, and `arcadeUserId()` prefers it —
+ // putting the dev id here is what makes tool calls run as that account.
+ accountId: id,
+ email: id,
+ name: "Dev user",
+ image: null,
+ }
+}
+
+/**
+ * The three endpoints the Better Auth browser client actually reaches in this app:
+ * `useSession()` gets `get-session`, `signOut()` posts `sign-out`, and
+ * `signIn.oauth2()` posts `sign-in/oauth2` and follows the `url` that comes back.
+ */
+export async function devAuthHandler(request: Request): Promise {
+ const url = new URL(request.url)
+ const path = url.pathname.slice("/api/auth".length)
+ const secure = url.protocol === "https:"
+
+ if (path === "/get-session") {
+ const user = devSessionUser(request.headers)
+ return Response.json(user ? sessionPayload(user) : null)
+ }
+
+ if (path === "/sign-out") {
+ return Response.json(
+ { success: true },
+ { headers: { "set-cookie": signedOutCookie("1", 31_536_000, secure) } }
+ )
+ }
+
+ if (path.startsWith("/sign-in")) {
+ // Signing back in is deleting the cookie. `redirect` + `url` is the shape the
+ // client's redirect plugin and ./hooks/api both follow; the url stays relative
+ // because this process cannot know its own public origin (portless proxies it).
+ const body: unknown = await request.json().catch(() => null)
+ return Response.json(
+ { redirect: true, url: callbackPath(body) },
+ { headers: { "set-cookie": signedOutCookie("", 0, secure) } }
+ )
+ }
+
+ return Response.json(
+ {
+ error: "dev_auth_unsupported",
+ message: `DEV_AUTH=true serves only get-session, sign-out and sign-in; ${path} needs a real OIDC client.`,
+ },
+ { status: 404 }
+ )
+}
+
+function sessionPayload(user: SessionUser) {
+ return {
+ session: {
+ id: "dev_session",
+ token: "dev_session",
+ userId: user.id,
+ expiresAt: EXPIRES_AT,
+ createdAt: ISSUED_AT,
+ updatedAt: ISSUED_AT,
+ },
+ user: {
+ ...user,
+ emailVerified: true,
+ createdAt: ISSUED_AT,
+ updatedAt: ISSUED_AT,
+ },
+ }
+}
+
+/**
+ * Where to land after signing in — a same-origin path only. An absolute
+ * `callbackURL` would make this an open redirect, and `//host` is absolute too.
+ */
+function callbackPath(body: unknown): string {
+ const value =
+ body && typeof body === "object" && "callbackURL" in body
+ ? body.callbackURL
+ : null
+ if (typeof value !== "string") return "/"
+ if (!value.startsWith("/") || value.startsWith("//")) return "/"
+ return value
+}
+
+function signedOutCookie(value: string, maxAge: number, secure: boolean) {
+ return [
+ `${SIGNED_OUT_COOKIE}=${value}`,
+ "Path=/",
+ `Max-Age=${maxAge}`,
+ "SameSite=Lax",
+ "HttpOnly",
+ // Omitted under `pnpm dev:ports`, which serves plain http on a raw port.
+ secure ? "Secure" : "",
+ ]
+ .filter(Boolean)
+ .join("; ")
+}
+
+/** Empty counts as absent, so a cookie a browser kept after `Max-Age=0` is ignored. */
+function readCookie(headers: Headers, name: string): string | null {
+ const header = headers.get("cookie")
+ if (!header) return null
+ for (const pair of header.split(";")) {
+ const trimmed = pair.trim()
+ if (trimmed.startsWith(`${name}=`)) {
+ return trimmed.slice(name.length + 1) || null
+ }
+ }
+ return null
+}
diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts
index d36decd..868f10a 100644
--- a/packages/api/src/schemas.ts
+++ b/packages/api/src/schemas.ts
@@ -372,7 +372,13 @@ export const MeUserSchema = z
/** Always 200: auth off, signed out, or signed in. */
export const MeResponseSchema = z
.object({
- configured: z.boolean(),
+ mode: z
+ .enum(["oidc", "dev", "off"])
+ .describe(
+ "`oidc` is login against Arcade's IdP. `dev` is DEV_AUTH=true: signed in by " +
+ "default, no IdP, non-production only. `off` means no login is available and " +
+ "runs use the user id in the request body."
+ ),
user: MeUserSchema.nullable(),
})
.meta({ id: "MeResponse" })