Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/sys-webhook-explicit-api-exposure.md
Original file line numberDiff line numberDiff line change
@@ -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.
147 changes: 147 additions & 0 deletions packages/plugins/plugin-webhooks/src/sys-webhook-api-exposure.test.ts
Original file line numberDiff line numberDiff line change
@@ -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');
});
});
60 changes: 60 additions & 0 deletions packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'],
},
});
Loading