Skip to content

Commit b9d5422

Browse files
os-billclaude
andauthored
fix(spec): UserSchema.image and OrganizationSchema.logo accept null, the shape better-auth serves (#18718)
Fixes #18509 Clause-②: yes (widening) > ⚠️ **The claim comment says `Clause-②: no`, and this line deliberately does not copy it.** The claim was written for the outcome the dispatching seat expected — "does not reproduce", therefore no diff. The measurement below went the other way, so this PR widens a **published accept set** (`@objectstack/spec`) and takes a `minor` changeset. AGENTS.md's Post-Task Checklist §3 makes `yes` mechanical for that act, and `check-changeset-no-major.mjs`'s level axis grades the declaration against the levels; declaring `no` here would be green **and false**, on exactly the gate the seat invoked when it asked for the line to be carried ("`Check Changeset` reads the BODY, not the card"). The divergence is named here and in the dev report rather than chosen silently — **the claim comment is the seat's to correct**, and the dev does not `PATCH` a body. ## Verdict: it reproduces. Both of them. #18509 asked for a measurement, not a widening, and warned that "does not reproduce" would be the good outcome. It is not the outcome. 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` definitions, **both keys are served present-and-null**: | route | key | served | |---|---|---| | `POST /auth/sign-up/email` | `user.image` | `null` | | `GET /auth/get-session` | `user.image` | `null` | | `POST /auth/organization/create` | `logo` | `null` | | `GET /auth/organization/list` | `[0].logo` | `null` | | `GET /auth/organization/get-full-organization` | `logo` | `null` | | `GET /auth/organization/get-full-organization` | `members[].user.image` | `null` | The mechanism is the #17235 one, confirmed at the DDL layer rather than assumed. Both columns are `Field.url({ required: false })` in the platform's own object definitions and reach SQLite as nullable columns — `PRAGMA table_info` reports `sys_user.image` as `varchar(255) notnull=0` and `sys_organization.logo` as `varchar(255) notnull=0`. better-auth SELECTs them and serialises the null. So the remedy is the ruled one, per the card's item 2: `.nullish()` — **not** `.nullable()`, which would retire the legal "key absent" shape. ## The two controls **LIT — the probe fires.** `SessionUserSchema.image`, the declaration #17235 fixed, is a field known to be served present-and-null. It shows up under this probe: `*** PRESENT-AND-NULL ***` on both the sign-up and get-session bodies, and `SessionUserSchema.safeParse` passes on them (because #18501 already widened it). A probe that could not see that value could not be trusted to report its absence elsewhere. ⭐ **The lit control earned its keep — it caught a dead first instrument.** The first version of this probe used the in-memory engine shape the plugin-auth suites use (a `Map` of plain objects). Under it `image` read **ABSENT**, not present-and-null, and `UserSchema.safeParse` **passed** — a clean, wrong "does not reproduce". The reason is the whole distinction this card is about: a schemaless store has no **columns**, so "never set" is key-absent there, while a real nullable column reads back as `null`. The LIT control was the only thing that said so. The instrument was replaced with a real store and the result inverted. **DARK — what must read 0.** The population was re-derived rather than trusted: `grep -rn '^\s*\(image\|avatar\|avatarUrl\|logo\)\s*:\s*z\.' --include=*.zod.ts packages/spec/src` returns **8** declarations, the same 8 the card reported. This PR moves exactly **2** of them. The other 6 — `ai/agent.zod.ts` `avatar`, `ui/app.zod.ts` `logo`, `api/auth.zod.ts` `SessionUser.image` (already `.nullish()`) and `RegisterRequest.image` (a REQUEST surface, client-authored, not better-auth-served), `kernel/plugin-registry.zod.ts` `logo`, `kernel/plugin-security-advanced.zod.ts` `image` — appear nowhere in the conclusion and are untouched. ## ⭐ How does `.url()` coexist with `null`? — the question this card could not answer Both keys carry `.url()`; `SessionUserSchema.image` did not. So this is not a copy of #17235 and the answer had to be measured. It was, on all three candidate forms: | input | `.url().optional()` (today) | `.url().nullish()` (ruled remedy) | `.url().nullable()` (the shape #17235 refused) | |---|---|---|---| | key ABSENT | pass | **pass** | **FAIL** `invalid_type` | | `null` | FAIL `invalid_type` | **pass** | pass | | `""` | FAIL `invalid_format` | FAIL `invalid_format` | FAIL `invalid_format` | | `"https://x/a.png"` | pass | pass | pass | | `"not-a-url"` | FAIL `invalid_format` | FAIL `invalid_format` | FAIL `invalid_format` | | `42` | FAIL `invalid_type` | FAIL `invalid_type` | FAIL `invalid_type` | **The answer: `.url()` and `null` do not compete, because they never meet.** `.nullish()` wraps the whole `z.string().url()`, so `null` and `undefined` are separate branches that the URL check never evaluates, while a present string is still required to be a well-formed URL. The middle column moves **exactly one row** from the left column, and it is the ruled one. The right column moves two rows in **opposite** directions — that second, upward move is the narrowing #17235 refused, and the table is why the same refusal holds here. ⭐ **The card's carried boundary note does not come live.** #18509 recorded that `SessionUser.image` accepts `""` and warned it "becomes live if step 2 adds `.url()` reasoning to this family". Measured: it does not. `SessionUser.image` has that hole because it is bare `z.string()`; these two keys carry `.url()`, so `""` is refused before and after — the `invalid_format` row is unchanged in every column. Nothing in this PR widens toward the empty string, and the note stays where the card put it. ## Before / after, on the real bodies ``` UserSchema.safeParse(SERVED_GET_SESSION_USER) before: FAIL [{ path: ["image"], code: "invalid_type", message: "Invalid input: expected string, received null" }] after: PASS OrganizationSchema.safeParse(SERVED_ORG_CREATE_BODY) before: FAIL logo [invalid_type] expected string, received null updatedAt [invalid_type] expected string, received undefined after: FAIL updatedAt [invalid_type] expected string, received undefined ``` `image` is the ONLY divergence `UserSchema` had against the served user, so that body now parses clean. `logo` was one of three on `OrganizationSchema` — see the scope fence below. ## ⛔ Scope fence: two further findings, named and NOT fixed here The same probe found two more divergences on `OrganizationSchema`, both out of this card's scope and both reported for separate filing rather than folded in: - **`metadata` is served present-and-null.** `/auth/organization/list` and `/auth/organization/get-full-organization` serve `"metadata": null` against `z.record(z.string(), z.unknown()).optional()`. Same present-and-null shape, different key, and a `z.record` rather than a `z.string().url()` — so it deserves its own reasoning, not this one by extension. - **`/auth/organization/create` omits `updatedAt`**, which the schema declares required. That is the opposite shape — a missing key, not a null one — and the remedy is a different question. Folding either in would be exactly the step #18509 exists to prevent. They are pinned as **current behaviour** in `organization.test.ts` so the fence is visible and a later fix has to come here and say so. ## Tests New pin blocks in `packages/spec/src/identity/identity.test.ts` and `organization.test.ts` assert the **whole accept set**, not just the row that moved — so a later flip to `.nullable()` (retiring the absent-key shape) or a drop of `.url()` (admitting `""`) goes red here instead of passing as "still accepts null". They assert issue **paths**, so a refusal is attributed to `image`/`logo` and not to a neighbour, and each block carries a lit control that removes a neighbouring required key and checks the instrument names it. **Ablation — the pins can fail.** Both declarations were reverted to `.optional()` from the committed state; the mutation was proved on disk before any result was read (anchor counts 1 → 0 for the injected text and 0 → 1 for the removed text, plus `git hash-object` differing from the `HEAD` blob on both files), and the restore was proved byte-exact the same way (`git diff HEAD` empty; both disk hashes equal to their `HEAD` blobs). These tests resolve `./identity.zod` **relatively**, i.e. to `src`, not through the package `exports` to `dist`, so no rebuild is interposed and the dist-preflight step does not apply. ``` ABLATED_TEST_EXIT=1 -> Test Files 2 failed | Tests 5 failed | 48 passed (53) × accepts `null` — the value every /auth/* user body carries × accepts `null` — the value every organization body carries × lit control: the instrument reports a neighbour when a neighbour is wrong (x2) × does NOT (yet) accept a served body whole — metadata/updatedAt are separate cards ``` The 48 that stayed green are the rows that must not move: absent, a valid URL, `""`, `"not-a-url"`, a number. ## Verification | leg | result | |---|---| | `pnpm --filter @objectstack/spec test` | **486 files / 13895 tests passed** | | `pnpm --filter @objectstack/spec typecheck` | **pass** — `tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck` (the first excludes `**/*.test.ts`; the third is what covers the new pins) | | `pnpm --filter @objectstack/spec check:generated` | **15/15 up to date** after regenerating the one it proved stale (`gen:docs`) | | `pnpm --filter '@objectstack/spec^...' build` | **empty run** — "No projects matched the filters"; `packages/spec` has no workspace dependencies, so there is no upstream closure. Reported as empty, not as a pass. | | gates | `check:nul-bytes`, `check:spec-docblock-symbol-anchors`, `check:comment-mask-adoption`, `check:comment-mask-corpus`, `check:doc-frontmatter`, `check:docs-section-name`, `check:keyed-text-bounds`, `check:pm-widening-tells`, `check:spec-parsed-alias`, `check:docs-spec-enumerations`, `check:doc-anchors`, `check:empty-changeset` — all exit 0 | **Lint was narrowed, and the narrowing is measured rather than assumed** (at `6f01ef3491`): the config-derived population is **6817** files (read by walking `git ls-files` through ESLint's own `isPathIgnored`, not guessed); **4** files were linted, counted from `--format json`, 0 errors / 0 warnings; and the narrowing excludes nothing because **type-aware linting is not enabled** — every `parserOptions` in `eslint.config.mjs` carries only `ecmaVersion` / `sourceType`, with no `project` or `projectService`, so each file is judged from its own source text and this diff cannot move the verdict of a file it did not touch. The repo-wide run is CI's. ## Generated artifacts `check:generated` proved exactly one artifact stale and it was regenerated with `--fix` (never the whole set). The diff is two table cells, both intended: `image` and `logo` render as `string | null` in `content/docs/references/identity/`. `authorable-surface.base.json` did not move and `check:authorable-surface` is green. ## Surface note The dispatch named the two `.zod.ts` files, plus `.changeset/*.md` and gate-required derivatives, and marked `plugin-auth` / `plugin-hono-server` / `client` read-only. **Those three were not written to** — the probe runs from the scratchpad against built `dist`, so the measurement sites were only read. Two files were touched beyond the literal list: the sibling `identity.test.ts` and `organization.test.ts`, because shipping a spec widening with no pin is the always-green hazard this repo refuses, and the Definition of Done requires the coverage. Both were checked for in-flight holders first (last touched by `2c86fe3ea7` and `4b5702ab77`, both landed). The regenerated `content/docs/references/identity/*.mdx` are the gate-required derivative. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3da78cc commit b9d5422

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 #18718'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 #18718'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)