diff --git a/.changeset/24997-webhook-capability-required.md b/.changeset/24997-webhook-capability-required.md new file mode 100644 index 00000000000..9358df691cb --- /dev/null +++ b/.changeset/24997-webhook-capability-required.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `defineStack` refuses a stack that declares a webhook while `requires` lacks `'webhooks'` or `'realtime'`, with `STACK_WEBHOOK_CAPABILITY_REQUIRED` + +A declared webhook is dispatched by two capabilities together: `'webhooks'` mounts `@objectstack/plugin-webhooks`, whose auto-enqueuer turns a record event into a delivery, and `'realtime'` mounts `@objectstack/service-realtime`, that auto-enqueuer's only source of record events. Neither token implies the other, and a scaffolded app's `requires` is `['automation', 'triggers']`, which carries neither. So a stack that declared a webhook without both validated, built and booted; the activation door answered `enabled: true`; every record write answered `200`; and no delivery was ever enqueued. The only trace was one boot `error`, after deploy. `defineStack` now refuses such a stack at load, the third declared-capability refusal beside `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and `STACK_TRIGGER_CAPABILITY_REQUIRED`, so every door that loads it (`os validate`, `os build`, `os serve` / `os dev`, an app's own `bootStack` tests) stops with the same sentence. + +Clause-②: no (narrowing) + + + +**BREAKING** (the `defineStack` accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. + +- **FROM:** a stack with a non-empty top-level `webhooks:` collection and a `requires` missing `'webhooks'`, `'realtime'` or both (an absent `requires` included) was accepted, and its webhooks never delivered. +- **TO:** `defineStack` throws `STACK_WEBHOOK_CAPABILITY_REQUIRED` (`status: 422`), header `defineStack webhook capability validation failed (N issues):`, with one `issues` entry per declared webhook naming the webhook and the missing token or tokens. One code for every arm: which token is missing is in the finding, not in the code. +- **Unchanged:** a stack with no declared webhook owes neither token; `defineStack(config, { strict: false })` skips the judgement by contract; the activation door answers as before; and the boot `error` that `@objectstack/plugin-webhooks` logs for a host that wires a stack by hand, past `defineStack`, stays. Every declared webhook counts, `isActive: false` included, because the activation door can switch it on. +- **The fix for an author:** add both tokens to the stack's `requires`, beside whatever it already declares, for example `requires: ['automation', 'triggers', 'webhooks', 'realtime']`. The refusal's own line names exactly the tokens to add. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 095ef028770..4f51e4b7446 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -101,7 +101,13 @@ A webhook reaches the dispatcher one of two ways: own `active` field. **The capabilities a declared webhook needs.** A stack that declares a webhook -must name two tokens in `requires`, beside whatever else it declares: +must name two tokens in `requires`, `'webhooks'` and `'realtime'`, beside +whatever else it declares. A stack that declares one while `requires` lacks +either token is **refused**: `defineStack` throws +`STACK_WEBHOOK_CAPABILITY_REQUIRED` (status 422), naming each declared webhook +and the missing token or tokens, so `os validate`, `os build`, `os serve` / +`os dev` and an app's own `bootStack` tests all stop at load with the same +sentence. Neither token is added for you: ```ts requires: ['automation', 'triggers', 'webhooks', 'realtime'], @@ -113,14 +119,18 @@ requires: ['automation', 'triggers', 'webhooks', 'realtime'], - `'realtime'` mounts the realtime service, which is the auto-enqueuer's only source of record events: the engine publishes `data.record.*` through it, and nothing else feeds the enqueuer (§4.1). With `'webhooks'` and without - `'realtime'` the declaration is read but never dispatched. Record writes still - succeed and no delivery is ever enqueued. The boot logs one `error` that names - each declared webhook and the missing token. Switching a declared webhook on - through the activation door delivers nothing on such a deployment either. + `'realtime'` the declaration would be read but never dispatched: record + writes would succeed and no delivery would ever be enqueued. That is why + `defineStack` refuses the stack. A host that wires a stack by hand, past + `defineStack`, still gets one boot `error` that names each declared webhook + and the missing token; switching a declared webhook on through the activation + door delivers nothing on such a deployment. The outbox itself comes from `messaging`, which every boot mounts unless it runs with `--preset minimal`. A scaffolded app's `requires` is -`['automation', 'triggers']`, so it carries neither of the two tokens above. +`['automation', 'triggers']`, so it carries neither of the two tokens above: +add both before you declare its first webhook. Every declared webhook counts, +`isActive: false` included, because the activation door can switch it on. On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 658c7a805b9..7eac0753bc7 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -352,7 +352,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +340 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -673,6 +673,7 @@ const result = AutomationApiErrorCode.parse(data); * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` * `STACK_UNMANAGED_INSTALL_UNCOPYABLE` +* `STACK_WEBHOOK_CAPABILITY_REQUIRED` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index d542c87a612..2c9a32d4f22 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +340 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -356,6 +356,7 @@ const result = ApiErrorSchema.parse(data); * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` * `STACK_UNMANAGED_INSTALL_UNCOPYABLE` +* `STACK_WEBHOOK_CAPABILITY_REQUIRED` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 7194045bbf2..a81814775c7 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -523,6 +523,7 @@ const result = ErrorCode.parse(data); * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` * `STACK_UNMANAGED_INSTALL_UNCOPYABLE` +* `STACK_WEBHOOK_CAPABILITY_REQUIRED` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` diff --git a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts index d61a8761110..aeb31a84c55 100644 --- a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts @@ -46,6 +46,20 @@ export const wmTaskChanged = defineWebhook({ isActive: true, }); +/** + * Built with `strict: false`, on purpose — DECLARED, not an oversight. This + * stack declares a webhook and no `requires`, so `defineStack`'s default + * judgement refuses it (`STACK_WEBHOOK_CAPABILITY_REQUIRED`: a declared webhook + * owes `requires: ['webhooks', 'realtime']`, ruling A on #24910), and every + * authoring door would stop it at load. What this fixture models is the one + * composition that judgement cannot reach: a host that wires the stack by hand, + * with the webhook plugin and NO realtime service, which is exactly what the + * proof needs — the declared-webhook reader must bind on the data engine alone. + * Declaring the pair instead would have `bootStack` mount realtime the way + * `serve` does, and a reader re-gated behind the dispatch prerequisites would + * then pass unseen. Everything else about the stack is an ordinary authored + * package. + */ export const webhookFixtureStack = defineStack({ manifest: { id: 'com.dogfood.webhook-fixture', @@ -57,4 +71,4 @@ export const webhookFixtureStack = defineStack({ }, objects: [WmTask], webhooks: [wmTaskChanged], -}); +}, { strict: false }); diff --git a/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts b/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts index f12fbbd3a90..f103f152591 100644 --- a/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts +++ b/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts @@ -57,6 +57,19 @@ const WcrTask = ObjectSchema.create({ fields: { name: Field.text({ label: 'Name', required: true }) }, }); +/** + * Built with `strict: false`, on purpose — DECLARED, not an oversight. This + * stack declares a webhook and no `requires`, so `defineStack`'s default + * judgement refuses it (`STACK_WEBHOOK_CAPABILITY_REQUIRED`: a declared webhook + * owes `requires: ['webhooks', 'realtime']`, ruling A on #24910). The pins + * below need a composition that judgement cannot reach: a hand-wired host + * WITHOUT `@objectstack/plugin-webhooks`, where the redactor must still hold + * because it is derived from the schema rather than registered by the plugin. + * Declaring the pair instead would have `bootStack` mount the webhook plugin + * the way `serve` does, and the "without plugin-webhooks" composition would + * silently stop existing. Everything else about the stack is an ordinary + * authored package. + */ const fixture = defineStack({ manifest: { id: 'com.dogfood.webhook-credential-redaction', @@ -79,7 +92,7 @@ const fixture = defineStack({ isActive: false, }), ], -}); +}, { strict: false }); const savedBody = () => ({ name: SAVED, diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 4e02e4d35e1..b3ec431391d 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1554,6 +1554,13 @@ export const ERROR_CODE_LEDGER = { // of another package's items. Its install-time counterpart is // `UNMANAGED_INSTALL_UNCOPYABLE` under `@objectstack/metadata-protocol`. 'STACK_UNMANAGED_INSTALL_UNCOPYABLE', + // [ruling A on #24910] The third declared-capability refusal of the same + // family and reading (`door: 'none'`, `status: 422`), beside + // `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and + // `STACK_TRIGGER_CAPABILITY_REQUIRED`: a declared webhook while `requires` + // omits `webhooks` or `realtime`, the pair that dispatches it. One code for + // every arm; which token is missing is in the finding. + 'STACK_WEBHOOK_CAPABILITY_REQUIRED', // [#16348] The COMPOSITION half of the same family, and `door: 'none'` on // the same reading: the six `composeStacks` refusals, one code per raise // site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index 74cc7acf7f6..e9c636a92e6 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -533,6 +533,17 @@ describe('every refusal site in defineStack carries the record — census, both code: 'STACK_TRIGGER_CAPABILITY_REQUIRED', config: { manifest, objects: [task], pages, requires: ['automation'], flows: [recordFlow] }, }, + { + site: 'webhook capability', + code: 'STACK_WEBHOOK_CAPABILITY_REQUIRED', + config: { + manifest, + objects: [task], + pages, + requires: ['webhooks'], + webhooks: [{ name: 'probe_hook', label: 'Hook', object: task.name, triggers: ['create'], url: 'https://hooks.example/probe' }], + }, + }, { site: 'unmanaged install mode', code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE', @@ -553,8 +564,8 @@ describe('every refusal site in defineStack carries the record — census, both }); } - it('the census reaches every code defineStack refuses with: eight distinct codes', () => { - expect(new Set(rows.map((r) => r.code)).size).toBe(8); + it('the census reaches every code defineStack refuses with: nine distinct codes', () => { + expect(new Set(rows.map((r) => r.code)).size).toBe(9); }); }); diff --git a/packages/spec/src/stack-duplicate-entry.test.ts b/packages/spec/src/stack-duplicate-entry.test.ts index 72cbcbe8abf..ec9316a7129 100644 --- a/packages/spec/src/stack-duplicate-entry.test.ts +++ b/packages/spec/src/stack-duplicate-entry.test.ts @@ -133,7 +133,8 @@ const BATCH: readonly Row[] = [ byName('permissions', 'Permission set', { name: 'dupe_perm', label: 'Perm', objects: {} }), byName('capabilities', 'Capability', { name: 'dupe.export', label: 'Export' }), byName('sharingRules', 'Sharing rule', { name: 'dupe_share', label: 'Share', object: 'dupe_thing', type: 'criteria', condition: 'true', sharedWith: { type: 'team', value: 'team_ops' } }), - byName('webhooks', 'Webhook', { name: 'dupe_webhook', label: 'Webhook', object: 'dupe_thing', triggers: ['create'], url: 'https://example.com/hook' }), + // A declared webhook owes the pair that dispatches it (ruling A on #24910). + byName('webhooks', 'Webhook', { name: 'dupe_webhook', label: 'Webhook', object: 'dupe_thing', triggers: ['create'], url: 'https://example.com/hook' }, { requires: ['webhooks', 'realtime'] }), byName('hooks', 'Hook', { name: 'dupe_hook', label: 'Hook', object: 'dupe_thing', events: ['beforeInsert'], handler: () => {} }), byName('mappings', 'Mapping', { name: 'dupe_map', label: 'Map', targetObject: 'dupe_thing', fieldMapping: [{ source: 'a', target: 'title' }] }), byName('analyticsCubes', 'Analytics cube', { name: 'dupe_cube', title: 'Cube', description: 'first', sql: 'dupe_thing', measures: {}, dimensions: {} }), diff --git a/packages/spec/src/stack-refusal-envelopes.test.ts b/packages/spec/src/stack-refusal-envelopes.test.ts index b839974ce81..f04a3e0b88c 100644 --- a/packages/spec/src/stack-refusal-envelopes.test.ts +++ b/packages/spec/src/stack-refusal-envelopes.test.ts @@ -6,7 +6,8 @@ * ## What was wrong * * `defineStack` had seven refusal sites (ADR-0131 D6 adds the eighth, the - * `unmanaged` install mode's). After #14552 one of them — the + * `unmanaged` install mode's; ruling A on #24910 the ninth, the declared + * webhook's). After #14552 one of them — the * cross-reference refusal, pinned in `stack-cross-reference-envelope.test.ts` * — carried `code` / `status`; the other six threw `new Error(message)` with * both `undefined`. A consumer that had learned to branch on `error.code` @@ -86,6 +87,15 @@ const recordFlow = { edges: [{ id: 'e1', source: 'start', target: 'end' }], }; +/** A declared webhook — dispatched only by `requires: ['webhooks', 'realtime']`, the pair. */ +const taskWebhook = { + name: 'probe_task_changed', + label: 'Task changed', + object: task.name, + triggers: ['create'], + url: 'https://hooks.example/probe', +}; + /** * One row per semantic refusal site. `refused` and `accepted` differ by the one * detail the site checks; `finding` is a verbatim fragment of the one `issues` @@ -152,6 +162,14 @@ const rows: Array<{ accepted: { manifest, objects: [task], requires: ['automation', 'triggers'], flows: [recordFlow] }, finding: "flow 'task_fanout' declares a 'record_change' trigger", }, + { + site: 'webhook capability — a declared webhook without realtime (ruling A on #24910)', + code: 'STACK_WEBHOOK_CAPABILITY_REQUIRED', + header: 'defineStack webhook capability validation failed', + refused: { manifest, objects: [task], requires: ['webhooks'], webhooks: [taskWebhook] }, + accepted: { manifest, objects: [task], requires: ['webhooks', 'realtime'], webhooks: [taskWebhook] }, + finding: "webhook 'probe_task_changed' is declared but `requires` does not include 'realtime'", + }, { site: 'unmanaged install mode — `unmanaged` permitted on a package the copy cannot write (ADR-0131 D6)', code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE', @@ -237,18 +255,18 @@ describe('every defineStack refusal carries an ADR-0112 envelope', () => { }); }); - describe('census over all eight refusal sites', () => { + describe('census over all nine refusal sites', () => { const crossReference = { manifest, objects: [task], data: [{ object: 'missing_object', records: [] }] }; const everySite = [...rows.map((r) => r.refused), { manifest: {} }, crossReference]; - it('eight sites, eight distinct STACK_* codes, every status 422', () => { + it('nine sites, nine distinct STACK_* codes, every status 422', () => { const envelopes = everySite.map((config) => refusal(config)); for (const envelope of envelopes) { expect(envelope).toBeInstanceOf(Error); expect(envelope?.code).toMatch(/^STACK_[A-Z_]+$/); expect(envelope?.status).toBe(422); } - expect(new Set(envelopes.map((e) => e?.code)).size).toBe(8); + expect(new Set(envelopes.map((e) => e?.code)).size).toBe(9); expect(envelopes.map((e) => e?.code)).toContain('STACK_CROSS_REFERENCE_INVALID'); }); diff --git a/packages/spec/src/stack-webhook-capability.test.ts b/packages/spec/src/stack-webhook-capability.test.ts new file mode 100644 index 00000000000..e8439e1e950 --- /dev/null +++ b/packages/spec/src/stack-webhook-capability.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Ruling A on #24910 — `defineStack` refuses a stack that declares a webhook + * while `requires` cannot dispatch it: `webhooks` mounts the dispatcher + * (`@objectstack/plugin-webhooks`), `realtime` mounts its only source of record + * events (`@objectstack/service-realtime`), and without either the stack + * validated, built, booted, answered `enabled: true` at the activation door and + * 200 on every record write, and never delivered. + * + * Pinned: the three arms (no `webhooks`, no `realtime`, both missing — absent + * `requires` included) on ONE code, `STACK_WEBHOOK_CAPABILITY_REQUIRED`, with + * the ADR-0112 envelope (`code` + `status: 422`), each finding naming the + * webhook and the missing tokens; the CONTROL (the pair, in any order, among + * other tokens, is accepted); each prescription applied once is accepted; no + * token is implied or added; and the boundaries (no webhook owes nothing, an + * inactive declaration still owes the pair, the trigger sibling reports first, + * `strict: false` skips by contract). + */ +import { describe, expect, it } from 'vitest'; +import { defineStack } from './stack.zod'; + +type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + +const refusalOf = (stack: Record, options?: { strict?: boolean }): Envelope | null => { + try { + defineStack(stack as never, options); + return null; + } catch (e) { + return e as Envelope; + } +}; + +const CODE = 'STACK_WEBHOOK_CAPABILITY_REQUIRED'; +const HEADER = /^defineStack webhook capability validation failed \(1 issue\):/; +const PAIR = ['webhooks', 'realtime']; + +const task = { name: 'task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }; +const webhook = (name = 'task_changed', extra: Record = {}) => ({ + name, + label: name, + object: 'task', + triggers: ['create', 'update'], + url: 'https://hooks.example/task', + ...extra, +}); +const stackOf = (requires: string[] | undefined, webhooks: unknown[] = [webhook()]) => + requires === undefined ? { objects: [task], webhooks } : { requires, objects: [task], webhooks }; + +describe('defineStack webhook capability — a declared webhook owes `requires: [\'webhooks\', \'realtime\']`', () => { + const ARMS: Array<[string, string[] | undefined, string, string]> = [ + // label, requires, the tokens the finding names, the prescription + ["no 'webhooks'", ['realtime'], "'webhooks'", "Add 'webhooks' to requires: ['webhooks', 'realtime']"], + ["no 'realtime'", ['webhooks'], "'realtime'", "Add 'realtime' to requires: ['webhooks', 'realtime']"], + ['both missing', ['automation', 'triggers'], "'webhooks' or 'realtime'", "Add requires: ['webhooks', 'realtime']"], + ['both missing — `requires: []`', [], "'webhooks' or 'realtime'", "Add requires: ['webhooks', 'realtime']"], + ['both missing — `requires` absent', undefined, "'webhooks' or 'realtime'", "Add requires: ['webhooks', 'realtime']"], + ]; + + describe.each(ARMS)('%s', (_label, requires, missing, prescription) => { + it(`refuses with ${CODE}, status 422, naming the webhook and the missing token(s)`, () => { + const refused = refusalOf(stackOf(requires)); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe(CODE); + expect(refused?.status).toBe(422); + expect(refused?.message).toMatch(HEADER); + expect(refused?.issues).toHaveLength(1); + const finding = refused?.issues?.[0] ?? ''; + expect(finding).toContain(`webhook 'task_changed' is declared but \`requires\` does not include ${missing} — `); + expect(finding).toContain('a record write would never enqueue a delivery'); + expect(finding).toContain(prescription); + // The finding rides the message too, one `✗` line per webhook. + expect(refused?.message).toContain(`✗ ${finding}`); + }); + }); + + it("the pair is ACCEPTED — the control: the scaffold's ['automation', 'triggers'] plus both tokens, and the pair in either order", () => { + expect(refusalOf(stackOf(['automation', 'triggers', 'webhooks', 'realtime']))).toBeNull(); + expect(refusalOf(stackOf(['realtime', 'webhooks']))).toBeNull(); + expect(refusalOf(stackOf(['webhooks', 'job', 'realtime']))).toBeNull(); + }); + + it('no token is implied or added — an accepted stack keeps exactly the `requires` it declared', () => { + const declared = ['automation', 'triggers', 'webhooks', 'realtime']; + const out = defineStack(stackOf(declared) as never) as { requires?: string[] }; + expect(out.requires).toEqual(declared); + }); + + describe('each prescription, applied literally once, is ACCEPTED', () => { + const cases: Array<[string, string[] | undefined, string[]]> = [ + ["['realtime'] + 'webhooks'", ['realtime'], PAIR], + ["['webhooks'] + 'realtime'", ['webhooks'], PAIR], + ["['automation', 'triggers'] + the pair", ['automation', 'triggers'], ['automation', 'triggers', ...PAIR]], + ['absent + the pair', undefined, PAIR], + ]; + it.each(cases)('%s', (_label, requires, fixed) => { + const refused = refusalOf(stackOf(requires)); + expect(refused?.code).toBe(CODE); + // The prescription names exactly the tokens the fix adds. + const finding = refused?.issues?.[0] ?? ''; + for (const token of fixed.filter((t) => !(requires ?? []).includes(t))) expect(finding).toContain(`'${token}'`); + expect(refusalOf(stackOf(fixed))).toBeNull(); + }); + }); + + it('a stack with no webhook owes neither token — absent, empty, or `requires` omitted', () => { + expect(refusalOf({ objects: [task] })).toBeNull(); + expect(refusalOf({ objects: [task], webhooks: [] })).toBeNull(); + expect(refusalOf({ requires: ['automation', 'triggers'], objects: [task], webhooks: [] })).toBeNull(); + }); + + it('an inactive declaration (`isActive: false`) still owes the pair — the activation door can switch it on', () => { + const refused = refusalOf(stackOf(['webhooks'], [webhook('dormant', { isActive: false })])); + expect(refused?.code).toBe(CODE); + expect(refused?.issues?.[0]).toContain("webhook 'dormant' is declared but `requires` does not include 'realtime'"); + }); + + it('reports every declared webhook together, one `issues` entry per webhook', () => { + const refused = refusalOf(stackOf(['webhooks'], [webhook('first_hook'), webhook('second_hook')])); + expect(refused?.code).toBe(CODE); + expect(refused?.message).toMatch(/^defineStack webhook capability validation failed \(2 issues\):/); + expect(refused?.issues).toHaveLength(2); + expect(refused?.issues?.[0]).toContain("webhook 'first_hook' is declared"); + expect(refused?.issues?.[1]).toContain("webhook 'second_hook' is declared"); + }); + + it('the map form is judged like the array form — the key is the webhook name', () => { + const { name: _drop, ...body } = webhook('mapped_hook'); + const refused = refusalOf({ requires: ['realtime'], objects: [task], webhooks: { mapped_hook: body } }); + expect(refused?.code).toBe(CODE); + expect(refused?.issues?.[0]).toContain("webhook 'mapped_hook' is declared but `requires` does not include 'webhooks'"); + }); + + it('the trigger sibling reports first — one throw-site family, checked in order', () => { + const flow = { + name: 'task_fanout', + label: 'task_fanout', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'start', config: { objectName: 'task', triggerType: 'record-after-create' } }, + { id: 'end', type: 'end', label: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + const refused = refusalOf({ requires: [], objects: [task], flows: [flow], webhooks: [webhook()] }); + expect(refused?.code).toBe('STACK_TRIGGER_CAPABILITY_REQUIRED'); + }); + + it('non-strict mode skips validation by contract (the webhook passes through unrefused)', () => { + expect(refusalOf(stackOf(['automation', 'triggers']), { strict: false })).toBeNull(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index b80de7677e0..3d8624dc4fa 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3267,6 +3267,22 @@ class StackTriggerCapabilityRequiredError extends StackRefusalError { } } +/** + * [ADR-0112 · ruling A on #24910] A webhook is declared while `requires` omits + * `webhooks`, `realtime`, or both — the pair that dispatches it — + * {@link validateWebhookCapability}, the third declared-capability refusal and, + * like its trigger sibling, of the class that fails SILENT. One code for every + * arm: which token is missing is in the finding, not in the code. Same + * `_REQUIRED` spelling as its trigger and hierarchy siblings. + */ +class StackWebhookCapabilityRequiredError extends StackRefusalError { + readonly code = 'STACK_WEBHOOK_CAPABILITY_REQUIRED'; + + constructor(message: string, issues: readonly string[]) { + super('StackWebhookCapabilityRequiredError', message, issues); + } +} + /** * [ADR-0131 D6 · ADR-0112] A package permits the `unmanaged` install mode * while it carries what an unmanaged install cannot copy — @@ -4548,6 +4564,95 @@ function validateTriggerCapability(data: unknown): string[] { return errors; } +/** + * Declared webhooks are the THIRD declared-capability class (ruling A on + * #24910), beside the hierarchy scopes and the auto-launched flows above, and + * of the fail-SILENT kind, like the flows. A declared webhook is dispatched by + * the PAIR `requires: ['webhooks', 'realtime']`: + * + * · `webhooks` mounts `@objectstack/plugin-webhooks` + * (`PLATFORM_CAPABILITY_PROVIDERS.webhooks`), whose auto-enqueuer turns a + * record event into a `sys_http_delivery` row. Without it nothing reads + * the declaration at all: no reader is bound, and the activation door + * answers that it cannot verify the webhook. + * · `realtime` mounts `@objectstack/service-realtime`, the auto-enqueuer's + * ONLY source of record events. Without it the plugin boots, the + * activation door answers `enabled: true`, every record write answers + * 200 — and no delivery is ever enqueued. The plugin's boot `error` + * (`[webhook-auto-enqueuer] disabled …`) is the only trace, after deploy, + * and it stays: it is what a host that wires a stack by hand, past this + * producer, still sees. + * + * Neither token implies the other on any runtime's resolver (`os serve` + * expands neither), and the messaging outbox the auto-enqueuer writes to is on + * the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`), so it is owed by + * nobody's `requires`. A stack that declares a webhook while `requires` omits + * either token validates, builds, boots and never delivers. Refuse it here, at + * authoring, so every door that loads the stack (`os validate`, `os build`, + * `os serve` / `os dev`, an app's own `bootStack` tests) refuses it with the + * same sentence. ⛔ Neither token is IMPLIED or added here: that would switch + * on a service the author did not name. + * + * One line per declared webhook, and its prescription is the WHOLE fix for the + * `requires` it was given, so following it once is enough: + * + * `realtime` present, `webhooks` missing → add `'webhooks'`; + * `webhooks` present, `realtime` missing → add `'realtime'`; + * neither → add both, `['webhooks', 'realtime']` + * — never `['webhooks']` alone, + * which the arm above would refuse. + * + * An ABSENT `requires` counts as omitting both tokens, for the reason the + * trigger rule gives: the CLI reads it as `[]` plus the always-on slate, which + * carries neither token. + * + * Every declared webhook counts, `isActive: false` included — unlike a retired + * flow, an inactive declaration is one activation-door write away from being + * switched on (the ledger row overrides the declared `isActive`, ADR-0131 D6), + * and the switch it would then flip delivers nothing. A stack with no webhook + * owes neither token. Only the top-level `webhooks:` collection is read: it is + * the one the declared-webhook reader dispatches (a connector's nested + * `webhooks[]` is retired under ADR-0049 and never dispatched). + */ +function validateWebhookCapability(data: unknown): string[] { + const errors: string[] = []; + const d = data as { requires?: unknown; webhooks?: unknown }; + const requires = Array.isArray(d?.requires) ? (d.requires as string[]) : []; + const hasWebhooks = requires.includes('webhooks'); + const hasRealtime = requires.includes('realtime'); + if (hasWebhooks && hasRealtime) return errors; + const webhooks = Array.isArray(d?.webhooks) ? (d.webhooks as unknown[]) : []; + for (const webhook of webhooks) { + const w = webhook as { name?: unknown } | null; + const name = typeof w?.name === 'string' ? w.name : '?'; + const subject = `webhook '${name}' is declared but \`requires\` does not include`; + if (!hasWebhooks && hasRealtime) { + errors.push( + `${subject} 'webhooks' — ` + + `no webhook dispatcher would be mounted, so a record write would never enqueue a delivery. ` + + `Add 'webhooks' to requires: ['webhooks', 'realtime'] ` + + `(@objectstack/plugin-webhooks dispatches the webhook).`, + ); + } else if (hasWebhooks) { + errors.push( + `${subject} 'realtime' — ` + + `the webhook dispatcher's only source of record events is the realtime service, and without it ` + + `a record write would never enqueue a delivery. ` + + `Add 'realtime' to requires: ['webhooks', 'realtime'] ` + + `(@objectstack/service-realtime publishes the record events; @objectstack/plugin-webhooks only dispatches them).`, + ); + } else { + errors.push( + `${subject} 'webhooks' or 'realtime' — ` + + `no webhook dispatcher would be mounted, so a record write would never enqueue a delivery. ` + + `Add requires: ['webhooks', 'realtime'] (@objectstack/plugin-webhooks dispatches the webhook from the ` + + `record events @objectstack/service-realtime publishes — 'webhooks' alone enqueues nothing).`, + ); + } + } + return errors; +} + /** * Reject `requires` tokens that are not part of the platform capability * vocabulary (framework#3265). An unknown token is a genuine typo or a stale @@ -4983,6 +5088,13 @@ function buildDefinedStack( throw new StackTriggerCapabilityRequiredError(`${header}\n\n${lines.join('\n')}`, triggerErrors); } + const webhookErrors = validateWebhookCapability(data); + if (webhookErrors.length > 0) { + const header = `defineStack webhook capability validation failed (${webhookErrors.length} issue${webhookErrors.length === 1 ? '' : 's'}):`; + const lines = webhookErrors.map((e) => ` ✗ ${e}`); + throw new StackWebhookCapabilityRequiredError(`${header}\n\n${lines.join('\n')}`, webhookErrors); + } + const unmanagedErrors = validateUnmanagedInstallMode(data); if (unmanagedErrors.length > 0) { const header = `defineStack unmanaged install-mode validation failed (${unmanagedErrors.length} issue${unmanagedErrors.length === 1 ? '' : 's'}):`;