@objectstack/plugin-webhooks 5.1.0 → 6.0.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 (55) hide show
  1. package/.turbo/turbo-build.log +35 -13
  2. package/CHANGELOG.md +17 -33
  3. package/dist/chunk-33LYZT7O.js +184 -0
  4. package/dist/chunk-33LYZT7O.js.map +1 -0
  5. package/dist/chunk-BS2QTZH3.js +256 -0
  6. package/dist/chunk-BS2QTZH3.js.map +1 -0
  7. package/dist/chunk-FA66GQEO.cjs +256 -0
  8. package/dist/chunk-FA66GQEO.cjs.map +1 -0
  9. package/dist/chunk-MJZGD37S.cjs +184 -0
  10. package/dist/chunk-MJZGD37S.cjs.map +1 -0
  11. package/dist/index.cjs +908 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +469 -0
  14. package/dist/index.d.ts +435 -70
  15. package/dist/index.js +872 -217
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-CIn7LSyB.d.cts +155 -0
  18. package/dist/outbox-CIn7LSyB.d.ts +155 -0
  19. package/dist/schema.cjs +9 -0
  20. package/dist/schema.cjs.map +1 -0
  21. package/dist/schema.d.cts +4787 -0
  22. package/dist/schema.d.ts +4787 -0
  23. package/dist/schema.js +9 -0
  24. package/dist/schema.js.map +1 -0
  25. package/dist/sql-outbox.cjs +8 -0
  26. package/dist/sql-outbox.cjs.map +1 -0
  27. package/dist/sql-outbox.d.cts +55 -0
  28. package/dist/sql-outbox.d.ts +55 -0
  29. package/dist/sql-outbox.js +8 -0
  30. package/dist/sql-outbox.js.map +1 -0
  31. package/package.json +30 -10
  32. package/src/auto-enqueuer.test.ts +391 -0
  33. package/src/auto-enqueuer.ts +335 -0
  34. package/src/dispatcher.test.ts +324 -0
  35. package/src/dispatcher.ts +218 -0
  36. package/src/http-sender.ts +187 -0
  37. package/src/index.ts +49 -12
  38. package/src/memory-outbox.test.ts +86 -0
  39. package/src/memory-outbox.ts +155 -0
  40. package/src/outbox.ts +175 -0
  41. package/src/partition.ts +19 -0
  42. package/src/retention.test.ts +116 -0
  43. package/src/retention.ts +144 -0
  44. package/src/schema.ts +22 -0
  45. package/src/sql-outbox.test.ts +490 -0
  46. package/src/sql-outbox.ts +343 -0
  47. package/src/sys-webhook-delivery.object.ts +224 -0
  48. package/src/webhook-outbox-plugin.ts +442 -0
  49. package/tsconfig.json +5 -13
  50. package/tsup.config.ts +14 -0
  51. package/dist/index.d.mts +0 -104
  52. package/dist/index.mjs +0 -216
  53. package/dist/index.mjs.map +0 -1
  54. package/src/webhooks-plugin.test.ts +0 -218
  55. package/src/webhooks-plugin.ts +0 -294
