From 983d4a4d2ba021dd94d1548b39e275bc2ded5b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:39:33 +0000 Subject: [PATCH 1/3] feat(platform-objects): admit update on sys_organization's data door, column-gated by the ADR-0092 D2 whitelist (#15873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling (a), decision batch #64, 2026-09-07 — the data door admits `update` and the identity write guard's per-object whitelist does the column gating. `enable.apiMethods` becomes ['get', 'list', 'update'] with the `userActions.edit` affordance the registry reconciler requires; better-auth's own columns (name / slug / logo / metadata) are readonly per ADR-0092 D4 and stay refused/stripped by the guard. create / delete still 405; no bulk (recorded in SINGLE_RECORD_WRITE_ONLY). Pins: declaration (platform-objects), column gate + D4 partition derived from the shipped whitelist (plugin-auth), and the real door with the 405 → 403 transition (dogfood). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .changeset/organization-update-door.md | 13 ++ .../sys-organization-update-door.test.ts | 91 ++++++++ .../src/identity/sys-organization.object.ts | 67 +++++- .../src/sys-organization-update-door.test.ts | 174 ++++++++++++++++ .../organization-update-door.dogfood.test.ts | 196 ++++++++++++++++++ .../api-methods-batch-conformance.test.ts | 25 +++ 6 files changed, 561 insertions(+), 5 deletions(-) create mode 100644 .changeset/organization-update-door.md create mode 100644 packages/platform-objects/src/identity/sys-organization-update-door.test.ts create mode 100644 packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts create mode 100644 packages/qa/dogfood/test/organization-update-door.dogfood.test.ts diff --git a/.changeset/organization-update-door.md b/.changeset/organization-update-door.md new file mode 100644 index 0000000000..c26f712b2c --- /dev/null +++ b/.changeset/organization-update-door.md @@ -0,0 +1,13 @@ +--- +"@objectstack/platform-objects": minor +--- + +`sys_organization` admits generic `update` on the data door, column-gated by the ADR-0092 D2 identity write guard (#15873 — maintainer ruling 2026-09-07, decision batch #64, option (a), verbatim 「同意」). + +The organization table carries four platform-owned columns better-auth never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` and `sort_order` (ADR-0105 D6), `timezone` (#14238). plugin-auth declares them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, the guard's per-object update whitelist), while the object's `enable.apiMethods: ['get', 'list']` answered every `PATCH /api/v1/data/sys_organization/:id` with 405 `OBJECT_API_METHOD_NOT_ALLOWED` before the engine — and the guard — was reached. Declared editable, reachable from no product surface: the columns could be set only by a system-context caller. The ruling answers the card's question — yes, an administrator sets these columns through the product — and refuses the alternative of declaring them system-writable only. + +What widens (Clause ②): the accept set of the published door. `enable.apiMethods` becomes `['get', 'list', 'update']`, and `userActions: { edit: true }` declares the affordance ADR-0103 D3's `reconcileManagedApiMethods` requires before it lets a `managedBy` object keep a write verb at registration (without it the verb is stripped with a warning and the door keeps answering 405 — the second silent gate #7727 measured on `sys_api_key`). `update` alone: `create` / `delete` still answer 405, and `bulk` is not granted (recorded in `SINGLE_RECORD_WRITE_ONLY`). + +What does not widen: the column set. The guard clamps every user-context update on this table to the whitelist. A PATCH of a better-auth column sent alone (`name`, `slug`, `logo`, `metadata`) is now refused by the guard's own verdict — 403 `PERMISSION_DENIED` — instead of the method gate's 405; sent beside a whitelisted column it is stripped and the whitelisted column lands. better-auth's own columns keep changing through better-auth's `organization/update` (the `update_organization` row action, unchanged). Per ADR-0092 D4's form-rendering constraint the four better-auth columns are now `readonly: true` on the object, so a standard edit form offers exactly what the guard admits; the engine's static-readonly strip exempts system-context writers, so better-auth's adapter is unaffected. + +Not breaking: no key, export or accepted value is removed; every request that succeeded before succeeds unchanged, and the 405 → 403 change applies only to requests that were refused before and are refused still. diff --git a/packages/platform-objects/src/identity/sys-organization-update-door.test.ts b/packages/platform-objects/src/identity/sys-organization-update-door.test.ts new file mode 100644 index 0000000000..b7087d645b --- /dev/null +++ b/packages/platform-objects/src/identity/sys-organization-update-door.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15873 — `sys_organization`'s data door admits `update`, and nothing else on + * the declaration moved (maintainer ruling 2026-09-07, decision batch #64, + * option (a), verbatim 「同意」). + * + * The card: four platform-owned columns (`require_mfa`, + * `parent_organization_id`, `sort_order`, `timezone`) were declared generically + * editable in plugin-auth's `MANAGED_EXTENSION_EDITABLE_FIELDS` — the ADR-0092 + * D2 identity write guard's per-object update whitelist — while this object's + * `enable.apiMethods: ['get', 'list']` answered 405 to every PATCH before the + * engine, and the guard, were ever reached. Declared editable, reachable from + * nowhere. The ruling widens the METHOD gate and leaves the COLUMN gate to the + * whitelist that already exists for exactly this. + * + * This file pins the DECLARATION, from source. Three things about it are each + * a way the widening could silently fail to be what was ruled: + * + * 1. the verb set is `update` and only `update` — `create` / `delete` stay + * 405 (organizations are minted and destroyed through better-auth's own + * endpoints), and `bulk` is not granted (the ruling widened one verb; the + * single-record-only choice is recorded in `SINGLE_RECORD_WRITE_ONLY`, + * `@objectstack/spec`'s `api-methods-batch-conformance.test.ts`); + * 2. the verb SURVIVES registration. A `managedBy` object runs through + * `reconcileManagedApiMethods` (objectql registry, ADR-0092 / ADR-0103 D3), + * which strips any write verb the resolved affordances do not grant and + * only warns — so `update` in `apiMethods` with no `userActions.edit` is a + * declaration that serves 405 anyway (the second silent gate #7727 measured + * on `sys_api_key`). The predicate the registry calls is asked here + * directly, with a positive control proving it can still refuse; + * 3. better-auth's own door for its own columns is untouched: the row actions + * still target `organization/update` with `name` / `slug` / `logo`. + * + * The RUNTIME half — that the real door answers 200 for a whitelisted column + * and 403 `PERMISSION_DENIED` (not 405, not 200) for a better-auth column — is + * `organization-update-door.dogfood.test.ts` in `packages/qa/dogfood`; the + * form-facing D4 partition (whitelisted columns writable, everything else + * `readonly`) is `sys-organization-update-door.test.ts` in plugin-auth, which + * derives it from the shipped whitelist rather than re-spelling it. + */ + +import { describe, it, expect } from 'vitest'; +import { checkManagedApiMethodAffordances } from '@objectstack/spec/data'; +import { SysOrganization } from './sys-organization.object'; + +describe('#15873 — sys_organization.enable.apiMethods admits `update`, and only `update`', () => { + it('declares exactly get / list / update — no create, no delete, no bulk', () => { + expect(SysOrganization.enable?.apiMethods).toEqual(['get', 'list', 'update']); + }); + + it('opens the generic EDIT affordance alone, so the verb survives `reconcileManagedApiMethods`', () => { + // `managedBy: 'better-auth'` defaults every write affordance to off; the + // one override is `edit`. `create` / `delete` / `import` stay bucket-default. + expect(SysOrganization.managedBy).toBe('better-auth'); + expect(SysOrganization.userActions).toEqual({ edit: true }); + }); + + it('the registry predicate keeps every declared verb — `update` is not stripped at registration', () => { + // The SAME predicate objectql's registry calls before it strips a verb + // (`checkManagedApiMethodAffordances` → `reconcileManagedApiMethods`). An + // empty conflict list is "the declaration and the runtime agree". + expect(checkManagedApiMethodAffordances(SysOrganization)).toEqual([]); + }); + + it('positive control: without `userActions.edit` the same predicate names `update` as stripped', () => { + // A zero-conflict answer above is only a reading if the predicate can + // still refuse this object. Remove the affordance and it must. + const { userActions: _dropped, ...withoutAffordance } = SysOrganization as any; + const conflicts = checkManagedApiMethodAffordances(withoutAffordance); + expect(conflicts.map((c) => c.verb)).toEqual(['update']); + }); +}); + +describe('#15873 — better-auth keeps its own door for its own columns', () => { + const action = (name: string) => (SysOrganization.actions ?? []).find((a: any) => a?.name === name) as any; + + it('`update_organization` still targets better-auth `organization/update` with name / slug / logo', () => { + const update = action('update_organization'); + expect(update, 'update_organization must stay declared').toBeTruthy(); + expect(update.target).toBe('/api/v1/auth/organization/update'); + expect(update.bodyShape).toEqual({ wrap: 'data' }); + expect((update.params ?? []).map((p: any) => p.field)).toEqual(['name', 'slug', 'logo']); + }); + + it('`create_organization` still targets better-auth `organization/create` — the data door does not create', () => { + const create = action('create_organization'); + expect(create, 'create_organization must stay declared').toBeTruthy(); + expect(create.target).toBe('/api/v1/auth/organization/create'); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 2f216ddbe1..413641ce65 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -17,6 +17,27 @@ export const SysOrganization = ObjectSchema.create({ icon: 'building-2', isSystem: true, managedBy: 'better-auth', + // [#15873 — maintainer ruling 2026-09-07, option (a), verbatim 「同意」] + // Declares the generic EDIT affordance so `enable.apiMethods` below can + // keep `update`: a `managedBy` object runs through + // `reconcileManagedApiMethods` (objectql registry, ADR-0092 / ADR-0103 D3), + // which strips any write verb the resolved affordances do not grant and only + // warns. Without this line the declaration and the runtime disagree + // silently, one layer deeper than the method gate — the second silent gate + // #7727 measured on `sys_api_key`. `create` / `delete` stay bucket-default + // (off): organizations are created and destroyed through better-auth's own + // endpoints (the row actions below). + // + // Safe to open only because the enforcement it fronts already exists (ADR-0092 + // D4's sequencing rule — the affordance never ships ahead of the guard): the + // D2 identity write guard clamps every user-context update on this table to + // the registered column whitelist, `MANAGED_EXTENSION_EDITABLE_FIELDS + // .sys_organization` in plugin-auth — the platform-owned extension columns + // (`require_mfa`, `parent_organization_id`, `sort_order`, `timezone`) and + // nothing else. Per D4's form-rendering constraint, every column outside that + // whitelist is marked `readonly` below, so the edit form cannot offer a write + // the server will refuse or strip. + userActions: { edit: true }, // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema, // but may add overlay row-level config. Use `no-overlay` if you need to // forbid sys_metadata overlays entirely. @@ -31,8 +52,12 @@ export const SysOrganization = ObjectSchema.create({ titleFormat: '{name}', highlightFields: ['name', 'slug'], - // Custom actions — generic CRUD is suppressed (better-auth-managed), - // but admins still need to create new orgs from the Setup app. + // Custom actions — generic create / delete are suppressed (better-auth- + // managed), and better-auth's own columns (`name`, `slug`, `logo`) are + // edited ONLY through `update_organization` below. The generic `update` the + // data door admits since #15873 reaches the platform-owned extension columns + // alone (see `userActions` above and `enable.apiMethods` at the bottom); + // admins still need to create new orgs from the Setup app. actions: [ { name: 'create_organization', @@ -176,9 +201,22 @@ export const SysOrganization = ObjectSchema.create({ fields: { // ── Identity ───────────────────────────────────────────────── + // ADR-0092 D4 — with the generic edit affordance open (#15873), every + // better-auth-owned column is `readonly` so the standard edit form renders + // it non-editable. This is UX only: the server boundary is plugin-auth's + // identity write guard (ADR-0092 D2), which strips these from a + // user-context update regardless — and a mixed payload that carries one + // beside a whitelisted column lands the whitelisted column and drops this + // one, so a form that offered it would report success on an edit that + // never happened. `name` / `slug` / `logo` change through better-auth's + // `organization/update` (the `update_organization` row action, whose + // params are declared on the action, not read off these flags). The + // engine's own static-`readonly` strip exempts system-context writers, so + // better-auth's adapter (which stamps `isSystem`) still writes them. name: Field.text({ label: 'Name', required: true, + readonly: true, searchable: true, maxLength: 255, group: 'Identity', @@ -187,6 +225,7 @@ export const SysOrganization = ObjectSchema.create({ slug: Field.text({ label: 'Slug', required: false, + readonly: true, searchable: true, maxLength: 255, description: 'URL-friendly identifier', @@ -197,13 +236,17 @@ export const SysOrganization = ObjectSchema.create({ logo: Field.url({ label: 'Logo', required: false, + readonly: true, group: 'Branding', }), // ── Configuration ──────────────────────────────────────────── + // better-auth's own `metadata` column (its organization schema declares + // it); not an extension field, so readonly under D4 like the three above. metadata: Field.textarea({ label: 'Metadata', required: false, + readonly: true, description: 'JSON-serialized organization metadata', group: 'Configuration', }), @@ -333,8 +376,22 @@ export const SysOrganization = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - // #1591 — reads only: writes are refused by the identity write guard - // (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403. - apiMethods: ['get', 'list'], + // #1591 closed the generic writes on this table — "refused by the identity + // write guard (ADR-0092 D2) and owned by better-auth; HTTP answers 405 + // before the 403" — and that reasoning still holds for better-auth's own + // columns, which the D2 whitelist keeps refusing on this path. It never + // covered the platform-owned extension columns, which better-auth neither + // reads nor writes and which `MANAGED_EXTENSION_EDITABLE_FIELDS` had + // declared editable through the ordinary path while this gate 405'd every + // PATCH before the engine was reached (#15873). Ruled 2026-09-07, option + // (a): the data door admits `update`, and the D2 whitelist does the column + // gating — a user-context PATCH of `name` is now refused by the guard's own + // verdict (403 PERMISSION_DENIED) instead of the method gate's 405, and + // `create` / `delete` stay 405. `update` alone, no `bulk`: the ruling + // widened one verb, and the single-record-only choice is on the record in + // `SINGLE_RECORD_WRITE_ONLY` (`api-methods-batch-conformance.test.ts`, + // @objectstack/spec), whose stale-entry check fails if `bulk` is added + // here without retiring it. + apiMethods: ['get', 'list', 'update'], }, }); diff --git a/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts b/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts new file mode 100644 index 0000000000..4b1a84e626 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15873 — the COLUMN gate on `sys_organization`, held while the METHOD gate + * widens (maintainer ruling 2026-09-07, decision batch #64, option (a), + * verbatim 「同意」: admit `update` in `sys_organization.apiMethods` and let the + * ADR-0092 D2 whitelist do the column gating). + * + * platform-objects now declares `enable.apiMethods: ['get', 'list', 'update']` + * plus `userActions: { edit: true }` on this table (pinned from source in + * `sys-organization-update-door.test.ts` there). The whole safety of that + * widening rests on this package: the identity write guard's per-object update + * whitelist — `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, registered + * at `kernel:ready` by `auth-plugin.ts` — must keep admitting exactly the four + * platform-owned extension columns and keep stripping everything else, + * better-auth's own `name` / `slug` / `logo` / `metadata` included. + * + * Two things are held here, both derived from the SHIPPED whitelist rather + * than re-spelled, because plugin-auth is the one package that imports both + * the object (via `@objectstack/platform-objects`) and the guard: + * + * 1. the guard's verdict per payload shape — a better-auth column alone is + * REFUSED (403 `PERMISSION_DENIED`, the ADR-0112 envelope the REST door + * relays), a whitelisted column passes un-stripped, and a mixed payload + * lands the whitelisted column and drops the rest (the D2 strip). The + * stripping direction is the one that matters most: it is what makes the + * opening column-scoped in practice rather than in a comment; + * 2. ADR-0092 D4's form-rendering constraint — with `userActions.edit` open, + * every column outside the whitelist must be `readonly` so the standard + * edit form cannot offer a write the server will refuse or strip, and no + * whitelisted column may be `readonly` (a column the form will not offer + * and the guard would admit is the declared-but-unreachable shape this + * card was filed for, one layer up). The partition is derived from the + * whitelist constant, so widening the whitelist without flipping the + * flag — or the reverse — fails here. + * + * The real door (`PATCH /api/v1/data/sys_organization/:id` under a signed-in + * user, method gate and guard composed) is `organization-update-door.dogfood + * .test.ts` in `packages/qa/dogfood`. + */ + +import { describe, it, expect } from 'vitest'; +import { SysOrganization } from '@objectstack/platform-objects/identity'; +import { + registerIdentityWriteGuard, + registerManagedUpdateWhitelist, +} from './identity-write-guard.js'; +import { managedExtensionEditableFields } from './managed-extension-fields.js'; + +/** The four columns the ruling names, in the whitelist's own order. */ +const RULED_COLUMNS = ['require_mfa', 'parent_organization_id', 'sort_order', 'timezone']; + +/** better-auth's own organization columns on this object — protocol fields. */ +const BETTER_AUTH_COLUMNS = ['name', 'slug', 'logo', 'metadata']; + +/** Engine-owned lifecycle columns: readonly by their own declaration, never in play. */ +const SYSTEM_COLUMNS = ['id', 'created_at', 'updated_at']; + +/** Fake engine capturing hook registrations (same shape the real engine builds). */ +function makeEngine(object: string, managedBy: string) { + const handlers: Record Promise>> = {}; + return { + handlers, + getSchema: () => ({ name: object, managedBy }), + registerHook: (event: string, handler: (ctx: any) => Promise) => { + (handlers[event] ??= []).push(handler); + }, + }; +} + +const USER_SESSION = { userId: 'usr_1', positions: [] }; + +/** Run the guard's `beforeUpdate` over `data` as a user-context write; returns the thrown error, or null. */ +async function guardedUpdate(data: Record): Promise { + const engine = makeEngine('sys_organization', 'better-auth'); + // The whitelist the plugin registers at `kernel:ready` is this map's row — + // registered here from the SAME constant, so the pin reads what ships. + registerManagedUpdateWhitelist('sys_organization', managedExtensionEditableFields('sys_organization')); + registerIdentityWriteGuard(engine as any, { packageId: 'test.sys-organization-update-door' }); + try { + await engine.handlers.beforeUpdate[0]({ + object: 'sys_organization', + session: USER_SESSION, + input: { id: 'org_1', data }, + }); + return null; + } catch (e) { + return e; + } +} + +describe('#15873 — the whitelist is the four ruled columns, every one a real column on the object', () => { + it('MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization is exactly the ruled set', () => { + expect([...managedExtensionEditableFields('sys_organization')].sort()).toEqual([...RULED_COLUMNS].sort()); + }); + + it('every whitelisted name is a declared field, and every better-auth column named here is too', () => { + const fields = Object.keys(SysOrganization.fields as Record); + for (const name of [...RULED_COLUMNS, ...BETTER_AUTH_COLUMNS, ...SYSTEM_COLUMNS]) { + expect(fields, `${name} is declared on sys_organization`).toContain(name); + } + // And the three lists above partition the object: a column this file does + // not classify is a column the D4 assertion below would judge blind. + expect([...fields].sort()).toEqual([...RULED_COLUMNS, ...BETTER_AUTH_COLUMNS, ...SYSTEM_COLUMNS].sort()); + }); +}); + +describe('#15873 — the guard holds the column gate on a user-context update', () => { + it.each(BETTER_AUTH_COLUMNS)('%s alone is REFUSED — 403 PERMISSION_DENIED, never silently ignored', async (column) => { + const data: Record = { id: 'org_1', [column]: 'hijacked' }; + const err = await guardedUpdate(data); + expect(err, `${column} must be refused`).toBeTruthy(); + // ADR-0112 envelope: code AND status. A bare `toThrow()` stays green + // against an implementation that throws a naked `Error`. + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.status).toBe(403); + }); + + it.each(RULED_COLUMNS)('%s alone passes the guard un-stripped', async (column) => { + const value = column === 'require_mfa' ? true : column === 'sort_order' ? 7 : column === 'timezone' ? 'Asia/Shanghai' : 'org_parent'; + const data: Record = { id: 'org_1', [column]: value }; + expect(await guardedUpdate(data)).toBeNull(); + expect(data, `the guard must not strip ${column}`).toEqual({ id: 'org_1', [column]: value }); + }); + + it('a mixed payload lands the whitelisted column and strips every better-auth column beside it', async () => { + // The whitelist STRIPS non-listed keys rather than rejecting the whole + // payload, so this is the shape that decides whether the opening is + // column-scoped in practice: `sort_order` must survive, and `name` / + // `slug` / `logo` / `metadata` must not ride along with it. + const data: Record = { + id: 'org_1', + sort_order: 9, + name: 'Hijacked', + slug: 'hijacked', + logo: 'https://example.invalid/logo.png', + metadata: '{"hijacked":true}', + }; + expect(await guardedUpdate(data)).toBeNull(); + expect(data).toEqual({ id: 'org_1', sort_order: 9 }); + }); + + it('the lifecycle stamps the write path injects pass through, but do not count as an editable field', async () => { + // The REST data routes stamp `updated_at` / `updated_by` on every update; + // a `name`-only PATCH arrives at the guard as {name, updated_at, + // updated_by} and must still be refused — otherwise it would degrade into + // a timestamp touch that reports success. + const err = await guardedUpdate({ id: 'org_1', name: 'Hijacked', updated_at: '2026-09-07T00:00:00Z', updated_by: 'usr_1' }); + expect(err?.code).toBe('PERMISSION_DENIED'); + expect(err?.status).toBe(403); + }); +}); + +describe('#15873 — ADR-0092 D4: the edit form offers exactly what the guard admits', () => { + const fields = SysOrganization.fields as Record; + + it.each(RULED_COLUMNS)('%s is writable in the form (not readonly) — the guard admits it', (column) => { + expect(fields[column]?.readonly, `${column} must not be readonly`).not.toBe(true); + }); + + it.each(BETTER_AUTH_COLUMNS)('%s is readonly in the form — the guard would strip or refuse it', (column) => { + expect(fields[column]?.readonly, `${column} must be readonly`).toBe(true); + }); + + it('the partition is total: every non-system column is either whitelisted or readonly, never both, never neither', () => { + const whitelist = managedExtensionEditableFields('sys_organization'); + for (const [name, def] of Object.entries(fields)) { + if (SYSTEM_COLUMNS.includes(name)) continue; + const editable = whitelist.has(name); + const readonly = def?.readonly === true; + expect(editable !== readonly, `${name}: whitelisted=${editable} readonly=${readonly}`).toBe(true); + } + }); +}); diff --git a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts new file mode 100644 index 0000000000..7373789b0d --- /dev/null +++ b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15873 — the platform-owned columns of `sys_organization` through the door + * the product actually uses. + * + * `sys_organization` carries four columns ObjectStack owns and better-auth + * never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` + * / `sort_order` (ADR-0105 D6) and `timezone` (#14238). plugin-auth declares + * them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS`, the ADR-0092 + * D2 identity write guard's per-object update whitelist), while the object set + * `enable.apiMethods = ['get', 'list']` — so every PATCH was refused at the + * ADR-0049 method gate with a 405 before the engine, and the guard, ran. The + * card measured the consequence: the four columns were settable only by a + * system-context caller (a plugin, a flow, a seed, SQL) and by no product + * surface at all. + * + * Maintainer ruling 2026-09-07 (decision batch #64, option (a), verbatim + * 「同意」): administrators set these columns through the product; the data + * door admits `update` and the D2 whitelist does the column gating. + * + * The fix is narrow in BOTH directions and this file pins both halves on the + * real door, the way #7727's `api-key-revoke-lifecycle.dogfood.test.ts` did + * for `sys_api_key`: + * + * - the METHOD opens (`apiMethods` gains `update`, backed by the + * `userActions.edit` affordance ADR-0103's `reconcileManagedApiMethods` + * requires before it lets a `managedBy` object keep a write verb), so a + * PATCH of a platform-owned column reaches the pipeline and lands; + * - the COLUMNS do not. The guard still fail-closed clamps user-context writes + * to the whitelist: better-auth's own `name` / `slug` / `logo` / `metadata` + * are refused when sent alone and stripped when smuggled beside a legal + * column, and `create` / `delete` stay 405. + * + * ⭐ The TRANSITION is pinned, not only the after-state. Before this change the + * `name` PATCH below answered 405 `OBJECT_API_METHOD_NOT_ALLOWED`; after it, + * the column guard's own verdict, 403 `PERMISSION_DENIED`. The assertion names + * both, so a pin that could not tell the two refusals apart — or a later change + * that turned either into a 200 or a 500 — does not pass here. + * + * Refusal cases assert `code` AND `status` (ADR-0112): a status-only assertion + * stays green against an implementation that answers the wrong refusal. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +describe('#15873: sys_organization platform-owned columns through PATCH /data/sys_organization/{id}', () => { + let stack: VerifyStack; + let token: string; + let orgId: string; + + /** The organization row as the door serves it back. */ + const readOrg = async (id: string): Promise> => { + const res = await stack.apiAs(token, 'GET', `/data/sys_organization/${id}`); + expect(res.status).toBe(200); + const body: any = await res.json(); + return (body.record ?? body.data ?? {}) as Record; + }; + + const patchOrg = (id: string, body: unknown) => stack.apiAs(token, 'PATCH', `/data/sys_organization/${id}`, body); + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + token = await stack.signIn(); + // The seeded admin is bound to the deployment's default organization at + // sign-up (plugin-auth's membership reconciler); read it back through the + // same door rather than assuming its id. + const list = await stack.apiAs(token, 'GET', '/data/sys_organization'); + expect(list.status).toBe(200); + const rows = (((await list.json()) as any).records ?? []) as Array>; + expect(rows.length, 'the showcase boots with at least one organization').toBeGreaterThan(0); + orgId = String(rows[0]!.id); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('the method gate moved: a better-auth column is refused by the COLUMN guard (403), no longer by the METHOD gate (405)', async () => { + const before = await readOrg(orgId); + + const res = await patchOrg(orgId, { name: 'Renamed Through The Data Door' }); + const body: any = await res.json(); + + // ⭐ The transition. `enable.apiMethods: ['get', 'list']` answered this + // request 405 OBJECT_API_METHOD_NOT_ALLOWED without reaching the engine. + expect({ status: res.status, code: body.code }).not.toEqual({ status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED' }); + // …and what answers now is the ADR-0092 D2 guard's own verdict. + expect(res.status).toBe(403); + expect(body.code).toBe('PERMISSION_DENIED'); + + // Refused, not silently degraded into a timestamp touch. + const after = await readOrg(orgId); + expect(after.name).toBe(before.name); + }); + + it('a platform-owned column lands: timezone and sort_order through the data door', async () => { + const res = await patchOrg(orgId, { timezone: 'Asia/Shanghai', sort_order: 7 }); + expect(res.status).toBe(200); + + // The consequence — a 200 that leaves the row unchanged is the defect + // wearing a success code (the guard strips, it does not always refuse). + const row = await readOrg(orgId); + expect(row.timezone).toBe('Asia/Shanghai'); + expect(row.sort_order).toBe(7); + }); + + it('require_mfa is admitted on the same door (written at its current value — flipping it would lock this session out)', async () => { + // `require_mfa: true` on the caller's own organization is enforced at the + // session-validation gate, so the assertions after this one would start + // answering the MFA challenge instead of the door. Writing the column at + // its present value still proves the point: were it NOT whitelisted, a + // payload carrying it alone is refused 403 by the guard ("none of the + // submitted fields are editable"), exactly as `name` is above. + const before = await readOrg(orgId); + const current = before.require_mfa === true; + const res = await patchOrg(orgId, { require_mfa: current }); + expect(res.status).toBe(200); + expect((await readOrg(orgId)).require_mfa === true).toBe(current); + }); + + it('opens the columns, not the table: better-auth columns smuggled beside a legal one are stripped, the legal one lands', async () => { + const before = await readOrg(orgId); + + const res = await patchOrg(orgId, { + sort_order: 9, + name: 'Hijacked', + slug: 'hijacked', + logo: 'https://example.invalid/hijacked.png', + metadata: '{"hijacked":true}', + }); + // The whitelisted field survives, so the write succeeds… + expect(res.status).toBe(200); + + // …but only that field was applied. + const after = await readOrg(orgId); + expect(after.sort_order).toBe(9); + expect(after.name).toBe(before.name); + expect(after.slug).toBe(before.slug); + expect(after.logo ?? null).toBe(before.logo ?? null); + expect(after.metadata ?? null).toBe(before.metadata ?? null); + }); + + it('the column still judges the value once the door is open: a non-IANA timezone is refused by validation, not stored', async () => { + // #14238's `valueDomain: 'iana_time_zone'` — before this change the domain + // could only be exercised below the door (system-context writes); now the + // door reaches it. ADR-0112 envelope, both discriminators. + const res = await patchOrg(orgId, { timezone: 'Mars/Olympus' }); + const body: any = await res.json(); + expect(res.status).toBe(400); + expect(body.code).toBe('VALIDATION_FAILED'); + expect((await readOrg(orgId)).timezone).toBe('Asia/Shanghai'); + }); + + it('still refuses create and delete on the identity table (405, method gate)', async () => { + // `update` was opened; `create` / `delete` were not. Organizations are + // minted through better-auth `organization/create` and destroyed through + // `organization/delete` (the row actions), never through the data door. + const created = await stack.apiAs(token, 'POST', '/data/sys_organization', { name: 'Forged', slug: 'forged' }); + expect(created.status).toBe(405); + const createdBody: any = await created.json(); + expect(createdBody.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + + const deleted = await stack.apiAs(token, 'DELETE', `/data/sys_organization/${orgId}`); + expect(deleted.status).toBe(405); + const deletedBody: any = await deleted.json(); + expect(deletedBody.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + }); + + it('the REGISTERED schema serves `update` (post-reconcile) and better-auth keeps its own door', async () => { + // The original defect was a DECLARATION disagreeing with the runtime, so + // pin what the runtime actually serves. `reconcileManagedApiMethods` + // strips a write verb whose affordance is missing and only warns — the + // object's own source saying `update` is not enough (#7727). + const engine = await stack.kernel.getServiceAsync('objectql'); + const schema = engine?.getSchema?.('sys_organization'); + expect(schema, 'sys_organization schema must be registered').toBeTruthy(); + + expect(schema.enable?.apiMethods).toContain('update'); + expect(schema.userActions?.edit).toBe(true); + // The opening is `update` only — `create` / `delete` must stay stripped, + // and `bulk` was not granted (single-record-only, on the record in + // `SINGLE_RECORD_WRITE_ONLY`, `@objectstack/spec`). + expect(schema.enable?.apiMethods).not.toContain('create'); + expect(schema.enable?.apiMethods).not.toContain('delete'); + expect(schema.enable?.apiMethods).not.toContain('bulk'); + + // better-auth's own columns change through better-auth's own endpoint — + // the row action the Setup app renders, unchanged by this card. + const actions = schema.actions as any[] | undefined; + const update = actions?.find((a) => a?.name === 'update_organization'); + expect(update, 'update_organization must stay declared').toBeTruthy(); + expect(update.target).toBe('/api/v1/auth/organization/update'); + expect((update.params ?? []).map((p: any) => p.field)).toEqual(['name', 'slug', 'logo']); + }); +}); diff --git a/packages/spec/src/data/api-methods-batch-conformance.test.ts b/packages/spec/src/data/api-methods-batch-conformance.test.ts index 9922c6b477..854b14ca33 100644 --- a/packages/spec/src/data/api-methods-batch-conformance.test.ts +++ b/packages/spec/src/data/api-methods-batch-conformance.test.ts @@ -109,6 +109,31 @@ const SINGLE_RECORD_WRITE_ONLY: Record = { 'API keys — the grid renders no checkbox column because the object grants no ' + 'delete affordance — and a promoted bulk revoke would fan out per row through ' + 'the action runner rather than hitting /batch (#7802).', + // #15873 — maintainer ruling 2026-09-07 (decision batch #64, option (a), + // verbatim 「同意」): the data door admits `update` so an administrator can set + // the four platform-owned columns (`require_mfa`, `parent_organization_id`, + // `sort_order`, `timezone`) the ADR-0092 D2 whitelist already admitted on + // the engine path. The ruling widened ONE verb on an identity table, and + // `bulk` is a second widening it did not take: granting it would open + // `POST /data/sys_organization/batch` and the `*Many` routes to every API + // client. The object's one list view (`all_orgs`) declares no `bulkActions` + // / selection, and the implicit bulk-delete entry gates on the `delete` + // affordance — off three times over (`managedBy: 'better-auth'` denies by + // default, `userActions` opens `edit` alone, `delete` is not in + // `apiMethods`) — so there is no multi-select to batch today. The cost the + // header prices — a promoted multi-select edit fanning out per row through + // the action runner — is accepted for a table that holds one row in + // single-org deployments. Should a batch organization edit gain a real + // caller, that is a further widening for the contract-review lane: delete + // this entry and add `'bulk'`; the stale-entry test below refuses to let + // both stand. + sys_organization: + 'Administrators set the platform-owned columns one organization at a time ' + + '(#15873 ruled `update` alone, column-gated by ADR-0092 D2). No console ' + + 'surface multi-selects organizations — the list view declares no bulk ' + + 'actions and the object grants no delete affordance — and a promoted bulk ' + + 'edit would fan out per row through the action runner rather than hitting ' + + '/batch (#7802).', }; /** Every `*.object.ts` under `packages/`, skipping build output and deps. */ From f471350e6cc4d358927323e376c6f96d59ead111 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:50:55 +0000 Subject: [PATCH 2/3] test(dogfood): mint the door test's organization through better-auth under the walled posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plain showcase boot holds no sys_organization row (system-context read returns []; the admin session has activeOrganizationId null), and organization/create is denied without an organization wall (#5261) — so the fixture boots multiTenant: 'posture-only' and creates the org the way the Setup app does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../organization-update-door.dogfood.test.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts index 7373789b0d..376cf9011f 100644 --- a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts @@ -62,16 +62,40 @@ describe('#15873: sys_organization platform-owned columns through PATCH /data/sy const patchOrg = (id: string, body: unknown) => stack.apiAs(token, 'PATCH', `/data/sys_organization/${id}`, body); beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + // ── The organization is minted the way the product mints it ───────────── + // + // Measured on the plain single-tenant showcase boot: ZERO `sys_organization` + // rows exist (system-context read) and the seeded admin's session carries + // `activeOrganizationId: null`, so there is no row for the door to reach — + // and `beforeCreateOrganization` denies `organization/create` outright + // unless an organization wall is in force (#5261). `multiTenant: + // 'posture-only'` registers the harness's stand-in for the enterprise + // `org-scoping` runtime so the tenancy service resolves a real `isolated` + // posture and the create route runs; it activates the POSTURE, never the + // WALL (`BootOptions.multiTenant` states the limit), and nothing below + // asserts isolation. It is the same fixture `org-create-default-team + // .dogfood.test.ts` opens the route with. + stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); token = await stack.signIn(); - // The seeded admin is bound to the deployment's default organization at - // sign-up (plugin-auth's membership reconciler); read it back through the - // same door rather than assuming its id. - const list = await stack.apiAs(token, 'GET', '/data/sys_organization'); - expect(list.status).toBe(200); - const rows = (((await list.json()) as any).records ?? []) as Array>; - expect(rows.length, 'the showcase boots with at least one organization').toBeGreaterThan(0); - orgId = String(rows[0]!.id); + + // better-auth's `organization/create` — the `create_organization` row + // action's target. The creator is seated as owner; the ACTIVE organization + // is set explicitly so the session is org-bound in the one field that + // reaches `ExecutionContext`. + const created = await stack.apiAs(token, 'POST', '/auth/organization/create', { + name: 'Door Org 15873', + slug: 'door-org-15873', + }); + expect(created.status, `organization/create returned ${created.status}: ${await created.clone().text()}`).toBe(200); + orgId = String(((await created.json()) as { id?: string }).id); + expect(orgId).toBeTruthy(); + const active = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationSlug: 'door-org-15873' }); + expect(active.status, `set-active: ${await active.clone().text()}`).toBe(200); + + // And the row is what the data door serves back, before anything is written. + const row = await readOrg(orgId); + expect(row.name).toBe('Door Org 15873'); + expect(row.slug).toBe('door-org-15873'); }, 120_000); afterAll(async () => { await stack?.stop?.(); }); From fc6cfffe42e81ee02da16c136c853c316f65743d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 21:28:53 +0000 Subject: [PATCH 3/3] test(dogfood),spec,changeset: name the two derived surfaces the update grant moves (#15873 contract-review patch round) Review FAIL was on the accept-set statement, not the code: granting `update` also derives the update-mode import door (API_METHOD_DERIVATION: import = any of create/update) and flips /auth/me/permissions for sys_organization (allowEdit true, apiOperations gains update and import). Both named in the changeset; pinned on the real door in the dogfood file (import: 200, timezone lands, name stripped; better-auth-only row refused per row; insert mode 405 naming create; /me/permissions allowEdit true with update+import and a sibling better-auth table as the clamp control); the SINGLE_RECORD_WRITE_ONLY sentence now says what is true (bulk not granted; the derived import door is, column-clamped per row). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .changeset/organization-update-door.md | 6 +- .../organization-update-door.dogfood.test.ts | 127 ++++++++++++++++++ .../api-methods-batch-conformance.test.ts | 26 ++-- 3 files changed, 149 insertions(+), 10 deletions(-) diff --git a/.changeset/organization-update-door.md b/.changeset/organization-update-door.md index c26f712b2c..62e920a176 100644 --- a/.changeset/organization-update-door.md +++ b/.changeset/organization-update-door.md @@ -6,7 +6,11 @@ The organization table carries four platform-owned columns better-auth never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` and `sort_order` (ADR-0105 D6), `timezone` (#14238). plugin-auth declares them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, the guard's per-object update whitelist), while the object's `enable.apiMethods: ['get', 'list']` answered every `PATCH /api/v1/data/sys_organization/:id` with 405 `OBJECT_API_METHOD_NOT_ALLOWED` before the engine — and the guard — was reached. Declared editable, reachable from no product surface: the columns could be set only by a system-context caller. The ruling answers the card's question — yes, an administrator sets these columns through the product — and refuses the alternative of declaring them system-writable only. -What widens (Clause ②): the accept set of the published door. `enable.apiMethods` becomes `['get', 'list', 'update']`, and `userActions: { edit: true }` declares the affordance ADR-0103 D3's `reconcileManagedApiMethods` requires before it lets a `managedBy` object keep a write verb at registration (without it the verb is stripped with a warning and the door keeps answering 405 — the second silent gate #7727 measured on `sys_api_key`). `update` alone: `create` / `delete` still answer 405, and `bulk` is not granted (recorded in `SINGLE_RECORD_WRITE_ONLY`). +What widens (Clause ②) — three published surfaces move, all column-clamped by the same guard: + +1. The data door's accept set. `enable.apiMethods` becomes `['get', 'list', 'update']`, and `userActions: { edit: true }` declares the affordance ADR-0103 D3's `reconcileManagedApiMethods` requires before it lets a `managedBy` object keep a write verb at registration (without it the verb is stripped with a warning and the door keeps answering 405 — the second silent gate #7727 measured on `sys_api_key`). `PATCH /api/v1/data/sys_organization/:id` is admitted; `create` / `delete` still answer 405; `bulk` (`/batch`, the `*Many` routes) is not granted (recorded in `SINGLE_RECORD_WRITE_ONLY`). +2. The derived `import` door. `API_METHOD_DERIVATION` (`@objectstack/spec` `api-derivation.ts`) derives `import` from `any: ['create', 'update']`, so granting `update` admits `POST /api/v1/data/sys_organization/import` (and the async `/import/jobs` route) in `writeMode: 'update'` — one request updates N rows, each row clamped per row by the ADR-0092 D2 guard under the caller's context (a row carrying only better-auth columns is refused `PERMISSION_DENIED`; `treatAsHistorical` does not elevate). Insert-mode and upsert-mode import stay 405 (the conjunct named is `create`). The door's own 405 envelope advertises the derived set in `allowed`. +3. `/auth/me/permissions`. For a principal the permission layer already admits (the seeded platform admin's `admin_full_access` wildcard), `sys_organization.allowEdit` goes `false → true` (`clampManagedObjectWrites` reads `userActions.edit` for the `better-auth` bucket) and `apiOperations` gains `update` and `import` (`annotateEffectiveApiOperations`) — the payload the console renders its edit affordance from. `organization_admin` / `member_default` stay hard-denied on every better-auth table by `managed-object-write-denies.ts`, unchanged. What does not widen: the column set. The guard clamps every user-context update on this table to the whitelist. A PATCH of a better-auth column sent alone (`name`, `slug`, `logo`, `metadata`) is now refused by the guard's own verdict — 403 `PERMISSION_DENIED` — instead of the method gate's 405; sent beside a whitelisted column it is stripped and the whitelisted column lands. better-auth's own columns keep changing through better-auth's `organization/update` (the `update_organization` row action, unchanged). Per ADR-0092 D4's form-rendering constraint the four better-auth columns are now `readonly: true` on the object, so a standard edit form offers exactly what the guard admits; the engine's static-readonly strip exempts system-context writers, so better-auth's adapter is unaffected. diff --git a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts index 376cf9011f..aff0bbd837 100644 --- a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts @@ -40,6 +40,33 @@ * * Refusal cases assert `code` AND `status` (ADR-0112): a status-only assertion * stays green against an implementation that answers the wrong refusal. + * + * ## Two more published surfaces move with the verb, and are pinned here too + * + * The contract review of PR #16687 measured what the first round did not name: + * + * - the DERIVED `import` door. `API_METHOD_DERIVATION` (`@objectstack/spec`, + * `api-derivation.ts`) derives `import` from `any: ['create', 'update']`, so + * granting `update` admits `POST /data/sys_organization/import` in + * `writeMode: 'update'` — one request updates N rows. It is column-safe for + * the same reason the PATCH is: the import runner writes each row under the + * caller's context, so the D2 guard clamps every row (`timezone` lands, + * `name` is stripped; a row carrying only better-auth columns is refused + * per row; `treatAsHistorical` does not elevate). Insert / upsert modes + * stay 405, and the conjunct the envelope names is `create`; + * - `/auth/me/permissions`, the payload the console renders its edit + * affordance from: `clampManagedObjectWrites` reads `userActions.edit` for + * the `better-auth` bucket and `annotateEffectiveApiOperations` reports the + * effective operation set, so for a principal the permission layer already + * admits, `sys_organization.allowEdit` goes false → true and + * `apiOperations` gains `update` and `import`. + * + * ⚠️ Instrument note for anything `reconcileManagedApiMethods` touches: it runs + * at REGISTRATION, not at build, so a property-read of `dist/` cannot see it — + * with `userActions` removed, `dist` still says `["get","list","update"]` while + * the registered schema says `["get","list"]`. The registered-schema pin at the + * bottom of this file, and the `/me/permissions` pin, are the instruments that + * can. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -191,6 +218,106 @@ describe('#15873: sys_organization platform-owned columns through PATCH /data/sy expect(deletedBody.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); }); + it('the payload the console consumes: /auth/me/permissions says sys_organization is editable, with update and import in apiOperations', async () => { + const res = await stack.apiAs(token, 'GET', '/auth/me/permissions'); + expect(res.status).toBe(200); + const body: any = await res.json(); + const entry = body.objects?.sys_organization; + expect(entry, 'sys_organization entry present in /me/permissions').toBeTruthy(); + + // `clampManagedObjectWrites` — the `better-auth` bucket is clamped to its + // `userActions`; `edit` is the one opened, `create` / `delete` stay off. + expect(entry.allowEdit).toBe(true); + expect(entry.allowCreate).toBe(false); + expect(entry.allowDelete).toBe(false); + + // `annotateEffectiveApiOperations` — the effective set the console renders: + // the ruled verb and the door it derives, never the ones not granted. + expect(entry.apiOperations).toContain('update'); + expect(entry.apiOperations).toContain('import'); + expect(entry.apiOperations).not.toContain('create'); + expect(entry.apiOperations).not.toContain('delete'); + expect(entry.apiOperations).not.toContain('bulk'); + + // Control: the clamp is live, not a wildcard fold reporting everything + // editable — a sibling better-auth table with no `userActions` stays + // `allowEdit: false` for the very same principal. + const control = body.objects?.sys_member; + expect(control, 'sys_member entry present (control)').toBeTruthy(); + expect(control.allowEdit).toBe(false); + expect(control.apiOperations ?? []).not.toContain('update'); + }); + + it('the derived import door is open in update mode, and the guard clamps every row: timezone lands, name is stripped', async () => { + const before = await readOrg(orgId); + + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { + format: 'json', + writeMode: 'update', + matchFields: ['id'], + rows: [{ id: orgId, name: 'Imported Name', timezone: 'Asia/Tokyo' }], + }); + expect(res.status).toBe(200); + const body: any = await res.json(); + expect(body.writeMode).toBe('update'); + expect(body.updated).toBe(1); + expect(body.errors).toBe(0); + + const after = await readOrg(orgId); + // The door is open: the whitelisted column landed through import… + expect(after.timezone).toBe('Asia/Tokyo'); + // …and the row was written under the caller's context, not as system: + // `name` did not land. Measured (ablation, contract-review patch round): + // with `name` added to the guard's whitelist this assertion STAYS green, + // because `name` is `readonly` (ADR-0092 D4) and the engine's + // static-readonly strip — after the guard, non-system callers only — holds + // it too. Two layers, one observable. The guard-SPECIFIC control on the + // import path is the next pin (a better-auth-only row is refused per row): + // under the same cut it goes red. What THIS assertion fails on is the + // runner elevating rows to system context, which exempts both layers. + expect(after.name).toBe(before.name); + expect(after.name).not.toBe('Imported Name'); + }); + + it('import: a row carrying only better-auth columns is refused per row, PERMISSION_DENIED — treatAsHistorical does not elevate', async () => { + const before = await readOrg(orgId); + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { + format: 'json', + writeMode: 'update', + matchFields: ['id'], + treatAsHistorical: true, + rows: [{ id: orgId, name: 'Imported Name 2' }], + }); + // The import route's contract is a per-row outcome report: the request is + // answered 200 and the refusal lives on the row. This is the guard's own + // verdict on the import path (measured red the moment the whitelist admits + // `name`), the same way the name-only PATCH pin above is on the PATCH path. + expect(res.status).toBe(200); + const body: any = await res.json(); + expect(body.updated).toBe(0); + expect(body.results?.[0]?.ok).toBe(false); + expect(body.results?.[0]?.code).toBe('PERMISSION_DENIED'); + expect((await readOrg(orgId)).name).toBe(before.name); + }); + + it('import: insert mode is still refused at the method gate — 405, and the conjunct named is create', async () => { + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { + format: 'json', + writeMode: 'insert', + rows: [{ name: 'Forged Via Import', slug: 'forged-via-import' }], + }); + expect(res.status).toBe(405); + const body: any = await res.json(); + expect(body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + // `deniedConjunctName` names the primitive that actually failed: import + // in insert mode needs `create`, which stays off. + expect(String(body.error)).toContain("'create'"); + // …and the same envelope advertises the derived door the ruling opened. + expect(body.allowed).toContain('update'); + expect(body.allowed).toContain('import'); + expect(body.allowed).not.toContain('create'); + }); + it('the REGISTERED schema serves `update` (post-reconcile) and better-auth keeps its own door', async () => { // The original defect was a DECLARATION disagreeing with the runtime, so // pin what the runtime actually serves. `reconcileManagedApiMethods` diff --git a/packages/spec/src/data/api-methods-batch-conformance.test.ts b/packages/spec/src/data/api-methods-batch-conformance.test.ts index 854b14ca33..173924f8c3 100644 --- a/packages/spec/src/data/api-methods-batch-conformance.test.ts +++ b/packages/spec/src/data/api-methods-batch-conformance.test.ts @@ -113,11 +113,18 @@ const SINGLE_RECORD_WRITE_ONLY: Record = { // verbatim 「同意」): the data door admits `update` so an administrator can set // the four platform-owned columns (`require_mfa`, `parent_organization_id`, // `sort_order`, `timezone`) the ADR-0092 D2 whitelist already admitted on - // the engine path. The ruling widened ONE verb on an identity table, and + // the engine path. The ruling named ONE verb on an identity table, and // `bulk` is a second widening it did not take: granting it would open // `POST /data/sys_organization/batch` and the `*Many` routes to every API - // client. The object's one list view (`all_orgs`) declares no `bulkActions` - // / selection, and the implicit bulk-delete entry gates on the `delete` + // client. What `update` DOES derive is admitted, and named: `import` is + // `any: ['create', 'update']` in `API_METHOD_DERIVATION`, so update-mode + // `POST /data/sys_organization/import` now passes the method gate and + // updates N rows in one request — each row clamped to the D2 whitelist under + // the caller's context, insert/upsert modes still 405. That is not the batch + // shape this ledger is about (`bulk` gates `/batch` and `*Many`, `import` + // does not read it), which is why the exemption stands beside it. The + // object's one list view (`all_orgs`) declares no `bulkActions` / + // selection, and the implicit bulk-delete entry gates on the `delete` // affordance — off three times over (`managedBy: 'better-auth'` denies by // default, `userActions` opens `edit` alone, `delete` is not in // `apiMethods`) — so there is no multi-select to batch today. The cost the @@ -128,12 +135,13 @@ const SINGLE_RECORD_WRITE_ONLY: Record = { // this entry and add `'bulk'`; the stale-entry test below refuses to let // both stand. sys_organization: - 'Administrators set the platform-owned columns one organization at a time ' + - '(#15873 ruled `update` alone, column-gated by ADR-0092 D2). No console ' + - 'surface multi-selects organizations — the list view declares no bulk ' + - 'actions and the object grants no delete affordance — and a promoted bulk ' + - 'edit would fan out per row through the action runner rather than hitting ' + - '/batch (#7802).', + 'Administrators set the platform-owned columns through single-record PATCH ' + + 'and the derived update-mode import door (#15873 ruled `update`; both are ' + + 'column-clamped per row by ADR-0092 D2). `bulk` — /batch and the *Many ' + + 'routes — is not granted: no console surface multi-selects organizations ' + + '(the list view declares no bulk actions and the object grants no delete ' + + 'affordance), and a promoted bulk edit would fan out per row through the ' + + 'action runner rather than hitting /batch (#7802).', }; /** Every `*.object.ts` under `packages/`, skipping build output and deps. */