Skip to content

Commit e6c34f6

Browse files
os-elon-muskclaude
andauthored
fix(plugin-auth,spec,client): the identity read routes serve what the spec declares (#19122)
Fixes #18728 **Direction: the maintainer's ruling C** (batch #158 item 4, comment 5729189649), quoted verbatim into the dispatch word and implemented as written. ⛔ Not re-argued here, and neither the card's A nor its B is implemented — both were superseded. Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier. The claim comment declared `Clause-②: no` **conditionally** — 「unless fallback A moves a declaration」. Fallback A fired and moved three, so the condition resolves to `yes`. --- ## Leg 1 — the measurement ruling C made fallback A conditional on Ruling C, verbatim: 「**Fallback A**, decided by measurement first: if the identity wire is produced by better-auth's own serializer and its documented shape carries no `updatedAt`, then for those routes the spec aligns to the documented wire (`updatedAt` optional there) and the reason is written on the card; `metadata` is decoded regardless (it is our column).」 So the first leg was that measurement, per route, taken against the installed **better-auth 1.7.3** in this worktree (`packages/plugins/plugin-auth/node_modules/better-auth`, realpath under the pnpm store, version read from its own manifest). | route | who serialises the response | evidence | documented `updatedAt`? | `metadata` before | |---|---|---|---|---| | `setActive` — `POST /organization/set-active` | **better-auth's own handler**, `ctx.json(organization)` from `findOrganizationById` | `crud-org.mjs:379` handler, `:427` the answer; `adapter.mjs:400` the read | **no** | stored JSON text | | `get` — `GET /organization/get-full-organization` | **better-auth's own handler**, `ctx.json(organization)` from `findFullOrganization` | `crud-org.mjs:336`, `:371`; `adapter.mjs:424` | **no** | stored JSON text | | `delete` — `POST /organization/delete` | **better-auth's own handler**, `ctx.json(org)` from `findOrganizationById` | `crud-org.mjs:239`, `:291`; `adapter.mjs:400` | **no** | stored JSON text | | `list` — `GET /organization/list` | **better-auth's own handler**, `ctx.json(organizations)` from `listOrganizations`; the organization arrives through the adapter factory's fallback join, itself another `findOne` on this model | `crud-org.mjs:436`, `:455`; `adapter.mjs:474`; `@better-auth/core` `dist/db/adapter/factory.mjs` `handleFallbackJoin` | **no** | stored JSON text | | `create` — `POST /organization/create` | better-auth's handler; its organization adapter decodes the echo itself | `adapter.mjs:141`, decode at `:152` | **no** | decoded already | | `update` — `POST /organization/update` | better-auth's handler; same, via `parseJSON` | `adapter.mjs:352`, decode at `:367` | **no** | decoded already | Three mechanisms make that table a reading rather than an impression: 1. **The routes are the vendor's, mounted through one catch-all.** `packages/plugins/plugin-auth/src/auth-route-ledger.ts:257-277` books every `organization/*` row `source: 'better-auth'`, and the ledger's own header states the mount is `rawApp.all(basePath + '/*')` with no per-route registration. Each handler answers `ctx.json(...)` directly; there is no ObjectStack post-processing between the adapter and the wire. 2. **The vendor's declared model has no `updatedAt`, and its output transform emits declared fields only.** `dist/plugins/organization/organization.mjs:705` declares `organization` as `name` / `slug` / `logo` / `createdAt` / `metadata`; `member` as `organizationId` / `userId` / `role` / `createdAt`; `invitation` as `organizationId` / `email` / `role` / `teamId` / `status` / `expiresAt` / `createdAt` / `inviterId`. `@better-auth/core` `dist/db/adapter/factory.mjs:144` `transformOutput` iterates `for (const key in tableSchema)` — an undeclared column is dropped before any route sees it. `filterOutputFields` (`@better-auth/core` `dist/utils/db.mjs:6`) then removes only `additionalFields` marked not-returned. 3. ⭐ **Lit control, same file and same grep.** `updatedAt` occurs exactly twice in `organization.mjs` — `:617` on `team` and `:694` on `organizationRole`, both of which DO declare it. So the zero on `organization` / `member` / `invitation` is a reading, not a grep artefact. A second control one level up: better-auth's **core** `user` and `account` models do declare `updatedAt` (`@better-auth/core` `dist/db/get-tables.mjs:185` and `:270`), which is why `packages/spec/src/identity/identity.zod.ts` is **not** touched by this PR. ⇒ both of fallback A's conditions hold on all four read routes. **Fallback A fires.** ### One premise inside the ruling's primary arm is false, and it is a finding Ruling C's primary arm reads 「every identity read route puts `updated_at` on the wire as `updatedAt` (**the column exists**)」. Measured: the column exists on `sys_organization` only. | object | `updated_at` column | evidence | |---|---|---| | `sys_organization` | **yes** | `packages/platform-objects/src/identity/sys-organization.object.ts:362` | | `sys_member` | **no** | `sys-member.object.ts` declares `id` / `created_at` / `organization_id` / `user_id` / `role` — nothing else | | `sys_invitation` | **no** | `sys-invitation.object.ts` declares `id` / `created_at` / `organization_id` / `email` / `role` / `status` / `inviter_id` / `expires_at` / `team_id` / `business_unit_id` / `positions` | And the audit family is not injected onto any of the three: all three declare `managedBy: 'better-auth'`, which is the single disposition under which `resolveInjectedSystemColumns` (`packages/spec/src/data/injected-system-columns.ts`) returns the empty plan — audit family included. `sys_organization` has `updated_at` because it declares the field itself. ⇒ for `Member` and `Invitation`, fallback A is forced a second time over: there is no stored value to serve. ⛔ Per the dispatch, `packages/platform-objects/**` is out of surface and a missing column is a finding rather than an edit — so nothing was added there, and this is reported rather than repaired. ### Re-taken card readings, and the drift The card invited re-taking its own first-hand readings rather than trusting them. Every one still holds in substance; the line numbers have drifted (PR #18718's docblocks moved them), so the card's citations no longer resolve: | card's citation | on this base (`07c6f822e`) | still true? | |---|---|---| | `organization.zod.ts:57` / `:105` / `:183` required `updatedAt` | `:89` / `:137` / `:215` | yes — all three required before this PR | | `identity.zod.ts:55` / `:142` required `updatedAt` | `:86` / `:173` | yes, and deliberately left alone | | three 「not relayed」 notes at client `:1213` / `:1249` / `:1335` | `:1212` / `:1248` / `:1335` | yes | | `return-type-precision.test.ts:1050` pins `string | null | undefined` | `:1050`, exactly | yes | | zero in-repo consumers of the three schemas | zero | yes — every hit is a generated artefact, a CHANGELOG or docs prose. Lit control: `ObjectSchema` has real consumers in `packages/cli/src/commands/`. ⭐ After this PR the count is no longer zero: `@objectstack/client` consumes all three, which is what makes a future drift go red in-repo. | --- ## Leg 2 — what changed, at each end **Producer** (`packages/plugins/plugin-auth/`) - **New** `src/organization-metadata-decode.ts` — decodes `sys_organization.metadata` from stored JSON text into an object, and OMITS the key when the column is unset (`null`, empty, or a stored `null` literal), because the spec declares the key optional and never nullable. Undecodable text and text that decodes to a scalar or array are passed through **untouched**: never invented, never thrown. That makes the consumer's spec parse refuse the body and name the field — loud and located, and distinguishable from an unset column. - **Wired into `src/objectql-adapter.ts`'s READ verbs only** (`findOne`, `findMany`). All four read routes reach the row through those, so one seam covers them with nothing to keep in sync. - ⛔ **Deliberately NOT the write verbs.** better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string (`typeof organization.metadata === 'string' ? JSON.parse(...) : void 0`, `adapter.mjs:152`). Handing it an object would fold the create echo's `metadata` to `undefined` — a regression that reads as "unset". Both directions are pinned. - Measured reason this could not be done by declaration instead: the adapter declares `supportsJSON: true` (`objectql-adapter.ts:827`), so `transformOutput`'s JSON branch — which fires only for a field typed `json` on an adapter declaring `supportsJSON: false` — is unreachable here, and the vendor types `metadata` as a string anyway. **Spec** (`packages/spec/src/identity/organization.zod.ts`, fallback A) - `OrganizationSchema.updatedAt`, `MemberSchema.updatedAt`, `InvitationSchema.updatedAt` become `.optional()`. `.optional()` and not nullish: the key is **absent** on the wire, never `null`. Each carries the measurement and the ruling's own words in its docblock. - ⛔ `identity.zod.ts` untouched — the vendor declares `updatedAt` on `user` and `account`, so no fallback applies there. - ⛔ PR #18718's `image` / `logo` nullish arm is untouched, neither extended nor reverted. - Regenerated by the sanctioned producers only (`check:generated --fix` ran `gen:api-surface-declarations` and `gen:docs`): `packages/spec/api-surface-declarations/identity.txt` and `content/docs/references/identity/organization.mdx`, three declaration moves each, nothing else. **Client** (`packages/client/`) - The three 「not relayed」 notes are **gone**, and the wires are relayed rather than transcribed: `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds (`teamId` and the two ADR-0105 D8 placement fields). The schemas are plain, non-strict objects, so those three extra keys are stripped on parse rather than refused — which is what makes the relay claim honest about the wire being a superset. - `return-type-precision.test.ts:1050` flipped from the stored-text union to the decoded object. Two `@ts-expect-error` directives there had to go with it, because the shapes they suppressed are now legal — each replaced by a positive pin rather than deleted: - reading `updatedAt` off the delete answer is now the spec's optional ISO string (with the measured note that optional is the ACCEPT set, and the value is absent on every route of this family); - reading into `metadata` on a read route is now legal, so the direction-2 suppression moved to `JSON.parse` of it — the thing that is now refused. --- ## Leg 3 — ⭐ the negative control "The client now relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so `packages/client/src/identity-wire-relay.test.ts` runs the real `safeParse` over the measured bodies and pairs every accepted one with a refused one. Each refusal asserts the **issue path**, not merely that it failed: | body | verdict | why it is the control | |---|---|---| | the served read-route body, `updatedAt` absent, `metadata` decoded | **accepted** | the relay itself | | same, `metadata` key absent | **accepted** | an unset column | | `slug` removed | **refused**, path `slug` | a genuinely required field is still required | | ⭐ `metadata` as the stored JSON text | **refused**, path `metadata` | the exact dimension the producer fix moves — this one distinguishes "the producer decodes" from "the schema stopped caring" | | `metadata: null` | **refused**, path `metadata` | the producer omits; `null` is not quietly admitted | | `createdAt: 'yesterday'` | **refused**, path `createdAt` | the datetime check is live | | ⭐ `updatedAt: 'whenever'`, on all three schemas | **refused**, path `updatedAt` | `.optional()` widened by ABSENCE only; a present value is still held to the datetime check | | `userId` removed / `inviterId` removed / `status: 'withdrawn'` | **refused**, each path named | the other two schemas, same discipline | The same discipline lands in `packages/spec/src/identity/organization.test.ts`, whose #18509 scope-fence pin asked in writing that 「a later fix for either one has to come here and say so」. It is taken down and answered: the served body now parses whole, and the `null` the schema still refuses is a shape nothing sends any more. --- ## Verification Everything below is a foreground run in this worktree at the head of this branch; heavy runs went through `scripts/pm/os-verify-lock.sh` and the verdict quoted is the one it printed. - **Red before the change, on the producer.** An ablation of the decode assignment — written to disk by `scripts/ablation-replace.mjs`, which proved the mutation landed (anchor 1 to 0, blob `6346e2ba97a5` to `25449f6b3393`) — turned exactly the three decode pins red and left the other eight green, then restored and proved the restore (blob equals HEAD, `git diff HEAD` empty). - **Red before the change, on the client.** Before the flip, the client's test project reported exactly three errors of mine: `return-type-precision.test.ts(1050,83)` TS2344 and two TS2578 unused-suppression errors at `:1068` and `:1076`. The other 54 errors in that run were TS2307 "cannot find module" from an unbuilt workspace and are gone after a build. - **Red before the change, on the spec.** The full spec suite failed on `organization.test.ts`'s scope-fence pin and on nothing else. - Green after: `@objectstack/plugin-auth` 113 files / 2376 tests; `@objectstack/spec` 493 files / 14521 tests (1 skipped, an environment-conditional skip in the suite's project split — there is no `describe.skip` or `skipIf` in the spec sources); `@objectstack/client` 48 files / 566 tests. Typecheck green on all three, including each one's test layer. - The gate families this diff derives were run and accounted: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD-FILE` reports **116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN**, with an exit code recorded per family. Two needed a second pass and are green now: `check:skill-examples` refused twice on a `dist` older than `src` (the good refusal — it declines to reach a verdict it cannot read) and passed after building the package directly; `check:engine-double-contract` asked for the new fake's `update` to route through `assertEngineUpdateDispatch` and for its ledger row, both of which it now has. - Repo-wide `pnpm lint` (`eslint . --no-inline-config`): exit 0. - `packages/spec` artefacts: `check:generated` reports all 16 up to date, on a tree rebuilt after the last source edit. - Control bytes: `check:nul-bytes` clean over 8954 files, plus a direct scan of this diff's own files. ## Acceptance notes - **Hold #7881 intersection, answered.** This PR's producer fix does touch `packages/plugins/plugin-auth/src/objectql-adapter.ts`, the declared trigger file of on-hold card #7881 (`rethrowAsBetterAuthError` maps engine errors code-by-code). That hold's restart condition is an **incident count**, not a file touch, so the trigger is **unaffected** and nothing here advances or satisfies it. ⛔ Its generalisation was deliberately not attempted, and no bodyless-500 path was measured on the four identity read routes fixed here. - **Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair; neither holds. The `metadata` half is a producer brought into line with a contract this package has published all along, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. The changeset therefore carries no ADR-0087 disposition — `check-adr-0087-registration` agrees, reporting 「this PR adds no declared-breaking changeset」 — while still shipping the one-line consumer note and the compiler as its delivery channel. ⭐ Called out because it is the one judgement in this diff the contract review should confirm rather than inherit. - **`AUTH_ORGANIZATION_SCHEMA` carries a dead field mapping.** `packages/plugins/plugin-auth/src/auth-schema-config.ts:185` maps `updatedAt` to `updated_at` for the organization model, and better-auth's organization model declares no `updatedAt` field at all — so the mapping sets a `fieldName` for a field the transform never iterates. It is inert rather than wrong, and out of this card's scope; reported as a finding. - **Left deliberately untouched:** `packages/platform-objects/**` (the two missing `updated_at` columns are reported above, not added), `content/docs/releases/**`, `packages/spec/src/identity/identity.zod.ts`, and PR #18718's nullish arm. - `needs:contract-review` is the seat's label — ⛔ this PR neither attaches nor clears it, and the report states what the PR carries plus the `check-clause2-carriers.mjs --pair` exit code. - The PR opens as a **draft** and stays draft. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f26fb8e commit e6c34f6

12 files changed

Lines changed: 852 additions & 112 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/client": minor
4+
"@objectstack/plugin-auth": minor
5+
---
6+
7+
The identity read routes now serve what `@objectstack/spec/identity` declares: `metadata` arrives DECODED on every organization route that reads the row back, and `updatedAt` is declared optional on `Organization` / `Member` / `Invitation` — the shape better-auth's own serializer documents (#18728).
8+
9+
Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier.
10+
11+
Three published schemas could not parse a served response. `OrganizationSchema` declared `updatedAt` required and `metadata` an object; the four organization read routes (`setActive`, `get`, `delete`, `list`) carried no `updatedAt` at all and served `metadata` as the stored JSON text. `@objectstack/client` had recorded that as three 「not relayed」 notes rather than as a defect, and with zero in-repo consumers nothing went red — the audience was entirely external. Maintainer ruling C (batch #158 item 4) fixed the producer and made the one remaining key conditional on a measurement, which is what decided each half:
12+
13+
- **`metadata` is decoded at the producer, unconditionally** — it is our column. plugin-auth's data adapter decodes `sys_organization.metadata` out of its stored JSON text on its READ verbs, so all four routes serve the object the spec declares, and an unset column is OMITTED rather than sent as `null`. ⛔ The write verbs are deliberately untouched: better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string, so decoding there would fold the create echo's `metadata` to `undefined`. Both directions are pinned.
14+
- **`updatedAt` aligns to the documented wire** — ruling C's own fallback A, and its two conditions were measured against the installed better-auth 1.7.3 rather than assumed. The routes are better-auth's endpoints mounted through a single catch-all, each answering `ctx.json(...)` with no ObjectStack post-processing; and the vendor's `organization`, `member` and `invitation` models declare no `updatedAt` field, while its adapter factory's output transform iterates the declared fields only, so an undeclared column is dropped before any route sees it. Control, in the same file: the vendor's `team` and `organizationRole` models DO declare `updatedAt`, so the absence is a reading. For `member` and `invitation` there is additionally no column to serve — `sys_member` and `sys_invitation` are `managedBy: 'better-auth'`, the one disposition under which the platform injects no audit family, and neither declares `updated_at` itself.
15+
- **`@objectstack/client` relays the schemas.** `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds on top (`teamId` and the two ADR-0105 D8 placement fields, which the non-strict schema strips). The three 「not relayed」 notes are gone.
16+
- **The negative controls are the point.** "The client relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so every accepted body is paired with a refused one — a required field genuinely missing, `metadata` still arriving as the stored JSON TEXT, and a `createdAt` or `updatedAt` present but not a datetime. `.optional()` widened the accept set by absence ONLY; a value that is there is still held to `z.string().datetime()`.
17+
18+
**Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair. Neither holds here: nothing is removed, renamed or retired; the one `packages/spec` edit only widens an accept set; and the `metadata` half is a producer brought into line with a contract this package has published all along — `OrganizationSchema.metadata` has declared an object since it was written, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. No ADR-0087 disposition is claimed because no breaking change is declared: no authored metadata moves, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. These three schemas are not metadata types — not in `DEFAULT_METADATA_TYPE_REGISTRY`, no authorable surface. ⚠️ Stated here rather than assumed silently, because it is the one judgement in this diff that the contract review the `Clause-②: yes` declaration commissions should confirm.
19+
20+
**What a consumer notices**, and where it is delivered: `organization.metadata` was the stored JSON text and is now the decoded object, so a caller that decoded it itself drops that step.
21+
22+
```ts
23+
// before — the caller decoded what the route sent
24+
const meta = JSON.parse(org.metadata ?? '{}');
25+
// after — the producer decoded it; the key is ABSENT when unset
26+
const meta = org.metadata ?? {};
27+
```
28+
29+
The channel that reaches that caller is the compiler, on the line that used to work: `JSON.parse` no longer accepts the value. `updatedAt` needs nothing in either direction — it was never on this family's wire, so no caller can have been reading a value, and the declaration now says so out loud instead of promising one.

content/docs/references/identity/organization.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const result = InvitationSchema.parse(data);
4242
| **expiresAt** | `string` || Invitation expiry timestamp |
4343
| **inviterId** | `string` || User ID of the inviter |
4444
| **createdAt** | `string` || Invitation creation timestamp |
45-
| **updatedAt** | `string` | | Last update timestamp |
45+
| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_invitation; absent on the wire) |
4646

4747

4848
---
@@ -71,7 +71,7 @@ const result = InvitationSchema.parse(data);
7171
| **userId** | `string` || User ID |
7272
| **role** | `string` || Member role (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) |
7373
| **createdAt** | `string` || Member creation timestamp |
74-
| **updatedAt** | `string` | | Last update timestamp |
74+
| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_member; absent on the wire) |
7575

7676

7777
---
@@ -88,7 +88,7 @@ const result = InvitationSchema.parse(data);
8888
| **logo** | `string \| null` | optional | Organization logo URL |
8989
| **metadata** | `Record<string, any>` | optional | Custom metadata |
9090
| **createdAt** | `string` || Organization creation timestamp |
91-
| **updatedAt** | `string` | | Last update timestamp |
91+
| **updatedAt** | `string` | optional | Last update timestamp (absent on the better-auth organization wire) |
9292

9393

9494
---
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18728] The identity wires this SDK declares are the spec's own schemas —
5+
* and a served body really parses through them.
6+
*
7+
* ## What this file is for
8+
*
9+
* Maintainer ruling C (batch #158 item 4) ended a state where three PUBLISHED
10+
* schemas could not parse a response: `OrganizationSchema` declared
11+
* `updatedAt` required and `metadata` an object, while the four organization
12+
* read routes carried no `updatedAt` and served `metadata` as the stored JSON
13+
* text. The client recorded that as three 「not relayed」 notes; the spec side
14+
* was untouched. Both ends moved:
15+
*
16+
* - **producer** — plugin-auth's data adapter decodes
17+
* `sys_organization.metadata` on its READ verbs, so `setActive`, `get`,
18+
* `delete` and `list` all serve the object the spec declares, with the key
19+
* omitted when the column is unset;
20+
* - **spec** — ruling C's own fallback A, on measurement: the wire is
21+
* better-auth's serializer and its documented organization / member /
22+
* invitation models declare no `updatedAt`, so the three schemas align to
23+
* the documented wire and declare it optional.
24+
*
25+
* ## Why a RUNTIME parse, next to the type-level pins
26+
*
27+
* `return-type-precision.test.ts` is type-level on purpose and pins that the
28+
* DECLARED types are the spec's. That cannot observe whether a served body
29+
* actually satisfies the schema — the declaration could be a relay and the
30+
* body could still be refused. So this file runs the parse.
31+
*
32+
* ## ⭐ The negative controls are the point of the file
33+
*
34+
* "The client now relays the spec schemas" and "the client stopped validating"
35+
* look identical from a green positive test. Every positive case below is
36+
* therefore paired with a body that MUST be refused:
37+
*
38+
* - a required field genuinely missing (`slug` / `userId` / `inviterId`);
39+
* - ⭐ `metadata` still arriving as the stored JSON TEXT — the exact dimension
40+
* the producer fix moves, so this one distinguishes "the producer decodes"
41+
* from "the schema stopped caring";
42+
* - `createdAt` present but not a datetime, and `updatedAt` present but not a
43+
* datetime — because `.optional()` must widen the accept set by exactly one
44+
* shape (absence) and must NOT drop the format check on a value that is
45+
* there.
46+
*
47+
* Each refusal asserts the ISSUE PATH, not merely `success === false`: a parse
48+
* that fails for an unrelated reason is not evidence about the field named.
49+
*/
50+
51+
import { describe, it, expect, expectTypeOf } from 'vitest';
52+
import {
53+
InvitationSchema,
54+
MemberSchema,
55+
OrganizationSchema,
56+
type Invitation,
57+
type Member,
58+
type Organization,
59+
} from '@objectstack/spec/identity';
60+
import type {
61+
OrganizationInvitationWire,
62+
OrganizationMemberWire,
63+
OrganizationWire,
64+
} from './index';
65+
66+
/** Paths of every issue a `safeParse` reported, as dotted strings. */
67+
function issuePaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }): string[] {
68+
return (result.error?.issues ?? []).map((i) => i.path.join('.'));
69+
}
70+
71+
// ---------------------------------------------------------------------------
72+
// The measured bodies — what the four read routes serve AFTER ruling C
73+
// ---------------------------------------------------------------------------
74+
75+
/**
76+
* An organization row as `setActive` / `get` / `delete` / `list` serve it:
77+
* better-auth's own organization columns, `metadata` decoded by the producer,
78+
* `logo` present-and-null for an organization created without one (PR #18718's
79+
* measurement), and NO `updatedAt` — the vendor's output transform walks its
80+
* own declared fields only.
81+
*/
82+
const ORGANIZATION_WIRE = {
83+
id: 'org_01HQ',
84+
name: 'Acme',
85+
slug: 'acme',
86+
logo: null,
87+
createdAt: '2026-09-07T09:27:01.545Z',
88+
metadata: { plan: 'pro' },
89+
};
90+
91+
/** The same row for an organization that never had metadata: the key is absent. */
92+
const ORGANIZATION_WIRE_NO_METADATA = {
93+
id: 'org_01HR',
94+
name: 'Beta',
95+
slug: 'beta',
96+
logo: null,
97+
createdAt: '2026-09-07T09:27:01.545Z',
98+
};
99+
100+
/** A membership row as better-auth serves it — its own member schema, no more. */
101+
const MEMBER_WIRE = {
102+
id: 'mem_01HQ',
103+
organizationId: 'org_01HQ',
104+
userId: 'usr_01HQ',
105+
role: 'owner',
106+
createdAt: '2026-09-07T09:27:01.545Z',
107+
};
108+
109+
/**
110+
* An invitation row as better-auth serves it: its invitation schema plus the
111+
* three members the platform adds — `teamId` (the vendor's own, written `null`
112+
* explicitly) and the two ADR-0105 D8 `additionalFields`.
113+
*/
114+
const INVITATION_WIRE = {
115+
id: 'inv_01HQ',
116+
organizationId: 'org_01HQ',
117+
email: 'invitee@example.com',
118+
role: 'member',
119+
status: 'pending',
120+
teamId: null,
121+
inviterId: 'usr_01HQ',
122+
expiresAt: '2026-09-09T09:27:01.545Z',
123+
createdAt: '2026-09-07T09:27:01.545Z',
124+
businessUnitId: null,
125+
positions: null,
126+
};
127+
128+
describe('[#18728] the identity wires parse through the spec schemas', () => {
129+
it('OrganizationSchema accepts a served read-route body whole', () => {
130+
const parsed = OrganizationSchema.safeParse(ORGANIZATION_WIRE);
131+
expect(issuePaths(parsed)).toEqual([]);
132+
expect(parsed.success).toBe(true);
133+
// The decoded object survives the parse as an object.
134+
expect(parsed.success && parsed.data.metadata).toEqual({ plan: 'pro' });
135+
// `updatedAt` is absent on the wire and stays absent after parsing —
136+
// `.optional()` admits absence, it does not invent a value.
137+
expect(parsed.success && 'updatedAt' in parsed.data).toBe(false);
138+
});
139+
140+
it('OrganizationSchema accepts a row whose metadata column was never set', () => {
141+
const parsed = OrganizationSchema.safeParse(ORGANIZATION_WIRE_NO_METADATA);
142+
expect(issuePaths(parsed)).toEqual([]);
143+
expect(parsed.success).toBe(true);
144+
});
145+
146+
it('MemberSchema accepts a served membership row whole', () => {
147+
const parsed = MemberSchema.safeParse(MEMBER_WIRE);
148+
expect(issuePaths(parsed)).toEqual([]);
149+
expect(parsed.success).toBe(true);
150+
});
151+
152+
it('InvitationSchema accepts a served invitation row whole, stripping the platform members', () => {
153+
const parsed = InvitationSchema.safeParse(INVITATION_WIRE);
154+
expect(issuePaths(parsed)).toEqual([]);
155+
expect(parsed.success).toBe(true);
156+
// The schema is a plain (non-strict) object, so the three keys it does
157+
// not declare are STRIPPED rather than refused. That is what makes the
158+
// relay claim honest: the wire is a superset of the spec's declaration.
159+
if (parsed.success) {
160+
expect('teamId' in parsed.data).toBe(false);
161+
expect('businessUnitId' in parsed.data).toBe(false);
162+
expect('positions' in parsed.data).toBe(false);
163+
}
164+
});
165+
166+
it('relays the spec declarations as the SDK types, not a transcription of them', () => {
167+
expectTypeOf<OrganizationWire>().toEqualTypeOf<Organization>();
168+
expectTypeOf<OrganizationMemberWire>().toEqualTypeOf<Member>();
169+
// The invitation wire narrows `status` per route and adds the three
170+
// platform members, so it is the spec's declaration EXTENDED — every
171+
// key the spec declares still comes from the spec.
172+
expectTypeOf<OrganizationInvitationWire>().toMatchObjectType<Omit<Invitation, 'status'>>();
173+
});
174+
});
175+
176+
describe('⭐ [#18728] negative controls — the spec parse still REFUSES a malformed body', () => {
177+
it('refuses an organization body missing a genuinely required field', () => {
178+
const { slug: _slug, ...withoutSlug } = ORGANIZATION_WIRE;
179+
const parsed = OrganizationSchema.safeParse(withoutSlug);
180+
expect(parsed.success).toBe(false);
181+
expect(issuePaths(parsed)).toContain('slug');
182+
});
183+
184+
it('⭐ refuses an organization body whose metadata is still the stored JSON TEXT', () => {
185+
// This is the body the four read routes served BEFORE the producer fix.
186+
// It must stay refused: if it ever parses, the producer has regressed
187+
// or the schema has been loosened to hide the regression.
188+
const parsed = OrganizationSchema.safeParse({
189+
...ORGANIZATION_WIRE,
190+
metadata: '{"plan":"pro"}',
191+
});
192+
expect(parsed.success).toBe(false);
193+
expect(issuePaths(parsed)).toContain('metadata');
194+
});
195+
196+
it('refuses an organization body whose metadata is null rather than absent', () => {
197+
// The producer OMITS the key for an unset column; `null` is not the
198+
// declared shape and is not quietly admitted.
199+
const parsed = OrganizationSchema.safeParse({ ...ORGANIZATION_WIRE, metadata: null });
200+
expect(parsed.success).toBe(false);
201+
expect(issuePaths(parsed)).toContain('metadata');
202+
});
203+
204+
it('refuses a non-datetime createdAt — the format check is live, not decorative', () => {
205+
const parsed = OrganizationSchema.safeParse({ ...ORGANIZATION_WIRE, createdAt: 'yesterday' });
206+
expect(parsed.success).toBe(false);
207+
expect(issuePaths(parsed)).toContain('createdAt');
208+
});
209+
210+
it('⭐ refuses a non-datetime updatedAt — `.optional()` widened by absence ONLY', () => {
211+
// The one shape fallback A added is the key being ABSENT. A value that
212+
// IS there is still held to `z.string().datetime()`, on all three
213+
// schemas — otherwise the widening would have quietly retired the
214+
// format check as well.
215+
for (const [name, schema, wire] of [
216+
['organization', OrganizationSchema, ORGANIZATION_WIRE],
217+
['member', MemberSchema, MEMBER_WIRE],
218+
['invitation', InvitationSchema, INVITATION_WIRE],
219+
] as const) {
220+
const parsed = schema.safeParse({ ...wire, updatedAt: 'whenever' });
221+
expect(parsed.success, name).toBe(false);
222+
expect(issuePaths(parsed), name).toContain('updatedAt');
223+
}
224+
});
225+
226+
it('refuses a membership row missing userId', () => {
227+
const { userId: _userId, ...withoutUserId } = MEMBER_WIRE;
228+
const parsed = MemberSchema.safeParse(withoutUserId);
229+
expect(parsed.success).toBe(false);
230+
expect(issuePaths(parsed)).toContain('userId');
231+
});
232+
233+
it('refuses an invitation row missing inviterId, and one with an unknown status', () => {
234+
const { inviterId: _inviterId, ...withoutInviter } = INVITATION_WIRE;
235+
const missing = InvitationSchema.safeParse(withoutInviter);
236+
expect(missing.success).toBe(false);
237+
expect(issuePaths(missing)).toContain('inviterId');
238+
239+
const badStatus = InvitationSchema.safeParse({ ...INVITATION_WIRE, status: 'withdrawn' });
240+
expect(badStatus.success).toBe(false);
241+
expect(issuePaths(badStatus)).toContain('status');
242+
});
243+
});

0 commit comments

Comments
 (0)