@@ -0,0 +1,442 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type {
5
+ IClusterService,
6
+ IDataEngine,
7
+ IRealtimeService,
8
+ } from '@objectstack/spec/contracts';
9
+ import { SysWebhook } from '@objectstack/platform-objects/integration';
10
+ import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
11
+ import { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
12
+ import { MemoryWebhookOutbox } from './memory-outbox.js';
13
+ import type { IWebhookOutbox } from './outbox.js';
14
+ import {
15
+ DeliveryRetentionSweeper,
16
+ type DeliveryRetentionOptions,
17
+ } from './retention.js';
18
+ import { SqlWebhookOutbox } from './sql-outbox.js';
19
+ import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
20
+
21
+ export interface WebhookOutboxPluginOptions
22
+ extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {
23
+ /**
24
+ * Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`
25
+ * is used — fine for local development, **not for production**: each
26
+ * node will see only its own rows.
27
+ *
28
+ * Pass a factory if you need the kernel-resolved `IDataEngine`:
29
+ *
30
+ * ```ts
31
+ * outbox: (ctx) => new SqlWebhookOutbox(
32
+ * ctx.getService('objectql'), { partitionCount: 8 },
33
+ * ),
34
+ * ```
35
+ */
36
+ outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
37
+
38
+ /**
39
+ * Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`
40
+ * or a random UUID generated at plugin init.
41
+ */
42
+ nodeId?: string;
43
+
44
+ /**
45
+ * If `false`, the plugin registers the outbox/dispatcher services but
46
+ * does NOT auto-start the loop — useful for tests that want to step
47
+ * the dispatcher manually via `dispatcher.tick()`.
48
+ *
49
+ * Default: true.
50
+ */
51
+ autoStart?: boolean;
52
+
53
+ /**
54
+ * Auto-enqueue config. When enabled (default `true` if the realtime
55
+ * + data engine services are available), the plugin subscribes to
56
+ * `data.record.*` events emitted by the engine and automatically
57
+ * enqueues a delivery row for every matching `sys_webhook` row.
58
+ *
59
+ * Set `false` to disable and only use the imperative
60
+ * `outbox.enqueue()` API.
61
+ */
62
+ autoEnqueue?: boolean | AutoEnqueuerOptions;
63
+
64
+ /**
65
+ * Retention sweep config. When enabled (default `true` if a SQL
66
+ * outbox is in use), a periodic timer prunes old `success` and
67
+ * `dead` rows from `sys_webhook_delivery`.
68
+ *
69
+ * Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
70
+ */
71
+ retention?: boolean | DeliveryRetentionOptions;
72
+ }
73
+
74
+ /**
75
+ * Wires a persistent, cluster-aware webhook outbox into the kernel.
76
+ *
77
+ * Registered services:
78
+ * - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
79
+ * - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)
80
+ * - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on
81
+ * - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
82
+ *
83
+ * End-to-end flow once auto-enqueue is enabled:
84
+ *
85
+ * engine.insert('contact', {...})
86
+ * → engine publishes data.record.created via IRealtimeService
87
+ * → AutoEnqueuer matches active sys_webhook rows in O(1)
88
+ * → outbox.enqueue() runs fire-and-forget (not on the write path)
89
+ * → dispatcher claims and POSTs (cluster-coordinated)
90
+ *
91
+ * **Cluster requirement** — this plugin depends on the cluster service
92
+ * (`ClusterServicePlugin`). With the default `memory` driver the
93
+ * dispatcher works correctly inside a single process; with a real driver
94
+ * (`@objectstack/service-cluster-redis`) it correctly coordinates work
95
+ * across nodes.
96
+ */
97
+ export class WebhookOutboxPlugin implements Plugin {
98
+ name = 'com.objectstack.plugin-webhook-outbox';
99
+ version = '1.1.0';
100
+ type = 'standard' as const;
101
+ dependencies = ['com.objectstack.service.cluster'];
102
+
103
+ private dispatcher: WebhookDispatcher | undefined;
104
+ private autoEnqueuer: AutoEnqueuer | undefined;
105
+ private retention: DeliveryRetentionSweeper | undefined;
106
+ private outboxInstance: IWebhookOutbox | undefined;
107
+
108
+ constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}
109
+
110
+ async init(ctx: PluginContext): Promise<void> {
111
+ const cluster = ctx.getService<IClusterService>('cluster');
112
+ if (!cluster) {
113
+ throw new Error(
114
+ 'WebhookOutboxPlugin: required service "cluster" not found — register ClusterServicePlugin first',
115
+ );
116
+ }
117
+
118
+ // Register the schemas this plugin owns at runtime. `sys_webhook`
119
+ // (config) lives in @objectstack/platform-objects but no other
120
+ // plugin claims it — the webhook plugin is the natural owner
121
+ // since it's the consumer of those rows. `sys_webhook_delivery`
122
+ // (telemetry) is plugin-private. Registering them here means a
123
+ // stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
124
+ // and both objects auto-appear in REST/Studio/Setup nav.
125
+ const manifest = ctx.getService<{ register(m: any): void }>('manifest');
126
+ if (manifest && typeof manifest.register === 'function') {
127
+ manifest.register({
128
+ id: 'com.objectstack.plugin-webhook-outbox.schema',
129
+ namespace: 'sys',
130
+ version: this.version,
131
+ type: 'plugin',
132
+ scope: 'system',
133
+ name: 'Webhook Outbox Schemas',
134
+ description:
135
+ 'Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).',
136
+ objects: [SysWebhook, SysWebhookDelivery],
137
+ });
138
+ } else {
139
+ ctx.logger.warn?.(
140
+ '[webhook-outbox] manifest service unavailable — sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',
141
+ );
142
+ }
143
+
144
+ const outbox = this.resolveOutbox(ctx);
145
+ this.outboxInstance = outbox;
146
+ const nodeId =
147
+ this.options.nodeId ??
148
+ process.env.OBJECTSTACK_NODE_ID ??
149
+ `node-${Math.random().toString(36).slice(2, 10)}`;
150
+
151
+ const dispatcher = new WebhookDispatcher({
152
+ nodeId,
153
+ cluster,
154
+ outbox,
155
+ partitionCount: this.options.partitionCount,
156
+ batchSize: this.options.batchSize,
157
+ intervalMs: this.options.intervalMs,
158
+ lockTtlMs: this.options.lockTtlMs,
159
+ claimTtlMs: this.options.claimTtlMs,
160
+ fetchImpl: this.options.fetchImpl,
161
+ onAttempt: this.options.onAttempt,
162
+ rng: this.options.rng,
163
+ logger: ctx.logger,
164
+ });
165
+ this.dispatcher = dispatcher;
166
+
167
+ ctx.registerService('webhook.outbox', outbox);
168
+ ctx.registerService('webhook.dispatcher', dispatcher);
169
+
170
+ if (this.options.autoStart !== false) {
171
+ dispatcher.start();
172
+ }
173
+
174
+ // Loud warning when running with the in-memory outbox in production —
175
+ // it loses data on restart and never shares rows across nodes. With
176
+ // the auto-pick logic above this only fires when no IDataEngine is
177
+ // available, but flag it loudly anyway.
178
+ const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
179
+ if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
180
+ ctx.logger.warn?.(
181
+ '[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Ensure ObjectQL is registered before WebhookOutboxPlugin so the SQL outbox can auto-select.',
182
+ );
183
+ }
184
+
185
+ // Auto-enqueue + retention need the kernel to be fully ready
186
+ // before ObjectQL / Realtime services are resolvable.
187
+ const autoEnqueueOpt = this.options.autoEnqueue ?? true;
188
+ const retentionOpt = this.options.retention ?? true;
189
+
190
+ const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
191
+ if (needsReadyHook && typeof (ctx as any).hook === 'function') {
192
+ (ctx as any).hook('kernel:ready', async () => {
193
+ await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
194
+ this.bootRetention(ctx, retentionOpt);
195
+ });
196
+ }
197
+
198
+ // Admin REST endpoint — POST /api/v1/webhooks/redeliver { deliveryId }.
199
+ // Wired in `kernel:ready` so the auth + http services are guaranteed
200
+ // resolvable. Gated on a session cookie so anonymous callers cannot
201
+ // replay deliveries; finer-grained RBAC (e.g. "only admins") can be
202
+ // layered on later — for now any signed-in user with access to the
203
+ // Setup app can redeliver. The action is also `disabled`-gated by
204
+ // status on the Studio side so the button only lights up on
205
+ // success / failed / dead rows.
206
+ if (typeof (ctx as any).hook === 'function') {
207
+ (ctx as any).hook('kernel:ready', () => {
208
+ this.registerAdminRoutes(ctx);
209
+ });
210
+ }
211
+
212
+ ctx.logger.info?.('[webhook-outbox] initialised', {
213
+ nodeId,
214
+ partitions: this.options.partitionCount ?? 8,
215
+ interval: this.options.intervalMs ?? 250,
216
+ autoEnqueue: autoEnqueueOpt !== false,
217
+ retention: retentionOpt !== false,
218
+ });
219
+ }
220
+
221
+ async dispose(): Promise<void> {
222
+ await this.autoEnqueuer?.stop();
223
+ this.retention?.stop();
224
+ await this.dispatcher?.stop();
225
+ }
226
+
227
+ private resolveOutbox(ctx: PluginContext): IWebhookOutbox {
228
+ const opt = this.options.outbox;
229
+ if (opt) {
230
+ return typeof opt === 'function'
231
+ ? (opt as (c: PluginContext) => IWebhookOutbox)(ctx)
232
+ : opt;
233
+ }
234
+ // No explicit override — auto-pick the right backend for the host.
235
+ // SqlWebhookOutbox needs an `IDataEngine`; if one is resolvable
236
+ // (the usual case in CLI-served stacks), use it so durable rows
237
+ // in `sys_webhook_delivery` actually round-trip through the
238
+ // dispatcher and the redeliver REST endpoint. Memory is only a
239
+ // last-resort fallback for tests / edge environments without an
240
+ // engine.
241
+ const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
242
+ if (engine) {
243
+ const partitionCount = this.options.partitionCount ?? 8;
244
+ const sql = new SqlWebhookOutbox(engine, { partitionCount });
245
+ ctx.logger.info?.(
246
+ '[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)',
247
+ { partitionCount },
248
+ );
249
+ return sql;
250
+ }
251
+ ctx.logger.warn?.(
252
+ '[webhook-outbox] no IDataEngine available — falling back to MemoryWebhookOutbox. Deliveries will NOT survive process restart and the redeliver REST endpoint will not see rows written through ObjectQL.',
253
+ );
254
+ return new MemoryWebhookOutbox();
255
+ }
256
+
257
+ private async bootAutoEnqueue(
258
+ ctx: PluginContext,
259
+ opt: boolean | AutoEnqueuerOptions,
260
+ ): Promise<void> {
261
+ if (opt === false) return;
262
+ const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
263
+ const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);
264
+ if (!engine || !realtime) {
265
+ ctx.logger.warn?.(
266
+ '[webhook-auto-enqueuer] disabled — ObjectQL or Realtime service not available',
267
+ { hasEngine: !!engine, hasRealtime: !!realtime },
268
+ );
269
+ return;
270
+ }
271
+ if (!this.outboxInstance) return;
272
+
273
+ const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;
274
+ this.autoEnqueuer = new AutoEnqueuer(
275
+ engine,
276
+ realtime,
277
+ this.outboxInstance,
278
+ { ...enqOpts, logger: ctx.logger },
279
+ );
280
+ await this.autoEnqueuer.start();
281
+ ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);
282
+ ctx.logger.info?.('[webhook-auto-enqueuer] started');
283
+ }
284
+
285
+ private bootRetention(
286
+ ctx: PluginContext,
287
+ opt: boolean | DeliveryRetentionOptions,
288
+ ): void {
289
+ if (opt === false) return;
290
+ // Only meaningful for SQL outbox — Memory has its own (process-lifetime) GC.
291
+ if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
292
+ const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
293
+ if (!engine) {
294
+ ctx.logger.warn?.(
295
+ '[webhook-retention] disabled — ObjectQL service not available',
296
+ );
297
+ return;
298
+ }
299
+ const retOpts = (typeof opt === 'object' ? opt : {}) as DeliveryRetentionOptions;
300
+ this.retention = new DeliveryRetentionSweeper(engine, {
301
+ ...retOpts,
302
+ logger: ctx.logger,
303
+ });
304
+ this.retention.start();
305
+ ctx.registerService('webhook.retention', this.retention);
306
+ ctx.logger.info?.('[webhook-retention] sweeper started');
307
+ }
308
+
309
+ private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {
310
+ for (const n of names) {
311
+ try {
312
+ const svc = ctx.getService<T>(n);
313
+ if (svc) return svc;
314
+ } catch {
315
+ // fall through
316
+ }
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ /**
322
+ * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
323
+ * is available. Silently no-ops in environments without an HTTP
324
+ * server (MSW, edge tests, pure library use). Auth is delegated to
325
+ * the better-auth session cookie — every authenticated user counts.
326
+ */
327
+ private registerAdminRoutes(ctx: PluginContext): void {
328
+ const http = this.tryGetService<any>(ctx, ['http-server']);
329
+ if (!http || typeof http.getRawApp !== 'function') {
330
+ ctx.logger.debug?.(
331
+ '[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted',
332
+ );
333
+ return;
334
+ }
335
+ const rawApp = http.getRawApp();
336
+ const outbox = this.outboxInstance;
337
+ if (!rawApp || !outbox) return;
338
+
339
+ rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {
340
+ // Auth gate — require a signed-in session.
341
+ const userId = await this.resolveSessionUserId(ctx, c);
342
+ if (!userId) {
343
+ return c.json(
344
+ {
345
+ success: false,
346
+ error: 'unauthenticated',
347
+ message: 'Sign in to redeliver webhook deliveries.',
348
+ },
349
+ 401,
350
+ );
351
+ }
352
+ let body: any;
353
+ try {
354
+ body = await c.req.json();
355
+ } catch {
356
+ return c.json(
357
+ {
358
+ success: false,
359
+ error: 'invalid_body',
360
+ message: 'Request body must be JSON.',
361
+ },
362
+ 400,
363
+ );
364
+ }
365
+ const deliveryId =
366
+ typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';
367
+ if (!deliveryId) {
368
+ return c.json(
369
+ {
370
+ success: false,
371
+ error: 'missing_delivery_id',
372
+ message: 'Body must include `deliveryId: string`.',
373
+ },
374
+ 400,
375
+ );
376
+ }
377
+ try {
378
+ const row = await outbox.redeliver(deliveryId);
379
+ ctx.logger.info?.('[webhook-outbox] redelivered', {
380
+ deliveryId,
381
+ requestedBy: userId,
382
+ });
383
+ return c.json({ success: true, data: { id: row.id, status: row.status } });
384
+ } catch (err: any) {
385
+ const code = err?.code;
386
+ if (code === 'not_found') {
387
+ return c.json(
388
+ { success: false, error: 'not_found', message: err.message },
389
+ 404,
390
+ );
391
+ }
392
+ if (code === 'not_eligible') {
393
+ return c.json(
394
+ { success: false, error: 'not_eligible', message: err.message },
395
+ 409,
396
+ );
397
+ }
398
+ ctx.logger.error?.(
399
+ '[webhook-outbox] redeliver failed',
400
+ err as Error,
401
+ );
402
+ return c.json(
403
+ {
404
+ success: false,
405
+ error: 'internal_error',
406
+ message: err?.message ?? String(err),
407
+ },
408
+ 500,
409
+ );
410
+ }
411
+ });
412
+
413
+ ctx.logger.info?.(
414
+ '[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver',
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Resolve the requesting user's id from a better-auth session cookie.
420
+ * Returns `undefined` for anonymous callers — the caller decides
421
+ * whether that's a 401.
422
+ */
423
+ private async resolveSessionUserId(
424
+ ctx: PluginContext,
425
+ c: any,
426
+ ): Promise<string | undefined> {
427
+ try {
428
+ const authService: any = this.tryGetService<any>(ctx, ['auth']);
429
+ if (!authService) return undefined;
430
+ let api: any = authService.api;
431
+ if (!api && typeof authService.getApi === 'function') {
432
+ api = await authService.getApi();
433
+ }
434
+ if (!api?.getSession) return undefined;
435
+ const session = await api.getSession({ headers: c.req.raw.headers });
436
+ const uid = session?.user?.id;
437
+ return typeof uid === 'string' && uid.length > 0 ? uid : undefined;
438
+ } catch {
439
+ return undefined;
440
+ }
441
+ }
442
+ }
package/tsconfig.json CHANGED
@@ -1,18 +1,10 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.json",
3
3
  "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "types": [
7
- "node"
8
- ]
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node"]
9
7
  },
