Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/17235-sessionuser-image-nullish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/spec": minor
---

`SessionUser.image` is declared `z.string().nullish()` — a string, `null`, or the key absent are all accepted — so a signed-in user who never set an avatar parses against the schema this platform publishes (#17235).

`z.string().optional()` admitted a string or the key's absence, and refused `null`. better-auth owns the avatar column, stores it nullable, and serialises it present-and-null, so every `/auth/*` session body the platform produces carried a value the declaration rejected. Measured through a real `AuthManager` (better-auth 1.7.2) over a real `ObjectQL` on a real `SqliteWasmDriver`: `get-session`, `sign-up/email` and `sign-in/email` all serve `"image": null` for a freshly signed-up user, and the full envelope failed on exactly that one path:

```
SessionResponseSchema.safeParse(await client.auth.me())
-> [{ path: ["data","user","image"], code: "invalid_type",
message: "Invalid input: expected string, received null" }]
```

That parse now succeeds on all three routes.

- **The declaration was the thing that was wrong.** AGENTS.md Prime Directive #12's default — fix the producer, never widen the consumer — rests on a premise it states out loud, that we own both ends. We do not: the nullable column belongs to a third-party model, so PD #12's own exit clause ("change the spec only when the spec itself is genuinely wrong, and then deliberately") is the operative sentence. Normalising `null` away at the producer seam was considered and refused: it is a permanent rewrite layer between the platform and a dependency's data model.
- **A pure widening, and nothing else.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today and no producer was ever measured omitting it, so `.nullable()` would have retired a live shape as the price of admitting `null`. Every body legal before this change is still legal.
- **Still refuses what it should.** A number and an object are rejected at `data.user.image` exactly as before; the only accept-set row that moved is `null`.
- **No key is added or removed** — `image` was already authored and already published, so no authorable surface moves and nothing is retired.
4 changes: 2 additions & 2 deletions content/docs/references/api/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const result = AuthProvider.parse(data);
| **email** | `string` | ✅ | Email address |
| **emailVerified** | `boolean` | optional (default: `false`) | Is email verified? |
| **name** | `string` | ✅ | Display name |
| **image** | `string` | optional | Avatar URL |
| **image** | `string \| null` | optional | Avatar URL |
| **username** | `string` | optional | Username (optional) |
| **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs |
| **tenantId** | `string` | optional | Current tenant ID |
Expand Down Expand Up @@ -201,7 +201,7 @@ const result = AuthProvider.parse(data);
| **email** | `string` | ✅ | Email address |
| **emailVerified** | `boolean` | optional (default: `false`) | Is email verified? |
| **name** | `string` | ✅ | Display name |
| **image** | `string` | optional | Avatar URL |
| **image** | `string \| null` | optional | Avatar URL |
| **username** | `string` | optional | Username (optional) |
| **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs |
| **tenantId** | `string` | optional | Current tenant ID |
Expand Down
28 changes: 14 additions & 14 deletions packages/client/src/auth-get-session-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
// - `① me() delivers the envelope it declares` — the card's first consequence.
// The decisive assertion is a PARSE against the declared schema, not a key
// spot-check: the defect is "the declared type is not delivered", so the
// declaration itself has to be the judge. Its second case pins the ONE gap
// the lift cannot close (`user.image`, declared string-or-absent, served
// `null`) as an exhaustive issue list, so the residue cannot quietly grow.
// declaration itself has to be the judge. Its second case pins that parse as
// an exhaustive issue list - empty since #17235 widened `user.image` to
// `z.string().nullish()` - so a regression cannot quietly grow back.
// - `② the raw keys survive` — `.user` is what the field reads today, while
// the declared `.data.user` was `undefined`. The fix must not buy the
// declared shape by breaking the workaround callers were pushed onto.
Expand Down Expand Up @@ -292,22 +292,22 @@ describe('[#16760] /get-session is lifted into the SessionResponse envelope it d
expect(res.data.session?.userId).toBe(res.data.user?.id);
});

it('leaves exactly one declared-type gap, and it is not the envelope', async () => {
it('parses as the FULL declared type — no residue left', async () => {
const { client } = await signedIn();
const res = await client.auth.me();

// The FULL declared type still does not parse — for a reason that has
// nothing to do with this card and that the lift cannot reach:
// `SessionUserSchema.image` is declared `z.string().optional()`, which
// does not admit `null`, and better-auth serves `"image": null` for a
// user who never set one. Filed as #17235 — delete this case with it.
// #17235's acceptance, on the real route: a signed-in user with NO
// avatar parses against the WHOLE declaration. `SessionUserSchema.image`
// is `z.string().nullish()`, so the `"image": null` better-auth serves
// for such a user is admitted instead of rejected.
//
// Pinned as the exhaustive issue list rather than as "it fails": if the
// envelope ever regresses, the missing `success` and `data` show up here
// as extra issues and this case reddens. It is the residue's tripwire,
// not an acceptance of it.
// Still pinned as the exhaustive issue list rather than as "it passes":
// if the envelope ever regresses, the missing `success` and `data` show
// up here as issues and this case reddens. The list is the tripwire; it
// is empty because the residue is closed, not because it stopped
// checking.
const issues = SessionResponseSchema.safeParse(res).error?.issues ?? [];
expect(issues.map((i) => i.path.join('.'))).toEqual(['data.user.image']);
expect(issues.map((i) => i.path.join('.'))).toEqual([]);
});
});

