Skip to content

Commit 6f01ef3

Browse files
committed
fix(spec): UserSchema.image and OrganizationSchema.logo accept null, the shape better-auth serves
Both were `z.string().url().optional()` — a URL string or the key absent, `null` refused. Both columns are better-auth-owned and nullable (`sys_user.image` / `sys_organization.logo` are each `Field.url({ required: false })`, reaching SQLite as `varchar(255)` with `notnull=0`), and better-auth SELECTs them and serialises them present-and-null for a user who never set an avatar and an organization created without a logo. Measured through a real `AuthManager` (better-auth 1.7.3) over a real `ObjectQL` on a real `SqliteWasmDriver`, with the platform's own object definitions — not inferred from the sibling ruling: /auth/sign-up/email -> user.image = null /auth/get-session -> user.image = null /auth/organization/create -> logo = null /auth/organization/list -> [0].logo = null /auth/organization/get-full-organization -> logo = null -> members[].user.image = null `.nullish()`, not `.nullable()`: the key's absence is a legal shape today, so `.nullable()` would retire a live shape as the price of admitting null. `.url()` is kept and does not fight `null` — `.nullish()` wraps the whole `z.string().url()`, so null and undefined are branches the URL check never sees while a present string must still be a well-formed URL. Of six inputs (absent / null / '' / a URL / a non-URL / a number) exactly one row moves. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
1 parent 72dd95f commit 6f01ef3

7 files changed

Lines changed: 260 additions & 8 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`UserSchema.image` and `OrganizationSchema.logo` are declared `z.string().url().nullish()` — a URL string, `null`, or the key absent are all accepted — so the user and organization bodies this platform serves parse against the schemas it publishes (#18509).
6+
7+
Both were `z.string().url().optional()`: a URL string or the key's absence, and `null` refused. Both columns are better-auth-owned and nullable — `sys_user.image` and `sys_organization.logo` are each `Field.url({ required: false })`, reaching SQLite as `varchar(255)` with `notnull=0` — and better-auth SELECTs them and serialises them present-and-null for a user who never set an avatar and an organization created without a logo.
8+
9+
Measured through a real `AuthManager` (better-auth 1.7.3) over a real `ObjectQL` on a real `SqliteWasmDriver`, with the platform's own `sys_user` / `sys_organization` object definitions:
10+
11+
```
12+
/auth/sign-up/email -> user.image = null
13+
/auth/get-session -> user.image = null
14+
/auth/organization/create -> logo = null
15+
/auth/organization/list -> [0].logo = null
16+
/auth/organization/get-full-organization
17+
-> logo = null
18+
-> members[].user.image = null
19+
20+
UserSchema.safeParse(<the served session user>)
21+
-> [{ path: ["image"], code: "invalid_type",
22+
message: "Invalid input: expected string, received null" }]
23+
OrganizationSchema.safeParse(<the served organization>)
24+
-> [{ path: ["logo"], code: "invalid_type",
25+
message: "Invalid input: expected string, received null" }, … ]
26+
```
27+
28+
Those two paths now parse.
29+
30+
- **Measured, not inferred.** #18509 exists because PR #18501's contract review named these two siblings as *not measured* rather than folding them into the `SessionUserSchema.image` ruling it had. The verdict here comes from the probe above, run the way that ruling's own evidence was taken; the analogy was only ever a reason to look.
31+
- **The declaration was the thing that was wrong.** Prime Directive #12's default — fix the producer, never widen the consumer — rests on the premise it states out loud, that we own both ends. We do not: the nullable columns belong to a third-party model, so PD #12's own exit clause is the operative sentence.
32+
- **A pure widening.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today, so `.nullable()` would retire a live shape as the price of admitting `null`. Every body legal before this change is still legal.
33+
- **`.url()` is kept, and it does not fight `null`.** These two declarations carry `.url()`, which `SessionUserSchema.image` did not, so the question had to be answered rather than copied. `.nullish()` wraps the whole `z.string().url()`: `null` and `undefined` are separate branches the URL check never sees, while a present string is still required to be a well-formed URL. Of six inputs — absent, `null`, `''`, a URL, a non-URL, a number — exactly one row moves, and it is the ruled one. `''` and `'not-a-url'` are still refused.
34+
- **No key is added or removed** — both keys were already authored and already published, so no authorable surface moves and nothing is retired.
35+
- **`OrganizationSchema` is not made whole by this.** The same probe found `metadata` served present-and-null and `/auth/organization/create` omitting the required `updatedAt`. Those are separate defects with their own reasoning, filed separately rather than folded in; #18509 asked about `logo`.

‎content/docs/references/identity/identity.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const result = AccountSchema.parse(data);
6363
| **email** | `string` | ✅ | User email address |
6464
| **emailVerified** | `boolean` | optional (default: `false`) | Whether email is verified |
6565
| **name** | `string` | optional | User display name |
66-
| **image** | `string` | optional | Profile image URL |
66+
| **image** | `string \| null` | optional | Profile image URL |
6767
| **createdAt** | `string` | ✅ | Account creation timestamp |
6868
| **updatedAt** | `string` | ✅ | Last update timestamp |
6969

‎content/docs/references/identity/organization.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const result = InvitationSchema.parse(data);
8585
| **id** | `string` | ✅ | Unique organization identifier |
8686
| **name** | `string` | ✅ | Organization display name |
8787
| **slug** | `string` | ✅ | Unique URL-friendly slug (lowercase alphanumeric, hyphens, underscores) |
88-
| **logo** | `string` | optional | Organization logo URL |
88+
| **logo** | `string \| null` | optional | Organization logo URL |
8989
| **metadata** | `Record<string, any>` | optional | Custom metadata |
9090
| **createdAt** | `string` | ✅ | Organization creation timestamp |
9191
| **updatedAt** | `string` | ✅ | Last update timestamp |

‎packages/spec/src/identity/identity.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,69 @@ describe('UserSchema', () => {
7070
});
7171
});
7272

