From 694fdb1273003cb66f851f814fa2c8ed5931be4f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:06:28 +0800 Subject: [PATCH] fix(webhooks): materialize stack-declared webhooks into the dispatcher (#3461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec `WebhookSchema` authoring surface (`defineStack({ webhooks })` / `defineWebhook()`, `object` / `isActive`) was disconnected from the runtime dispatcher, which fans out off `sys_webhook` DATA rows (`object_name` / `active`) written only by hand through the object's CRUD UI. Nothing turned a declared webhook into a dispatchable row, so authoring `webhooks:` on a stack was a silent no-op (ADR-0078) — the showcase itself shipped one that did nothing. - `bootstrapDeclaredWebhooks` reads declared `webhook` metadata from the ObjectQL registry (where manifest decomposition already parks `stack.webhooks`), validates each through `WebhookSchema.parse()` (the spec schema finally gets a real consumer), and materializes it into a `sys_webhook` row: `object → object_name`, `isActive → active`, full envelope → `definition_json`. Runs on the DATA ENGINE alone, before the auto-enqueuer's first refresh — NOT gated behind the realtime/messaging dispatch prerequisites (else a realtime-less deployment reproduces the silent no-op). - Seed-not-clobber provenance (mirrors sys_sharing_rule #2909): `sys_webhook` gains `managed_by` / `customized`. Declared webhooks re-seed as `managed_by: 'package'`; a row an admin created (`admin`) or edited (`customized`, stamped by a beforeUpdate hook) is never overwritten. - showcase: require the `webhooks` + `realtime` capabilities (so the dispatcher actually mounts) and ship the demo webhook inactive (placeholder endpoint). - Fix the stale `SysWebhookDelivery` import in the i18n extract config (dead since delivery moved to service-messaging). Connector `webhooks` remain not-yet-enforced (#3197). Registering `webhook` as a metadata type + GOVERNED liveness enrollment is a tracked follow-up. Verified: 9 new unit tests (mapping / idempotency / seed-not-clobber / invalid / end-to-end dispatch), red-proofed; and a real showcase boot — declared webhook materializes into a sys_webhook row, same-DB reboot stays idempotent, and an admin's customized edit survives redeploy. Co-Authored-By: Claude Fable 5 --- .../webhook-authoring-surface-bridge.md | 39 +++ examples/app-showcase/objectstack.config.ts | 2 +- .../src/automation/webhooks/index.ts | 26 +- .../scripts/i18n-extract.config.ts | 6 +- .../src/bootstrap-declared-webhooks.test.ts | 283 ++++++++++++++++++ .../src/bootstrap-declared-webhooks.ts | 205 +++++++++++++ .../plugin-webhooks/src/sys-webhook.object.ts | 53 +++- .../src/webhook-outbox-plugin.ts | 46 +++ .../plugin-webhooks/src/webhook-provenance.ts | 86 ++++++ packages/spec/src/automation/webhook.zod.ts | 17 +- 10 files changed, 742 insertions(+), 21 deletions(-) create mode 100644 .changeset/webhook-authoring-surface-bridge.md create mode 100644 packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts create mode 100644 packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts create mode 100644 packages/plugins/plugin-webhooks/src/webhook-provenance.ts diff --git a/.changeset/webhook-authoring-surface-bridge.md b/.changeset/webhook-authoring-surface-bridge.md new file mode 100644 index 0000000000..98d72f5896 --- /dev/null +++ b/.changeset/webhook-authoring-surface-bridge.md @@ -0,0 +1,39 @@ +--- +"@objectstack/plugin-webhooks": minor +"@objectstack/spec": patch +--- + +fix(webhooks): materialize stack-declared webhooks into the dispatcher (#3461) + +A webhook authored declaratively — `defineStack({ webhooks })` / `defineWebhook()`, +validated against the spec `WebhookSchema` — was a **silent no-op**. The runtime +dispatcher (`AutoEnqueuer`) fans out off `sys_webhook` DATA rows (`object_name` / +`active`), which until now were only ever written by hand through the object's +CRUD UI. Nothing turned a declared webhook (`object` / `isActive`) into a +dispatchable row, so authoring `webhooks:` on a stack produced `webhook` metadata +that never fired (ADR-0078). The showcase app itself shipped a `webhooks:` entry +that did nothing. + +`@objectstack/plugin-webhooks` now bridges the two on boot: + +- **`bootstrapDeclaredWebhooks`** reads declared `webhook` metadata from the + ObjectQL registry (where the manifest decomposition already parks + `stack.webhooks`), validates each through `WebhookSchema.parse()` — the spec + schema finally has a real consumer — and materializes it into a `sys_webhook` + row, mapping `object → object_name`, `isActive → active`, and stashing the full + envelope (headers / secret / retry / timeout) in `definition_json`. The + auto-enqueuer's first cache refresh then picks the row up and dispatches it. +- **Seed-not-clobber provenance** (mirrors `sys_sharing_rule`, #2909): `sys_webhook` + gains `managed_by` / `customized` columns. Declared webhooks re-seed every boot + as `managed_by: 'package'`, but a row an admin created (`managed_by: 'admin'`) or + edited in Setup (`customized: true`, stamped by a `beforeUpdate` hook) is never + overwritten — a deactivated noisy webhook survives redeploys. + +Connector-declared `webhooks` remain not-yet-enforced (that is a separate seam, +#3197). Registering `webhook` as a first-class metadata type + enrolling it in the +liveness `GOVERNED` set is a tracked follow-up. + +Migration: none required. Existing hand-authored `sys_webhook` rows default to +`managed_by: 'admin'` and are never touched by the seeder. Anyone who authored +`webhooks:` on a stack expecting it to fire will find it now does — review those +declarations (especially `url` / `isActive`) before upgrading. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 8338a4770a..c887d19e23 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -101,7 +101,7 @@ export default defineStack({ // blueprint flow to auto-create a writable "app package" home // (ADR-0033 zero-package app building) and the Studio package // selector to list DB packages. - requires: ['ui', 'automation', 'approvals', 'messaging', 'triggers', 'job', 'marketplace'], + requires: ['ui', 'automation', 'approvals', 'messaging', 'triggers', 'job', 'marketplace', 'webhooks', 'realtime'], // Concrete connectors for the `connector_action` node. The baseline engine // ships the dispatch node + an empty registry; these plugins populate it. diff --git a/examples/app-showcase/src/automation/webhooks/index.ts b/examples/app-showcase/src/automation/webhooks/index.ts index 61714ae008..10c5b009c9 100644 --- a/examples/app-showcase/src/automation/webhooks/index.ts +++ b/examples/app-showcase/src/automation/webhooks/index.ts @@ -1,19 +1,29 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { defineWebhook } from '@objectstack/spec/automation'; + /** * Outbound webhook — fans out task changes to an external endpoint with a - * retry policy. Validated as part of `defineStack({ webhooks })`. + * retry policy. Authored via `defineStack({ webhooks })`; on boot the webhooks + * plugin materializes this into a `sys_webhook` row (#3461) that the + * auto-enqueuer dispatches off. + * + * Shipped INACTIVE on purpose: `hooks.example` is a placeholder endpoint, so an + * active subscription would emit a failed HTTP delivery on every task change. + * The demo flow is to open Setup → Integrations → Webhooks and flip this row to + * Active (pointing it at a real endpoint) — that admin edit is remembered + * (`customized: true`) and survives redeploys. */ -export const TaskChangedWebhook = { +export const TaskChangedWebhook = defineWebhook({ name: 'showcase_task_changed', label: 'Task Changed → External', object: 'showcase_task', - triggers: ['create', 'update', 'delete'] as ('create' | 'update' | 'delete')[], + triggers: ['create', 'update', 'delete'], url: 'https://hooks.example/showcase/task', - method: 'POST' as const, - retryPolicy: { maxRetries: 3, backoffStrategy: 'exponential' as const, initialDelayMs: 1000, maxDelayMs: 30000 }, - isActive: true, - description: 'Sends task lifecycle events to an external system.', -}; + method: 'POST', + retryPolicy: { maxRetries: 3, backoffStrategy: 'exponential', initialDelayMs: 1000, maxDelayMs: 30000 }, + isActive: false, + description: 'Sends task lifecycle events to an external system. Activate in Setup and point at a real endpoint.', +}); export const allWebhooks = [TaskChangedWebhook]; diff --git a/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts b/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts index 354dc7ea11..12dd677795 100644 --- a/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts @@ -14,7 +14,9 @@ import { defineStack } from '@objectstack/spec'; import { SysWebhook } from '../src/sys-webhook.object.js'; -import { SysWebhookDelivery } from '../src/sys-webhook-delivery.object.js'; +// NOTE: sys_webhook_delivery moved to @objectstack/service-messaging +// (sys_http_delivery, ADR-0018 M3) — this plugin no longer owns a delivery +// object, so it is not extracted here. import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; @@ -22,7 +24,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; export default defineStack({ name: 'plugin-webhooks-i18n-extract', - objects: [SysWebhook, SysWebhookDelivery] as any, + objects: [SysWebhook] as any, translations: [ { en: { objects: enObjects } }, { 'zh-CN': { objects: zhCNObjects } }, diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts new file mode 100644 index 0000000000..4efc769730 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredWebhooks — the ingestion bridge that closes #3461. + * + * Verifies that stack/connector-declared `webhook` metadata (spec shape: + * `object` / `isActive`) is materialized into `sys_webhook` data rows + * (`object_name` / `active` / `definition_json`), idempotently and without + * clobbering admin edits — and that the dispatcher then sees those rows. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js'; +import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { bindWebhookProvenanceStamp } from './webhook-provenance.js'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +interface HookEntry { + event: string; + handler: (ctx: any) => any; + object?: string; + packageId?: string; +} + +/** + * A small engine fake that mirrors the real ObjectQL surface the bridge and + * provenance hook touch: `find({ filter | where })`, `insert`, update-by-id (the + * patch carries `id`, no `where`), a `_registry.listItems(type)` for declared + * metadata, and `beforeUpdate` hooks that run inside `update()`. + */ +class FakeEngine { + rows: Record = {}; + private hooks: HookEntry[] = []; + private declared: Record = {}; + + constructor(seed?: { rows?: Record; declared?: Record }) { + if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); + if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); + } + + // Declared-metadata registry (where manifest decomposition parks stack.webhooks). + get _registry() { + return { + listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })), + }; + } + + private matches(row: any, cond?: Record): boolean { + if (!cond) return true; + return Object.entries(cond).every(([k, v]) => row[k] === v); + } + + async find(name: string, q?: any): Promise { + const all = this.rows[name] ?? []; + const cond = q?.filter ?? q?.where; + const out = all.filter((r) => this.matches(r, cond)); + return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; + } + async findOne(name: string, q?: any): Promise { + return (await this.find(name, q))[0] ?? null; + } + async insert(name: string, data: any): Promise { + const arr = (this.rows[name] = this.rows[name] ?? []); + arr.push({ ...data }); + return data; + } + async update(name: string, data: any, opts?: any): Promise { + // Run beforeUpdate hooks (the provenance stamp lives here). + const id = data?.id ?? opts?.where?.id; + const ctx = { input: { id, data }, session: opts?.context }; + for (const h of this.hooks) { + if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) { + await h.handler(ctx); + } + } + const arr = this.rows[name] ?? []; + const cond = opts?.where ?? (id ? { id } : undefined); + for (const r of arr) { + if (this.matches(r, cond)) Object.assign(r, data); + } + return { affected: 0 }; + } + async delete(): Promise { + return { affected: 0 }; + } + async count(name: string): Promise { + return (this.rows[name] ?? []).length; + } + async aggregate(): Promise { + return []; + } + + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { + this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); + } + unregisterHooksByPackage(packageId: string): number { + const before = this.hooks.length; + this.hooks = this.hooks.filter((h) => h.packageId !== packageId); + return before - this.hooks.length; + } +} + +class FakeRealtime { + private subs = new Map(); + private n = 0; + async publish(event: any): Promise { + for (const sub of this.subs.values()) { + const o = sub.opts ?? {}; + if (o.object && event.object !== o.object) continue; + await sub.handler(event); + } + } + async subscribe(_channel: string, handler: any, opts?: any): Promise { + const id = `s-${++this.n}`; + this.subs.set(id, { handler, opts }); + return id; + } + async unsubscribe(id: string): Promise { + this.subs.delete(id); + } +} + +const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] }; + +function declaredWebhook(over: Record = {}): any { + return { + name: 'task_changed', + label: 'Task Changed', + object: 'showcase_task', + triggers: ['create', 'update'], + url: 'https://hooks.example/task', + method: 'POST', + isActive: true, + ...over, + }; +} + +async function flush() { + await new Promise((r) => setTimeout(r, 0)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('bootstrapDeclaredWebhooks', () => { + it('materializes a declared webhook into a sys_webhook row (object→object_name, isActive→active)', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const res = await bootstrapDeclaredWebhooks(engine as any, null); + + expect(res).toEqual({ seeded: 1, skipped: 0 }); + const rows = engine.rows['sys_webhook']; + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.name).toBe('task_changed'); + expect(row.object_name).toBe('showcase_task'); // object → object_name + expect(row.active).toBe(true); // isActive → active + expect(row.method).toBe('post'); // lowercased to match the select options + expect(row.managed_by).toBe('package'); + expect(row.customized).toBe(false); + // Full validated envelope stashed for the enqueuer's advanced-config read. + const defn = JSON.parse(row.definition_json); + expect(defn.object).toBe('showcase_task'); + expect(defn.timeoutMs).toBe(30000); // default filled by WebhookSchema.parse + }); + + it('maps isActive:false → active:false so a placeholder webhook ships inactive', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } }); + await bootstrapDeclaredWebhooks(engine as any, null); + expect(engine.rows['sys_webhook'][0].active).toBe(false); + }); + + it('is idempotent — a second boot updates in place, never duplicates', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + await bootstrapDeclaredWebhooks(engine as any, null); + await bootstrapDeclaredWebhooks(engine as any, null); + expect(engine.rows['sys_webhook']).toHaveLength(1); + }); + + it('propagates a declared change to a pristine (non-customized) row', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + await bootstrapDeclaredWebhooks(engine as any, null); + + engine['declared'].webhook = [declaredWebhook({ url: 'https://hooks.example/task-v2' })]; + await bootstrapDeclaredWebhooks(engine as any, null); + + expect(engine.rows['sys_webhook']).toHaveLength(1); + expect(engine.rows['sys_webhook'][0].url).toBe('https://hooks.example/task-v2'); + }); + + it('seed-not-clobber: an admin edit (customized) survives the next boot', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + bindWebhookProvenanceStamp(engine as any); + await bootstrapDeclaredWebhooks(engine as any, null); + + // Admin deactivates the noisy webhook through the CRUD door (non-system). + const id = engine.rows['sys_webhook'][0].id; + await engine.update('sys_webhook', { id, active: false }, { context: ADMIN_CTX }); + expect(engine.rows['sys_webhook'][0].customized).toBe(true); // hook stamped it + + // Redeploy re-runs the seeder — the declared row is still active:true, but + // the admin's active:false must win. + await bootstrapDeclaredWebhooks(engine as any, null); + expect(engine.rows['sys_webhook'][0].active).toBe(false); + }); + + it('never overwrites an admin-authored row that collides by name', async () => { + const engine = new FakeEngine({ + rows: { + sys_webhook: [ + { id: 'admin-1', name: 'task_changed', url: 'https://admin.example', active: true, managed_by: 'admin', customized: false }, + ], + }, + declared: { webhook: [declaredWebhook()] }, + }); + const res = await bootstrapDeclaredWebhooks(engine as any, null); + expect(res).toEqual({ seeded: 0, skipped: 1 }); + expect(engine.rows['sys_webhook']).toHaveLength(1); + expect(engine.rows['sys_webhook'][0].url).toBe('https://admin.example'); // untouched + }); + + it('skips an invalid declared webhook (bad URL) with a warning, without crashing boot', async () => { + const warn = vi.fn(); + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] }, + }); + const res = await bootstrapDeclaredWebhooks(engine as any, null, { warn }); + + expect(res.seeded).toBe(1); // the good one still lands + expect(res.skipped).toBe(1); + expect(engine.rows['sys_webhook'].map((r) => r.name)).toEqual(['good']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed validation'), expect.objectContaining({ name: 'bad' })); + }); + + it('is a no-op when nothing is declared', async () => { + const engine = new FakeEngine(); + const res = await bootstrapDeclaredWebhooks(engine as any, null); + expect(res).toEqual({ seeded: 0, skipped: 0 }); + expect(engine.rows['sys_webhook']).toBeUndefined(); + }); + + it('end-to-end: a declared webhook, once materialized, dispatches on a matching data event', async () => { + const engine = new FakeEngine({ + declared: { + webhook: [ + declaredWebhook({ + triggers: ['create'], + secret: 'shh', + headers: { 'X-Env': 'prod' }, + }), + ], + }, + }); + await bootstrapDeclaredWebhooks(engine as any, null); + + const realtime = new FakeRealtime(); + const calls: any[] = []; + const enqueue: HttpEnqueueFn = async (input) => { + calls.push(input); + return 'id'; + }; + const ae = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish({ + type: 'data.record.created', + object: 'showcase_task', + payload: { recordId: 't-1' }, + timestamp: '2026-05-24T00:00:00.000Z', + }); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://hooks.example/task'); + // headers + secret came from the definition_json envelope the bridge wrote. + expect(calls[0].signingSecret).toBe('shh'); + expect(calls[0].headers).toEqual({ 'X-Env': 'prod' }); + await ae.stop(); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts new file mode 100644 index 0000000000..86cd161c11 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks` + * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461). + * + * ## The disconnect this closes + * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`, + * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and + * is generically decomposed into the ObjectQL registry at boot as metadata + * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads + * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only + * ever written by hand through the object's CRUD UI. Nothing bridged the two — + * so authoring `webhooks:` on a stack produced metadata artifacts that never + * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that + * missing ingestion path. + * + * ## Shape translation (authoring → runtime row) + * The spec shape diverges from the runtime column names; we map only at this + * boundary and stash the full validated envelope in `definition_json` (whence + * the enqueuer reads headers / secret / timeout): + * - `object` → `object_name` + * - `isActive` → `active` + * - `triggers` / `url` / `method` / `label` / `description` → same-named columns + * - the entire parsed {@link Webhook} → `definition_json` (JSON string) + * + * Each item is validated through `WebhookSchema.parse()` first — this gives the + * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get + * applied) and rejects malformed authoring with a warning instead of crashing + * boot. + * + * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909) + * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks + * ship with the app/package, so they seed with `managed_by: 'package'` + * provenance and re-seed on every boot — but a row an admin has created + * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by + * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly, + * an admin's `active: false` on a noisy webhook survives redeploys. + * + * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache + * refresh already sees the declared rows. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { WebhookSchema, type Webhook } from '@objectstack/spec/automation'; + +/** System write context — the boot seeder is not an admin authoring action. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +interface Logger { + info?: (msg: string, meta?: unknown) => void; + warn?: (msg: string, meta?: unknown) => void; +} + +/** Random id with a stable prefix — mirrors the sharing-rule seeder. */ +function uid(prefix: string): string { + const g: any = globalThis as any; + if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`; + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Read declared `webhook` items from the ObjectQL registry (where the manifest + * decomposition parks `stack.webhooks`), falling back to the metadata service. + * Items may be wrapped as `{ content }` — unwrap to the raw authoring object. + */ +function readDeclared(engine: any, metadataService: any, type: string): any[] { + try { + const reg = engine?._registry; + if (reg?.listItems) { + const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + if (items.length > 0) return items; + } + } catch { + /* fall through to metadata service */ + } + try { + const listed = metadataService?.list?.(type); + const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []); + return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : []; + } catch { + return []; + } +} + +export interface BootstrapDeclaredWebhooksResult { + seeded: number; + skipped: number; +} + +/** + * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run + * on every boot. + */ +export async function bootstrapDeclaredWebhooks( + engine: IDataEngine, + metadataService: any, + logger?: Logger, + subscriptionsObject = 'sys_webhook', +): Promise { + const declared = readDeclared(engine, metadataService, 'webhook'); + if (declared.length === 0) return { seeded: 0, skipped: 0 }; + + const now = new Date().toISOString(); + let seeded = 0; + let skipped = 0; + + for (const raw of declared) { + // Validate + fill defaults through the canonical spec schema. A real + // consumer at last — a malformed webhook warns and is skipped, never + // crashing boot. + let wh: Webhook; + try { + wh = WebhookSchema.parse(raw); + } catch (err: any) { + logger?.warn?.('[webhook] declared webhook failed validation — skipped', { + name: (raw as any)?.name, + error: err?.message ?? String(err), + }); + skipped += 1; + continue; + } + + try { + const existing = await engine.find(subscriptionsObject, { + filter: { name: wh.name }, + limit: 1, + context: SYSTEM_CTX, + } as any); + const row: any = Array.isArray(existing) ? existing[0] : undefined; + + if (row) { + // Admin owns a same-named row, or has edited this seeded one — never + // clobber. `active: false` on a noisy webhook must survive redeploys. + if (row.managed_by === 'admin') { + logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', { + name: wh.name, + }); + skipped += 1; + continue; + } + if (row.customized === true) { + skipped += 1; + continue; + } + const patch = { + id: row.id, + ...mapWebhookToRow(wh), + // Adopt pristine/legacy (pre-provenance) rows so future boots + // recognize them as package-managed. + managed_by: 'package', + updated_at: now, + }; + await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any); + seeded += 1; + continue; + } + + const newRow = { + id: uid('whk'), + ...mapWebhookToRow(wh), + managed_by: 'package', + customized: false, + created_at: now, + updated_at: now, + }; + await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any); + seeded += 1; + } catch (err: any) { + logger?.warn?.('[webhook] declared webhook seed failed', { + name: wh.name, + error: err?.message ?? String(err), + }); + skipped += 1; + } + } + + logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', { + seeded, + skipped, + total: declared.length, + }); + return { seeded, skipped }; +} + +/** + * Translate a validated {@link Webhook} into `sys_webhook` column values. + * `object → object_name`, `isActive → active`; the full envelope is stashed in + * `definition_json` for the enqueuer's advanced-config read (headers/secret/…). + */ +function mapWebhookToRow(wh: Webhook): Record { + return { + name: wh.name, + label: wh.label ?? wh.name, + object_name: wh.object ?? null, + triggers: wh.triggers ?? [], + url: wh.url, + // Store lowercase to match the object's Field.select option values + // (get/post/…); the enqueuer upper-cases before delivery either way. + method: String(wh.method ?? 'POST').toLowerCase(), + description: wh.description ?? null, + active: wh.isActive !== false, + definition_json: JSON.stringify(wh), + }; +} diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index cef1ec3917..5ac357c33e 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -10,12 +10,19 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Studio UI without code changes. The canonical Zod schema for the * `definition_json` envelope lives at `@objectstack/spec/automation/webhook`. * - * One row per `name`. The automation runtime - * (`@objectstack/service-automation`, built-in `http_request` node) loads - * active rows on boot + on `sys_webhook:changed` events, registers - * `afterInsert` / `afterUpdate` / `afterDelete` listeners for the - * targeted object, and dispatches outbound HTTP calls when matching - * record events fire. + * ## Two authoring doors, one row + * Rows land here two ways, distinguished by the `managed_by` provenance column: + * - **admin** — created/edited directly through this object's CRUD UI. + * - **package** — declared in code (`defineStack({ webhooks })` / + * `defineWebhook()`) and materialized on boot by + * `bootstrapDeclaredWebhooks` (#3461). Re-seeded every boot, but an admin + * edit stamps `customized: true` and freezes the row (seed-not-clobber, + * mirrors `sys_sharing_rule` #2909). + * + * One row per `name`. This plugin's {@link AutoEnqueuer} loads active rows on + * boot + on `sys_webhook:changed` events, and turns matching `data.record.*` + * events into deliveries on the shared `service-messaging` HTTP outbox + * (ADR-0018 M3 — `sys_http_delivery`, drained by the messaging dispatcher). * * Ownership (ADR-0029 K2.a): this object is **owned by * `@objectstack/plugin-webhooks`** — the plugin that consumes these rows — @@ -43,7 +50,7 @@ export const SysWebhook = ObjectSchema.create({ // create/edit/delete so admins can at least toggle `active` and edit // simple URL/method fields without round-tripping through code. userActions: { create: true, edit: true, delete: true, import: false }, - description: 'Outbound HTTP webhook subscription. Authored via defineWebhook() in code or the Studio editor; executed by the HTTP connector plugin.', + description: 'Outbound HTTP webhook subscription. Declared in code via defineStack({ webhooks }) / defineWebhook() (materialized into rows on boot) or authored directly in the Studio editor; dispatched by the webhook auto-enqueuer onto the shared HTTP outbox.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', @@ -174,6 +181,38 @@ export const SysWebhook = ObjectSchema.create({ group: 'Definition', }), + // ── Provenance (#3461 — record-authoritative seed-not-clobber) ── + // Mirrors sys_sharing_rule (#2909). Both columns are `readonly`: the + // engine strips them from non-system payloads (forge/clear-proof), while + // bootstrapDeclaredWebhooks and the provenance stamp hook write with + // isSystem. Deliberately NOT a write gate: webhooks are a first-class admin + // authoring/tuning surface — admins may edit or deactivate a package row; + // the seeder simply stops overwriting it once `customized` is stamped. + managed_by: Field.select( + ['platform', 'package', 'admin'], + { + label: 'Managed By', + required: false, + readonly: true, + defaultValue: 'admin', + description: + 'Record provenance: platform = framework built-in / package = app/package-declared ' + + '(boot-seeded from defineStack webhooks) / admin = created in Setup.', + group: 'System', + }, + ), + + customized: Field.boolean({ + label: 'Customized', + required: false, + readonly: true, + defaultValue: false, + description: + 'Set when an admin edits a package-declared webhook; boot seeding will no longer ' + + 'overwrite the row (a deactivated noisy webhook survives redeploys). Meaningless on admin rows.', + group: 'System', + }), + created_at: Field.datetime({ label: 'Created At', required: true, diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index ff5e04d305..30662b96d5 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -5,6 +5,8 @@ import type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts' import type { EnqueueHttpInput } from '@objectstack/service-messaging'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; import { SysWebhook } from './sys-webhook.object.js'; +import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js'; /** * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface @@ -62,6 +64,8 @@ export class WebhookOutboxPlugin implements Plugin { dependencies = ['com.objectstack.service.messaging']; private autoEnqueuer: AutoEnqueuer | undefined; + /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */ + private boundEngine: any; constructor(private readonly options: WebhookOutboxPluginOptions = {}) {} @@ -117,6 +121,12 @@ export class WebhookOutboxPlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { + // Materialize declared webhooks FIRST — this only needs the data + // engine, so it must not be gated behind the auto-enqueue + // dispatch prerequisites (realtime + messaging). Otherwise a + // deployment without realtime would silently fail to materialize + // declared webhooks — the very no-op this bridge closes (#3461). + await this.bootDeclaredWebhooks(ctx); await this.bootAutoEnqueue(ctx, autoEnqueueOpt); this.registerAdminRoutes(ctx); }); @@ -129,6 +139,10 @@ export class WebhookOutboxPlugin implements Plugin { async dispose(): Promise { await this.autoEnqueuer?.stop(); + if (this.boundEngine) { + try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ } + this.boundEngine = undefined; + } } private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined { @@ -136,6 +150,38 @@ export class WebhookOutboxPlugin implements Plugin { return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined; } + /** + * [#3461] Bridge the declarative authoring surface to the dispatcher: + * materialize stack/connector-declared `webhook` metadata into `sys_webhook` + * rows so the auto-enqueuer (and the Studio UI) can see them. + * + * Gated on the DATA ENGINE alone — deliberately independent of the + * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing + * rows is a pure write; a deployment that mounts the webhook plugin without + * realtime must still get its declared webhooks into the table (and the + * Setup UI), even if nothing dispatches them yet. Runs before + * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows. + */ + private async bootDeclaredWebhooks(ctx: PluginContext): Promise { + const engine = this.tryGetService(ctx, ['objectql', 'data']); + if (!engine) { + ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available'); + return; + } + // Bind the provenance stamp so an admin edit freezes a seeded row. + this.boundEngine = engine; + bindWebhookProvenanceStamp(engine as any, ctx.logger as any); + let metadataService: any; + try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } + try { + await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any); + } catch (err: any) { + ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', { + error: err?.message ?? String(err), + }); + } + } + private async bootAutoEnqueue( ctx: PluginContext, opt: boolean | AutoEnqueuerOptions, diff --git a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts new file mode 100644 index 0000000000..e71d77a09a --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#3461] Provenance stamp for `sys_webhook`. + * + * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed + * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such + * as flipping a noisy webhook to `active: false` — is the authority. The seeder + * skips rows marked `customized`, so this hook is the half that DETECTS the + * admin edit: any non-system update touching a `package`/`platform`-seeded row + * stamps `customized: true` onto the payload. + * + * Why a data hook (and not a write gate or the REST layer), verbatim to the + * sys_sharing_rule rationale (#2909 T1): + * - admins edit webhooks through several doors (Setup UI generic data door, + * scripts, console) — an engine hook covers them all; + * - there is deliberately NO write gate here: webhooks are a first-class admin + * authoring surface, so edits are allowed — they just have to be remembered; + * - both provenance columns are `readonly`, and the engine's readonly strip + * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run + * — so a caller can never forge/clear `customized`, while this hook's stamp + * survives. + * + * Known boundary: multi-row updates (no single `input.id`) are not stamped — + * every webhook-editing UI path updates by id. + */ + +interface MinimalEngine { + find(object: string, opts?: any): Promise; + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; + unregisterHooksByPackage(packageId: string): number; +} + +interface MinimalLogger { + info?: (msg: string, meta?: Record) => void; + warn?: (msg: string, meta?: Record) => void; +} + +export const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +export function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void { + if (typeof engine?.registerHook !== 'function') return; + engine.registerHook( + 'beforeUpdate', + async (ctx: any) => { + // Seeder / boot reconcilers write with isSystem — the package door, not + // an admin customization. + if ((ctx?.session as any)?.isSystem) return; + const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id; + if (!id) return; // multi-row update — see boundary note above + const data = ctx?.input?.data; + if (!data || typeof data !== 'object') return; + try { + // `previous` is not resolved before beforeUpdate hooks run — read the + // current row ourselves (system ctx: this is a provenance check, not + // an authorization decision). + const rows = await engine.find('sys_webhook', { + filter: { id }, + fields: ['id', 'managed_by', 'customized'], + limit: 1, + context: SYSTEM_CTX, + }); + const row = Array.isArray(rows) ? rows[0] : undefined; + if (!row) return; + if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) { + (data as any).customized = true; + } + } catch (err: any) { + logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', { + id, + error: err?.message, + }); + } + }, + { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 }, + ); + logger?.info?.('[webhook] provenance stamp hook bound'); +} + +export function unbindWebhookProvenanceStamp(engine: MinimalEngine): void { + if (typeof engine?.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE); + } +} diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index d25d1b0353..55a1e63685 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -33,13 +33,24 @@ export const WebhookTriggerType = z.enum([ /** * CANONICAL WEBHOOK DEFINITION - * + * * This is the single source of truth for webhook configuration across ObjectStack. * All other protocols (workflow, connector, etc.) should import and reference this schema. - * + * * Webhook Protocol - Outbound HTTP Integration * Push data to external URLs when events occur in the system. - * + * + * **RUNTIME MATERIALIZATION (#3461):** + * A webhook authored on a stack (`defineStack({ webhooks })`) is not dispatched + * directly from this envelope. On boot, `@objectstack/plugin-webhooks` + * materializes each declared webhook into a `sys_webhook` data row (mapping + * `object → object_name`, `isActive → active`, and stashing the full envelope + * in `definition_json`); the auto-enqueuer dispatches off those rows. Declared + * webhooks re-seed every boot as `managed_by: 'package'`, but a row an admin has + * edited in Setup (`customized: true`) is never clobbered — a deactivated noisy + * webhook survives redeploys. Authoring `webhooks:` is therefore live, not a + * no-op. (Connector `webhooks` remain NOT-yet-enforced — see #3197.) + * * **NAMING CONVENTION:** * Webhook names are machine identifiers and must be lowercase snake_case. *