Found while implementing #16760 (lifting /get-session's bare answer into the SessionResponse envelope it declares). Out of that card's ruled scope — it is a packages/spec declaration, and #16760 was bounded to two client methods with SessionResponse explicitly unchanged — so recorded here rather than fixed there. No assignee, no priority, no grade: left for triage.
The declaration
packages/spec/src/api/auth.zod.ts:
export const SessionUserSchema = lazySchema(() => z.object({
id: z.string().describe('User ID'),
email: z.string().email().describe('Email address'),
emailVerified: z.boolean().default(false).describe('Is email verified?'),
name: z.string().describe('Display name'),
image: z.string().optional().describe('Avatar URL'),
...
}));
z.string().optional() admits a string or the key being absent. It does NOT admit null.
What the routes serve
better-auth stores the avatar column as nullable and serializes it present-and-null for a user who has never set one. Measured through a real AuthManager (better-auth 1.7.2, organization plugin) over a real ObjectQL on a real SqliteWasmDriver, on a freshly signed-up user:
GET /api/v1/auth/get-session (signed in) -> 200
user keys: ["name","email","emailVerified","image","createdAt","updatedAt","id","positions","isPlatformAdmin"]
user.image === null
The key is present. Its value is null. Same on the sign-up/email and sign-in/email bodies.
Consequence
After #16760 lands, auth.me() delivers a body whose envelope is correct and whose data.session parses — and the whole thing still fails its own declared type, on exactly one issue:
SessionResponseSchema.safeParse(await client.auth.me())
-> [{ path: ["data","user","image"], code: "invalid_type",
message: "Invalid input: expected string, received null" }]
That is the entire residue: one key, on every session body the platform produces. Any consumer that validates a session against the published schema — rather than trusting it — rejects a perfectly ordinary signed-in user whose only distinguishing feature is that they have no avatar.
It is pinned as an exhaustive issue list in packages/client/src/auth-get-session-envelope.test.ts (case ①, "leaves exactly one declared-type gap"), so the residue cannot silently grow; that pin is the row to delete when this is fixed.
Class
Declared contract not delivered (b). The spec declares a shape no producer in the tree emits, and the mismatch is unreachable from the client side: better-auth owns the bytes, so a consumer-side accommodation would be exactly the lenient alias AGENTS.md Prime Directive #12 forbids. The fix belongs in the declaration.
Options seen, not decided
- Widen to
z.string().nullish() (or .nullable().optional()) so the declaration admits what every producer already sends. Smallest change; makes the type honest about a nullable column.
- Keep the declaration and strip nulls at the producer seam, so
image is absent rather than null. Larger, and it touches bytes better-auth writes.
Either is a published-schema decision with an api-surface face, which is why this is filed rather than fixed as a rider.
Refs: #16760 (where it was measured), #17234 (the sibling login / register envelope finding, whose issue list includes this same row).
Generated by Claude Code
Found while implementing #16760 (lifting
/get-session's bare answer into theSessionResponseenvelope it declares). Out of that card's ruled scope — it is apackages/specdeclaration, and #16760 was bounded to two client methods withSessionResponseexplicitly unchanged — so recorded here rather than fixed there. No assignee, no priority, no grade: left for triage.The declaration
packages/spec/src/api/auth.zod.ts:z.string().optional()admits a string or the key being absent. It does NOT admitnull.What the routes serve
better-auth stores the avatar column as nullable and serializes it present-and-null for a user who has never set one. Measured through a real
AuthManager(better-auth 1.7.2, organization plugin) over a realObjectQLon a realSqliteWasmDriver, on a freshly signed-up user:The key is present. Its value is
null. Same on thesign-up/emailandsign-in/emailbodies.Consequence
After #16760 lands,
auth.me()delivers a body whose envelope is correct and whosedata.sessionparses — and the whole thing still fails its own declared type, on exactly one issue:That is the entire residue: one key, on every session body the platform produces. Any consumer that validates a session against the published schema — rather than trusting it — rejects a perfectly ordinary signed-in user whose only distinguishing feature is that they have no avatar.
It is pinned as an exhaustive issue list in
packages/client/src/auth-get-session-envelope.test.ts(case ①, "leaves exactly one declared-type gap"), so the residue cannot silently grow; that pin is the row to delete when this is fixed.Class
Declared contract not delivered (b). The spec declares a shape no producer in the tree emits, and the mismatch is unreachable from the client side: better-auth owns the bytes, so a consumer-side accommodation would be exactly the lenient alias AGENTS.md Prime Directive #12 forbids. The fix belongs in the declaration.
Options seen, not decided
z.string().nullish()(or.nullable().optional()) so the declaration admits what every producer already sends. Smallest change; makes the type honest about a nullable column.imageis absent rather than null. Larger, and it touches bytes better-auth writes.Either is a published-schema decision with an
api-surfaceface, which is why this is filed rather than fixed as a rider.Refs: #16760 (where it was measured), #17234 (the sibling
login/registerenvelope finding, whose issue list includes this same row).Generated by Claude Code