73+
/**
74+
* [#18509] `UserSchema.image` accepts `null` — the shape better-auth serves.
75+
*
76+
* `sys_user.image` is `Field.url({ required: false })`, which reaches SQLite as
77+
* `image varchar(255)` with `notnull=0`. better-auth SELECTs that column and
78+
* serialises it present-and-null for a user who never set an avatar, so
79+
* `/auth/sign-up/email` and `/auth/get-session` both carry `"image": null`.
80+
* Measured on a real `AuthManager` over ObjectQL + driver-sqlite-wasm; the
81+
* evidence and its controls are in PR #18510's body.
82+
*
83+
* The whole accept set is pinned, not just the row that moved, so that a later
84+
* flip to `.nullable()` (which would retire the legal absent-key shape) or a
85+
* drop of `.url()` (which would start admitting `''` and `'not-a-url'`) goes
86+
* red here rather than passing as "still accepts null".
87+
*/
88+
describe('[#18509] UserSchema.image accept set', () => {
89+
const base = {
90+
id: 'user_123',
91+
email: 'test@example.com',
92+
createdAt: '2026-01-01T00:00:00.000Z',
93+
updatedAt: '2026-01-01T00:00:00.000Z',
94+
};
95+
/** Issue paths, so a refusal is attributed to `image` and not to a neighbour. */
96+
const failedPaths = (value: unknown, present = true) => {
97+
const input = present ? { ...base, image: value } : { ...base };
98+
const result = UserSchema.safeParse(input);
99+
return result.success ? [] : result.error.issues.map((i) => i.path.join('.'));
100+
};
101+
102+
it('accepts `null` — the value every /auth/* user body carries', () => {
103+
expect(failedPaths(null)).toEqual([]);
104+
});
105+
106+
it('still accepts the key being ABSENT — `.nullish()`, not `.nullable()`', () => {
107+
expect(failedPaths(undefined, false)).toEqual([]);
108+
});
109+
110+
it('still accepts a well-formed URL', () => {
111+
expect(failedPaths('https://example.com/avatar.jpg')).toEqual([]);
112+
});
113+
114+
it('still refuses a malformed URL — `.url()` keeps its force on the string branch', () => {
115+
expect(failedPaths('not-a-url')).toEqual(['image']);
116+
});
117+
118+
it('still refuses the empty string', () => {
119+
expect(failedPaths('')).toEqual(['image']);
120+
});
121+
122+
it('still refuses a non-string, non-null value', () => {
123+
expect(failedPaths(42)).toEqual(['image']);
124+
});
125+
126+
it('lit control: the instrument reports a neighbour when a neighbour is wrong', () => {
127+
const { email: _dropped, ...withoutEmail } = base;
128+
const result = UserSchema.safeParse({ ...withoutEmail, image: null });
129+
expect(result.success).toBe(false);
130+
if (!result.success) {
131+
expect(result.error.issues.map((i) => i.path.join('.'))).toEqual(['email']);
132+
}
133+
});
134+
});
135+
73136
describe('AccountSchema', () => {
74137
it('should accept valid OAuth account', () => {
75138
const account: Account = {

‎packages/spec/src/identity/identity.zod.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,40 @@ export const UserSchema = lazySchema(() => z.object({
4040
name: z.string().optional().describe('User display name'),
4141

4242
/**
43-
* User's profile image URL
44-
*/
45-
image: z.string().url().optional().describe('Profile image URL'),
43+
* User's profile image URL.
44+
*
45+
* `null` is accepted alongside a URL string and alongside the key being
46+
* absent. better-auth owns this column, `sys_user.image` is declared
47+
* `Field.url({ required: false })` and reaches SQLite as
48+
* `image varchar(255)` with `notnull=0`, and better-auth SELECTs it and
49+
* serialises it present-and-null for a user who never set an avatar. Measured
50+
* on a real `AuthManager` over ObjectQL + driver-sqlite-wasm (#18509): the
51+
* `/auth/sign-up/email` and `/auth/get-session` bodies both carry
52+
* `"image": null`, and so does `members[].user.image` inside
53+
* `/auth/organization/get-full-organization`. The declaration was the thing
54+
* that was wrong — Prime Directive #12's default (fix the producer, never
55+
* widen the consumer) rests on the premise it states out loud, that we own
56+
* both ends, which does not hold for a third-party model.
57+
*
58+
* Same defect and same remedy as `SessionUserSchema.image` (#17235 / PR
59+
* #18501, ruling batch #138 item 1), reached here by measurement rather than
60+
* by analogy — #18509 exists precisely because that review refused to infer
61+
* this key's verdict from that one.
62+
*
63+
* `.nullish()`, NOT `.nullable()`: the key's ABSENCE is a legal shape today,
64+
* so `.nullable()` would retire a live shape as the price of admitting
65+
* `null`. Pure widening only.
66+
*
67+
* `.url()` is KEPT, and it is not in tension with `null`. `.nullish()` wraps
68+
* the whole `z.string().url()`, so `null` and `undefined` are separate
69+
* branches the URL check never sees, while a present string is still required
70+
* to be a well-formed URL. Measured: of the six inputs
71+
* (absent / `null` / `''` / a URL / a non-URL / a number) exactly ONE moves,
72+
* and it is the ruled one — `''` and `'not-a-url'` are still refused, which
73+
* is why the empty-avatar-URL boundary note carried on #18509 does not become
74+
* live here the way it would on a declaration without `.url()`.
75+
*/
76+
image: z.string().url().nullish().describe('Profile image URL'),
4677

4778
/**
4879
* Account creation timestamp

‎packages/spec/src/identity/organization.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,97 @@ describe('OrganizationSchema', () => {
115115
});
116116
});
117117

118+
/**
119+
* [#18509] `OrganizationSchema.logo` accepts `null` — the shape better-auth
120+
* serves.
121+
*
122+
* `logo` is one of better-auth's own `sys_organization` columns, declared
123+
* `Field.url({ required: false })` and reaching SQLite as `logo varchar(255)`
124+
* with `notnull=0`. `/auth/organization/create`, `/auth/organization/list` and
125+
* `/auth/organization/get-full-organization` all serve `"logo": null` for an
126+
* organization created without one. Measured on a real `AuthManager` over
127+
* ObjectQL + driver-sqlite-wasm; evidence and controls in PR #18510's body.
128+
*
129+
* The whole accept set is pinned, not just the row that moved — see the sibling
130+
* block in `identity.test.ts` for why.
131+
*/
132+
describe('[#18509] OrganizationSchema.logo accept set', () => {
133+
const base = {
134+
id: 'org_123',
135+
name: 'Acme Corporation',
136+
slug: 'acme-corp',
137+
createdAt: '2026-01-01T00:00:00.000Z',
138+
updatedAt: '2026-01-01T00:00:00.000Z',
139+
};
140+
const failedPaths = (value: unknown, present = true) => {
141+
const input = present ? { ...base, logo: value } : { ...base };
142+
const result = OrganizationSchema.safeParse(input);
143+
return result.success ? [] : result.error.issues.map((i) => i.path.join('.'));
144+
};
145+
146+
it('accepts `null` — the value every organization body carries', () => {
147+
expect(failedPaths(null)).toEqual([]);
148+
});
149+
150+
it('still accepts the key being ABSENT — `.nullish()`, not `.nullable()`', () => {
151+
expect(failedPaths(undefined, false)).toEqual([]);
152+
});
153+
154+
it('still accepts a well-formed URL', () => {
155+
expect(failedPaths('https://example.com/logo.png')).toEqual([]);
156+
});
157+
158+
it('still refuses a malformed URL — `.url()` keeps its force on the string branch', () => {
159+
expect(failedPaths('not-a-url')).toEqual(['logo']);
160+
});
161+
162+
it('still refuses the empty string', () => {
163+
expect(failedPaths('')).toEqual(['logo']);
164+
});
165+
166+
it('still refuses a non-string, non-null value', () => {
167+
expect(failedPaths(42)).toEqual(['logo']);
168+
});
169+
170+
it('lit control: the instrument reports a neighbour when a neighbour is wrong', () => {
171+
const { slug: _dropped, ...withoutSlug } = base;
172+
const result = OrganizationSchema.safeParse({ ...withoutSlug, logo: null });
173+
expect(result.success).toBe(false);
174+
if (!result.success) {
175+
expect(result.error.issues.map((i) => i.path.join('.'))).toEqual(['slug']);
176+
}
177+
});
178+
179+
/**
180+
* ⛔ Scope fence, deliberately pinned as CURRENT behaviour rather than fixed:
181+
* the same measurement found `metadata` served present-and-null and
182+
* `/auth/organization/create` omitting the required `updatedAt`. Those are
183+
* separate defects, filed separately — #18509 asked about `logo`. This pin
184+
* exists so that the fence is visible and so that a later fix for either one
185+
* has to come here and say so.
186+
*/
187+
it('does NOT (yet) accept a served body whole — metadata/updatedAt are separate cards', () => {
188+
const served = {
189+
id: 'org_123',
190+
name: 'Acme Corporation',
191+
slug: 'acme-corp',
192+
logo: null,
193+
metadata: null,
194+
createdAt: '2026-01-01T00:00:00.000Z',
195+
// `updatedAt` absent, exactly as `/auth/organization/create` serves it
196+
};
197+
const result = OrganizationSchema.safeParse(served);
198+
expect(result.success).toBe(false);
199+
if (!result.success) {
200+
// `logo` is gone from this list — that is this card's contribution.
201+
expect(result.error.issues.map((i) => i.path.join('.')).sort()).toEqual([
202+
'metadata',
203+
'updatedAt',
204+
]);
205+
}
206+
});
207+
});
208+
118209
describe('MemberSchema', () => {
119210
it('should accept valid member data', () => {
120211
const member: Member = {

‎packages/spec/src/identity/organization.zod.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,41 @@ export const OrganizationSchema = lazySchema(() => z.object({
3636
.describe('Unique URL-friendly slug (lowercase alphanumeric, hyphens, underscores)'),
3737

3838
/**
39-
* Organization logo URL
40-
*/
41-
logo: z.string().url().optional().describe('Organization logo URL'),
39+
* Organization logo URL.
40+
*
41+
* `null` is accepted alongside a URL string and alongside the key being
42+
* absent. `logo` is one of better-auth's own `sys_organization` columns,
43+
* declared `Field.url({ required: false })` and reaching SQLite as
44+
* `logo varchar(255)` with `notnull=0`; better-auth serialises it
45+
* present-and-null for an organization created without one. Measured on a
46+
* real `AuthManager` over ObjectQL + driver-sqlite-wasm (#18509): the
47+
* `/auth/organization/create`, `/auth/organization/list` and
48+
* `/auth/organization/get-full-organization` bodies all carry `"logo": null`.
49+
*
50+
* Same defect and same remedy as `SessionUserSchema.image` (#17235 / PR
51+
* #18501, ruling batch #138 item 1), reached here by measurement rather than
52+
* by analogy — #18509 exists precisely because that review refused to infer
53+
* this key's verdict from that one.
54+
*
55+
* `.nullish()`, NOT `.nullable()`: the key's ABSENCE is a legal shape today,
56+
* so `.nullable()` would retire a live shape as the price of admitting
57+
* `null`. Pure widening only.
58+
*
59+
* `.url()` is KEPT, and it is not in tension with `null`. `.nullish()` wraps
60+
* the whole `z.string().url()`, so `null` and `undefined` are separate
61+
* branches the URL check never sees, while a present string is still required
62+
* to be a well-formed URL. Measured: of the six inputs
63+
* (absent / `null` / `''` / a URL / a non-URL / a number) exactly ONE moves,
64+
* and it is the ruled one.
65+
*
66+
* ⚠️ This widening does NOT make a served organization body parse clean.
67+
* The same measurement found two further divergences on this schema —
68+
* `metadata` is served present-and-null, and `/auth/organization/create`
69+
* omits `updatedAt`, which is declared required. Those are separate defects
70+
* with their own reasoning and are filed separately rather than folded in
71+
* here; #18509 asked about `logo`.
72+
*/
73+
logo: z.string().url().nullish().describe('Organization logo URL'),
4274

4375
/**
4476
* Custom metadata for the organization

0 commit comments

Comments
 (0)