From ff1490d8dbecbcc3ed362e41aed8ae7d079a87d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 07:45:45 +0000 Subject: [PATCH 1/2] feat(plugin-webhooks): declare sys_webhook's data-API exposure explicitly (#9756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cards (#7799, #7986, #8025 option 2) each observed that `sys_webhook` declared no `enable` block and named narrowing its read surface as the next step; none owned the line, so the full default API held by omission rather than by judgement. The census #9756 mandated (measured before writing anything) derives all six primitives: the Setup/Studio console needs get/list/create/update/delete (`userActions` opens all three writes, four list views, `nav_webhooks`), a predicate deactivate/delete over sys_webhook is a supported operator gesture (#4639, with a self-heal branch built for it) and gates on `bulk`, and every other consumer — AutoEnqueuer, bootstrapDeclaredWebhooks, provenance stamp, redeliver-guard, the secret sweep — reaches the rows through `engine.*`, which never consults `enable.apiMethods`. So the declaration records the posture; it does NOT narrow the surface. The six primitives resolve to the closure the absent block already produced, and that equality is pinned rather than left for a later reader to rediscover. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../src/sys-webhook-api-exposure.test.ts | 147 ++++++++++++++++++ .../plugin-webhooks/src/sys-webhook.object.ts | 60 +++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/plugins/plugin-webhooks/src/sys-webhook-api-exposure.test.ts diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook-api-exposure.test.ts b/packages/plugins/plugin-webhooks/src/sys-webhook-api-exposure.test.ts new file mode 100644 index 0000000000..a9e17f71aa --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/sys-webhook-api-exposure.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + API_PRIMITIVES, + apiExposureDenialReason, + checkManagedApiMethodAffordances, + effectiveOperationsArray, + resolveEffectiveApiMethods, + type EnableLike, +} from '@objectstack/spec/data'; +import { REGISTERED_ERROR_CODES } from '@objectstack/spec/api'; +import { SysWebhook } from './sys-webhook.object.js'; + +/** + * #9756 — `sys_webhook`'s data-API exposure, and the honest size of it. + * + * Three cards (#7799, #7986, #8025 option 2) each observed that this object + * declared no `enable` block and named narrowing its read surface as the next + * step; none of them owned the line, so it was never written. #9756's own + * mandate was to measure the consumers BEFORE narrowing anything, and the + * measurement is what this file pins — including the part that is easy to lose: + * + * ⛔ the declaration that landed narrows NOTHING. + * + * Every primitive is required by a real consumer, so the authored set is all + * six, whose effective closure is identical to the one the absent block already + * produced. The value delivered is that the posture is now a decision on the + * record rather than a default nobody wrote down — not a reduction in what is + * reachable. The `narrows nothing` block below is the pin that keeps a later + * reader (or a survey grepping for `enable:`) from concluding otherwise, and it + * is the assertion the ablation flips. + */ + +/** The census (#9756). Each row is a consumer that reaches this object through a GATED surface. */ +const CENSUS: ReadonlyArray<{ consumer: string; via: string; operation: string; bulkChild?: string }> = [ + // Setup/Studio console — `nav_webhooks` + the object's four list views. + { consumer: 'console list views', via: 'REST GET /data/sys_webhook', operation: 'list' }, + { consumer: 'console record detail', via: 'REST GET /data/sys_webhook/:id', operation: 'get' }, + // `userActions: { create, edit, delete }` — this object is an admin authoring surface. + { consumer: 'console create', via: 'REST POST /data/sys_webhook', operation: 'create' }, + { consumer: 'console edit', via: 'REST PATCH /data/sys_webhook/:id', operation: 'update' }, + { consumer: 'console delete', via: 'REST DELETE /data/sys_webhook/:id', operation: 'delete' }, + // #4639 — a predicate write over sys_webhook ("deactivate every webhook on an + // object") is a supported operator gesture; `AutoEnqueuer.handleSelfHealEvent` + // carries a `data.records.*` branch built expressly for it. Both *Many routes + // gate on the `bulk` primitive AND the batched child verb. + { consumer: 'operator predicate deactivate (#4639)', via: 'REST updateMany', operation: 'bulk', bulkChild: 'update' }, + { consumer: 'operator predicate delete (#4639)', via: 'REST deleteMany', operation: 'bulk', bulkChild: 'delete' }, + { consumer: 'console bulk create', via: 'REST createMany', operation: 'bulk', bulkChild: 'create' }, + // Derived verbs the console's grid affordances read off the effective set. + { consumer: 'console export', via: 'REST GET /data/sys_webhook/export', operation: 'export' }, + { consumer: 'console import', via: 'REST POST /data/sys_webhook/import', operation: 'import' }, +]; + +const ENABLE = SysWebhook.enable as EnableLike; + +describe('#9756 — sys_webhook declares its data-API exposure explicitly', () => { + it('declares exactly the six primitives the census derived', () => { + expect(ENABLE?.apiMethods).toEqual(['get', 'list', 'create', 'update', 'delete', 'bulk']); + // Authored values are primitives only — legacy verbs are derived, never + // declared (#3543). The monorepo-wide form of this lives in spec's + // `api-methods-batch-conformance.test.ts`; asserted here too so the object's + // own suite fails at the source rather than in another package. + expect([...(ENABLE?.apiMethods ?? [])].sort()).toEqual([...API_PRIMITIVES].sort()); + }); + + it('admits every consumer the census found (anti-vacuity floor included)', () => { + expect(CENSUS.length).toBeGreaterThanOrEqual(10); + const refused = CENSUS.filter( + ({ operation, bulkChild }) => apiExposureDenialReason(ENABLE, operation, { bulkChild }) !== null, + ).map(({ consumer, via, operation }) => `${consumer} (${via}) — '${operation}' refused`); + expect(refused).toEqual([]); + }); + + it('keeps every declared write verb through registration — nothing is stripped at boot', () => { + // `sys_webhook` is `managedBy: 'config'`, so its whitelist is reconciled + // against its resolved CRUD affordances at registration + // (`reconcileManagedApiMethods`, objectql `registry.ts`) — a verb the + // affordances refuse is stripped with only a `console.warn`. The judgement + // is this predicate (ADR-0092/ADR-0103); the registry is only its reaction, + // so pinning the predicate pins what boot will do. Closing + // `userActions.delete`, say, would silently take `delete` away from the API + // and this is what notices. + expect(checkManagedApiMethodAffordances(SysWebhook)).toEqual([]); + }); + + it('⛔ narrows NOTHING — the effective surface equals what the absent block produced', () => { + // THE assertion of this file. `resolveEffectiveApiMethods` seeds its + // `unrestricted` branch with the same `API_PRIMITIVES` set, so declaring + // all six reproduces the closure the omission already had. If a later + // change makes this pair diverge, the object's exposure really did move and + // the docblock above (and #9756's report) stop describing it. + const declared = resolveEffectiveApiMethods(ENABLE); + const absent = resolveEffectiveApiMethods({ ...ENABLE, apiMethods: undefined }); + + expect(effectiveOperationsArray(declared)).toEqual(effectiveOperationsArray(absent)); + expect([...declared.primitives].sort()).toEqual([...absent.primitives].sort()); + // The one thing that DID change — and the only thing. + expect(absent.mode).toBe('unrestricted'); + expect(declared.mode).toBe('restricted'); + }); + + it('leaves the reachable-cleartext fields reachable — the card is not closed by this', () => { + // `url` (#8025, won't-fix on masking) and a legacy row's un-migrated + // `definition_json.headers` (#7986, still read by `readLegacyHeaders`) are + // served by `get`/`list`, which the console requires. Stated as an + // assertion so nobody reads the new `enable` block as having removed them. + expect(apiExposureDenialReason(ENABLE, 'get')).toBeNull(); + expect(apiExposureDenialReason(ENABLE, 'list')).toBeNull(); + expect(Object.keys(SysWebhook.fields)).toContain('url'); + expect(Object.keys(SysWebhook.fields)).toContain('definition_json'); + }); +}); + +describe('#9756 — the gate this declaration is read by is live (counterfactual)', () => { + // The shipped block refuses none of the census, so a refusal pin needs a + // counterfactual subject: a narrowed block proves the mechanism reaching this + // object's `enable` really does refuse, rather than the suite passing because + // nothing is ever gated. ADR-0112: assert the discriminant AND the code, not + // that something merely threw. + const READ_ONLY: EnableLike = { apiMethods: ['get', 'list'] }; + + it('refuses a write with the ADR-0112 method-not-allowed discriminant', () => { + expect(apiExposureDenialReason(READ_ONLY, 'create')).toBe('method-not-allowed'); + expect(apiExposureDenialReason(READ_ONLY, 'update')).toBe('method-not-allowed'); + expect(apiExposureDenialReason(READ_ONLY, 'delete')).toBe('method-not-allowed'); + expect(apiExposureDenialReason(READ_ONLY, 'bulk', { bulkChild: 'update' })).toBe('method-not-allowed'); + // Reads stay open — the control that makes the three above an oracle rather + // than "this helper refuses everything". + expect(apiExposureDenialReason(READ_ONLY, 'get')).toBeNull(); + expect(apiExposureDenialReason(READ_ONLY, 'list')).toBeNull(); + }); + + it('names an ADR-0112-registered code for each refusal envelope', () => { + // The `{ status, code }` envelopes themselves are built by + // `apiAccessDenialFromEnable` (`@objectstack/rest`) and the MCP bridge, from + // this same discriminant — 405 `OBJECT_API_METHOD_NOT_ALLOWED` and 404 + // `OBJECT_API_DISABLED`. This package does not depend on `@objectstack/rest` + // and does not grow a dependency to assert someone else's envelope; what is + // pinned here is that both codes are registered vocabulary, so a rename + // cannot pass silently on the spec side. + expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_DISABLED'); + expect(apiExposureDenialReason({ apiEnabled: false }, 'get')).toBe('api-disabled'); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 31a75f1b21..e2c6e61fdd 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -314,4 +314,64 @@ export const SysWebhook = ObjectSchema.create({ { fields: ['object_name'] }, { fields: ['active', 'object_name'] }, ], + + /** + * [#9756] The data-API exposure of this object, declared EXPLICITLY. + * + * ## Why the block exists + * + * Three cards observed that `sys_webhook` declared no `enable` block at all + * and each named narrowing its read surface as the next step — #7799 (the + * signing secret), #7986 (the custom headers) and #8025 option 2 (the URL) — + * and each assumed a later one would write the line. None did. The condition + * held not because anyone judged the full default API correct here, but + * because the omission was never anybody's deliverable. That is the standard + * #8025 set and #9756 quotes back: *an omission is not a decision unless + * someone wrote it down.* This block is that decision, written down. + * + * ## The census the set is derived from (#9756, measured before writing) + * + * | consumer | reaches this object through | needs | + * |:---|:---|:---| + * | Setup/Studio console — `nav_webhooks` (`webhook-outbox-plugin.ts`), the four list views above, `userActions` create/edit/delete | REST `/api/v1/data/sys_webhook` — the gated data API | `get` `list` `create` `update` `delete` | + * | Operator predicate write — "deactivate every webhook on an object" (#4639, for which `AutoEnqueuer.handleSelfHealEvent` carries a `data.records.*` branch built expressly for this gesture) | REST `updateMany` / `deleteMany`, both gated on the `bulk` primitive | `bulk` | + * | `AutoEnqueuer` cache refresh, `bootstrapDeclaredWebhooks`, `stampWebhookProvenance`, `redeliver-guard`, `migrateLegacyWebhookSecrets`, the `headers_secret` write gate | `engine.find/findOne/insert/update` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated: unaffected by anything declared here | + * + * ⇒ every primitive is required by a real, measured consumer, so the set is + * all six. No consumer outside the admin/operator surface was found. + * + * ## ⛔ This narrows NOTHING — do not read it as if it did + * + * `resolveEffectiveApiMethods` (`@objectstack/spec/data`) seeds the + * `unrestricted` branch with the very same `API_PRIMITIVES` set, so the six + * primitives resolve to the operation closure the *absent* block already + * produced. The serialized effective set (`/me/permissions`, the 405 + * `allowed` array) is byte-identical, and no route or `callData` action + * reaches an operation whose answer differs. Only `mode` changes, + * `unrestricted` → `restricted`. + * + * So the presence of this block is NOT evidence that the reachable cleartext + * on this object was reduced. It was not, and `apiMethods` is the wrong + * instrument for it: `url` (#8025 — won't-fix on masking, because the URL is + * the routing key an operator must be able to see, search, sort and edit) and + * a legacy row's un-migrated `definition_json.headers` (#7986 — + * `readLegacyHeaders` in `auto-enqueuer.ts` still reads them and warns) are + * both served by `get`/`list`, which is exactly what the console requires. + * Any set that removes them removes the admin surface with them. A survey + * that greps this file for `enable:` and stops is measuring the wrong thing; + * #9756's report carries the census that says so. + * + * Contrast the sibling `sys_http_delivery` (`['get','list']`, + * `service-messaging`), whose narrowing is real: that table is engine-owned — + * written only by `SqlHttpOutbox` through context-less raw-engine writes, + * never authored — so closing its write surface costs nothing. `sys_webhook` + * is a first-class admin authoring surface. That is the whole difference, and + * it is why the sibling's shape could not simply be copied here. + * + * Pinned — the census, the no-narrowing equality, and the registration-time + * survival of every write verb — in `sys-webhook-api-exposure.test.ts`. + */ + enable: { + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, }); From d9653e87bf87ba927c3e12dbe9008526b7fe0fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 08:02:13 +0000 Subject: [PATCH 2/2] chore(changeset): patch for sys_webhook's explicit data-API exposure (#9756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Published package (plugin-webhooks 17.0.0, not private) whose shipped object metadata changed ⇒ patch changeset. Not declared-breaking: nothing authorable is removed or renamed, and the effective operation closure is unchanged, so no ADR-0087 disposition marker is required. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../sys-webhook-explicit-api-exposure.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .changeset/sys-webhook-explicit-api-exposure.md diff --git a/.changeset/sys-webhook-explicit-api-exposure.md b/.changeset/sys-webhook-explicit-api-exposure.md new file mode 100644 index 0000000000..2e1589a290 --- /dev/null +++ b/.changeset/sys-webhook-explicit-api-exposure.md @@ -0,0 +1,46 @@ +--- +"@objectstack/plugin-webhooks": patch +--- + +chore(plugin-webhooks): `sys_webhook` declares its data-API exposure explicitly — recording the posture, not narrowing it (#9756) + +`sys_webhook` shipped with no `enable` block at all, so it kept the full default +data API. Three cards each noticed and each named the narrowing as the next +step — #7799 (the signing secret), #7986 (the custom headers), #8025 option 2 +(the URL) — and each assumed a later one would write the line. None did, and the +last of them closed `completed` with the line still unwritten. The posture was +never a judgement; it was a default nobody had written down. + +It is written down now: + +```ts +enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] } +``` + +**The effective surface is unchanged, and that is the honest headline.** The set +is derived from a census of who actually reaches the object, taken before +anything was edited: + +| consumer | reaches it through | needs | +|:---|:---|:---| +| Setup/Studio console — `nav_webhooks`, four list views, `userActions` create/edit/delete | REST `/api/v1/data/sys_webhook` (gated) | `get` `list` `create` `update` `delete` | +| Operator predicate write — "deactivate every webhook on an object" (#4639) | REST `updateMany`/`deleteMany` (gated on `bulk`) | `bulk` | +| `AutoEnqueuer`, `bootstrapDeclaredWebhooks`, the provenance stamp, `redeliver-guard`, the secret sweep | `engine.*` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated | + +Every primitive is required by a real consumer, so the set is all six — whose +operation closure is what the absent block already produced. Nothing that was +reachable becomes unreachable, and `/me/permissions` reports the identical +`apiOperations` array. No caller needs to change anything. + +⛔ **Do not read this as the read-surface narrowing those three cards asked +for.** It is not one, and `apiMethods` cannot be one here: `url` (#8025 — +won't-fix on masking, because the URL is the routing key an operator must be +able to see, search, sort and edit) and a legacy row's un-migrated +`definition_json.headers` (#7986 — still read, and warned about, by +`readLegacyHeaders`) are served by `get`/`list`, which is exactly what the admin +console requires. Any set that removes them removes the admin surface too. The +sibling `sys_http_delivery` can hold `['get','list']` because it is engine-owned +and never authored; `sys_webhook` is a first-class admin authoring surface. + +The equality above is pinned in `sys-webhook-api-exposure.test.ts` rather than +left as a claim, so a later change that does move the surface has to say so.