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
39 changes: 39 additions & 0 deletions .changeset/webhook-authoring-surface-bridge.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 18 additions & 8 deletions examples/app-showcase/src/automation/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -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];
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@

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';
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 } },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any[]> = {};
private hooks: HookEntry[] = [];
private declared: Record<string, any[]> = {};

constructor(seed?: { rows?: Record<string, any[]>; declared?: Record<string, any[]> }) {
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<string, any>): boolean {
if (!cond) return true;
return Object.entries(cond).every(([k, v]) => row[k] === v);
}

async find(name: string, q?: any): Promise<any[]> {
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<any> {
return (await this.find(name, q))[0] ?? null;
}
async insert(name: string, data: any): Promise<any> {
const arr = (this.rows[name] = this.rows[name] ?? []);
arr.push({ ...data });
return data;
}
async update(name: string, data: any, opts?: any): Promise<any> {
// 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<any> {
return { affected: 0 };
}
async count(name: string): Promise<number> {
return (this.rows[name] ?? []).length;
}
async aggregate(): Promise<any[]> {
return [];
}

registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): 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<string, { handler: any; opts?: any }>();
private n = 0;
async publish(event: any): Promise<void> {
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<string> {
const id = `s-${++this.n}`;
this.subs.set(id, { handler, opts });
return id;
}
async unsubscribe(id: string): Promise<void> {
this.subs.delete(id);
}
}

const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] };

function declaredWebhook(over: Record<string, any> = {}): 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();
});
});
Loading