Filed unassigned while fixing cloud#1437 (the EE compose stack's 403 INVALID_ORIGIN). Not that card's defect — that one is a deploy-artifact fix and is handled in objectstack-ai/cloud#1459. This is the framework-side sharp edge found next to it.
Claim
Setting OS_AUTH_URL= (or OS_BASE_URL=) to an empty value — the shape you get from OS_AUTH_URL= on its own line in an env file, or a templating layer that renders an unset variable to the empty string — does not fall back to the built-in default. It is taken as a real base URL of "", and the resulting trustedOrigins allow-list ends up empty, so every origin is refused. That is a harder failure than an unset variable, and it is silent: the process boots, /api/v1/health and /api/v1/ready answer 200, and only authentication is dead.
Mechanism, with file and line (pin 907c11d2)
packages/types/src/env.ts:52-62 — readEnvWithDeprecation returns the preferred variable whenever it is !== undefined:
const preferredValue = env[preferred];
if (preferredValue !== undefined) return preferredValue;
An empty variable is present-but-empty, so this returns '', not undefined.
packages/cli/src/commands/serve.ts:2175-2177 — the fallback chain is ??, which only skips null / undefined:
const baseUrl = readEnvWithDeprecation('OS_AUTH_URL', 'BETTER_AUTH_URL', …)
?? process.env.OS_BASE_URL
?? `http://localhost:${port}`;
'' ?? x evaluates to ''. So neither OS_BASE_URL nor the http://localhost:${port} default is ever consulted, and baseUrl is ''.
serve.ts:2198-2204 — the baseUrl origin is added inside a try whose catch is empty:
try {
const u = new URL(baseUrl);
…push baseOrigin…
} catch { /* ignore malformed baseUrl */ }
new URL('') throws, so nothing is pushed and the failure is swallowed.
With OS_TRUSTED_ORIGINS unset, no preview mode, NODE_ENV=production (so the isDevhttp://localhost:* convenience is off) and no OS_ROOT_DOMAIN, trustedOrigins is then [] — no origin is trusted at all.
Not claimed
⛔ Not measured. This is read from the source at the pin, not observed in a running server. The reasoning is short and each step is a language semantic (!== undefined, ?? not catching '', new URL('') throwing), but nobody has booted a server with OS_AUTH_URL= and watched the allow-list come out empty. Worth confirming before acting — in particular whether better-auth has any behaviour of its own that masks an empty baseURL.
Also unexamined: how many other readEnvWithDeprecation call sites feed a ?? chain and therefore share this shape. The two named above are the ones this came from, not a survey.
Why it's worth a card
The empty-value shape is a normal thing for env plumbing to produce — a commented-out value that lost its #, a Helm/systemd/CI template rendering an absent key, an operator who typed the name and meant to fill it in later. In every one of those cases the operator's intent is "I have not set this", and the runtime's own default is the correct answer.
The cloud#1437 fix guards its own deployment against this with a compose-level ${OS_AUTH_URL:-…} default (the :- form substitutes on empty, not just on unset), and documents "never leave it set-but-empty" in deploy/.env.example. That protects the shipped compose stack only. Any other way of running the image — Helm, a PaaS, plain docker run, a hand-written compose file — still has the sharp edge.
Possible directions (not a recommendation — needs triage)
- Treat empty as unset at the read site (trim-and-
|| undefined) so the documented precedence chain resolves as written. Changes behaviour for every caller of readEnvWithDeprecation, so it wants a deliberate decision rather than a drive-by. - Or keep the read literal but make the
catch at serve.ts:2204 loud instead of silent, so an unusable base URL is a boot-time complaint rather than an empty allow-list.
The second is strictly smaller and fixes the "silent" half without touching shared env semantics; the first fixes the class. Which one is right is a call for whoever owns this surface.
Filed unassigned while fixing cloud#1437 (the EE compose stack's
403 INVALID_ORIGIN). Not that card's defect — that one is a deploy-artifact fix and is handled in objectstack-ai/cloud#1459. This is the framework-side sharp edge found next to it.Claim
Setting
OS_AUTH_URL=(orOS_BASE_URL=) to an empty value — the shape you get fromOS_AUTH_URL=on its own line in an env file, or a templating layer that renders an unset variable to the empty string — does not fall back to the built-in default. It is taken as a real base URL of"", and the resultingtrustedOriginsallow-list ends up empty, so every origin is refused. That is a harder failure than an unset variable, and it is silent: the process boots,/api/v1/healthand/api/v1/readyanswer200, and only authentication is dead.Mechanism, with file and line (pin
907c11d2)packages/types/src/env.ts:52-62—readEnvWithDeprecationreturns the preferred variable whenever it is!== undefined:An empty variable is present-but-empty, so this returns
'', notundefined.packages/cli/src/commands/serve.ts:2175-2177— the fallback chain is??, which only skipsnull/undefined:'' ?? xevaluates to''. So neitherOS_BASE_URLnor thehttp://localhost:${port}default is ever consulted, andbaseUrlis''.serve.ts:2198-2204— the baseUrl origin is added inside atrywhosecatchis empty:new URL('')throws, so nothing is pushed and the failure is swallowed.With
OS_TRUSTED_ORIGINSunset, no preview mode,NODE_ENV=production(so theisDevhttp://localhost:*convenience is off) and noOS_ROOT_DOMAIN,trustedOriginsis then[]— no origin is trusted at all.Not claimed
⛔ Not measured. This is read from the source at the pin, not observed in a running server. The reasoning is short and each step is a language semantic (
!== undefined,??not catching'',new URL('')throwing), but nobody has booted a server withOS_AUTH_URL=and watched the allow-list come out empty. Worth confirming before acting — in particular whether better-auth has any behaviour of its own that masks an emptybaseURL.Also unexamined: how many other
readEnvWithDeprecationcall sites feed a??chain and therefore share this shape. The two named above are the ones this came from, not a survey.Why it's worth a card
The empty-value shape is a normal thing for env plumbing to produce — a commented-out value that lost its
#, a Helm/systemd/CI template rendering an absent key, an operator who typed the name and meant to fill it in later. In every one of those cases the operator's intent is "I have not set this", and the runtime's own default is the correct answer.The cloud#1437 fix guards its own deployment against this with a compose-level
${OS_AUTH_URL:-…}default (the:-form substitutes on empty, not just on unset), and documents "never leave it set-but-empty" indeploy/.env.example. That protects the shipped compose stack only. Any other way of running the image — Helm, a PaaS, plaindocker run, a hand-written compose file — still has the sharp edge.Possible directions (not a recommendation — needs triage)
|| undefined) so the documented precedence chain resolves as written. Changes behaviour for every caller ofreadEnvWithDeprecation, so it wants a deliberate decision rather than a drive-by.catchatserve.ts:2204loud instead of silent, so an unusable base URL is a boot-time complaint rather than an empty allow-list.The second is strictly smaller and fixes the "silent" half without touching shared env semantics; the first fixes the class. Which one is right is a call for whoever owns this surface.