Expand Down
47 changes: 25 additions & 22 deletions packages/client/src/auth-login-register-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@
//
// - `① the declared envelope is delivered` — the defect proper. The judge is a
// PARSE against the declaration, not a key spot-check.
// - `② the residue is exhaustive` — `SessionResponseSchema` still does not
// parse, for one reason that is NOT this card's: `data.user.image` (#17235).
// Pinned as the complete issue list so a regression on `success` OR
// `data.session` shows up here as an extra issue instead of hiding inside
// "it already failed".
// - `② the parse is exhaustive` — `SessionResponseSchema` parses WHOLE. The
// one-time residue `data.user.image` closed with #17235, which widened the
// declaration to `z.string().nullish()`. Still pinned as the complete issue
// list so a regression on `success` OR `data.session` shows up here as an
// issue instead of hiding inside "it already failed".
// - `③ the instrument can still fail` — the negative control. The same parse,
// on the same returned value with `success` taken back out, must report
// `success` again (and still report the `data.user.image` residue).
// Without it, a green ① could equally mean the assertion broke.
// `success` again. Without it, a green ① could equally mean the assertion
// broke.
// - `④ the credential survives byte-identical` — the regression this fix could
// most easily have caused. `data.token` is the body's own token, and
// `client.token` is still armed from it.
Expand Down Expand Up @@ -304,27 +304,30 @@ describe('[#17234] auth.login / auth.register deliver the SessionResponse envelo
});
});

describe('② the residue is exhaustive, and neither `success` nor `data.session` is in it', () => {
// ONE issue remains on the FULL declared type, and it is not this card's:
describe('② the parse is exhaustive, and neither `success` nor `data.session` is in it', () => {
// NO issue remains on the FULL declared type. The one that used to —
//
// data.user.image — `SessionUserSchema.image` is `z.string().optional()`,
// which does not admit `null`, and better-auth serves
// `"image": null` for a user who never set one. Filed
// as #17235, and NOT specific to these two methods.
// data.user.image — `SessionUserSchema.image` was `z.string().optional()`,
// which does not admit `null`, while better-auth serves
// `"image": null` for a user who never set one
//
// Pinned as the EXHAUSTIVE list rather than as "it still fails": if
// `success` OR `data.session` ever regress they reappear here as extra
// issues and these cases redden. It is the residue's tripwire, not an
// acceptance of it.
const RESIDUE = ['data.user.image'];
// closed with #17235, which widened that declaration to `z.string()
// .nullish()` (a string, `null`, or the key absent alike). These two cases
// are now #17235's acceptance on the `register()` / `login()` routes.
//
// Still pinned as the EXHAUSTIVE list rather than as "it passes": if
// `success` OR `data.session` ever regress they reappear here as issues
// and these cases redden. The list is the tripwire; it is empty because
// the residue is closed, not because it stopped checking.
const RESIDUE: string[] = [];

it('register() reports exactly the one issue that is not this card\'s', async () => {
it('register() parses as the full declared type', async () => {
const { res } = await registered();
const issues = SessionResponseSchema.safeParse(res).error?.issues ?? [];
expect(issues.map((i) => i.path.join('.'))).toEqual(RESIDUE);
});

it('login() reports exactly the one issue that is not this card\'s', async () => {
it('login() parses as the full declared type', async () => {
const { res } = await signedIn();
const issues = SessionResponseSchema.safeParse(res).error?.issues ?? [];
expect(issues.map((i) => i.path.join('.'))).toEqual(RESIDUE);
Expand All @@ -343,7 +346,7 @@ describe('[#17234] auth.login / auth.register deliver the SessionResponse envelo
success?: unknown;
};
const issues = SessionResponseSchema.safeParse(withoutSuccess).error?.issues ?? [];
expect(issues.map((i) => i.path.join('.'))).toEqual(['success', 'data.user.image']);
expect(issues.map((i) => i.path.join('.'))).toEqual(['success']);
});

it('and reports `data.session` again once the fix is taken back out — a fabricated body without a session still fails', async () => {
Expand All @@ -359,7 +362,7 @@ describe('[#17234] auth.login / auth.register deliver the SessionResponse envelo
const { session: _droppedSession, ...dataWithoutSession } = data;
const issues =
SessionResponseSchema.safeParse({ ...rest, data: dataWithoutSession }).error?.issues ?? [];
expect(issues.map((i) => i.path.join('.'))).toEqual(['data.session', 'data.user.image']);
expect(issues.map((i) => i.path.join('.'))).toEqual(['data.session']);
});
});

Expand Down
16 changes: 15 additions & 1 deletion packages/spec/src/api/auth.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,21 @@ export const SessionUserSchema = lazySchema(() => z.object({
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'),
/**
* `null` is accepted alongside a string and alongside the key being absent.
* better-auth owns this column, stores it nullable, and serialises it
* present-and-null for a user who never set an avatar, so every `/auth/*`
* session body the platform produces carries `"image": null` (#17235). The
* declaration was the thing that was wrong: AGENTS.md Prime Directive #12's
* default (fix the producer, never widen the consumer) rests on a premise it
* states out loud - that we own both ends - which does not hold for a
* third-party model, so its exit clause is the operative sentence.
*
* `.nullish()`, NOT `.nullable()`: the key's ABSENCE is a legal shape today
* and no producer was ever measured omitting it, so retiring that shape
* would be a narrowing riding along with a widening. Pure widening only.
*/
image: z.string().nullish().describe('Avatar URL'),
username: z.string().optional().describe('Username (optional)'),
roles: z.array(z.string()).optional().default([]).describe('Assigned role IDs'),
tenantId: z.string().optional().describe('Current tenant ID'),
Expand Down
Loading