@objectstack/plugin-webhooks 16.1.0 → 17.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +20 -20
  2. package/CHANGELOG.md +247 -0
  3. package/dist/{chunk-RBCYKJVL.js → chunk-6EPMRZ7I.js} +28 -2
  4. package/dist/chunk-6EPMRZ7I.js.map +1 -0
  5. package/dist/{chunk-PTMJ5BLL.cjs → chunk-QUTQSOQC.cjs} +28 -2
  6. package/dist/chunk-QUTQSOQC.cjs.map +1 -0
  7. package/dist/index.cjs +222 -24
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +15 -0
  10. package/dist/index.d.ts +15 -0
  11. package/dist/index.js +202 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/schema.cjs +2 -2
  14. package/dist/schema.d.cts +339 -143
  15. package/dist/schema.d.ts +339 -143
  16. package/dist/schema.js +1 -1
  17. package/dist/translations-CBQRUIS5.cjs +383 -0
  18. package/dist/translations-CBQRUIS5.cjs.map +1 -0
  19. package/dist/translations-Y3CXWOTM.js +383 -0
  20. package/dist/translations-Y3CXWOTM.js.map +1 -0
  21. package/package.json +4 -4
  22. package/scripts/i18n-extract.config.ts +5 -3
  23. package/src/bootstrap-declared-webhooks.test.ts +283 -0
  24. package/src/bootstrap-declared-webhooks.ts +205 -0
  25. package/src/sys-webhook.object.ts +50 -12
  26. package/src/translations/bundle-ownership.test.ts +41 -0
  27. package/src/translations/en.objects.generated.ts +27 -113
  28. package/src/translations/es-ES.objects.generated.ts +27 -113
  29. package/src/translations/ja-JP.objects.generated.ts +27 -113
  30. package/src/translations/zh-CN.objects.generated.ts +27 -113
  31. package/src/webhook-outbox-plugin.ts +46 -0
  32. package/src/webhook-provenance.ts +86 -0
  33. package/dist/chunk-PTMJ5BLL.cjs.map +0 -1
  34. package/dist/chunk-RBCYKJVL.js.map +0 -1
  35. package/dist/translations-EPJYANXJ.js +0 -727
  36. package/dist/translations-EPJYANXJ.js.map +0 -1
  37. package/dist/translations-L2JAQDJB.cjs +0 -727
  38. package/dist/translations-L2JAQDJB.cjs.map +0 -1
@@ -0,0 +1,283 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * bootstrapDeclaredWebhooks — the ingestion bridge that closes #3461.
5
+ *
6
+ * Verifies that stack/connector-declared `webhook` metadata (spec shape:
7
+ * `object` / `isActive`) is materialized into `sys_webhook` data rows
8
+ * (`object_name` / `active` / `definition_json`), idempotently and without
9
+ * clobbering admin edits — and that the dispatcher then sees those rows.
10
+ */
11
+
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js';
14
+ import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';
15
+ import { bindWebhookProvenanceStamp } from './webhook-provenance.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Fakes
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface HookEntry {
22
+ event: string;
23
+ handler: (ctx: any) => any;
24
+ object?: string;
25
+ packageId?: string;
26
+ }
27
+
28
+ /**
29
+ * A small engine fake that mirrors the real ObjectQL surface the bridge and
30
+ * provenance hook touch: `find({ filter | where })`, `insert`, update-by-id (the
31
+ * patch carries `id`, no `where`), a `_registry.listItems(type)` for declared
32
+ * metadata, and `beforeUpdate` hooks that run inside `update()`.
33
+ */
34
+ class FakeEngine {
35
+ rows: Record<string, any[]> = {};
36
+ private hooks: HookEntry[] = [];
37
+ private declared: Record<string, any[]> = {};
38
+
39
+ constructor(seed?: { rows?: Record<string, any[]>; declared?: Record<string, any[]> }) {
40
+ if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows));
41
+ if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared));
42
+ }
43
+
44
+ // Declared-metadata registry (where manifest decomposition parks stack.webhooks).
45
+ get _registry() {
46
+ return {
47
+ listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })),
48
+ };
49
+ }
50
+
51
+ private matches(row: any, cond?: Record<string, any>): boolean {
52
+ if (!cond) return true;
53
+ return Object.entries(cond).every(([k, v]) => row[k] === v);
54
+ }
55
+
56
+ async find(name: string, q?: any): Promise<any[]> {
57
+ const all = this.rows[name] ?? [];
58
+ const cond = q?.filter ?? q?.where;
59
+ const out = all.filter((r) => this.matches(r, cond));
60
+ return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out;
61
+ }
62
+ async findOne(name: string, q?: any): Promise<any> {
63
+ return (await this.find(name, q))[0] ?? null;
64
+ }
65
+ async insert(name: string, data: any): Promise<any> {
66
+ const arr = (this.rows[name] = this.rows[name] ?? []);
67
+ arr.push({ ...data });
68
+ return data;
69
+ }
70
+ async update(name: string, data: any, opts?: any): Promise<any> {
71
+ // Run beforeUpdate hooks (the provenance stamp lives here).
72
+ const id = data?.id ?? opts?.where?.id;
73
+ const ctx = { input: { id, data }, session: opts?.context };
74
+ for (const h of this.hooks) {
75
+ if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) {
76
+ await h.handler(ctx);
77
+ }
78
+ }
79
+ const arr = this.rows[name] ?? [];
80
+ const cond = opts?.where ?? (id ? { id } : undefined);
81
+ for (const r of arr) {
82
+ if (this.matches(r, cond)) Object.assign(r, data);
83
+ }
84
+ return { affected: 0 };
85
+ }
86
+ async delete(): Promise<any> {
87
+ return { affected: 0 };
88
+ }
89
+ async count(name: string): Promise<number> {
90
+ return (this.rows[name] ?? []).length;
91
+ }
92
+ async aggregate(): Promise<any[]> {
93
+ return [];
94
+ }
95
+
96
+ registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void {
97
+ this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId });
98
+ }
99
+ unregisterHooksByPackage(packageId: string): number {
100
+ const before = this.hooks.length;
101
+ this.hooks = this.hooks.filter((h) => h.packageId !== packageId);
102
+ return before - this.hooks.length;
103
+ }
104
+ }
105
+
106
+ class FakeRealtime {
107
+ private subs = new Map<string, { handler: any; opts?: any }>();
108
+ private n = 0;
109
+ async publish(event: any): Promise<void> {
110
+ for (const sub of this.subs.values()) {
111
+ const o = sub.opts ?? {};
112
+ if (o.object && event.object !== o.object) continue;
113
+ await sub.handler(event);
114
+ }
115
+ }
116
+ async subscribe(_channel: string, handler: any, opts?: any): Promise<string> {
117
+ const id = `s-${++this.n}`;
118
+ this.subs.set(id, { handler, opts });
119
+ return id;
120
+ }
121
+ async unsubscribe(id: string): Promise<void> {
122
+ this.subs.delete(id);
123
+ }
124
+ }
125
+
126
+ const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] };
127
+
128
+ function declaredWebhook(over: Record<string, any> = {}): any {
129
+ return {
130
+ name: 'task_changed',
131
+ label: 'Task Changed',
132
+ object: 'showcase_task',
133
+ triggers: ['create', 'update'],
134
+ url: 'https://hooks.example/task',
135
+ method: 'POST',
136
+ isActive: true,
137
+ ...over,
138
+ };
139
+ }
140
+
141
+ async function flush() {
142
+ await new Promise((r) => setTimeout(r, 0));
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Tests
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe('bootstrapDeclaredWebhooks', () => {
150
+ it('materializes a declared webhook into a sys_webhook row (object→object_name, isActive→active)', async () => {
151
+ const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
152
+ const res = await bootstrapDeclaredWebhooks(engine as any, null);
153
+
154
+ expect(res).toEqual({ seeded: 1, skipped: 0 });
155
+ const rows = engine.rows['sys_webhook'];
156
+ expect(rows).toHaveLength(1);
157
+ const row = rows[0];
158
+ expect(row.name).toBe('task_changed');
159
+ expect(row.object_name).toBe('showcase_task'); // object → object_name
160
+ expect(row.active).toBe(true); // isActive → active
161
+ expect(row.method).toBe('post'); // lowercased to match the select options
162
+ expect(row.managed_by).toBe('package');
163
+ expect(row.customized).toBe(false);
164
+ // Full validated envelope stashed for the enqueuer's advanced-config read.
165
+ const defn = JSON.parse(row.definition_json);
166
+ expect(defn.object).toBe('showcase_task');
167
+ expect(defn.timeoutMs).toBe(30000); // default filled by WebhookSchema.parse
168
+ });
169
+
170
+ it('maps isActive:false → active:false so a placeholder webhook ships inactive', async () => {
171
+ const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } });
172
+ await bootstrapDeclaredWebhooks(engine as any, null);
173
+ expect(engine.rows['sys_webhook'][0].active).toBe(false);
174
+ });
175
+
176
+ it('is idempotent — a second boot updates in place, never duplicates', async () => {
177
+ const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
178
+ await bootstrapDeclaredWebhooks(engine as any, null);
179
+ await bootstrapDeclaredWebhooks(engine as any, null);
180
+ expect(engine.rows['sys_webhook']).toHaveLength(1);
181
+ });
182
+
183
+ it('propagates a declared change to a pristine (non-customized) row', async () => {
184
+ const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
185
+ await bootstrapDeclaredWebhooks(engine as any, null);
186
+
187
+ engine['declared'].webhook = [declaredWebhook({ url: 'https://hooks.example/task-v2' })];
188
+ await bootstrapDeclaredWebhooks(engine as any, null);
189
+
190
+ expect(engine.rows['sys_webhook']).toHaveLength(1);
191
+ expect(engine.rows['sys_webhook'][0].url).toBe('https://hooks.example/task-v2');
192
+ });
193
+
194
+ it('seed-not-clobber: an admin edit (customized) survives the next boot', async () => {
195
+ const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
196
+ bindWebhookProvenanceStamp(engine as any);
197
+ await bootstrapDeclaredWebhooks(engine as any, null);
198
+
199
+ // Admin deactivates the noisy webhook through the CRUD door (non-system).
200
+ const id = engine.rows['sys_webhook'][0].id;
201
+ await engine.update('sys_webhook', { id, active: false }, { context: ADMIN_CTX });
202
+ expect(engine.rows['sys_webhook'][0].customized).toBe(true); // hook stamped it
203
+
204
+ // Redeploy re-runs the seeder — the declared row is still active:true, but
205
+ // the admin's active:false must win.
206
+ await bootstrapDeclaredWebhooks(engine as any, null);
207
+ expect(engine.rows['sys_webhook'][0].active).toBe(false);
208
+ });
209
+
210
+ it('never overwrites an admin-authored row that collides by name', async () => {
211
+ const engine = new FakeEngine({
212
+ rows: {
213
+ sys_webhook: [
214
+ { id: 'admin-1', name: 'task_changed', url: 'https://admin.example', active: true, managed_by: 'admin', customized: false },
215
+ ],
216
+ },
217
+ declared: { webhook: [declaredWebhook()] },
218
+ });
219
+ const res = await bootstrapDeclaredWebhooks(engine as any, null);
220
+ expect(res).toEqual({ seeded: 0, skipped: 1 });
221
+ expect(engine.rows['sys_webhook']).toHaveLength(1);
222
+ expect(engine.rows['sys_webhook'][0].url).toBe('https://admin.example'); // untouched
223
+ });
224
+
225
+ it('skips an invalid declared webhook (bad URL) with a warning, without crashing boot', async () => {
226
+ const warn = vi.fn();
227
+ const engine = new FakeEngine({
228
+ declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] },
229
+ });
230
+ const res = await bootstrapDeclaredWebhooks(engine as any, null, { warn });
231
+
232
+ expect(res.seeded).toBe(1); // the good one still lands
233
+ expect(res.skipped).toBe(1);
234
+ expect(engine.rows['sys_webhook'].map((r) => r.name)).toEqual(['good']);
235
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed validation'), expect.objectContaining({ name: 'bad' }));
236
+ });
237
+
238
+ it('is a no-op when nothing is declared', async () => {
239
+ const engine = new FakeEngine();
240
+ const res = await bootstrapDeclaredWebhooks(engine as any, null);
241
+ expect(res).toEqual({ seeded: 0, skipped: 0 });
242
+ expect(engine.rows['sys_webhook']).toBeUndefined();
243
+ });
244
+
245
+ it('end-to-end: a declared webhook, once materialized, dispatches on a matching data event', async () => {
246
+ const engine = new FakeEngine({
247
+ declared: {
248
+ webhook: [
249
+ declaredWebhook({
250
+ triggers: ['create'],
251
+ secret: 'shh',
252
+ headers: { 'X-Env': 'prod' },
253
+ }),
254
+ ],
255
+ },
256
+ });
257
+ await bootstrapDeclaredWebhooks(engine as any, null);
258
+
259
+ const realtime = new FakeRealtime();
260
+ const calls: any[] = [];
261
+ const enqueue: HttpEnqueueFn = async (input) => {
262
+ calls.push(input);
263
+ return 'id';
264
+ };
265
+ const ae = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0 });
266
+ await ae.start();
267
+
268
+ await realtime.publish({
269
+ type: 'data.record.created',
270
+ object: 'showcase_task',
271
+ payload: { recordId: 't-1' },
272
+ timestamp: '2026-05-24T00:00:00.000Z',
273
+ });
274
+ await flush();
275
+
276
+ expect(calls).toHaveLength(1);
277
+ expect(calls[0].url).toBe('https://hooks.example/task');
278
+ // headers + secret came from the definition_json envelope the bridge wrote.
279
+ expect(calls[0].signingSecret).toBe('shh');
280
+ expect(calls[0].headers).toEqual({ 'X-Env': 'prod' });
281
+ await ae.stop();
282
+ });
283
+ });
@@ -0,0 +1,205 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`
5
+ * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).
6
+ *
7
+ * ## The disconnect this closes
8
+ * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,
9
+ * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and
10
+ * is generically decomposed into the ObjectQL registry at boot as metadata
11
+ * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads
12
+ * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only
13
+ * ever written by hand through the object's CRUD UI. Nothing bridged the two —
14
+ * so authoring `webhooks:` on a stack produced metadata artifacts that never
15
+ * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that
16
+ * missing ingestion path.
17
+ *
18
+ * ## Shape translation (authoring → runtime row)
19
+ * The spec shape diverges from the runtime column names; we map only at this
20
+ * boundary and stash the full validated envelope in `definition_json` (whence
21
+ * the enqueuer reads headers / secret / timeout):
22
+ * - `object` → `object_name`
23
+ * - `isActive` → `active`
24
+ * - `triggers` / `url` / `method` / `label` / `description` → same-named columns
25
+ * - the entire parsed {@link Webhook} → `definition_json` (JSON string)
26
+ *
27
+ * Each item is validated through `WebhookSchema.parse()` first — this gives the
28
+ * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get
29
+ * applied) and rejects malformed authoring with a warning instead of crashing
30
+ * boot.
31
+ *
32
+ * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)
33
+ * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks
34
+ * ship with the app/package, so they seed with `managed_by: 'package'`
35
+ * provenance and re-seed on every boot — but a row an admin has created
36
+ * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by
37
+ * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,
38
+ * an admin's `active: false` on a noisy webhook survives redeploys.
39
+ *
40
+ * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache
41
+ * refresh already sees the declared rows.
42
+ */
43
+
44
+ import type { IDataEngine } from '@objectstack/spec/contracts';
45
+ import { WebhookSchema, type Webhook } from '@objectstack/spec/automation';
46
+
47
+ /** System write context — the boot seeder is not an admin authoring action. */
48
+ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
49
+
50
+ interface Logger {
51
+ info?: (msg: string, meta?: unknown) => void;
52
+ warn?: (msg: string, meta?: unknown) => void;
53
+ }
54
+
55
+ /** Random id with a stable prefix — mirrors the sharing-rule seeder. */
56
+ function uid(prefix: string): string {
57
+ const g: any = globalThis as any;
58
+ if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
59
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
60
+ }
61
+
62
+ /**
63
+ * Read declared `webhook` items from the ObjectQL registry (where the manifest
64
+ * decomposition parks `stack.webhooks`), falling back to the metadata service.
65
+ * Items may be wrapped as `{ content }` — unwrap to the raw authoring object.
66
+ */
67
+ function readDeclared(engine: any, metadataService: any, type: string): any[] {
68
+ try {
69
+ const reg = engine?._registry;
70
+ if (reg?.listItems) {
71
+ const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean);
72
+ if (items.length > 0) return items;
73
+ }
74
+ } catch {
75
+ /* fall through to metadata service */
76
+ }
77
+ try {
78
+ const listed = metadataService?.list?.(type);
79
+ const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);
80
+ return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : [];
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ export interface BootstrapDeclaredWebhooksResult {
87
+ seeded: number;
88
+ skipped: number;
89
+ }
90
+
91
+ /**
92
+ * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run
93
+ * on every boot.
94
+ */
95
+ export async function bootstrapDeclaredWebhooks(
96
+ engine: IDataEngine,
97
+ metadataService: any,
98
+ logger?: Logger,
99
+ subscriptionsObject = 'sys_webhook',
100
+ ): Promise<BootstrapDeclaredWebhooksResult> {
101
+ const declared = readDeclared(engine, metadataService, 'webhook');
102
+ if (declared.length === 0) return { seeded: 0, skipped: 0 };
103
+
104
+ const now = new Date().toISOString();
105
+ let seeded = 0;
106
+ let skipped = 0;
107
+
108
+ for (const raw of declared) {
109
+ // Validate + fill defaults through the canonical spec schema. A real
110
+ // consumer at last — a malformed webhook warns and is skipped, never
111
+ // crashing boot.
112
+ let wh: Webhook;
113
+ try {
114
+ wh = WebhookSchema.parse(raw);
115
+ } catch (err: any) {
116
+ logger?.warn?.('[webhook] declared webhook failed validation — skipped', {
117
+ name: (raw as any)?.name,
118
+ error: err?.message ?? String(err),
119
+ });
120
+ skipped += 1;
121
+ continue;
122
+ }
123
+
124
+ try {
125
+ const existing = await engine.find(subscriptionsObject, {
126
+ filter: { name: wh.name },
127
+ limit: 1,
128
+ context: SYSTEM_CTX,
129
+ } as any);
130
+ const row: any = Array.isArray(existing) ? existing[0] : undefined;
131
+
132
+ if (row) {
133
+ // Admin owns a same-named row, or has edited this seeded one — never
134
+ // clobber. `active: false` on a noisy webhook must survive redeploys.
135
+ if (row.managed_by === 'admin') {
136
+ logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {
137
+ name: wh.name,
138
+ });
139
+ skipped += 1;
140
+ continue;
141
+ }
142
+ if (row.customized === true) {
143
+ skipped += 1;
144
+ continue;
145
+ }
146
+ const patch = {
147
+ id: row.id,
148
+ ...mapWebhookToRow(wh),
149
+ // Adopt pristine/legacy (pre-provenance) rows so future boots
150
+ // recognize them as package-managed.
151
+ managed_by: 'package',
152
+ updated_at: now,
153
+ };
154
+ await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);
155
+ seeded += 1;
156
+ continue;
157
+ }
158
+
159
+ const newRow = {
160
+ id: uid('whk'),
161
+ ...mapWebhookToRow(wh),
162
+ managed_by: 'package',
163
+ customized: false,
164
+ created_at: now,
165
+ updated_at: now,
166
+ };
167
+ await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);
168
+ seeded += 1;
169
+ } catch (err: any) {
170
+ logger?.warn?.('[webhook] declared webhook seed failed', {
171
+ name: wh.name,
172
+ error: err?.message ?? String(err),
173
+ });
174
+ skipped += 1;
175
+ }
176
+ }
177
+
178
+ logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {
179
+ seeded,
180
+ skipped,
181
+ total: declared.length,
182
+ });
183
+ return { seeded, skipped };
184
+ }
185
+
186
+ /**
187
+ * Translate a validated {@link Webhook} into `sys_webhook` column values.
188
+ * `object → object_name`, `isActive → active`; the full envelope is stashed in
189
+ * `definition_json` for the enqueuer's advanced-config read (headers/secret/…).
190
+ */
191
+ function mapWebhookToRow(wh: Webhook): Record<string, unknown> {
192
+ return {
193
+ name: wh.name,
194
+ label: wh.label ?? wh.name,
195
+ object_name: wh.object ?? null,
196
+ triggers: wh.triggers ?? [],
197
+ url: wh.url,
198
+ // Store lowercase to match the object's Field.select option values
199
+ // (get/post/…); the enqueuer upper-cases before delivery either way.
200
+ method: String(wh.method ?? 'POST').toLowerCase(),
201
+ description: wh.description ?? null,
202
+ active: wh.isActive !== false,
203
+ definition_json: JSON.stringify(wh),
204
+ };
205
+ }
@@ -10,19 +10,25 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
10
10
  * Studio UI without code changes. The canonical Zod schema for the
11
11
  * `definition_json` envelope lives at `@objectstack/spec/automation/webhook`.
12
12
  *
13
- * One row per `name`. The automation runtime
14
- * (`@objectstack/service-automation`, built-in `http_request` node) loads
15
- * active rows on boot + on `sys_webhook:changed` events, registers
16
- * `afterInsert` / `afterUpdate` / `afterDelete` listeners for the
17
- * targeted object, and dispatches outbound HTTP calls when matching
18
- * record events fire.
13
+ * ## Two authoring doors, one row
14
+ * Rows land here two ways, distinguished by the `managed_by` provenance column:
15
+ * - **admin** created/edited directly through this object's CRUD UI.
16
+ * - **package** declared in code (`defineStack({ webhooks })` /
17
+ * `defineWebhook()`) and materialized on boot by
18
+ * `bootstrapDeclaredWebhooks` (#3461). Re-seeded every boot, but an admin
19
+ * edit stamps `customized: true` and freezes the row (seed-not-clobber,
20
+ * mirrors `sys_sharing_rule` #2909).
21
+ *
22
+ * One row per `name`. This plugin's {@link AutoEnqueuer} loads active rows on
23
+ * boot + on `sys_webhook:changed` events, and turns matching `data.record.*`
24
+ * events into deliveries on the shared `service-messaging` HTTP outbox
25
+ * (ADR-0018 M3 — `sys_http_delivery`, drained by the messaging dispatcher).
19
26
  *
20
27
  * Ownership (ADR-0029 K2.a): this object is **owned by
21
- * `@objectstack/plugin-webhooks`** — the plugin that consumes these rows
22
- * alongside its sibling `sys_webhook_delivery`. It used to live in the
23
- * `@objectstack/platform-objects` monolith and be imported here; the
24
- * definition now lives with its owner so the plugin ships both data and
25
- * behavior as one unit.
28
+ * `@objectstack/plugin-webhooks`** — the plugin that consumes these rows. It
29
+ * used to live in the `@objectstack/platform-objects` monolith and be imported
30
+ * here; the definition now lives with its owner so the plugin ships both data
31
+ * and behavior as one unit.
26
32
  *
27
33
  * Platform-wide on purpose: every project (standalone, single-tenant,
28
34
  * cloud) can integrate with external systems (Slack, Stripe, internal
@@ -43,7 +49,7 @@ export const SysWebhook = ObjectSchema.create({
43
49
  // create/edit/delete so admins can at least toggle `active` and edit
44
50
  // simple URL/method fields without round-tripping through code.
45
51
  userActions: { create: true, edit: true, delete: true, import: false },
46
- description: 'Outbound HTTP webhook subscription. Authored via defineWebhook() in code or the Studio editor; executed by the HTTP connector plugin.',
52
+ 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.',
47
53
  displayNameField: 'name',
48
54
  nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
49
55
  titleFormat: '{label}',
@@ -174,6 +180,38 @@ export const SysWebhook = ObjectSchema.create({
174
180
  group: 'Definition',
175
181
  }),
176
182
 
183
+ // ── Provenance (#3461 — record-authoritative seed-not-clobber) ──
184
+ // Mirrors sys_sharing_rule (#2909). Both columns are `readonly`: the
185
+ // engine strips them from non-system payloads (forge/clear-proof), while
186
+ // bootstrapDeclaredWebhooks and the provenance stamp hook write with
187
+ // isSystem. Deliberately NOT a write gate: webhooks are a first-class admin
188
+ // authoring/tuning surface — admins may edit or deactivate a package row;
189
+ // the seeder simply stops overwriting it once `customized` is stamped.
190
+ managed_by: Field.select(
191
+ ['platform', 'package', 'admin'],
192
+ {
193
+ label: 'Managed By',
194
+ required: false,
195
+ readonly: true,
196
+ defaultValue: 'admin',
197
+ description:
198
+ 'Record provenance: platform = framework built-in / package = app/package-declared ' +
199
+ '(boot-seeded from defineStack webhooks) / admin = created in Setup.',
200
+ group: 'System',
201
+ },
202
+ ),
203
+
204
+ customized: Field.boolean({
205
+ label: 'Customized',
206
+ required: false,
207
+ readonly: true,
208
+ defaultValue: false,
209
+ description:
210
+ 'Set when an admin edits a package-declared webhook; boot seeding will no longer ' +
211
+ 'overwrite the row (a deactivated noisy webhook survives redeploys). Meaningless on admin rows.',
212
+ group: 'System',
213
+ }),
214
+
177
215
  created_at: Field.datetime({
178
216
  label: 'Created At',
179
217
  required: true,
@@ -0,0 +1,41 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+ //
3
+ // Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated
4
+ // object-translation bundles must carry ONLY objects its extract config
5
+ // (`scripts/i18n-extract.config.ts`) actually imports. When an object moves to
6
+ // another package, its translations move with it — a leftover copy here
7
+ // silently DIES on the next `os i18n extract` run, taking curated translations
8
+ // with it (the sys_audit_log incident). This test turns that silent loss into a
9
+ // red build: an object present in the bundle but not in the ownership list below
10
+ // means either (a) the extract config gained an object — add it here — or (b) a
11
+ // moved/removed object's keys were left behind — remove them from the bundles
12
+ // (or migrate them to the owning package), then keep this list in sync.
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import { enObjects } from './en.objects.generated.js';
16
+
17
+ // Objects the extract config (scripts/i18n-extract.config.ts) imports —
18
+ // keep the two lists in sync when adding/moving objects.
19
+ const OWNED_OBJECTS = new Set([
20
+ 'sys_webhook',
21
+ ]);
22
+
23
+ describe('objects translation bundle ownership (ADR-0029 D8)', () => {
24
+ it('the en bundle contains no objects owned by other packages', () => {
25
+ const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o));
26
+ expect(
27
+ strays,
28
+ `bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` +
29
+ 'their curated translations would be silently deleted on the next `os i18n extract`. ' +
30
+ 'Remove the dead block from the four locale bundles, or add the object to the extract config + this list.',
31
+ ).toEqual([]);
32
+ });
33
+
34
+ it('every owned object is present in the bundle (extract config regression)', () => {
35
+ const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects));
36
+ expect(
37
+ missing,
38
+ `objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`,
39
+ ).toEqual([]);
40
+ });
41
+ });