From c6454e3a9105520da4f5fc34794408915180021b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:53:25 +0000 Subject: [PATCH 1/2] fix(cli): serve says so when the auth base URL is unusable, instead of swallowing it (#10202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve` resolved the auth base URL through a `??` chain and parsed it inside `try { new URL(baseUrl) } catch { /* ignore malformed baseUrl */ }`. That catch was the only witness that the configured value could not be parsed, and it discarded the witness: the deployment's own origin never reached the CSRF allow-list, boot continued, and `/api/v1/health` kept answering 200 while sign-in answered 403 INVALID_ORIGIN naming nothing. Extract the resolution into an exported seam (`resolveAuthBaseUrl`) that reports which variable supplied the value and whether it parses, plus `formatUnusableAuthBaseUrlDiagnostic` for the sentence. Resolution itself is byte-for-byte unchanged — same chain, same precedence, same `${protocol}//${host}` origin spelling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .../serve-auth-base-url-diagnostic.test.ts | 264 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 156 ++++++++++- 2 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts diff --git a/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts b/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts new file mode 100644 index 0000000000..f958f2aa2c --- /dev/null +++ b/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os serve`'s auth base-URL resolution, and the diagnostic for an unusable one + * (#10202). + * + * ## The defect, MEASURED before it was fixed + * + * Three language semantics compose into a silent failure: + * + * 1. `readEnvWithDeprecation` returns the preferred variable whenever it is + * `!== undefined` — a present-but-empty variable resolves to `''`. + * 2. serve's fallback chain coalesces with `??`, which falls through only on + * `null`/`undefined`. `'' ?? x` is `''`, so neither `OS_BASE_URL` nor the + * `http://localhost:` default is consulted. + * 3. `new URL('')` throws — and the throw landed in + * `catch { /* ignore malformed baseUrl *\/ }`, the ONLY witness that the + * configured base URL was unusable. + * + * Measured on a real `os serve` boot of `examples/app-todo`, `NODE_ENV=production`, + * `OS_TRUSTED_ORIGINS` / `OS_ROOT_DOMAIN` / preview mode all unset, probing + * `POST /api/v1/auth/sign-in/email` with bogus credentials so a TRUSTED origin + * answers `401 INVALID_EMAIL_OR_PASSWORD` and an UNTRUSTED one `403 INVALID_ORIGIN`: + * + * origin OS_AUTH_URL= OS_AUTH_URL=https://app.example.com unset + * https://app.example.com 403 401 403 + * http://localhost: 401 403 401 + * http://tenant.localhost:401 403 403 + * /api/v1/health, /api/v1/ready 200 200 200 + * + * Two things that table settles, and one it corrects: + * + * • CONFIRMED — with a set-but-empty `OS_AUTH_URL` the deployment's OWN origin + * is refused while health and ready keep answering `200`. Authentication is + * dead and nothing says so. + * • CORRECTED — the filing predicted `trustedOrigins` would be `[]` and that + * "every origin is refused". It is not. serve's local array is empty, but + * serve passes `trustedOrigins.length ? trustedOrigins : undefined`, and + * `AuthManager` substitutes a localhost wildcard trio for an absent list. + * So better-auth receives a NON-empty allow-list — the masking layer the + * filing suspected is real, and it is ObjectStack's own AuthManager rather + * than better-auth. + * • WORSE THAN CLAIMED — empty is therefore strictly MORE permissive than + * unset: `http://tenant.localhost:` is trusted in the empty case and + * refused in the unset case. An env template that renders an absent key to + * the empty string silently widens a production CSRF allow-list. + * + * ## What these tests pin, and what they deliberately do not + * + * They pin the SEAM: what the chain resolves to, which variable supplied it, + * whether it parses, and the sentence produced when it does not. The resolution + * itself is unchanged by #10202 — the precedence assertions below describe + * behaviour that predates the fix and must keep holding, including the one that + * looks like the bug (an empty `OS_AUTH_URL` does NOT fall through). Making + * empty behave as unset would change every `readEnvWithDeprecation` caller and + * is a separate, deliberate decision; nothing here presumes it. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { + resolveAuthBaseUrl, + formatUnusableAuthBaseUrlDiagnostic, + AUTH_BASE_URL_ENV_NAMES, +} from './serve.js'; + +const TOUCHED = ['OS_AUTH_URL', 'BETTER_AUTH_URL', 'OS_BASE_URL'] as const; +let saved: Record = {}; + +beforeEach(() => { + saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + for (const k of TOUCHED) delete process.env[k]; +}); + +afterEach(() => { + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe('resolveAuthBaseUrl — precedence (pre-existing behaviour, unchanged)', () => { + it('falls back to http://localhost: when no variable is set', () => { + expect(resolveAuthBaseUrl(3000)).toEqual({ + value: 'http://localhost:3000', + source: null, + baseOrigin: 'http://localhost:3000', + }); + }); + + it('prefers OS_AUTH_URL over the legacy BETTER_AUTH_URL and over OS_BASE_URL', () => { + process.env.OS_AUTH_URL = 'https://auth.example.com'; + process.env.BETTER_AUTH_URL = 'https://legacy.example.com'; + process.env.OS_BASE_URL = 'https://base.example.com'; + + const r = resolveAuthBaseUrl(3000); + expect(r.value).toBe('https://auth.example.com'); + expect(r.source).toBe('OS_AUTH_URL'); + }); + + it('honours the legacy BETTER_AUTH_URL when OS_AUTH_URL is unset', () => { + process.env.BETTER_AUTH_URL = 'https://legacy.example.com'; + process.env.OS_BASE_URL = 'https://base.example.com'; + + const r = resolveAuthBaseUrl(3000); + expect(r.value).toBe('https://legacy.example.com'); + expect(r.source).toBe('BETTER_AUTH_URL'); + }); + + it('falls through to OS_BASE_URL when neither auth-url name is set', () => { + process.env.OS_BASE_URL = 'https://base.example.com'; + + const r = resolveAuthBaseUrl(3000); + expect(r.value).toBe('https://base.example.com'); + expect(r.source).toBe('OS_BASE_URL'); + }); + + it('does NOT treat a set-but-empty OS_AUTH_URL as unset — the chain still stops there', () => { + // This is the shape the defect is made of, pinned as-is. `??` skips only + // unset values, so OS_BASE_URL and the localhost default stay unconsulted. + process.env.OS_AUTH_URL = ''; + process.env.OS_BASE_URL = 'https://base.example.com'; + + const r = resolveAuthBaseUrl(3000); + expect(r.value).toBe(''); + expect(r.source).toBe('OS_AUTH_URL'); + expect(r.value).not.toBe('https://base.example.com'); + }); + + it('names the origin as protocol//host, keeping port and dropping path', () => { + // Pinned against `URL.origin`, which the inline code never used and which + // answers the string "null" for a non-special scheme. + process.env.OS_AUTH_URL = 'https://app.example.com:8443/mounted/here?q=1'; + expect(resolveAuthBaseUrl(3000).baseOrigin).toBe('https://app.example.com:8443'); + }); +}); + +describe('resolveAuthBaseUrl — a usable base URL stays silent', () => { + it.each([ + ['OS_AUTH_URL', 'https://app.example.com'], + ['BETTER_AUTH_URL', 'https://legacy.example.com'], + ['OS_BASE_URL', 'https://base.example.com'], + ])('%s=%s parses, yields its own origin, and produces no diagnostic', (name, url) => { + process.env[name] = url; + + const r = resolveAuthBaseUrl(3000); + expect(r.baseOrigin).toBe(url); + expect(formatUnusableAuthBaseUrlDiagnostic(r)).toBeNull(); + }); + + it('produces no diagnostic for the built-in default either', () => { + expect(formatUnusableAuthBaseUrlDiagnostic(resolveAuthBaseUrl(3000))).toBeNull(); + }); +}); + +describe('formatUnusableAuthBaseUrlDiagnostic — the failure is loud', () => { + it('reports a set-but-empty OS_AUTH_URL, naming the variable and the value', () => { + process.env.OS_AUTH_URL = ''; + + const r = resolveAuthBaseUrl(3000); + expect(r.baseOrigin).toBeNull(); + + const text = formatUnusableAuthBaseUrlDiagnostic(r); + expect(text).not.toBeNull(); + expect(text).toContain('OS_AUTH_URL'); + // The value it resolved to, quoted, so an empty string is visible at all. + expect(text).toContain('""'); + expect(text).toContain('EMPTY'); + // The trap itself: the operator believes they left it unset. + expect(text).toContain('NOT the same as an unset one'); + // The symptom, so the sentence is findable from what the operator sees. + expect(text).toContain('403 INVALID_ORIGIN'); + }); + + it('reports a set-but-empty legacy BETTER_AUTH_URL under its own name', () => { + process.env.BETTER_AUTH_URL = ''; + + const text = formatUnusableAuthBaseUrlDiagnostic(resolveAuthBaseUrl(3000)); + expect(text).toContain('BETTER_AUTH_URL'); + expect(text).not.toContain('OS_AUTH_URL is set'); + }); + + it('reports a set-but-empty OS_BASE_URL under its own name', () => { + process.env.OS_BASE_URL = ''; + + const text = formatUnusableAuthBaseUrlDiagnostic(resolveAuthBaseUrl(3000)); + expect(text).toContain('OS_BASE_URL'); + expect(text).toContain('""'); + }); + + it('reports a NON-empty but unparseable value, quoting what it resolved to', () => { + // The other half of "unusable": a bare host has no scheme and throws too. + process.env.OS_AUTH_URL = 'app.example.com'; + + const r = resolveAuthBaseUrl(3000); + expect(r.baseOrigin).toBeNull(); + + const text = formatUnusableAuthBaseUrlDiagnostic(r); + expect(text).toContain('OS_AUTH_URL'); + expect(text).toContain('"app.example.com"'); + expect(text).toContain('not a usable URL'); + // Not the empty-vs-unset lecture — that would be wrong for this case. + expect(text).not.toContain('NOT the same as an unset one'); + }); + + it('prescribes the two ways out: set a real origin, or remove the variable', () => { + process.env.OS_AUTH_URL = ''; + + const text = formatUnusableAuthBaseUrlDiagnostic(resolveAuthBaseUrl(3000)) ?? ''; + expect(text).toContain('https://app.example.com'); + expect(text).toContain('remove the variable'); + }); + + it('carries no raw control bytes — chalk colouring happens at the call site', () => { + process.env.OS_AUTH_URL = ''; + + const text = formatUnusableAuthBaseUrlDiagnostic(resolveAuthBaseUrl(3000)) ?? ''; + // Written as an escape, never as the byte itself: one raw control character + // makes grep treat the whole file as binary. + expect(text).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/); + }); +}); + +describe('the allow-list is never silently short of the base origin', () => { + /** + * The invariant the fix exists for, stated as the call site consumes it: + * EXACTLY ONE of the two outcomes happens for any resolved value — either an + * origin is available to push, or a diagnostic is produced. The old `catch` + * allowed a third: neither. + */ + it.each([ + ['unset (built-in default)', undefined], + ['a normal https origin', 'https://app.example.com'], + ['a normal http origin with a port', 'http://10.0.0.5:8080'], + ['set-but-empty', ''], + ['whitespace only', ' '], + ['a bare host, no scheme', 'app.example.com'], + ['a value that is not a URL at all', 'not a url'], + ])('%s: an origin to trust, or a complaint — never neither', (_label, value) => { + if (value !== undefined) process.env.OS_AUTH_URL = value; + + const r = resolveAuthBaseUrl(3000); + const text = formatUnusableAuthBaseUrlDiagnostic(r); + + const gotOrigin = r.baseOrigin !== null; + const gotDiagnostic = text !== null; + expect(gotOrigin || gotDiagnostic).toBe(true); + expect(gotOrigin && gotDiagnostic).toBe(false); + }); +}); + +describe('AUTH_BASE_URL_ENV_NAMES', () => { + it('lists the variables the chain reads, in precedence order', () => { + expect(AUTH_BASE_URL_ENV_NAMES).toEqual(['OS_AUTH_URL', 'BETTER_AUTH_URL', 'OS_BASE_URL']); + }); + + it('every name it lists can actually supply the base URL', () => { + for (const name of AUTH_BASE_URL_ENV_NAMES) { + for (const k of TOUCHED) delete process.env[k]; + process.env[name] = `https://${name.toLowerCase()}.example.com`; + expect(resolveAuthBaseUrl(3000).source).toBe(name); + } + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ab81571ca5..cca735b3c2 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2172,9 +2172,12 @@ export default class Serve extends Command { } else if (!secret) { console.warn(chalk.yellow(' ⚠ AuthPlugin skipped — set OS_AUTH_SECRET to enable authentication in production')); } else { - const baseUrl = readEnvWithDeprecation('OS_AUTH_URL', 'BETTER_AUTH_URL', { silent: true }) - ?? process.env.OS_BASE_URL - ?? `http://localhost:${port}`; + // Resolution is UNCHANGED (same chain, same precedence) — the seam + // additionally reports where the value came from and whether it + // parses, so an unusable one can be said out loud instead of + // vanishing into an empty catch (#10202). + const baseUrlResolution = resolveAuthBaseUrl(port); + const baseUrl = baseUrlResolution.value; const socialProviders: Record = {}; if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) @@ -2196,11 +2199,20 @@ export default class Serve extends Command { }); } // Always add the configured baseUrl so first-party redirects work. - try { - const u = new URL(baseUrl); - const baseOrigin = `${u.protocol}//${u.host}`; - if (!trustedOrigins.includes(baseOrigin)) trustedOrigins.push(baseOrigin); - } catch { /* ignore malformed baseUrl */ } + // An unusable value is NOT silently dropped (#10202): the catch that + // used to stand here was the only witness that the configured base + // URL could not be parsed, and it discarded that witness. Boot then + // continued with this deployment's own origin missing from the CSRF + // allow-list, health/ready still answering 200, and the operator's + // first news of it a browser-side 403 INVALID_ORIGIN naming nothing. + const unusableBaseUrlDiagnostic = formatUnusableAuthBaseUrlDiagnostic(baseUrlResolution); + if (baseUrlResolution.baseOrigin !== null) { + if (!trustedOrigins.includes(baseUrlResolution.baseOrigin)) { + trustedOrigins.push(baseUrlResolution.baseOrigin); + } + } else if (unusableBaseUrlDiagnostic) { + console.warn(chalk.yellow(unusableBaseUrlDiagnostic)); + } // Preview-mode subdomain wildcards (`--.`). // Honour `OS_PREVIEW_BASE_DOMAINS` (used by the cloud preview routing) // and add `http://*.:*` patterns. @@ -3823,6 +3835,134 @@ export function resolveTenancyPostureOrRefusal(): TenancyPostureGateVerdict { } } +/** + * Which env var supplied the auth base URL, in the precedence order `serve` + * reads them. `null` in {@link AuthBaseUrlResolution.source} means no variable + * was set and the built-in `http://localhost:` default won. + */ +export const AUTH_BASE_URL_ENV_NAMES = ['OS_AUTH_URL', 'BETTER_AUTH_URL', 'OS_BASE_URL'] as const; +export type AuthBaseUrlEnvName = (typeof AUTH_BASE_URL_ENV_NAMES)[number]; + +/** + * What the auth base-URL precedence chain resolved to, and whether that value + * is usable as a URL. + */ +export interface AuthBaseUrlResolution { + /** The resolved value, UNCHANGED — including `''` when a variable is set-but-empty. */ + value: string; + /** The variable that supplied {@link value}, or `null` for the built-in default. */ + source: AuthBaseUrlEnvName | null; + /** `${protocol}//${host}` of {@link value}, or `null` when it will not parse. */ + baseOrigin: string | null; +} + +/** + * Resolve the auth base URL and report BOTH where it came from and whether it + * parses (#10202). + * + * ## Why this is a seam and not three lines inside `run()` + * + * The chain and the `new URL()` that consumes it used to sit inline, with the + * parse wrapped in `try { … } catch { /* ignore malformed baseUrl *\/ }`. That + * catch is the defect: it is the ONLY place that learns the configured base URL + * is unusable, and it threw the knowledge away. Nothing else in the boot notices + * — `/api/v1/health` and `/api/v1/ready` answer `200` and the server serves + * every non-auth route normally, so the first symptom reaches the operator as + * `403 INVALID_ORIGIN` from a browser, with nothing in the log pointing at the + * variable that caused it. + * + * MEASURED on a real `os serve` boot (`NODE_ENV=production`, `OS_AUTH_URL=` + * set-but-empty, no `OS_TRUSTED_ORIGINS`, no `OS_ROOT_DOMAIN`, no preview mode): + * health `200`, ready `200`, and `POST /api/v1/auth/sign-in/email` from + * `https://app.example.com` answered `403 INVALID_ORIGIN`. + * + * ## The shape that produces an unusable value + * + * `readEnvWithDeprecation` returns the preferred variable whenever it is + * `!== undefined`, so a **present-but-empty** variable resolves to `''`, not + * `undefined`. The chain below then coalesces with `??`, which falls through + * only on `null`/`undefined` — never on `''`. So `OS_AUTH_URL=` on its own line + * in an env file (or a template rendering an absent key) consults NEITHER + * `OS_BASE_URL` nor the `http://localhost:` default, and `new URL('')` + * throws. + * + * The resolution itself is deliberately left EXACTLY as it was — this function + * changes only what is known about the result, never what the result is. + * Treating empty as unset inside the shared `readEnvWithDeprecation` would + * change behaviour for every caller and is a separate, deliberate decision. + * + * `baseOrigin` is spelled `${protocol}//${host}` rather than `URL.origin` + * because that is what the inline code computed, and the two disagree for + * non-special schemes (`URL.origin` answers the string `"null"`). + */ +export function resolveAuthBaseUrl(port: number | string): AuthBaseUrlResolution { + const value = readEnvWithDeprecation('OS_AUTH_URL', 'BETTER_AUTH_URL', { silent: true }) + ?? process.env.OS_BASE_URL + ?? `http://localhost:${port}`; + + // Mirrors readEnvWithDeprecation's own precedence (preferred, then legacy), + // for REPORTING only — the value above is what actually takes effect. + const env = process.env; + const source: AuthBaseUrlEnvName | null = + env.OS_AUTH_URL !== undefined ? 'OS_AUTH_URL' + : env.BETTER_AUTH_URL !== undefined ? 'BETTER_AUTH_URL' + : env.OS_BASE_URL !== undefined ? 'OS_BASE_URL' + : null; + + let baseOrigin: string | null = null; + try { + const u = new URL(value); + baseOrigin = `${u.protocol}//${u.host}`; + } catch { + // Unusable. Reported by formatUnusableAuthBaseUrlDiagnostic — NOT swallowed. + } + + return { value, source, baseOrigin }; +} + +/** + * The boot-time complaint for a base URL that will not parse, or `null` when it + * parses fine. + * + * Names the variable and the value it resolved to, because those are the two + * facts the operator cannot recover from the symptom: a `403 INVALID_ORIGIN` in + * a browser names neither. The empty case additionally says that empty is not + * the same as unset, since the whole trap is that an operator who typed + * `OS_AUTH_URL=` believes they have left it unset. + * + * Returns text rather than printing it so the decision to warn stays at the + * call site (and so a test can read the sentence without capturing a stream). + */ +export function formatUnusableAuthBaseUrlDiagnostic( + resolution: AuthBaseUrlResolution, +): string | null { + if (resolution.baseOrigin !== null) return null; + + const { value, source } = resolution; + const shown = JSON.stringify(value); + const named = source === null + ? ` The built-in default resolved to ${shown}, which is not a usable URL.\n` + : value === '' + ? ` ${source} is set but EMPTY (${shown}).\n` + : ` ${source} is set to ${shown}, which is not a usable URL.\n`; + + const emptyNote = (value === '' && source !== null) + ? ' An empty value is NOT the same as an unset one: the fallback chain skips only\n' + + ` UNSET variables, so ${source === 'OS_BASE_URL' ? 'the' : 'OS_BASE_URL and the'} built-in` + + ' http://localhost: default were never consulted.\n' + : ''; + + return '\n' + + ' ⚠ Auth base URL is unusable — its origin was NOT added to the CSRF allow-list.\n' + + named + + emptyNote + + ' Sign-in and sign-up will answer 403 INVALID_ORIGIN for this deployment\'s own\n' + + ' origin, while /api/v1/health and /api/v1/ready keep answering 200.\n' + + ' Fix: set OS_AUTH_URL to this deployment\'s public origin (e.g.\n' + + ' https://app.example.com), or remove the variable entirely to fall back to\n' + + ' OS_BASE_URL / http://localhost:.'; +} + /** * Constructor options for `StorageServicePlugin`, plus the local root to name in * the production warning (absent when the host configured a backend itself). From 76de54c3430c1b2d79274674534c593587ea90c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:55:50 +0000 Subject: [PATCH 2/2] docs(changeset): record the serve auth base-URL diagnostic (#10202) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .changeset/serve-auth-base-url-loud.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/serve-auth-base-url-loud.md diff --git a/.changeset/serve-auth-base-url-loud.md b/.changeset/serve-auth-base-url-loud.md new file mode 100644 index 0000000000..31b0345d3c --- /dev/null +++ b/.changeset/serve-auth-base-url-loud.md @@ -0,0 +1,24 @@ +--- +"@objectstack/cli": patch +--- + +**Bug fix (silent failure made loud):** `serve` now prints a boot-time diagnostic when the configured auth base URL cannot be parsed, instead of discarding the failure in an empty `catch` (#10202). + +The base URL was resolved through a `??` chain and parsed inside `try { new URL(baseUrl) } catch { /* ignore malformed baseUrl */ }`. That catch was the only place in the boot that learned the value was unusable, and it threw the knowledge away: the deployment's own origin never reached the `trustedOrigins` allow-list, boot continued normally, and the operator's first news of it was a browser-side `403 INVALID_ORIGIN` that names neither the variable nor the value. + +The shape that reaches it is ordinary env plumbing. `readEnvWithDeprecation` returns the preferred variable whenever it is `!== undefined`, so a **present-but-empty** variable resolves to `''` rather than `undefined`; `??` falls through only on `null`/`undefined`, so `OS_AUTH_URL=` on its own line in an env file (or a Helm/systemd/CI template rendering an absent key) consults neither `OS_BASE_URL` nor the `http://localhost:` default; and `new URL('')` throws. + +Measured on a real `os serve` boot with `NODE_ENV=production`, `OS_AUTH_URL=` set-but-empty and `OS_TRUSTED_ORIGINS` / `OS_ROOT_DOMAIN` / preview mode unset, probing `POST /api/v1/auth/sign-in/email` so a trusted origin answers `401 INVALID_EMAIL_OR_PASSWORD` and an untrusted one `403 INVALID_ORIGIN`: + +| Origin | `OS_AUTH_URL=` (empty) | `OS_AUTH_URL=https://app.example.com` | unset | +| --- | --- | --- | --- | +| `https://app.example.com` | 403 | **401** | 403 | +| `http://localhost:` | **401** | 403 | **401** | +| `http://tenant.localhost:` | **401** | 403 | 403 | +| `/api/v1/health`, `/api/v1/ready` | 200 | 200 | 200 | + +Two corrections to how this was expected to behave, both from that table. The allow-list does **not** come out empty: `serve` passes `trustedOrigins.length ? trustedOrigins : undefined`, and `AuthManager` substitutes a localhost wildcard trio for an absent list — so better-auth receives a non-empty list and localhost origins are trusted. Which makes set-but-empty strictly **more permissive than unset**: `http://tenant.localhost:` is trusted in the empty case and refused in the unset case, so an env template that renders an absent key to the empty string silently widens a production CSRF allow-list. + +**What changed is only what is said, never what is resolved.** The precedence chain, its order, and the `${protocol}//${host}` origin spelling are byte-for-byte identical; a set-but-empty `OS_AUTH_URL` still stops the chain exactly as before. Treating empty as unset inside the shared `readEnvWithDeprecation` would change behaviour for every caller of that helper and remains a separate, deliberate decision. The diagnostic is a warning, not a refusal to boot: a deployment running set-but-empty today keeps starting, and now says why authentication will not work. + +The resolution is exported as a seam — `resolveAuthBaseUrl()` and `formatUnusableAuthBaseUrlDiagnostic()`, alongside this file's sibling helpers — so the behaviour is reachable from tests without booting a server.