Skip to content

Commit 90417a8

Browse files
os-warrenclaude
andauthored
chore(plugin-webhooks): declare sys_webhook's data-API exposure explicitly — and record that it narrows nothing (#9756) (#9927)
* feat(plugin-webhooks): declare sys_webhook's data-API exposure explicitly (#9756) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * chore(changeset): patch for sys_webhook's explicit data-API exposure (#9756) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03520eb commit 90417a8

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/plugin-webhooks": patch
3+
---
4+
5+
chore(plugin-webhooks): `sys_webhook` declares its data-API exposure explicitly — recording the posture, not narrowing it (#9756)
6+
7+
`sys_webhook` shipped with no `enable` block at all, so it kept the full default
8+
data API. Three cards each noticed and each named the narrowing as the next
9+
step — #7799 (the signing secret), #7986 (the custom headers), #8025 option 2
10+
(the URL) — and each assumed a later one would write the line. None did, and the
11+
last of them closed `completed` with the line still unwritten. The posture was
12+
never a judgement; it was a default nobody had written down.
13+
14+
It is written down now:
15+
16+
```ts
17+
enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] }
18+
```
19+
20+
**The effective surface is unchanged, and that is the honest headline.** The set
21+
is derived from a census of who actually reaches the object, taken before
22+
anything was edited:
23+
24+
| consumer | reaches it through | needs |
25+
|:---|:---|:---|
26+
| Setup/Studio console — `nav_webhooks`, four list views, `userActions` create/edit/delete | REST `/api/v1/data/sys_webhook` (gated) | `get` `list` `create` `update` `delete` |
27+
| Operator predicate write — "deactivate every webhook on an object" (#4639) | REST `updateMany`/`deleteMany` (gated on `bulk`) | `bulk` |
28+
| `AutoEnqueuer`, `bootstrapDeclaredWebhooks`, the provenance stamp, `redeliver-guard`, the secret sweep | `engine.*` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated |
29+
30+
Every primitive is required by a real consumer, so the set is all six — whose
31+
operation closure is what the absent block already produced. Nothing that was
32+
reachable becomes unreachable, and `/me/permissions` reports the identical
33+
`apiOperations` array. No caller needs to change anything.
34+
35+
⛔ **Do not read this as the read-surface narrowing those three cards asked
36+
for.** It is not one, and `apiMethods` cannot be one here: `url` (#8025 —
37+
won't-fix on masking, because the URL is the routing key an operator must be
38+
able to see, search, sort and edit) and a legacy row's un-migrated
39+
`definition_json.headers` (#7986 — still read, and warned about, by
40+
`readLegacyHeaders`) are served by `get`/`list`, which is exactly what the admin
41+
console requires. Any set that removes them removes the admin surface too. The
42+
sibling `sys_http_delivery` can hold `['get','list']` because it is engine-owned
43+
and never authored; `sys_webhook` is a first-class admin authoring surface.
44+
45+
The equality above is pinned in `sys-webhook-api-exposure.test.ts` rather than
46+
left as a claim, so a later change that does move the surface has to say so.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
API_PRIMITIVES,
6+
apiExposureDenialReason,
7+
checkManagedApiMethodAffordances,
8+
effectiveOperationsArray,
9+
resolveEffectiveApiMethods,
10+
type EnableLike,
11+
} from '@objectstack/spec/data';
12+
import { REGISTERED_ERROR_CODES } from '@objectstack/spec/api';
13+
import { SysWebhook } from './sys-webhook.object.js';
14+
15+
/**
16+
* #9756 — `sys_webhook`'s data-API exposure, and the honest size of it.
17+
*
18+
* Three cards (#7799, #7986, #8025 option 2) each observed that this object
19+
* declared no `enable` block and named narrowing its read surface as the next
20+
* step; none of them owned the line, so it was never written. #9756's own
21+
* mandate was to measure the consumers BEFORE narrowing anything, and the
22+
* measurement is what this file pins — including the part that is easy to lose:
23+
*
24+
* ⛔ the declaration that landed narrows NOTHING.
25+
*
26+
* Every primitive is required by a real consumer, so the authored set is all
27+
* six, whose effective closure is identical to the one the absent block already
28+
* produced. The value delivered is that the posture is now a decision on the
29+
* record rather than a default nobody wrote down — not a reduction in what is
30+
* reachable. The `narrows nothing` block below is the pin that keeps a later
31+
* reader (or a survey grepping for `enable:`) from concluding otherwise, and it
32+
* is the assertion the ablation flips.
33+
*/
34+
35+
/** The census (#9756). Each row is a consumer that reaches this object through a GATED surface. */
36+
const CENSUS: ReadonlyArray<{ consumer: string; via: string; operation: string; bulkChild?: string }> = [
37+
// Setup/Studio console — `nav_webhooks` + the object's four list views.
38+
{ consumer: 'console list views', via: 'REST GET /data/sys_webhook', operation: 'list' },
39+
{ consumer: 'console record detail', via: 'REST GET /data/sys_webhook/:id', operation: 'get' },
40+
// `userActions: { create, edit, delete }` — this object is an admin authoring surface.
41+
{ consumer: 'console create', via: 'REST POST /data/sys_webhook', operation: 'create' },
42+
{ consumer: 'console edit', via: 'REST PATCH /data/sys_webhook/:id', operation: 'update' },
43+
{ consumer: 'console delete', via: 'REST DELETE /data/sys_webhook/:id', operation: 'delete' },
44+
// #4639 — a predicate write over sys_webhook ("deactivate every webhook on an
45+
// object") is a supported operator gesture; `AutoEnqueuer.handleSelfHealEvent`
46+
// carries a `data.records.*` branch built expressly for it. Both *Many routes
47+
// gate on the `bulk` primitive AND the batched child verb.
48+
{ consumer: 'operator predicate deactivate (#4639)', via: 'REST updateMany', operation: 'bulk', bulkChild: 'update' },
49+
{ consumer: 'operator predicate delete (#4639)', via: 'REST deleteMany', operation: 'bulk', bulkChild: 'delete' },
50+
{ consumer: 'console bulk create', via: 'REST createMany', operation: 'bulk', bulkChild: 'create' },
51+
// Derived verbs the console's grid affordances read off the effective set.
52+
{ consumer: 'console export', via: 'REST GET /data/sys_webhook/export', operation: 'export' },
53+
{ consumer: 'console import', via: 'REST POST /data/sys_webhook/import', operation: 'import' },
54+
];
55+
56+
const ENABLE = SysWebhook.enable as EnableLike;
57+
58+
describe('#9756 — sys_webhook declares its data-API exposure explicitly', () => {
59+
it('declares exactly the six primitives the census derived', () => {
60+
expect(ENABLE?.apiMethods).toEqual(['get', 'list', 'create', 'update', 'delete', 'bulk']);
61+
// Authored values are primitives only — legacy verbs are derived, never
62+
// declared (#3543). The monorepo-wide form of this lives in spec's
63+
// `api-methods-batch-conformance.test.ts`; asserted here too so the object's
64+
// own suite fails at the source rather than in another package.
65+
expect([...(ENABLE?.apiMethods ?? [])].sort()).toEqual([...API_PRIMITIVES].sort());
66+
});
67+
68+
it('admits every consumer the census found (anti-vacuity floor included)', () => {
69+
expect(CENSUS.length).toBeGreaterThanOrEqual(10);
70+
const refused = CENSUS.filter(
71+
({ operation, bulkChild }) => apiExposureDenialReason(ENABLE, operation, { bulkChild }) !== null,
72+
).map(({ consumer, via, operation }) => `${consumer} (${via}) — '${operation}' refused`);
73+
expect(refused).toEqual([]);
74+
});
75+
76+
it('keeps every declared write verb through registration — nothing is stripped at boot', () => {
77+
// `sys_webhook` is `managedBy: 'config'`, so its whitelist is reconciled
78+
// against its resolved CRUD affordances at registration
79+
// (`reconcileManagedApiMethods`, objectql `registry.ts`) — a verb the
80+
// affordances refuse is stripped with only a `console.warn`. The judgement
81+
// is this predicate (ADR-0092/ADR-0103); the registry is only its reaction,
82+
// so pinning the predicate pins what boot will do. Closing
83+
// `userActions.delete`, say, would silently take `delete` away from the API
84+
// and this is what notices.
85+
expect(checkManagedApiMethodAffordances(SysWebhook)).toEqual([]);
86+
});
87+
88+
it('⛔ narrows NOTHING — the effective surface equals what the absent block produced', () => {
89+
// THE assertion of this file. `resolveEffectiveApiMethods` seeds its
90+
// `unrestricted` branch with the same `API_PRIMITIVES` set, so declaring
91+
// all six reproduces the closure the omission already had. If a later
92+
// change makes this pair diverge, the object's exposure really did move and
93+
// the docblock above (and #9756's report) stop describing it.
94+
const declared = resolveEffectiveApiMethods(ENABLE);
95+
const absent = resolveEffectiveApiMethods({ ...ENABLE, apiMethods: undefined });
96+
97+
expect(effectiveOperationsArray(declared)).toEqual(effectiveOperationsArray(absent));
98+
expect([...declared.primitives].sort()).toEqual([...absent.primitives].sort());
99+
// The one thing that DID change — and the only thing.
100+
expect(absent.mode).toBe('unrestricted');
101+
expect(declared.mode).toBe('restricted');
102+
});
103+
104+
it('leaves the reachable-cleartext fields reachable — the card is not closed by this', () => {
105+
// `url` (#8025, won't-fix on masking) and a legacy row's un-migrated
106+
// `definition_json.headers` (#7986, still read by `readLegacyHeaders`) are
107+
// served by `get`/`list`, which the console requires. Stated as an
108+
// assertion so nobody reads the new `enable` block as having removed them.
109+
expect(apiExposureDenialReason(ENABLE, 'get')).toBeNull();
110+
expect(apiExposureDenialReason(ENABLE, 'list')).toBeNull();
111+
expect(Object.keys(SysWebhook.fields)).toContain('url');
112+
expect(Object.keys(SysWebhook.fields)).toContain('definition_json');
113+
});
114+
});
115+
116+
describe('#9756 — the gate this declaration is read by is live (counterfactual)', () => {
117+
// The shipped block refuses none of the census, so a refusal pin needs a
118+
// counterfactual subject: a narrowed block proves the mechanism reaching this
119+
// object's `enable` really does refuse, rather than the suite passing because
120+
// nothing is ever gated. ADR-0112: assert the discriminant AND the code, not
121+
// that something merely threw.
122+
const READ_ONLY: EnableLike = { apiMethods: ['get', 'list'] };
123+
124+
it('refuses a write with the ADR-0112 method-not-allowed discriminant', () => {
125+
expect(apiExposureDenialReason(READ_ONLY, 'create')).toBe('method-not-allowed');
126+
expect(apiExposureDenialReason(READ_ONLY, 'update')).toBe('method-not-allowed');
127+
expect(apiExposureDenialReason(READ_ONLY, 'delete')).toBe('method-not-allowed');
128+
expect(apiExposureDenialReason(READ_ONLY, 'bulk', { bulkChild: 'update' })).toBe('method-not-allowed');
129+
// Reads stay open — the control that makes the three above an oracle rather
130+
// than "this helper refuses everything".
131+
expect(apiExposureDenialReason(READ_ONLY, 'get')).toBeNull();
132+
expect(apiExposureDenialReason(READ_ONLY, 'list')).toBeNull();
133+
});
134+
135+
it('names an ADR-0112-registered code for each refusal envelope', () => {
136+
// The `{ status, code }` envelopes themselves are built by
137+
// `apiAccessDenialFromEnable` (`@objectstack/rest`) and the MCP bridge, from
138+
// this same discriminant — 405 `OBJECT_API_METHOD_NOT_ALLOWED` and 404
139+
// `OBJECT_API_DISABLED`. This package does not depend on `@objectstack/rest`
140+
// and does not grow a dependency to assert someone else's envelope; what is
141+
// pinned here is that both codes are registered vocabulary, so a rename
142+
// cannot pass silently on the spec side.
143+
expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_METHOD_NOT_ALLOWED');
144+
expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_DISABLED');
145+
expect(apiExposureDenialReason({ apiEnabled: false }, 'get')).toBe('api-disabled');
146+
});
147+
});

‎packages/plugins/plugin-webhooks/src/sys-webhook.object.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,64 @@ export const SysWebhook = ObjectSchema.create({
314314
{ fields: ['object_name'] },
315315
{ fields: ['active', 'object_name'] },
316316
],
317+
318+
/**
319+
* [#9756] The data-API exposure of this object, declared EXPLICITLY.
320+
*
321+
* ## Why the block exists
322+
*
323+
* Three cards observed that `sys_webhook` declared no `enable` block at all
324+
* and each named narrowing its read surface as the next step — #7799 (the
325+
* signing secret), #7986 (the custom headers) and #8025 option 2 (the URL) —
326+
* and each assumed a later one would write the line. None did. The condition
327+
* held not because anyone judged the full default API correct here, but
328+
* because the omission was never anybody's deliverable. That is the standard
329+
* #8025 set and #9756 quotes back: *an omission is not a decision unless
330+
* someone wrote it down.* This block is that decision, written down.
331+
*
332+
* ## The census the set is derived from (#9756, measured before writing)
333+
*
334+
* | consumer | reaches this object through | needs |
335+
* |:---|:---|:---|
336+
* | 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` |
337+
* | 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` |
338+
* | `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 |
339+
*
340+
* ⇒ every primitive is required by a real, measured consumer, so the set is
341+
* all six. No consumer outside the admin/operator surface was found.
342+
*
343+
* ## ⛔ This narrows NOTHING — do not read it as if it did
344+
*
345+
* `resolveEffectiveApiMethods` (`@objectstack/spec/data`) seeds the
346+
* `unrestricted` branch with the very same `API_PRIMITIVES` set, so the six
347+
* primitives resolve to the operation closure the *absent* block already
348+
* produced. The serialized effective set (`/me/permissions`, the 405
349+
* `allowed` array) is byte-identical, and no route or `callData` action
350+
* reaches an operation whose answer differs. Only `mode` changes,
351+
* `unrestricted` → `restricted`.
352+
*
353+
* So the presence of this block is NOT evidence that the reachable cleartext
354+
* on this object was reduced. It was not, and `apiMethods` is the wrong
355+
* instrument for it: `url` (#8025 — won't-fix on masking, because the URL is
356+
* the routing key an operator must be able to see, search, sort and edit) and
357+
* a legacy row's un-migrated `definition_json.headers` (#7986 —
358+
* `readLegacyHeaders` in `auto-enqueuer.ts` still reads them and warns) are
359+
* both served by `get`/`list`, which is exactly what the console requires.
360+
* Any set that removes them removes the admin surface with them. A survey
361+
* that greps this file for `enable:` and stops is measuring the wrong thing;
362+
* #9756's report carries the census that says so.
363+
*
364+
* Contrast the sibling `sys_http_delivery` (`['get','list']`,
365+
* `service-messaging`), whose narrowing is real: that table is engine-owned —
366+
* written only by `SqlHttpOutbox` through context-less raw-engine writes,
367+
* never authored — so closing its write surface costs nothing. `sys_webhook`
368+
* is a first-class admin authoring surface. That is the whole difference, and
369+
* it is why the sibling's shape could not simply be copied here.
370+
*
371+
* Pinned — the census, the no-narrowing equality, and the registration-time
372+
* survival of every write verb — in `sys-webhook-api-exposure.test.ts`.
373+
*/
374+
enable: {
375+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
376+
},
317377
});

0 commit comments

Comments
 (0)