Skip to content

SessionUser.image is declared z.string().optional(), but every /auth/* session route serves "image": null — no real session body parses as SessionResponse #17235

Description

@claude

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions