@objectstack/plugin-webhooks 16.0.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.
- package/.turbo/turbo-build.log +22 -22
- package/CHANGELOG.md +257 -0
- package/dist/{chunk-RBCYKJVL.js → chunk-6EPMRZ7I.js} +28 -2
- package/dist/chunk-6EPMRZ7I.js.map +1 -0
- package/dist/{chunk-PTMJ5BLL.cjs → chunk-QUTQSOQC.cjs} +28 -2
- package/dist/chunk-QUTQSOQC.cjs.map +1 -0
- package/dist/index.cjs +222 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +202 -4
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -2
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +339 -143
- package/dist/schema.d.ts +339 -143
- package/dist/schema.js +1 -1
- package/dist/translations-CBQRUIS5.cjs +383 -0
- package/dist/translations-CBQRUIS5.cjs.map +1 -0
- package/dist/translations-Y3CXWOTM.js +383 -0
- package/dist/translations-Y3CXWOTM.js.map +1 -0
- package/package.json +5 -5
- package/scripts/i18n-extract.config.ts +5 -3
- package/src/bootstrap-declared-webhooks.test.ts +283 -0
- package/src/bootstrap-declared-webhooks.ts +205 -0
- package/src/sys-webhook.object.ts +50 -12
- package/src/translations/bundle-ownership.test.ts +41 -0
- package/src/translations/en.objects.generated.ts +27 -113
- package/src/translations/es-ES.objects.generated.ts +27 -113
- package/src/translations/ja-JP.objects.generated.ts +27 -113
- package/src/translations/zh-CN.objects.generated.ts +27 -113
- package/src/webhook-outbox-plugin.ts +46 -0
- package/src/webhook-provenance.ts +86 -0
- package/dist/chunk-PTMJ5BLL.cjs.map +0 -1
- package/dist/chunk-RBCYKJVL.js.map +0 -1
- package/dist/translations-EPJYANXJ.js +0 -727
- package/dist/translations-EPJYANXJ.js.map +0 -1
- package/dist/translations-L2JAQDJB.cjs +0 -727
- 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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
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
|
+
});
|