10
- "include": [
11
- "src/**/*"
12
- ],
13
- "exclude": [
14
- "dist",
15
- "node_modules",
16
- "**/*.test.ts"
17
- ]
8
+ "include": ["src"],
9
+ "exclude": ["node_modules", "dist"]
18
10
  }
package/tsup.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { defineConfig } from 'tsup';
4
+
5
+ export default defineConfig({
6
+ entry: ['src/index.ts', 'src/sql-outbox.ts', 'src/schema.ts'],
7
+ splitting: true,
8
+ sourcemap: true,
9
+ clean: true,
10
+ dts: true,
11
+ format: ['esm', 'cjs'],
12
+ target: 'es2020',
13
+ external: ['vitest'],
14
+ });
package/dist/index.d.mts DELETED
@@ -1,104 +0,0 @@
1
- import { Plugin, PluginContext } from '@objectstack/core';
2
-
3
- /**
4
- * A single webhook delivery target.
5
- */
6
- interface WebhookSink {
7
- /** Unique sink id used for log correlation. */
8
- id: string;
9
- /** Target HTTPS URL. */
10
- url: string;
11
- /** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */
12
- secret?: string;
13
- /**
14
- * Restrict to specific object names (logical names, e.g. `lead`, `account`).
15
- * Omit / empty → all objects.
16
- */
17
- objects?: string[];
18
- /**
19
- * Restrict to specific event types. Omit / empty → all `data.record.*` events.
20
- */
21
- eventTypes?: string[];
22
- /** Extra headers to send (Authorization, Tenant, etc.). */
23
- headers?: Record<string, string>;
24
- /** Per-request timeout in milliseconds. Default 5000. */
25
- timeoutMs?: number;
26
- /** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */
27
- retries?: number;
28
- }
29
- /**
30
- * Delivery attempt outcome surfaced to in-process listeners / tests.
31
- */
32
- type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';
33
- interface WebhookDeliveryRecord {
34
- sinkId: string;
35
- url: string;
36
- eventType: string;
37
- object?: string;
38
- status: WebhookDeliveryStatus;
39
- httpStatus?: number;
40
- attempt: number;
41
- error?: string;
42
- }
43
- /**
44
- * Plugin configuration.
45
- *
46
- * Sinks may be supplied programmatically OR via env vars when none are
47
- * passed (suitable for 12-factor / Docker deployments):
48
- *
49
- * OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.
50
- * OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.
51
- * OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.
52
- * OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist
53
- * (e.g. `data.record.created`).
54
- */
55
- interface WebhooksPluginOptions {
56
- /** Explicit sink list (takes precedence over env vars). */
57
- sinks?: WebhookSink[];
58
- /** Override fetch (mainly for tests). Defaults to globalThis.fetch. */
59
- fetchImpl?: typeof fetch;
60
- /** Hook invoked with each delivery outcome (mainly for tests / metrics). */
61
- onDelivery?: (record: WebhookDeliveryRecord) => void;
62
- }
63
- /**
64
- * WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.
65
- *
66
- * @example
67
- * ```ts
68
- * kernel.use(new WebhooksPlugin({
69
- * sinks: [
70
- * { id: 'crm-sync', url: 'https://hooks.example.com/in',
71
- * secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },
72
- * ],
73
- * }));
74
- * ```
75
- */
76
- declare class WebhooksPlugin implements Plugin {
77
- name: string;
78
- version: string;
79
- type: string;
80
- dependencies: string[];
81
- private readonly options;
82
- private subscriptionIds;
83
- private realtime?;
84
- private sinks;
85
- private logger?;
86
- constructor(options?: WebhooksPluginOptions);
87
- init(ctx: PluginContext): Promise<void>;
88
- start(ctx: PluginContext): Promise<void>;
89
- stop(ctx: PluginContext): Promise<void>;
90
- /**
91
- * Resolve sinks from constructor options, falling back to env vars when
92
- * none provided. Exposed for testing.
93
- */
94
- private resolveSinks;
95
- /**
96
- * Dispatch a single event to a sink, with HMAC signing, timeout, and
97
- * exponential-backoff retry. Failures past the retry budget are logged
98
- * but never thrown — webhook delivery must never break the originating
99
- * mutation.
100
- */
101
- private dispatch;
102
- }
103
-
104
- export { type WebhookDeliveryRecord, type WebhookDeliveryStatus, type WebhookSink, WebhooksPlugin, type WebhooksPluginOptions };