diff --git a/.changeset/sys-api-key-no-batch-route.md b/.changeset/sys-api-key-no-batch-route.md new file mode 100644 index 0000000000..9d9eb5792d --- /dev/null +++ b/.changeset/sys-api-key-no-batch-route.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": patch +"@objectstack/platform-objects": patch +--- + +fix(spec,platform-objects): put `sys_api_key`'s missing batch route on the record (#7802) + +`@objectstack/spec`'s `apiMethods` conformance scan was failing on `main` — and, +because the scan lives in `spec` while the object it judges lives in +`platform-objects`, failing for every PR that touched `spec` and no others. +#7769 had added `update` to `sys_api_key`'s `enable.apiMethods` so the Setup +UI's Revoke button had a working route, which tripped the rule "a whitelist that +grants single-record writes must also grant `bulk`". + +Resolved as the rule's second documented outcome — a registered exemption, not a +widened object. `sys_api_key` now carries the monorepo's only +`SINGLE_RECORD_WRITE_ONLY` entry, with the evidence behind it: + +- **No batch surface exists to deny.** The console renders no checkbox column on + any of the object's list views: multi-select is auto-enabled only when a bulk + action exists, the sole implicit one is bulk-delete, and this object grants no + delete affordance (`managedBy: 'better-auth'` denies by default, `userActions` + opens `edit` alone, `delete` is not in `apiMethods`). +- **A future multi-select revoke would not need `bulk` either.** `revoke_api_key` + / `restore_api_key` are `list_item` actions; promoting one into a view's + `bulkActions` resolves it to a `custom` def that the grid executor fans out + through the action runner as N single-record PATCHes — never `/batch`. + +So `POST /api/v1/data/sys_api_key/batch` and the `*Many` routes keep answering +405 for API keys, deliberately: the object's authorable surface is the single +`revoked` boolean that ADR-0092 D2's identity write guard admits, and nothing +asks to write it in bulk. #7769's `update` grant is untouched — the Revoke +button keeps working. Adding `bulk` later requires retiring the exemption in the +same commit; the conformance suite's stale-entry check refuses to let both stand. diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 52409f82c4..c19c099971 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -259,6 +259,19 @@ export const SysApiKey = ObjectSchema.create({ // `key`, `user_id`, `expires_at`, `name`, … — is stripped, and a PATCH // that touches nothing else is refused 403 `PERMISSION_DENIED` rather // than degrading into a silent no-op. + // + // The batch primitive stays off DELIBERATELY (#7802), which is why this is + // the monorepo's only single-record-write whitelist without it. Revoking is + // a one-row, one-column gesture: the console renders no checkbox column for + // this object (no bulk action can arise — the grid's only implicit one is + // bulk-delete, and this object grants no delete affordance), and promoting + // a row action into a view's `bulkActions` fans out per row through the + // action runner rather than calling `/batch`. So the primitive would open + // `POST /data/sys_api_key/batch` and the `*Many` routes to API clients for + // no caller. The decision is on the record — with its evidence and the + // conditions that would reverse it — in `SINGLE_RECORD_WRITE_ONLY` in + // `@objectstack/spec`'s `data/api-methods-batch-conformance.test.ts`, whose + // stale-entry check fails if `bulk` is added here without retiring it. apiMethods: ['get', 'list', 'update'], }, }); 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 a89dae0300..bd2e982cb0 100644 --- a/packages/spec/src/data/api-methods-batch-conformance.test.ts +++ b/packages/spec/src/data/api-methods-batch-conformance.test.ts @@ -42,14 +42,49 @@ const WRITE_PRIMITIVES = ['create', 'update', 'delete'] as const; /** * Objects that deliberately expose single-record writes but NO batch route, - * keyed by object name with the reason. Empty today: every tightened whitelist - * in the monorepo either grants `bulk` or grants no write verb at all. + * keyed by object name with the reason. Every other tightened whitelist in the + * monorepo either grants `bulk` or grants no write verb at all. * * Adding an entry is a real decision — batch denial is invisible until a user * multi-selects rows and `data-objectstack` rethrows the 405 without falling * back to per-row writes. Write down why the object is worth that. */ -const SINGLE_RECORD_WRITE_ONLY: Record = {}; +const SINGLE_RECORD_WRITE_ONLY: Record = { + // #7802. `update` arrived in #7727/#7769 for exactly one gesture on exactly + // one column: the `revoke_api_key` / `restore_api_key` row actions PATCH + // `revoked` on ONE key. The multi-select surface this rule protects does not + // exist for API keys, and the shape a future one would take does not need + // `bulk` either — both read off the console build this release pins + // (`.objectui-sha` 6314e87f2, `packages/plugin-grid`): + // + // · No checkbox column is rendered. None of the object's four list views + // declares `bulkActions` / `bulkActionDefs` / `selection`, and `ObjectGrid` + // auto-enables multi-select only when a bulk action exists. The single + // implicit one is bulk-delete, gated on the resolved `delete` affordance — + // false here three times over (`managedBy: 'better-auth'` denies by + // default, `userActions` opens `edit` alone, and `delete` is not in + // `apiMethods`). So there is no selection to batch. + // · A multi-select revoke, if the product ever wants one, still would not + // reach `/batch`. Both actions are `locations: ['list_item']`; naming one + // in a view's `bulkActions` promotes it to `operation: 'custom'` + + // `actionDef`, which `useBulkExecutor` fans out through the action runner + // as N single-record PATCHes against the route #7769 opened. The data-plane + // `bulk` primitive is reached only by an `update`/`delete` bulk def, which + // this object neither declares nor can acquire implicitly. + // + // Granting `bulk` would therefore open `POST /data/sys_api_key/batch` and the + // `*Many` routes to every API client, on a better-auth identity table whose + // authorable surface is one boolean — ADR-0092 D2's write guard whitelists + // `revoked` and strips everything else — with no consumer asking for it. + // Should a batch key lifecycle ever gain a real caller, delete this entry and + // add `'bulk'`; the stale-entry test below refuses to let both stand. + sys_api_key: + 'Revoke/restore is a one-row, one-column PATCH (`revoked`, the only column ' + + "ADR-0092 D2's identity write guard admits). No console surface multi-selects " + + '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).', +}; /** Every `*.object.ts` under `packages/`, skipping build output and deps. */ function walkObjectFiles(dir: string, out: string[] = []): string[] {