diff --git a/.changeset/18728-identity-wires-relay-the-spec.md b/.changeset/18728-identity-wires-relay-the-spec.md new file mode 100644 index 00000000000..510301267fc --- /dev/null +++ b/.changeset/18728-identity-wires-relay-the-spec.md @@ -0,0 +1,29 @@ +--- +"@objectstack/spec": minor +"@objectstack/client": minor +"@objectstack/plugin-auth": minor +--- + +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). + +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. + +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: + +- **`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. +- **`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. +- **`@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. +- **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()`. + +**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. + +**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. + +```ts +// before — the caller decoded what the route sent +const meta = JSON.parse(org.metadata ?? '{}'); +// after — the producer decoded it; the key is ABSENT when unset +const meta = org.metadata ?? {}; +``` + +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. diff --git a/content/docs/references/identity/organization.mdx b/content/docs/references/identity/organization.mdx index 7eaf8c18f1d..7274e32fa88 100644 --- a/content/docs/references/identity/organization.mdx +++ b/content/docs/references/identity/organization.mdx @@ -42,7 +42,7 @@ const result = InvitationSchema.parse(data); | **expiresAt** | `string` | ✅ | Invitation expiry timestamp | | **inviterId** | `string` | ✅ | User ID of the inviter | | **createdAt** | `string` | ✅ | Invitation creation timestamp | -| **updatedAt** | `string` | ✅ | Last update timestamp | +| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_invitation; absent on the wire) | --- @@ -71,7 +71,7 @@ const result = InvitationSchema.parse(data); | **userId** | `string` | ✅ | User ID | | **role** | `string` | ✅ | Member role (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) | | **createdAt** | `string` | ✅ | Member creation timestamp | -| **updatedAt** | `string` | ✅ | Last update timestamp | +| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_member; absent on the wire) | --- @@ -88,7 +88,7 @@ const result = InvitationSchema.parse(data); | **logo** | `string \| null` | optional | Organization logo URL | | **metadata** | `Record` | optional | Custom metadata | | **createdAt** | `string` | ✅ | Organization creation timestamp | -| **updatedAt** | `string` | ✅ | Last update timestamp | +| **updatedAt** | `string` | optional | Last update timestamp (absent on the better-auth organization wire) | --- diff --git a/packages/client/src/identity-wire-relay.test.ts b/packages/client/src/identity-wire-relay.test.ts new file mode 100644 index 00000000000..9d9c92e84dc --- /dev/null +++ b/packages/client/src/identity-wire-relay.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18728] The identity wires this SDK declares are the spec's own schemas — + * and a served body really parses through them. + * + * ## What this file is for + * + * Maintainer ruling C (batch #158 item 4) ended a state where three PUBLISHED + * schemas could not parse a response: `OrganizationSchema` declared + * `updatedAt` required and `metadata` an object, while the four organization + * read routes carried no `updatedAt` and served `metadata` as the stored JSON + * text. The client recorded that as three 「not relayed」 notes; the spec side + * was untouched. Both ends moved: + * + * - **producer** — plugin-auth's data adapter decodes + * `sys_organization.metadata` on its READ verbs, so `setActive`, `get`, + * `delete` and `list` all serve the object the spec declares, with the key + * omitted when the column is unset; + * - **spec** — ruling C's own fallback A, on measurement: the wire is + * better-auth's serializer and its documented organization / member / + * invitation models declare no `updatedAt`, so the three schemas align to + * the documented wire and declare it optional. + * + * ## Why a RUNTIME parse, next to the type-level pins + * + * `return-type-precision.test.ts` is type-level on purpose and pins that the + * DECLARED types are the spec's. That cannot observe whether a served body + * actually satisfies the schema — the declaration could be a relay and the + * body could still be refused. So this file runs the parse. + * + * ## ⭐ The negative controls are the point of the file + * + * "The client now relays the spec schemas" and "the client stopped validating" + * look identical from a green positive test. Every positive case below is + * therefore paired with a body that MUST be refused: + * + * - a required field genuinely missing (`slug` / `userId` / `inviterId`); + * - ⭐ `metadata` still arriving as the stored JSON TEXT — the exact dimension + * the producer fix moves, so this one distinguishes "the producer decodes" + * from "the schema stopped caring"; + * - `createdAt` present but not a datetime, and `updatedAt` present but not a + * datetime — because `.optional()` must widen the accept set by exactly one + * shape (absence) and must NOT drop the format check on a value that is + * there. + * + * Each refusal asserts the ISSUE PATH, not merely `success === false`: a parse + * that fails for an unrelated reason is not evidence about the field named. + */ + +import { describe, it, expect, expectTypeOf } from 'vitest'; +import { + InvitationSchema, + MemberSchema, + OrganizationSchema, + type Invitation, + type Member, + type Organization, +} from '@objectstack/spec/identity'; +import type { + OrganizationInvitationWire, + OrganizationMemberWire, + OrganizationWire, +} from './index'; + +/** Paths of every issue a `safeParse` reported, as dotted strings. */ +function issuePaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }): string[] { + return (result.error?.issues ?? []).map((i) => i.path.join('.')); +} + +// --------------------------------------------------------------------------- +// The measured bodies — what the four read routes serve AFTER ruling C +// --------------------------------------------------------------------------- + +/** + * An organization row as `setActive` / `get` / `delete` / `list` serve it: + * better-auth's own organization columns, `metadata` decoded by the producer, + * `logo` present-and-null for an organization created without one (PR #18718's + * measurement), and NO `updatedAt` — the vendor's output transform walks its + * own declared fields only. + */ +const ORGANIZATION_WIRE = { + id: 'org_01HQ', + name: 'Acme', + slug: 'acme', + logo: null, + createdAt: '2026-09-07T09:27:01.545Z', + metadata: { plan: 'pro' }, +}; + +/** The same row for an organization that never had metadata: the key is absent. */ +const ORGANIZATION_WIRE_NO_METADATA = { + id: 'org_01HR', + name: 'Beta', + slug: 'beta', + logo: null, + createdAt: '2026-09-07T09:27:01.545Z', +}; + +/** A membership row as better-auth serves it — its own member schema, no more. */ +const MEMBER_WIRE = { + id: 'mem_01HQ', + organizationId: 'org_01HQ', + userId: 'usr_01HQ', + role: 'owner', + createdAt: '2026-09-07T09:27:01.545Z', +}; + +/** + * An invitation row as better-auth serves it: its invitation schema plus the + * three members the platform adds — `teamId` (the vendor's own, written `null` + * explicitly) and the two ADR-0105 D8 `additionalFields`. + */ +const INVITATION_WIRE = { + id: 'inv_01HQ', + organizationId: 'org_01HQ', + email: 'invitee@example.com', + role: 'member', + status: 'pending', + teamId: null, + inviterId: 'usr_01HQ', + expiresAt: '2026-09-09T09:27:01.545Z', + createdAt: '2026-09-07T09:27:01.545Z', + businessUnitId: null, + positions: null, +}; + +describe('[#18728] the identity wires parse through the spec schemas', () => { + it('OrganizationSchema accepts a served read-route body whole', () => { + const parsed = OrganizationSchema.safeParse(ORGANIZATION_WIRE); + expect(issuePaths(parsed)).toEqual([]); + expect(parsed.success).toBe(true); + // The decoded object survives the parse as an object. + expect(parsed.success && parsed.data.metadata).toEqual({ plan: 'pro' }); + // `updatedAt` is absent on the wire and stays absent after parsing — + // `.optional()` admits absence, it does not invent a value. + expect(parsed.success && 'updatedAt' in parsed.data).toBe(false); + }); + + it('OrganizationSchema accepts a row whose metadata column was never set', () => { + const parsed = OrganizationSchema.safeParse(ORGANIZATION_WIRE_NO_METADATA); + expect(issuePaths(parsed)).toEqual([]); + expect(parsed.success).toBe(true); + }); + + it('MemberSchema accepts a served membership row whole', () => { + const parsed = MemberSchema.safeParse(MEMBER_WIRE); + expect(issuePaths(parsed)).toEqual([]); + expect(parsed.success).toBe(true); + }); + + it('InvitationSchema accepts a served invitation row whole, stripping the platform members', () => { + const parsed = InvitationSchema.safeParse(INVITATION_WIRE); + expect(issuePaths(parsed)).toEqual([]); + expect(parsed.success).toBe(true); + // The schema is a plain (non-strict) object, so the three keys it does + // not declare are STRIPPED rather than refused. That is what makes the + // relay claim honest: the wire is a superset of the spec's declaration. + if (parsed.success) { + expect('teamId' in parsed.data).toBe(false); + expect('businessUnitId' in parsed.data).toBe(false); + expect('positions' in parsed.data).toBe(false); + } + }); + + it('relays the spec declarations as the SDK types, not a transcription of them', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // The invitation wire narrows `status` per route and adds the three + // platform members, so it is the spec's declaration EXTENDED — every + // key the spec declares still comes from the spec. + expectTypeOf().toMatchObjectType>(); + }); +}); + +describe('⭐ [#18728] negative controls — the spec parse still REFUSES a malformed body', () => { + it('refuses an organization body missing a genuinely required field', () => { + const { slug: _slug, ...withoutSlug } = ORGANIZATION_WIRE; + const parsed = OrganizationSchema.safeParse(withoutSlug); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toContain('slug'); + }); + + it('⭐ refuses an organization body whose metadata is still the stored JSON TEXT', () => { + // This is the body the four read routes served BEFORE the producer fix. + // It must stay refused: if it ever parses, the producer has regressed + // or the schema has been loosened to hide the regression. + const parsed = OrganizationSchema.safeParse({ + ...ORGANIZATION_WIRE, + metadata: '{"plan":"pro"}', + }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toContain('metadata'); + }); + + it('refuses an organization body whose metadata is null rather than absent', () => { + // The producer OMITS the key for an unset column; `null` is not the + // declared shape and is not quietly admitted. + const parsed = OrganizationSchema.safeParse({ ...ORGANIZATION_WIRE, metadata: null }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toContain('metadata'); + }); + + it('refuses a non-datetime createdAt — the format check is live, not decorative', () => { + const parsed = OrganizationSchema.safeParse({ ...ORGANIZATION_WIRE, createdAt: 'yesterday' }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toContain('createdAt'); + }); + + it('⭐ refuses a non-datetime updatedAt — `.optional()` widened by absence ONLY', () => { + // The one shape fallback A added is the key being ABSENT. A value that + // IS there is still held to `z.string().datetime()`, on all three + // schemas — otherwise the widening would have quietly retired the + // format check as well. + for (const [name, schema, wire] of [ + ['organization', OrganizationSchema, ORGANIZATION_WIRE], + ['member', MemberSchema, MEMBER_WIRE], + ['invitation', InvitationSchema, INVITATION_WIRE], + ] as const) { + const parsed = schema.safeParse({ ...wire, updatedAt: 'whenever' }); + expect(parsed.success, name).toBe(false); + expect(issuePaths(parsed), name).toContain('updatedAt'); + } + }); + + it('refuses a membership row missing userId', () => { + const { userId: _userId, ...withoutUserId } = MEMBER_WIRE; + const parsed = MemberSchema.safeParse(withoutUserId); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toContain('userId'); + }); + + it('refuses an invitation row missing inviterId, and one with an unknown status', () => { + const { inviterId: _inviterId, ...withoutInviter } = INVITATION_WIRE; + const missing = InvitationSchema.safeParse(withoutInviter); + expect(missing.success).toBe(false); + expect(issuePaths(missing)).toContain('inviterId'); + + const badStatus = InvitationSchema.safeParse({ ...INVITATION_WIRE, status: 'withdrawn' }); + expect(badStatus.success).toBe(false); + expect(issuePaths(badStatus)).toContain('status'); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4f96d1e085b..c12b554d20a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -171,7 +171,13 @@ import type { InstalledPackage } from '@objectstack/spec/kernel'; import type { ResolvedBook } from '@objectstack/spec/system'; import type { ConnectorDescriptor } from '@objectstack/spec/integration'; import type { ExplainDecision } from '@objectstack/spec/security'; -import type { InvitationStatus } from '@objectstack/spec/identity'; +// [#18728] The identity wires are RELAYED from the spec, not re-declared here +// (maintainer ruling C, batch #158 item 4). `Invitation` / `Member` / +// `Organization` are `z.input` of the three published schemas; the schemas +// themselves are imported as VALUES by `identity-wire-relay.test.ts`, which +// parses the measured wire bodies through them and pins the refusal of a body +// missing a required field. +import type { Invitation, InvitationStatus, Member, Organization } from '@objectstack/spec/identity'; import { Logger, createLogger } from '@objectstack/core/logger'; import { RealtimeAPI } from './realtime-api'; @@ -1190,11 +1196,28 @@ export interface AuthSetInitialPasswordResult { /** * The columns every organization answer of the `organizations.*` family - * carries — exactly better-auth's organization schema (`id`, `name`, `slug`, - * `logo`, `metadata`, `createdAt`), served BARE (no `{ success, data }` - * envelope). The adapter's output transform walks that schema and nothing - * else, so `sys_organization`'s `updated_at` and every other ObjectStack column - * stay off the wire — measured against a real server on a real SQL driver. + * carries, served BARE (no `{ success, data }` envelope). + * + * ⭐ **This IS `@objectstack/spec/identity`'s `Organization`** — relayed, not + * transcribed (#18728, maintainer ruling C batch #158 item 4). The two + * divergences that used to make the published schema unable to parse a served + * body are closed at their own ends: + * + * - **`metadata` is DECODED.** plugin-auth's data adapter decodes + * `sys_organization.metadata` from its stored JSON text into an object on + * every route that reads the row back (`setActive`, `get`, `delete`, + * `list`), and omits the key when the column is unset — so the object the + * spec declares is what arrives. Previously only the two write echoes + * decoded it; see `organization-metadata-decode.ts` in plugin-auth for why + * the seam is the adapter's read verbs and why it must not touch the write + * ones. + * - **`updatedAt` is OPTIONAL in the spec**, which is the ruling's own + * fallback A: the wire is better-auth's own serializer and its documented + * organization shape declares no `updatedAt`, so the schema aligns to the + * documented wire. In practice the key is ABSENT on every route of this + * family — the vendor's `transformOutput` emits declared fields only, so + * `sys_organization.updated_at` never reaches it even though the column + * exists. Read it as "may be absent", and expect absent. * * ⚠️ **`createdAt` is an ISO-8601 string, never `Date`** (maintainer ruling on * #12104): the adapter is declared `supportsDates: false`, better-auth revives @@ -1202,35 +1225,23 @@ export interface AuthSetInitialPasswordResult { * ISO string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"`. * There is no revival layer in this SDK; `new Date(x)` is the caller's step. * - * ⚠️ **`metadata` arrives as the stored JSON TEXT, not an object**, on every - * route that reads the row back (`setActive`, `get`, `delete`, `list`): better-auth - * stores it `JSON.stringify`-ed in a text column and only the two write routes - * decode it — see {@link OrganizationEchoWire}. `JSON.parse(metadata)` is the - * caller's step here. `null` (SQL) or absent (a store that does not - * materialise an unset column) when never set; same for `logo`. - * - * `@objectstack/spec/identity`'s `Organization` is NOT relayed: it declares - * `updatedAt` required and `metadata` as an object, and neither is what this - * wire carries. + * ⚠️ `logo` is `null` (SQL) or absent (a store that does not materialise an + * unset column) when never set — the `.nullish()` arm PR #18718 landed. */ -export interface OrganizationWire { - id: string; - name: string; - slug: string; - /** `null` (SQL) or absent (document store) when unset. */ - logo?: string | null; - /** ISO-8601. */ - createdAt: string; - /** The stored JSON text (`'{"plan":"pro"}'`), undecoded; `null`/absent when unset. */ - metadata?: string | null; -} +export type OrganizationWire = Organization; /** * The organization as the two WRITE routes echo it back — `create` and - * `update` — which are the only two that decode `metadata` before answering - * (`JSON.parse` in the create handler, `parseJSON` in the update adapter). - * An unset `metadata` is ABSENT here (the handlers fold it to `undefined`), - * never `null`. Every other column is {@link OrganizationWire}'s. + * `update`. An unset `metadata` is ABSENT here (the handlers fold it to + * `undefined`), never `null`. + * + * ⭐ Since #18728 this is the SAME shape as {@link OrganizationWire}: the read + * routes decode `metadata` too and omit it when unset, so the two used to + * differ only in that one member and no longer differ at all. The name is kept + * — it is published SDK surface, and it still records WHICH routes these are + * (the write echoes decode in better-auth's own organization adapter, + * `JSON.parse` on create and `parseJSON` on update, independently of the + * producer fix on the read side). */ export interface OrganizationEchoWire extends Omit { /** Decoded object; absent when unset. */ @@ -1239,23 +1250,24 @@ export interface OrganizationEchoWire extends Omit /** * A membership row as better-auth serves it — its own member schema, nothing - * of ObjectStack's `sys_member` beyond it (no `updatedAt`). `role` is one of - * the closed ADR-0108 vocabulary (`owner` / `admin` / `delegated_admin` / - * `member`), typed `string` because the wire mirrors the vendor's column, not - * because the set is open; the platform refuses a multi-role - * (`'admin,member'`) at the door with `400 VALIDATION_FAILED`. + * of ObjectStack's `sys_member` beyond it. + * + * ⭐ **This IS `@objectstack/spec/identity`'s `Member`** — relayed, not + * transcribed (#18728, ruling C's fallback A). `updatedAt` is optional in the + * spec and absent on this wire, for two reasons that stack: better-auth's + * `member` model declares no such field, and `sys_member` provisions no + * `updated_at` column to serve from either (it is `managedBy: 'better-auth'`, + * so the audit family is not injected). * - * `@objectstack/spec/identity`'s `Member` is not relayed: it declares - * `updatedAt` required and the wire never carries it. + * `role` is one of the closed ADR-0108 vocabulary (`owner` / `admin` / + * `delegated_admin` / `member`), typed `string` because the wire mirrors the + * vendor's column, not because the set is open; the platform refuses a + * multi-role (`'admin,member'`) at the door with `400 VALIDATION_FAILED`. + * + * ⚠️ `createdAt` is an ISO-8601 string, never `Date` — see + * {@link OrganizationWire}. */ -export interface OrganizationMemberWire { - id: string; - organizationId: string; - userId: string; - role: string; - /** ISO-8601. */ - createdAt: string; -} +export type OrganizationMemberWire = Member; /** * The four-column user projection better-auth hand-picks onto a member on the @@ -1331,31 +1343,32 @@ export interface OrganizationFullTeamWire extends Omit { - id: string; - organizationId: string; - email: string; - role: string; - status: Status; - teamId: string | null; - inviterId: string; - /** ISO-8601. */ - expiresAt: string; - /** ISO-8601. */ - createdAt: string; - /** ADR-0105 D8 placement: `null` (SQL) or absent when the invitation carries none. */ - businessUnitId?: string | null; - /** ADR-0105 D8 placement: `null` (SQL) or absent when the invitation carries none. */ - positions?: string[] | null; -} +export type OrganizationInvitationWire = + Omit & { + status: Status; + teamId: string | null; + /** ADR-0105 D8 placement: `null` (SQL) or absent when the invitation carries none. */ + businessUnitId?: string | null; + /** ADR-0105 D8 placement: `null` (SQL) or absent when the invitation carries none. */ + positions?: string[] | null; + }; /** What `POST /organization/accept-invitation` answers. */ export interface OrganizationInvitationAcceptResult { @@ -1393,10 +1406,10 @@ export interface OrganizationRemoveMemberResult { /** * What `GET /organization/get-full-organization` answers: the row (metadata - * as stored JSON text, see {@link OrganizationWire}) plus every invitation of - * any status, every member with its user joined, and — because this platform - * mounts the organization plugin with `teams: { enabled: true }` - * unconditionally — the organization's teams. + * decoded, see {@link OrganizationWire}) plus every invitation of any status, + * every member with its user joined, and — because this platform mounts the + * organization plugin with `teams: { enabled: true }` unconditionally — the + * organization's teams. */ export interface OrganizationFullWire extends OrganizationWire { invitations: OrganizationInvitationWire[]; @@ -3631,8 +3644,8 @@ export class ObjectStackClient { * * POST /api/v1/auth/organization/set-active * - * Answers the organization row as STORED (`metadata` is the JSON text, - * see {@link OrganizationWire}). Answers `null` — measured, a 4-byte body + * Answers the organization row with `metadata` DECODED (see + * {@link OrganizationWire}). Answers `null` — measured, a 4-byte body * — when `organizationId` is the empty string and the session has no * active organization to fall back to; a non-member is a thrown 403. */ @@ -3649,7 +3662,7 @@ export class ObjectStackClient { * Get full organization detail (members, invitations, teams). * GET /api/v1/auth/organization/get-full-organization?organizationId=... * - * `metadata` is the stored JSON text here (see {@link OrganizationWire}). + * `metadata` is decoded here (see {@link OrganizationWire}). * Answers `null` (measured) when `organizationId` is the empty string and * the session has no active organization; an unknown id is a thrown 400. */ @@ -3761,7 +3774,8 @@ export class ObjectStackClient { * * POST /api/v1/auth/organization/delete * - * Answers the deleted organization's row as it was stored (measured) — + * Answers the deleted organization's row as it stood immediately before + * deletion (measured; `metadata` decoded, see {@link OrganizationWire}) — * NOT the bare id string the vendor's OpenAPI stub declares. * * better-auth removes the organization row, all members, and all diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 7189fe155d3..8ed2d396e45 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -1045,9 +1045,22 @@ export async function returnTypePrecisionPins14314(): Promise { expectTypeOf((await client.organizations.invitations.accept('i')).invitation.status).toEqualTypeOf<'accepted'>(); expectTypeOf((await client.organizations.invitations.reject('i')).invitation.status).toEqualTypeOf<'rejected'>(); expectTypeOf((await client.organizations.invitations.reject('i')).member).toEqualTypeOf(); - // The two metadata shapes: decoded on the write echo, stored text on the read row. + // [#18728] ONE metadata shape now — decoded on the write echo AND on every + // read row, because plugin-auth's adapter decodes `sys_organization + // .metadata` out of its stored JSON text on the read verbs and omits the + // key when the column is unset. Before ruling C's producer fix the second + // line read `string | null | undefined`; the pair is kept side by side so + // a regression on either end is one diff line. expectTypeOf((await client.organizations.update('o', {})).metadata).toEqualTypeOf | undefined>(); - expectTypeOf((await client.organizations.delete('o')).metadata).toEqualTypeOf(); + expectTypeOf((await client.organizations.delete('o')).metadata).toEqualTypeOf | undefined>(); + // [#18728] The relay itself, made mechanical: the read wire IS the spec's + // `Organization`, so `updatedAt` is the spec's optional ISO string rather + // than a member this SDK re-declares. ⚠️ Optional is the ACCEPT set, not a + // promise of delivery — measured, better-auth's `transformOutput` emits its + // own declared fields only, so this key is absent on every route of the + // family even though `sys_organization.updated_at` exists (ruling C's + // fallback A). Read it as "may be absent", and expect absent. + expectTypeOf((await client.organizations.delete('o')).updatedAt).toEqualTypeOf(); // `create.members` is the literal one-element tuple the handler answers. expectTypeOf((await client.organizations.create({ name: 'n' })).members).toEqualTypeOf<[OrganizationMemberWire]>(); // `removeMember.member.user` is conditional on the by-email path. @@ -1065,18 +1078,16 @@ export async function returnTypePrecisionPins14314(): Promise { void (await client.organizations.listMembers('o')).data; // @ts-expect-error the wire sends an ISO string; `Date` methods do not exist on it void (await client.organizations.leave('o')).createdAt.getTime(); - // @ts-expect-error `sys_organization.updated_at` never reaches the wire — the adapter walks the vendor schema only - void (await client.organizations.delete('o')).updatedAt; // @ts-expect-error delete answers the organization ROW, not the id string the vendor's OpenAPI stub declares void (await client.organizations.delete('o')).length; // @ts-expect-error updateMemberRole answers the member BARE, not `{ member }` as the vendor's stub declares void (await client.organizations.updateMemberRole('o', { memberId: 'm', role: 'r' })).member; // @ts-expect-error setActive can answer `null` (empty id, no active organization) — narrow before reading void (await client.organizations.setActive('o')).id; - // @ts-expect-error on the read routes `metadata` is the stored JSON TEXT, not an object - void (await client.organizations.get('o'))?.metadata?.plan; // @ts-expect-error on the write echo `metadata` is already decoded — it is not a string to parse void JSON.parse((await client.organizations.update('o', {})).metadata); + // @ts-expect-error [#18728] and on the READ routes too now — the producer decodes it, so there is nothing to parse + void JSON.parse((await client.organizations.get('o'))!.metadata); // @ts-expect-error updateMemberRole strips the user join; only the joined routes carry `user` void (await client.organizations.updateMemberRole('o', { memberId: 'm', role: 'r' })).user; } diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 125fa5e6a3d..36ddc338d1a 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -22,6 +22,7 @@ import { reattachInternalFieldsOnRead, type InternalFieldResolvingEngine, } from './internal-field-readback.js'; +import { decodeOrganizationMetadataOnRead } from './organization-metadata-decode.js'; /** * Mapping from better-auth model names to ObjectStack protocol object names. @@ -910,6 +911,15 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { result, bridged && select ? select.map(camelToSnake) : select, ); + // [#18728] Ruling C's producer half: `sys_organization.metadata` is a + // text column holding JSON, and better-auth decodes it on its two + // write echoes only. Decode it here so all four READ routes + // (`set-active`, `get-full-organization`, `delete`, `list` — every one + // of them reaches the row through this verb, `list` via the factory's + // fallback join) serve the object `OrganizationSchema` declares. + // ⛔ Deliberately NOT in `create` / `update`: see + // `organization-metadata-decode.ts`. + decodeOrganizationMetadataOnRead(objectName, result as Record); const norm = normaliseLegacyDates(model, result); return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, @@ -950,6 +960,9 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { await reattachInternalFieldsOnRead(internalFieldEngine, objectName, results); return results.map((r) => { + // [#18728] Same producer half as `findOne` above — an organization + // page serves the decoded object too. + decodeOrganizationMetadataOnRead(objectName, r as Record); const norm = normaliseLegacyDates(model, r as Record); return bridged ? remapKeys(norm, snakeToCamel) : norm; }) as T[]; diff --git a/packages/plugins/plugin-auth/src/organization-metadata-decode.test.ts b/packages/plugins/plugin-auth/src/organization-metadata-decode.test.ts new file mode 100644 index 00000000000..d0d081bbfe2 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-metadata-decode.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18728] Producer half of maintainer ruling C: `sys_organization.metadata` + * reaches the wire DECODED on every route that reads the row back. + * + * Two layers, and the second is the one that pins the wire rather than the + * helper: + * + * 1. the helper's own branches — unset, decodable, undecodable, not-a-record; + * 2. ⭐ the same behaviour observed THROUGH better-auth's real adapter factory + * with the organization plugin mounted, which is what the four read routes + * go through. `findOne` serves `set-active`, `delete` and + * `get-full-organization`; `findMany` serves the member page, and the + * organization inside `GET /organization/list` arrives through the + * factory's fallback join — itself another `findOne` on this model. + * + * ⛔ And the asymmetry, pinned in both directions: `create` / `update` must + * still hand better-auth the stored STRING, because the vendor's own + * organization adapter decodes those two echoes itself and discriminates on + * the value still being a string (`typeof organization.metadata === 'string'` + * on create, `parseJSON` on update). Decoding there would fold the create + * echo's `metadata` to `undefined` — a regression that reads as "unset". + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { IDataEngine } from '@objectstack/core'; +import { SystemObjectName } from '@objectstack/spec/system'; +import { organization } from 'better-auth/plugins/organization'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { decodeOrganizationMetadataOnRead } from './organization-metadata-decode.js'; +import { createObjectQLAdapterFactory } from './objectql-adapter.js'; +import { buildOrganizationPluginSchema } from './auth-schema-config.js'; + +describe('decodeOrganizationMetadataOnRead', () => { + it('decodes the stored JSON text into an object', () => { + const row: Record = { id: 'o', metadata: '{"plan":"pro"}' }; + decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, row); + expect(row.metadata).toEqual({ plan: 'pro' }); + }); + + it('OMITS the key for an unset column — absent, never null', () => { + for (const unset of [null, undefined, '']) { + const row: Record = { id: 'o', metadata: unset }; + decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, row); + expect('metadata' in row).toBe(false); + } + // `JSON.parse('null')` is a legal parse of an unset-looking value. + const stored: Record = { id: 'o', metadata: 'null' }; + decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, stored); + expect('metadata' in stored).toBe(false); + }); + + it('leaves undecodable text EXACTLY as it is — never invents, never throws', () => { + const row: Record = { id: 'o', metadata: 'not json at all' }; + expect(() => decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, row)).not.toThrow(); + // Passing the value through is what makes the consumer's spec parse refuse + // the body and name the field, instead of the read reporting "no metadata". + expect(row.metadata).toBe('not json at all'); + }); + + it('does not launder a JSON scalar or array into the declared record shape', () => { + for (const text of ['42', '"pro"', 'true', '[1,2]']) { + const row: Record = { id: 'o', metadata: text }; + decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, row); + expect(row.metadata).toBe(text); + } + }); + + it('is a no-op for an already-decoded value, a missing key, and every other object', () => { + const decoded: Record = { id: 'o', metadata: { plan: 'pro' } }; + decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, decoded); + expect(decoded.metadata).toEqual({ plan: 'pro' }); + + const noKey: Record = { id: 'o' }; + decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, noKey); + expect(noKey).toEqual({ id: 'o' }); + + // Another object's `metadata` column is not ours to reinterpret. + const other: Record = { id: 'x', metadata: '{"plan":"pro"}' }; + decodeOrganizationMetadataOnRead(SystemObjectName.USER, other); + expect(other.metadata).toBe('{"plan":"pro"}'); + + expect(() => decodeOrganizationMetadataOnRead(SystemObjectName.ORGANIZATION, null)).not.toThrow(); + }); +}); + +describe('⭐ the four read routes serve the decoded object, through the real adapter factory', () => { + const STORED_ROW = { + id: 'org_01HQ', + name: 'Acme', + slug: 'acme', + logo: null, + created_at: '2026-09-07T09:27:01.545Z', + updated_at: '2026-09-07T10:00:00.000Z', + metadata: '{"plan":"pro"}', + }; + + const makeAdapter = (row: Record | null = { ...STORED_ROW }) => { + const engine = { + insert: vi.fn().mockImplementation((_m: string, d: any) => Promise.resolve({ ...STORED_ROW, ...d })), + findOne: vi.fn().mockResolvedValue(row ? { ...row } : null), + find: vi.fn().mockResolvedValue(row ? [{ ...row }] : []), + count: vi.fn().mockResolvedValue(0), + update: vi.fn().mockImplementation((_m: string, d: any, options?: any) => { + // The real engine's three-way dispatch — a double looser than this is + // no double at all (`check:engine-double-contract`). + assertEngineUpdateDispatch(d, options); + return Promise.resolve({ ...STORED_ROW, ...d }); + }), + delete: vi.fn().mockResolvedValue(undefined), + } as unknown as IDataEngine; + // The plugin must be mounted: better-auth's factory validates the model + // against the merged schema before delegating, and `sys_organization` is + // only a known model once the organization plugin's schema is in. + const adapter: any = (createObjectQLAdapterFactory(engine) as any)({ + plugins: [organization({ schema: buildOrganizationPluginSchema() })], + } as any); + return { engine, adapter }; + }; + + it('findOne (set-active · delete · get-full-organization) answers metadata as an object', async () => { + const { engine, adapter } = makeAdapter(); + const found = await adapter.findOne({ + model: 'organization', + where: [{ field: 'id', value: 'org_01HQ', operator: 'eq', connector: 'AND' }], + }); + expect(engine.findOne).toHaveBeenCalledWith(SystemObjectName.ORGANIZATION, expect.anything()); + expect(found.metadata).toEqual({ plan: 'pro' }); + }); + + it('findMany (the list join) answers metadata as an object on every row', async () => { + const { adapter } = makeAdapter(); + const rows = await adapter.findMany({ model: 'organization', limit: 10 }); + expect(rows).toHaveLength(1); + expect(rows[0].metadata).toEqual({ plan: 'pro' }); + }); + + it('⚠️ the vendor transform still drops updated_at — which is why the spec declares updatedAt optional', async () => { + // Ruling C's fallback A, observed rather than recalled: the column IS in + // the stored row above, and better-auth's `transformOutput` walks its own + // declared fields only, so it never reaches the caller. Nothing in this + // repo can put it on this wire without declaring it to the vendor. + const { adapter } = makeAdapter(); + const found = await adapter.findOne({ + model: 'organization', + where: [{ field: 'id', value: 'org_01HQ', operator: 'eq', connector: 'AND' }], + }); + expect('updatedAt' in found).toBe(false); + expect('updated_at' in found).toBe(false); + }); + + it('omits metadata entirely when the stored column is null', async () => { + const { adapter } = makeAdapter({ ...STORED_ROW, metadata: null }); + const found = await adapter.findOne({ + model: 'organization', + where: [{ field: 'id', value: 'org_01HQ', operator: 'eq', connector: 'AND' }], + }); + expect(found.metadata).toBeUndefined(); + }); + + it('⛔ leaves the create echo as the stored STRING — the vendor decodes that one itself', async () => { + const { adapter } = makeAdapter(); + const created = await adapter.create({ + model: 'organization', + data: { name: 'Acme', slug: 'acme', metadata: '{"plan":"pro"}' }, + }); + // better-auth's `createOrganization` reads this back with + // `typeof organization.metadata === 'string' ? JSON.parse(...) : void 0`. + // Hand it an object and the echo's metadata becomes `undefined`. + expect(created.metadata).toBe('{"plan":"pro"}'); + }); + + it('⛔ leaves the update echo as the stored STRING — same reason (parseJSON)', async () => { + const { adapter } = makeAdapter(); + const updated = await adapter.update({ + model: 'organization', + where: [{ field: 'id', value: 'org_01HQ', operator: 'eq', connector: 'AND' }], + update: { metadata: '{"plan":"enterprise"}' }, + }); + expect(updated.metadata).toBe('{"plan":"enterprise"}'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/organization-metadata-decode.ts b/packages/plugins/plugin-auth/src/organization-metadata-decode.ts new file mode 100644 index 00000000000..6346e2ba97a --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-metadata-decode.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18728] `sys_organization.metadata` reaches the wire DECODED on every route + * that reads the row back — the producer half of the maintainer's ruling C + * (batch #158 item 4): the spec declares `OrganizationSchema.metadata` an + * object, so the producer emits an object rather than the consumer learning to + * tolerate text. + * + * ## What was wrong, measured + * + * The column is a text column holding JSON (`sys_organization.metadata` is + * `Field.textarea`, described "JSON-serialized organization metadata"), and + * better-auth declares its own `organization.metadata` as `type: "string"`. + * Its adapter factory's `transformOutput` decodes a string into JSON only for + * a field declared `type: "json"` AND only when the adapter sets + * `supportsJSON: false` — this adapter declares `supportsJSON: true`, so + * neither half applies and the stored text passes straight through. + * + * Only better-auth's two WRITE paths decode it, and they do it in the plugin's + * own organization adapter rather than in the transform: + * `createOrganization` (`JSON.parse`) and `updateOrganization` (`parseJSON`). + * Every READ path returns the row as the data adapter handed it over — + * `findOrganizationById` (which serves `POST /organization/set-active` and + * `POST /organization/delete`), `findFullOrganization` (`GET + * /organization/get-full-organization`) and `listOrganizations` (`GET + * /organization/list`, where the organization arrives through the factory's + * fallback join, itself another `findOne` on this model). + * + * So all four read routes served the stored TEXT while + * `@objectstack/spec/identity`'s `Organization` declared an object — a + * published schema that could not parse a served body. + * + * ## Why the seam is the data adapter's READ verbs, and only those + * + * Every one of the four read routes reaches the row through this adapter's + * `findOne` / `findMany`, so decoding there covers all four at once with no + * per-route code and nothing to keep in sync (Route & surface ownership §1). + * + * ⛔ It must NOT be applied to `create` / `update`. better-auth's own + * organization adapter decodes those two echoes itself, and it discriminates + * on the value still being a string: + * `metadata: organization.metadata && typeof organization.metadata === 'string' + * ? JSON.parse(organization.metadata) : void 0`. Handing it an already-decoded + * object would fold the create echo's `metadata` to `undefined` — the exact + * shape of a regression that reads as "unset" rather than as an error. + * `organization-metadata-decode.test.ts` pins both directions. + * + * ## Absent, not null + * + * The spec declares `metadata` `.optional()` — an object or the key absent, + * never `null`. SQL stores an unset column as `null`, so an unset value is + * DELETED here rather than passed on; a document store that never + * materialised the column is already absent and is left alone. + * + * ## A value we cannot decode is left exactly as it is + * + * ⛔ Never invent a shape for text that will not parse, and ⛔ never throw: + * either would turn one bad row into a read outage or into a silent "this + * organization has no metadata". The undecodable value is passed through + * untouched, which makes the spec parse at the consumer refuse the body and + * name the field — loud, located, and distinguishable from an unset column + * ("Absence must be loud"). The same applies to text that decodes to a JSON + * scalar or array: it is not the declared shape, so it is not laundered into + * one. + */ + +import { SystemObjectName } from '@objectstack/spec/system'; + +/** + * Is `value` a JSON object — the one shape `OrganizationSchema.metadata` + * declares (`z.record(z.string(), z.unknown())`)? + * + * Arrays are excluded deliberately: `typeof [] === 'object'` and an array + * parses out of JSON text, but it is not a record. + */ +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Decode `metadata` on an organization row on its way OUT of the data adapter. + * + * Mutates `row` in place — the adapter's read verbs hand their result straight + * to better-auth, so there is nothing to return and no copy to keep. + * + * A no-op for every other object, and a no-op for a row that carries no + * `metadata` key at all. + * + * @param objectName The resolved ObjectStack object name the row came from. + * @param row The row as the data engine returned it. + */ +export function decodeOrganizationMetadataOnRead( + objectName: string, + row: Record | null | undefined, +): void { + if (objectName !== SystemObjectName.ORGANIZATION) return; + if (!row || typeof row !== 'object') return; + if (!('metadata' in row)) return; + + const raw = row.metadata; + + // Unset: SQL `null`, an empty text column, or an explicit `undefined`. + // The spec says absent, so make it absent. + if (raw === null || raw === undefined || raw === '') { + delete row.metadata; + return; + } + + // Already the declared shape — a document store, or a caller that decoded + // upstream. Nothing to do. + if (isJsonObject(raw)) return; + + // Anything that is not text is not ours to reinterpret. + if (typeof raw !== 'string') return; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Undecodable text: pass it through untouched so the consumer's spec parse + // refuses the body and names the field. See the docblock. + return; + } + + // `JSON.parse('null')` is a legal parse of an unset-looking value. + if (parsed === null) { + delete row.metadata; + return; + } + + // A scalar or an array is not the declared record shape — leave the stored + // text in place rather than laundering it into one. + if (!isJsonObject(parsed)) return; + + row.metadata = parsed; +} diff --git a/packages/spec/api-surface-declarations/identity.txt b/packages/spec/api-surface-declarations/identity.txt index b1d83928082..7e38938398a 100644 --- a/packages/spec/api-surface-declarations/identity.txt +++ b/packages/spec/api-surface-declarations/identity.txt @@ -256,7 +256,7 @@ declare const InvitationSchema: z.ZodObject<{ expiresAt: z.ZodString; inviterId: z.ZodString; createdAt: z.ZodString; - updatedAt: z.ZodString; + updatedAt: z.ZodOptional; }, z.core.$strip>; // ── InvitationStatus (const) ── @@ -293,7 +293,7 @@ declare const MemberSchema: z.ZodObject<{ userId: z.ZodString; role: z.ZodString; createdAt: z.ZodString; - updatedAt: z.ZodString; + updatedAt: z.ZodOptional; }, z.core.$strip>; // ── ORGANIZATION_ADMIN (const) ── @@ -316,7 +316,7 @@ declare const OrganizationSchema: z.ZodObject<{ logo: z.ZodOptional>; metadata: z.ZodOptional>; createdAt: z.ZodString; - updatedAt: z.ZodString; + updatedAt: z.ZodOptional; }, z.core.$strip>; // ── Position (type) ── diff --git a/packages/spec/src/identity/organization.test.ts b/packages/spec/src/identity/organization.test.ts index adad18090a8..5608b5a56f0 100644 --- a/packages/spec/src/identity/organization.test.ts +++ b/packages/spec/src/identity/organization.test.ts @@ -177,33 +177,86 @@ describe('[#18509] OrganizationSchema.logo accept set', () => { }); /** - * ⛔ Scope fence, deliberately pinned as CURRENT behaviour rather than fixed: - * the same measurement found `metadata` served present-and-null and - * `/auth/organization/create` omitting the required `updatedAt`. Those are - * separate defects, filed separately — #18509 asked about `logo`. This pin - * exists so that the fence is visible and so that a later fix for either one - * has to come here and say so. + * ⭐ [#18728] The scope fence this block used to pin is DOWN, and #18509's own + * pin asked whoever took it down to come here and say so. Saying so: + * + * The fence pinned two refusals as current behaviour — `metadata` served + * present-and-null, and `updatedAt` absent while declared required — and + * maintainer ruling C (batch #158 item 4) closed each at a different end: + * + * - `updatedAt` is now `.optional()` on this schema, which is the ruling's + * own fallback A: the wire is better-auth's serializer and its documented + * organization model declares no such field, so the schema aligns to the + * documented wire rather than the producer inventing a value. + * - `metadata` was fixed at the PRODUCER, not here. plugin-auth's data + * adapter decodes `sys_organization.metadata` from its stored JSON text on + * its read verbs and OMITS the key when the column is unset, so the served + * body now carries an object or nothing — never `null`. + * + * So a served body parses whole, and the `null` this schema still refuses is + * a shape nothing sends any more. Both halves are pinned below, because + * "accepts the served body" and "stopped checking" are otherwise the same + * green. */ - it('does NOT (yet) accept a served body whole — metadata/updatedAt are separate cards', () => { + it('[#18728] accepts a served read-route body WHOLE — updatedAt absent, metadata decoded', () => { const served = { id: 'org_123', name: 'Acme Corporation', slug: 'acme-corp', logo: null, - metadata: null, + metadata: { plan: 'pro' }, createdAt: '2026-01-01T00:00:00.000Z', - // `updatedAt` absent, exactly as `/auth/organization/create` serves it + // `updatedAt` absent, exactly as every route of this family serves it }; const result = OrganizationSchema.safeParse(served); - expect(result.success).toBe(false); - if (!result.success) { - // `logo` is gone from this list — that is this card's contribution. - expect(result.error.issues.map((i) => i.path.join('.')).sort()).toEqual([ - 'metadata', - 'updatedAt', - ]); + expect(result.error?.issues.map((i) => i.path.join('.')) ?? []).toEqual([]); + expect(result.success).toBe(true); + }); + + it('[#18728] accepts the same body with metadata OMITTED — an unset column', () => { + const { metadata: _unset, ...withoutMetadata } = { + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + logo: null, + metadata: { plan: 'pro' }, + createdAt: '2026-01-01T00:00:00.000Z', + }; + const result = OrganizationSchema.safeParse(withoutMetadata); + expect(result.success).toBe(true); + }); + + it('⭐ [#18728] still REFUSES metadata as null or as the stored JSON text', () => { + // The producer omits an unset column and decodes a set one, so neither of + // these is a shape any route sends. They must stay refused: if either ever + // parses, the producer has regressed or this schema has been loosened to + // hide the regression. + for (const wrong of [null, '{"plan":"pro"}']) { + const result = OrganizationSchema.safeParse({ + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + logo: null, + metadata: wrong, + createdAt: '2026-01-01T00:00:00.000Z', + }); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.path.join('.'))).toEqual(['metadata']); } }); + + it('⭐ [#18728] `.optional()` widened updatedAt by ABSENCE only — a present value is still a datetime', () => { + const result = OrganizationSchema.safeParse({ + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + logo: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: 'whenever', + }); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.path.join('.'))).toEqual(['updatedAt']); + }); }); describe('MemberSchema', () => { diff --git a/packages/spec/src/identity/organization.zod.ts b/packages/spec/src/identity/organization.zod.ts index 45036769f03..da528144b87 100644 --- a/packages/spec/src/identity/organization.zod.ts +++ b/packages/spec/src/identity/organization.zod.ts @@ -82,11 +82,38 @@ export const OrganizationSchema = lazySchema(() => z.object({ * Organization creation timestamp */ createdAt: z.string().datetime().describe('Organization creation timestamp'), - + /** - * Last update timestamp + * Last update timestamp — OPTIONAL, because the documented wire carries none. + * + * [#18728] Maintainer ruling C (batch #158 item 4) fixes the producer rather + * than the consumer, and makes this one key conditional on a measurement: + * 「**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)」. Both halves measured against the + * installed better-auth 1.7.3, so fallback A applies: + * + * - **The serializer is the vendor's.** The `organization/*` routes are + * better-auth's own endpoints, mounted through plugin-auth's single + * catch-all (`AUTH_ROUTE_LEDGER` books every one of them + * `source: 'better-auth'`); each read route answers `ctx.json()` with no ObjectStack post-processing. + * - **The documented shape has no `updatedAt`.** better-auth's organization + * plugin declares `organization` as `name` / `slug` / `logo` / + * `createdAt` / `metadata` and nothing else, and its adapter factory's + * `transformOutput` iterates the declared fields ONLY — an undeclared + * column is dropped before any route sees it. Control, same file and same + * grep: the vendor's `team` and `organizationRole` models DO declare + * `updatedAt`, so the absence here is a reading rather than a miss. + * + * `sys_organization.updated_at` does exist as a column — the vendor's + * serializer simply never emits it. Declaring absence is therefore the + * honest shape (Prime Directive #10: never advertise what the runtime does + * not deliver), and `.optional()` NOT `.nullish()`: the key is absent on the + * wire, never `null`. */ - updatedAt: z.string().datetime().describe('Last update timestamp'), + updatedAt: z.string().datetime().optional().describe('Last update timestamp (absent on the better-auth organization wire)'), })); export type Organization = z.input; @@ -130,11 +157,27 @@ export const MemberSchema = lazySchema(() => z.object({ * Member creation timestamp */ createdAt: z.string().datetime().describe('Member creation timestamp'), - + /** - * Last update timestamp + * Last update timestamp — OPTIONAL, and here there is no column at all. + * + * [#18728] Fallback A of maintainer ruling C, on two independent measurements + * (see {@link OrganizationSchema}'s `updatedAt` for the ruling's text and for + * the vendor-serializer half, which holds identically for this model): + * + * 1. better-auth's `member` model declares `organizationId` / `userId` / + * `role` / `createdAt` — no `updatedAt` — and its `transformOutput` + * emits declared fields only. + * 2. ⭐ `sys_member` provisions no `updated_at` COLUMN either. It declares + * `id` / `created_at` / `organization_id` / `user_id` / `role`, and it is + * `managedBy: 'better-auth'`, which is the one disposition under which + * `resolveInjectedSystemColumns` injects nothing — the audit family + * included. So unlike the organization row there is no stored value to + * put on the wire in the first place. + * + * `.optional()` NOT `.nullish()`: absent, never `null`. */ - updatedAt: z.string().datetime().describe('Last update timestamp'), + updatedAt: z.string().datetime().optional().describe('Last update timestamp (no such column on sys_member; absent on the wire)'), })); export type Member = z.input; @@ -208,11 +251,20 @@ export const InvitationSchema = lazySchema(() => z.object({ * Invitation creation timestamp */ createdAt: z.string().datetime().describe('Invitation creation timestamp'), - + /** - * Last update timestamp + * Last update timestamp — OPTIONAL, and here there is no column at all. + * + * [#18728] Fallback A of maintainer ruling C, same two measurements as + * {@link MemberSchema}'s `updatedAt`: better-auth's `invitation` model + * declares `organizationId` / `email` / `role` / `teamId` / `status` / + * `expiresAt` / `createdAt` / `inviterId` and no `updatedAt`, and + * `sys_invitation` — `managedBy: 'better-auth'`, so nothing is injected — + * provisions no `updated_at` column. + * + * `.optional()` NOT `.nullish()`: absent, never `null`. */ - updatedAt: z.string().datetime().describe('Last update timestamp'), + updatedAt: z.string().datetime().optional().describe('Last update timestamp (no such column on sys_invitation; absent on the wire)'), })); export type Invitation = z.input; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 987921a5a69..12320bb28ec 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2606,6 +2606,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/organization-metadata-decode.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts", "verb": "delete",