@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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +22 -22
  2. package/CHANGELOG.md +257 -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.cjs.map +1 -1
  15. package/dist/schema.d.cts +339 -143
  16. package/dist/schema.d.ts +339 -143
  17. package/dist/schema.js +1 -1
  18. package/dist/translations-CBQRUIS5.cjs +383 -0
  19. package/dist/translations-CBQRUIS5.cjs.map +1 -0
  20. package/dist/translations-Y3CXWOTM.js +383 -0
  21. package/dist/translations-Y3CXWOTM.js.map +1 -0
  22. package/package.json +5 -5
  23. package/scripts/i18n-extract.config.ts +5 -3
  24. package/src/bootstrap-declared-webhooks.test.ts +283 -0
  25. package/src/bootstrap-declared-webhooks.ts +205 -0
  26. package/src/sys-webhook.object.ts +50 -12
  27. package/src/translations/bundle-ownership.test.ts +41 -0
  28. package/src/translations/en.objects.generated.ts +27 -113
  29. package/src/translations/es-ES.objects.generated.ts +27 -113
  30. package/src/translations/ja-JP.objects.generated.ts +27 -113
  31. package/src/translations/zh-CN.objects.generated.ts +27 -113
  32. package/src/webhook-outbox-plugin.ts +46 -0
  33. package/src/webhook-provenance.ts +86 -0
  34. package/dist/chunk-PTMJ5BLL.cjs.map +0 -1
  35. package/dist/chunk-RBCYKJVL.js.map +0 -1
  36. package/dist/translations-EPJYANXJ.js +0 -727
  37. package/dist/translations-EPJYANXJ.js.map +0 -1
  38. package/dist/translations-L2JAQDJB.cjs +0 -727
  39. package/dist/translations-L2JAQDJB.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
2
 
3
- var _chunkPTMJ5BLLcjs = require('./chunk-PTMJ5BLL.cjs');
3
+ var _chunkQUTQSOQCcjs = require('./chunk-QUTQSOQC.cjs');
4
4
 
5
5
  // src/auto-enqueuer.ts
6
6
  var AutoEnqueuer = class {
@@ -222,6 +222,163 @@ function mapActionToTrigger(action) {
222
222
  }
223
223
  var DISPATCHABLE_WEBHOOK_TRIGGERS = /* @__PURE__ */ new Set(["create", "update", "delete"]);
224
224
 
225
+ // src/bootstrap-declared-webhooks.ts
226
+ var _automation = require('@objectstack/spec/automation');
227
+ var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
228
+ function uid(prefix) {
229
+ const g = globalThis;
230
+ if (_optionalChain([g, 'access', _35 => _35.crypto, 'optionalAccess', _36 => _36.randomUUID])) return `${prefix}_${g.crypto.randomUUID()}`;
231
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
232
+ }
233
+ function readDeclared(engine, metadataService, type) {
234
+ try {
235
+ const reg = _optionalChain([engine, 'optionalAccess', _37 => _37._registry]);
236
+ if (_optionalChain([reg, 'optionalAccess', _38 => _38.listItems])) {
237
+ const items = (_nullishCoalesce(reg.listItems(type), () => ( []))).map((i) => _nullishCoalesce(_optionalChain([i, 'optionalAccess', _39 => _39.content]), () => ( i))).filter(Boolean);
238
+ if (items.length > 0) return items;
239
+ }
240
+ } catch (e3) {
241
+ }
242
+ try {
243
+ const listed = _optionalChain([metadataService, 'optionalAccess', _40 => _40.list, 'optionalCall', _41 => _41(type)]);
244
+ const arr = typeof _optionalChain([listed, 'optionalAccess', _42 => _42.then]) === "function" ? [] : _nullishCoalesce(listed, () => ( []));
245
+ return Array.isArray(arr) ? arr.map((i) => _nullishCoalesce(_optionalChain([i, 'optionalAccess', _43 => _43.content]), () => ( i))).filter(Boolean) : [];
246
+ } catch (e4) {
247
+ return [];
248
+ }
249
+ }
250
+ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscriptionsObject = "sys_webhook") {
251
+ const declared = readDeclared(engine, metadataService, "webhook");
252
+ if (declared.length === 0) return { seeded: 0, skipped: 0 };
253
+ const now = (/* @__PURE__ */ new Date()).toISOString();
254
+ let seeded = 0;
255
+ let skipped = 0;
256
+ for (const raw of declared) {
257
+ let wh;
258
+ try {
259
+ wh = _automation.WebhookSchema.parse(raw);
260
+ } catch (err) {
261
+ _optionalChain([logger, 'optionalAccess', _44 => _44.warn, 'optionalCall', _45 => _45("[webhook] declared webhook failed validation \u2014 skipped", {
262
+ name: _optionalChain([raw, 'optionalAccess', _46 => _46.name]),
263
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _47 => _47.message]), () => ( String(err)))
264
+ })]);
265
+ skipped += 1;
266
+ continue;
267
+ }
268
+ try {
269
+ const existing = await engine.find(subscriptionsObject, {
270
+ filter: { name: wh.name },
271
+ limit: 1,
272
+ context: SYSTEM_CTX
273
+ });
274
+ const row = Array.isArray(existing) ? existing[0] : void 0;
275
+ if (row) {
276
+ if (row.managed_by === "admin") {
277
+ _optionalChain([logger, 'optionalAccess', _48 => _48.warn, 'optionalCall', _49 => _49("[webhook] declared name collides with an admin-authored row \u2014 seed skipped", {
278
+ name: wh.name
279
+ })]);
280
+ skipped += 1;
281
+ continue;
282
+ }
283
+ if (row.customized === true) {
284
+ skipped += 1;
285
+ continue;
286
+ }
287
+ const patch = {
288
+ id: row.id,
289
+ ...mapWebhookToRow(wh),
290
+ // Adopt pristine/legacy (pre-provenance) rows so future boots
291
+ // recognize them as package-managed.
292
+ managed_by: "package",
293
+ updated_at: now
294
+ };
295
+ await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX });
296
+ seeded += 1;
297
+ continue;
298
+ }
299
+ const newRow = {
300
+ id: uid("whk"),
301
+ ...mapWebhookToRow(wh),
302
+ managed_by: "package",
303
+ customized: false,
304
+ created_at: now,
305
+ updated_at: now
306
+ };
307
+ await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX });
308
+ seeded += 1;
309
+ } catch (err) {
310
+ _optionalChain([logger, 'optionalAccess', _50 => _50.warn, 'optionalCall', _51 => _51("[webhook] declared webhook seed failed", {
311
+ name: wh.name,
312
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _52 => _52.message]), () => ( String(err)))
313
+ })]);
314
+ skipped += 1;
315
+ }
316
+ }
317
+ _optionalChain([logger, 'optionalAccess', _53 => _53.info, 'optionalCall', _54 => _54("[webhook] declared webhooks materialized into sys_webhook", {
318
+ seeded,
319
+ skipped,
320
+ total: declared.length
321
+ })]);
322
+ return { seeded, skipped };
323
+ }
324
+ function mapWebhookToRow(wh) {
325
+ return {
326
+ name: wh.name,
327
+ label: _nullishCoalesce(wh.label, () => ( wh.name)),
328
+ object_name: _nullishCoalesce(wh.object, () => ( null)),
329
+ triggers: _nullishCoalesce(wh.triggers, () => ( [])),
330
+ url: wh.url,
331
+ // Store lowercase to match the object's Field.select option values
332
+ // (get/post/…); the enqueuer upper-cases before delivery either way.
333
+ method: String(_nullishCoalesce(wh.method, () => ( "POST"))).toLowerCase(),
334
+ description: _nullishCoalesce(wh.description, () => ( null)),
335
+ active: wh.isActive !== false,
336
+ definition_json: JSON.stringify(wh)
337
+ };
338
+ }
339
+
340
+ // src/webhook-provenance.ts
341
+ var WEBHOOK_PROVENANCE_PACKAGE = "plugin-webhooks:provenance";
342
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
343
+ function bindWebhookProvenanceStamp(engine, logger) {
344
+ if (typeof _optionalChain([engine, 'optionalAccess', _55 => _55.registerHook]) !== "function") return;
345
+ engine.registerHook(
346
+ "beforeUpdate",
347
+ async (ctx) => {
348
+ if (_optionalChain([ctx, 'optionalAccess', _56 => _56.session, 'optionalAccess', _57 => _57.isSystem])) return;
349
+ const id = _nullishCoalesce(_optionalChain([ctx, 'optionalAccess', _58 => _58.input, 'optionalAccess', _59 => _59.id]), () => ( _optionalChain([ctx, 'optionalAccess', _60 => _60.input, 'optionalAccess', _61 => _61.data, 'optionalAccess', _62 => _62.id])));
350
+ if (!id) return;
351
+ const data = _optionalChain([ctx, 'optionalAccess', _63 => _63.input, 'optionalAccess', _64 => _64.data]);
352
+ if (!data || typeof data !== "object") return;
353
+ try {
354
+ const rows = await engine.find("sys_webhook", {
355
+ filter: { id },
356
+ fields: ["id", "managed_by", "customized"],
357
+ limit: 1,
358
+ context: SYSTEM_CTX2
359
+ });
360
+ const row = Array.isArray(rows) ? rows[0] : void 0;
361
+ if (!row) return;
362
+ if ((row.managed_by === "package" || row.managed_by === "platform") && row.customized !== true) {
363
+ data.customized = true;
364
+ }
365
+ } catch (err) {
366
+ _optionalChain([logger, 'optionalAccess', _65 => _65.warn, 'optionalCall', _66 => _66("[webhook] provenance stamp failed (edit proceeds unstamped)", {
367
+ id,
368
+ error: _optionalChain([err, 'optionalAccess', _67 => _67.message])
369
+ })]);
370
+ }
371
+ },
372
+ { object: "sys_webhook", packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 }
373
+ );
374
+ _optionalChain([logger, 'optionalAccess', _68 => _68.info, 'optionalCall', _69 => _69("[webhook] provenance stamp hook bound")]);
375
+ }
376
+ function unbindWebhookProvenanceStamp(engine) {
377
+ if (typeof _optionalChain([engine, 'optionalAccess', _70 => _70.unregisterHooksByPackage]) === "function") {
378
+ engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);
379
+ }
380
+ }
381
+
225
382
  // src/webhook-outbox-plugin.ts
226
383
  var WebhookOutboxPlugin = class {
227
384
  constructor(options = {}) {
@@ -242,7 +399,7 @@ var WebhookOutboxPlugin = class {
242
399
  scope: "system",
243
400
  name: "Webhook Schemas",
244
401
  description: "Registers sys_webhook (configuration). Deliveries use messaging's sys_http_delivery outbox.",
245
- objects: [_chunkPTMJ5BLLcjs.SysWebhook],
402
+ objects: [_chunkQUTQSOQCcjs.SysWebhook],
246
403
  navigationContributions: [
247
404
  {
248
405
  app: "setup",
@@ -256,7 +413,7 @@ var WebhookOutboxPlugin = class {
256
413
  ]
257
414
  });
258
415
  } else {
259
- _optionalChain([ctx, 'access', _35 => _35.logger, 'access', _36 => _36.warn, 'optionalCall', _37 => _37(
416
+ _optionalChain([ctx, 'access', _71 => _71.logger, 'access', _72 => _72.warn, 'optionalCall', _73 => _73(
260
417
  "[webhook-outbox] manifest service unavailable \u2014 sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin."
261
418
  )]);
262
419
  }
@@ -265,47 +422,88 @@ var WebhookOutboxPlugin = class {
265
422
  try {
266
423
  const i18n = ctx.getService("i18n");
267
424
  if (i18n && typeof i18n.loadTranslations === "function") {
268
- const { WebhooksTranslations } = await Promise.resolve().then(() => _interopRequireWildcard(require("./translations-L2JAQDJB.cjs")));
425
+ const { WebhooksTranslations } = await Promise.resolve().then(() => _interopRequireWildcard(require("./translations-CBQRUIS5.cjs")));
269
426
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
270
427
  i18n.loadTranslations(locale, data);
271
428
  }
272
429
  }
273
- } catch (e3) {
430
+ } catch (e5) {
274
431
  }
275
432
  });
276
433
  }
277
434
  const autoEnqueueOpt = _nullishCoalesce(this.options.autoEnqueue, () => ( true));
278
435
  if (typeof ctx.hook === "function") {
279
436
  ctx.hook("kernel:ready", async () => {
437
+ await this.bootDeclaredWebhooks(ctx);
280
438
  await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
281
439
  this.registerAdminRoutes(ctx);
282
440
  });
283
441
  }
284
- _optionalChain([ctx, 'access', _38 => _38.logger, 'access', _39 => _39.info, 'optionalCall', _40 => _40("[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)", {
442
+ _optionalChain([ctx, 'access', _74 => _74.logger, 'access', _75 => _75.info, 'optionalCall', _76 => _76("[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)", {
285
443
  autoEnqueue: autoEnqueueOpt !== false
286
444
  })]);
287
445
  }
288
446
  async dispose() {
289
- await _optionalChain([this, 'access', _41 => _41.autoEnqueuer, 'optionalAccess', _42 => _42.stop, 'call', _43 => _43()]);
447
+ await _optionalChain([this, 'access', _77 => _77.autoEnqueuer, 'optionalAccess', _78 => _78.stop, 'call', _79 => _79()]);
448
+ if (this.boundEngine) {
449
+ try {
450
+ unbindWebhookProvenanceStamp(this.boundEngine);
451
+ } catch (e6) {
452
+ }
453
+ this.boundEngine = void 0;
454
+ }
290
455
  }
291
456
  getMessaging(ctx) {
292
457
  const svc = this.tryGetService(ctx, ["messaging"]);
293
458
  return svc && typeof svc.enqueueHttp === "function" ? svc : void 0;
294
459
  }
460
+ /**
461
+ * [#3461] Bridge the declarative authoring surface to the dispatcher:
462
+ * materialize stack/connector-declared `webhook` metadata into `sys_webhook`
463
+ * rows so the auto-enqueuer (and the Studio UI) can see them.
464
+ *
465
+ * Gated on the DATA ENGINE alone — deliberately independent of the
466
+ * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing
467
+ * rows is a pure write; a deployment that mounts the webhook plugin without
468
+ * realtime must still get its declared webhooks into the table (and the
469
+ * Setup UI), even if nothing dispatches them yet. Runs before
470
+ * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.
471
+ */
472
+ async bootDeclaredWebhooks(ctx) {
473
+ const engine = this.tryGetService(ctx, ["objectql", "data"]);
474
+ if (!engine) {
475
+ _optionalChain([ctx, 'access', _80 => _80.logger, 'access', _81 => _81.warn, 'optionalCall', _82 => _82("[webhook] declared-webhook bootstrap skipped \u2014 no data engine available")]);
476
+ return;
477
+ }
478
+ this.boundEngine = engine;
479
+ bindWebhookProvenanceStamp(engine, ctx.logger);
480
+ let metadataService;
481
+ try {
482
+ metadataService = ctx.getService("metadata");
483
+ } catch (e7) {
484
+ }
485
+ try {
486
+ await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger);
487
+ } catch (err) {
488
+ _optionalChain([ctx, 'access', _83 => _83.logger, 'access', _84 => _84.warn, 'optionalCall', _85 => _85("[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)", {
489
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _86 => _86.message]), () => ( String(err)))
490
+ })]);
491
+ }
492
+ }
295
493
  async bootAutoEnqueue(ctx, opt) {
296
494
  if (opt === false) return;
297
495
  const engine = this.tryGetService(ctx, ["objectql", "data"]);
298
496
  const realtime = this.tryGetService(ctx, ["realtime"]);
299
497
  const messaging = this.getMessaging(ctx);
300
498
  if (!engine || !realtime || !messaging) {
301
- _optionalChain([ctx, 'access', _44 => _44.logger, 'access', _45 => _45.warn, 'optionalCall', _46 => _46(
499
+ _optionalChain([ctx, 'access', _87 => _87.logger, 'access', _88 => _88.warn, 'optionalCall', _89 => _89(
302
500
  "[webhook-auto-enqueuer] disabled \u2014 ObjectQL, Realtime, or Messaging service not available",
303
501
  { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging }
304
502
  )]);
305
503
  return;
306
504
  }
307
505
  if (!messaging.isHttpDeliveryReady()) {
308
- _optionalChain([ctx, 'access', _47 => _47.logger, 'access', _48 => _48.warn, 'optionalCall', _49 => _49(
506
+ _optionalChain([ctx, 'access', _90 => _90.logger, 'access', _91 => _91.warn, 'optionalCall', _92 => _92(
309
507
  "[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) \u2014 webhook deliveries will not be durable"
310
508
  )]);
311
509
  }
@@ -318,14 +516,14 @@ var WebhookOutboxPlugin = class {
318
516
  );
319
517
  await this.autoEnqueuer.start();
320
518
  ctx.registerService("webhook.autoEnqueuer", this.autoEnqueuer);
321
- _optionalChain([ctx, 'access', _50 => _50.logger, 'access', _51 => _51.info, 'optionalCall', _52 => _52("[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)")]);
519
+ _optionalChain([ctx, 'access', _93 => _93.logger, 'access', _94 => _94.info, 'optionalCall', _95 => _95("[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)")]);
322
520
  }
323
521
  tryGetService(ctx, names) {
324
522
  for (const n of names) {
325
523
  try {
326
524
  const svc = ctx.getService(n);
327
525
  if (svc) return svc;
328
- } catch (e4) {
526
+ } catch (e8) {
329
527
  }
330
528
  }
331
529
  return void 0;
@@ -338,7 +536,7 @@ var WebhookOutboxPlugin = class {
338
536
  registerAdminRoutes(ctx) {
339
537
  const http = this.tryGetService(ctx, ["http-server"]);
340
538
  if (!http || typeof http.getRawApp !== "function") {
341
- _optionalChain([ctx, 'access', _53 => _53.logger, 'access', _54 => _54.debug, 'optionalCall', _55 => _55("[webhook-outbox] HTTP server not available; redeliver endpoint not mounted")]);
539
+ _optionalChain([ctx, 'access', _96 => _96.logger, 'access', _97 => _97.debug, 'optionalCall', _98 => _98("[webhook-outbox] HTTP server not available; redeliver endpoint not mounted")]);
342
540
  return;
343
541
  }
344
542
  const rawApp = http.getRawApp();
@@ -355,10 +553,10 @@ var WebhookOutboxPlugin = class {
355
553
  let body;
356
554
  try {
357
555
  body = await c.req.json();
358
- } catch (e5) {
556
+ } catch (e9) {
359
557
  return c.json({ success: false, error: "invalid_body", message: "Request body must be JSON." }, 400);
360
558
  }
361
- const deliveryId = typeof _optionalChain([body, 'optionalAccess', _56 => _56.deliveryId]) === "string" ? body.deliveryId.trim() : "";
559
+ const deliveryId = typeof _optionalChain([body, 'optionalAccess', _99 => _99.deliveryId]) === "string" ? body.deliveryId.trim() : "";
362
560
  if (!deliveryId) {
363
561
  return c.json(
364
562
  { success: false, error: "missing_delivery_id", message: "Body must include `deliveryId: string`." },
@@ -367,24 +565,24 @@ var WebhookOutboxPlugin = class {
367
565
  }
368
566
  try {
369
567
  const row = await messaging.redeliverHttp(deliveryId);
370
- _optionalChain([ctx, 'access', _57 => _57.logger, 'access', _58 => _58.info, 'optionalCall', _59 => _59("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId })]);
568
+ _optionalChain([ctx, 'access', _100 => _100.logger, 'access', _101 => _101.info, 'optionalCall', _102 => _102("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId })]);
371
569
  return c.json({ success: true, data: { id: row.id, status: row.status } });
372
570
  } catch (err) {
373
- const code = _optionalChain([err, 'optionalAccess', _60 => _60.code]);
571
+ const code = _optionalChain([err, 'optionalAccess', _103 => _103.code]);
374
572
  if (code === "not_found") {
375
573
  return c.json({ success: false, error: "not_found", message: err.message }, 404);
376
574
  }
377
575
  if (code === "not_eligible") {
378
576
  return c.json({ success: false, error: "not_eligible", message: err.message }, 409);
379
577
  }
380
- _optionalChain([ctx, 'access', _61 => _61.logger, 'access', _62 => _62.error, 'optionalCall', _63 => _63("[webhook-outbox] redeliver failed", err)]);
578
+ _optionalChain([ctx, 'access', _104 => _104.logger, 'access', _105 => _105.error, 'optionalCall', _106 => _106("[webhook-outbox] redeliver failed", err)]);
381
579
  return c.json(
382
- { success: false, error: "internal_error", message: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _64 => _64.message]), () => ( String(err))) },
580
+ { success: false, error: "internal_error", message: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _107 => _107.message]), () => ( String(err))) },
383
581
  500
384
582
  );
385
583
  }
386
584
  });
387
- _optionalChain([ctx, 'access', _65 => _65.logger, 'access', _66 => _66.info, 'optionalCall', _67 => _67("[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver")]);
585
+ _optionalChain([ctx, 'access', _108 => _108.logger, 'access', _109 => _109.info, 'optionalCall', _110 => _110("[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver")]);
388
586
  }
389
587
  async resolveSessionUserId(ctx, c) {
390
588
  try {
@@ -394,11 +592,11 @@ var WebhookOutboxPlugin = class {
394
592
  if (!api && typeof authService.getApi === "function") {
395
593
  api = await authService.getApi();
396
594
  }
397
- if (!_optionalChain([api, 'optionalAccess', _68 => _68.getSession])) return void 0;
595
+ if (!_optionalChain([api, 'optionalAccess', _111 => _111.getSession])) return void 0;
398
596
  const session = await api.getSession({ headers: c.req.raw.headers });
399
- const uid = _optionalChain([session, 'optionalAccess', _69 => _69.user, 'optionalAccess', _70 => _70.id]);
400
- return typeof uid === "string" && uid.length > 0 ? uid : void 0;
401
- } catch (e6) {
597
+ const uid2 = _optionalChain([session, 'optionalAccess', _112 => _112.user, 'optionalAccess', _113 => _113.id]);
598
+ return typeof uid2 === "string" && uid2.length > 0 ? uid2 : void 0;
599
+ } catch (e10) {
402
600
  return void 0;
403
601
  }
404
602
  }
@@ -407,5 +605,5 @@ var WebhookOutboxPlugin = class {
407
605
 
408
606
 
409
607
 
410
- exports.AutoEnqueuer = AutoEnqueuer; exports.SysWebhook = _chunkPTMJ5BLLcjs.SysWebhook; exports.WebhookOutboxPlugin = WebhookOutboxPlugin;
608
+ exports.AutoEnqueuer = AutoEnqueuer; exports.SysWebhook = _chunkQUTQSOQCcjs.SysWebhook; exports.WebhookOutboxPlugin = WebhookOutboxPlugin;
411
609
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/index.cjs","../src/auto-enqueuer.ts","../src/webhook-outbox-plugin.ts"],"names":[],"mappings":"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","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<'create' | 'update' | 'delete'>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,\n );\n if (triggers.size === 0) {\n // No dispatchable triggers (or a manual-only webhook with none) —\n // skip auto-enqueue.\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.type?.startsWith('data.record.')) return;\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n const payload = event.payload ?? {};\n const recordId =\n (payload as any).recordId ??\n (payload as any).id ??\n (payload as any).after?.id ??\n (payload as any).before?.id ??\n 'unknown';\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n payload: {\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n ...payload,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n if (!event.type?.startsWith('data.record.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set(['create', 'update', 'delete']);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<any>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: 'unauthenticated', message: 'Sign in to redeliver webhook deliveries.' },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: 'invalid_body', message: 'Request body must be JSON.' }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: 'missing_delivery_id', message: 'Body must include `deliveryId: string`.' },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'not_found') {\n return c.json({ success: false, error: 'not_found', message: err.message }, 404);\n }\n if (code === 'not_eligible') {\n return c.json({ success: false, error: 'not_eligible', message: err.message }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: 'internal_error', message: err?.message ?? String(err) },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/auto-enqueuer.ts","../src/bootstrap-declared-webhooks.ts","../src/webhook-provenance.ts","../src/webhook-outbox-plugin.ts"],"names":["SYSTEM_CTX"],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<'create' | 'update' | 'delete'>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,\n );\n if (triggers.size === 0) {\n // No dispatchable triggers (or a manual-only webhook with none) —\n // skip auto-enqueue.\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.type?.startsWith('data.record.')) return;\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n const payload = event.payload ?? {};\n const recordId =\n (payload as any).recordId ??\n (payload as any).id ??\n (payload as any).after?.id ??\n (payload as any).before?.id ??\n 'unknown';\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n payload: {\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n ...payload,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n if (!event.type?.startsWith('data.record.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set(['create', 'update', 'delete']);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`\n * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).\n *\n * ## The disconnect this closes\n * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,\n * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and\n * is generically decomposed into the ObjectQL registry at boot as metadata\n * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads\n * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only\n * ever written by hand through the object's CRUD UI. Nothing bridged the two —\n * so authoring `webhooks:` on a stack produced metadata artifacts that never\n * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that\n * missing ingestion path.\n *\n * ## Shape translation (authoring → runtime row)\n * The spec shape diverges from the runtime column names; we map only at this\n * boundary and stash the full validated envelope in `definition_json` (whence\n * the enqueuer reads headers / secret / timeout):\n * - `object` → `object_name`\n * - `isActive` → `active`\n * - `triggers` / `url` / `method` / `label` / `description` → same-named columns\n * - the entire parsed {@link Webhook} → `definition_json` (JSON string)\n *\n * Each item is validated through `WebhookSchema.parse()` first — this gives the\n * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get\n * applied) and rejects malformed authoring with a warning instead of crashing\n * boot.\n *\n * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)\n * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks\n * ship with the app/package, so they seed with `managed_by: 'package'`\n * provenance and re-seed on every boot — but a row an admin has created\n * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by\n * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,\n * an admin's `active: false` on a noisy webhook survives redeploys.\n *\n * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache\n * refresh already sees the declared rows.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { WebhookSchema, type Webhook } from '@objectstack/spec/automation';\n\n/** System write context — the boot seeder is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\n/** Random id with a stable prefix — mirrors the sharing-rule seeder. */\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Read declared `webhook` items from the ObjectQL registry (where the manifest\n * decomposition parks `stack.webhooks`), falling back to the metadata service.\n * Items may be wrapped as `{ content }` — unwrap to the raw authoring object.\n */\nfunction readDeclared(engine: any, metadataService: any, type: string): any[] {\n try {\n const reg = engine?._registry;\n if (reg?.listItems) {\n const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean);\n if (items.length > 0) return items;\n }\n } catch {\n /* fall through to metadata service */\n }\n try {\n const listed = metadataService?.list?.(type);\n const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);\n return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : [];\n } catch {\n return [];\n }\n}\n\nexport interface BootstrapDeclaredWebhooksResult {\n seeded: number;\n skipped: number;\n}\n\n/**\n * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run\n * on every boot.\n */\nexport async function bootstrapDeclaredWebhooks(\n engine: IDataEngine,\n metadataService: any,\n logger?: Logger,\n subscriptionsObject = 'sys_webhook',\n): Promise<BootstrapDeclaredWebhooksResult> {\n const declared = readDeclared(engine, metadataService, 'webhook');\n if (declared.length === 0) return { seeded: 0, skipped: 0 };\n\n const now = new Date().toISOString();\n let seeded = 0;\n let skipped = 0;\n\n for (const raw of declared) {\n // Validate + fill defaults through the canonical spec schema. A real\n // consumer at last — a malformed webhook warns and is skipped, never\n // crashing boot.\n let wh: Webhook;\n try {\n wh = WebhookSchema.parse(raw);\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook failed validation — skipped', {\n name: (raw as any)?.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n continue;\n }\n\n try {\n const existing = await engine.find(subscriptionsObject, {\n filter: { name: wh.name },\n limit: 1,\n context: SYSTEM_CTX,\n } as any);\n const row: any = Array.isArray(existing) ? existing[0] : undefined;\n\n if (row) {\n // Admin owns a same-named row, or has edited this seeded one — never\n // clobber. `active: false` on a noisy webhook must survive redeploys.\n if (row.managed_by === 'admin') {\n logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {\n name: wh.name,\n });\n skipped += 1;\n continue;\n }\n if (row.customized === true) {\n skipped += 1;\n continue;\n }\n const patch = {\n id: row.id,\n ...mapWebhookToRow(wh),\n // Adopt pristine/legacy (pre-provenance) rows so future boots\n // recognize them as package-managed.\n managed_by: 'package',\n updated_at: now,\n };\n await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);\n seeded += 1;\n continue;\n }\n\n const newRow = {\n id: uid('whk'),\n ...mapWebhookToRow(wh),\n managed_by: 'package',\n customized: false,\n created_at: now,\n updated_at: now,\n };\n await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);\n seeded += 1;\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook seed failed', {\n name: wh.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n }\n }\n\n logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {\n seeded,\n skipped,\n total: declared.length,\n });\n return { seeded, skipped };\n}\n\n/**\n * Translate a validated {@link Webhook} into `sys_webhook` column values.\n * `object → object_name`, `isActive → active`; the full envelope is stashed in\n * `definition_json` for the enqueuer's advanced-config read (headers/secret/…).\n */\nfunction mapWebhookToRow(wh: Webhook): Record<string, unknown> {\n return {\n name: wh.name,\n label: wh.label ?? wh.name,\n object_name: wh.object ?? null,\n triggers: wh.triggers ?? [],\n url: wh.url,\n // Store lowercase to match the object's Field.select option values\n // (get/post/…); the enqueuer upper-cases before delivery either way.\n method: String(wh.method ?? 'POST').toLowerCase(),\n description: wh.description ?? null,\n active: wh.isActive !== false,\n definition_json: JSON.stringify(wh),\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#3461] Provenance stamp for `sys_webhook`.\n *\n * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed\n * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such\n * as flipping a noisy webhook to `active: false` — is the authority. The seeder\n * skips rows marked `customized`, so this hook is the half that DETECTS the\n * admin edit: any non-system update touching a `package`/`platform`-seeded row\n * stamps `customized: true` onto the payload.\n *\n * Why a data hook (and not a write gate or the REST layer), verbatim to the\n * sys_sharing_rule rationale (#2909 T1):\n * - admins edit webhooks through several doors (Setup UI generic data door,\n * scripts, console) — an engine hook covers them all;\n * - there is deliberately NO write gate here: webhooks are a first-class admin\n * authoring surface, so edits are allowed — they just have to be remembered;\n * - both provenance columns are `readonly`, and the engine's readonly strip\n * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run\n * — so a caller can never forge/clear `customized`, while this hook's stamp\n * survives.\n *\n * Known boundary: multi-row updates (no single `input.id`) are not stamped —\n * every webhook-editing UI path updates by id.\n */\n\ninterface MinimalEngine {\n find(object: string, opts?: any): Promise<any[]>;\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n warn?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n engine.registerHook(\n 'beforeUpdate',\n async (ctx: any) => {\n // Seeder / boot reconcilers write with isSystem — the package door, not\n // an admin customization.\n if ((ctx?.session as any)?.isSystem) return;\n const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;\n if (!id) return; // multi-row update — see boundary note above\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object') return;\n try {\n // `previous` is not resolved before beforeUpdate hooks run — read the\n // current row ourselves (system ctx: this is a provenance check, not\n // an authorization decision).\n const rows = await engine.find('sys_webhook', {\n filter: { id },\n fields: ['id', 'managed_by', 'customized'],\n limit: 1,\n context: SYSTEM_CTX,\n });\n const row = Array.isArray(rows) ? rows[0] : undefined;\n if (!row) return;\n if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {\n (data as any).customized = true;\n }\n } catch (err: any) {\n logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', {\n id,\n error: err?.message,\n });\n }\n },\n { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 },\n );\n logger?.info?.('[webhook] provenance stamp hook bound');\n}\n\nexport function unbindWebhookProvenanceStamp(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\nimport { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';\nimport { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */\n private boundEngine: any;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<any>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n // Materialize declared webhooks FIRST — this only needs the data\n // engine, so it must not be gated behind the auto-enqueue\n // dispatch prerequisites (realtime + messaging). Otherwise a\n // deployment without realtime would silently fail to materialize\n // declared webhooks — the very no-op this bridge closes (#3461).\n await this.bootDeclaredWebhooks(ctx);\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n if (this.boundEngine) {\n try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ }\n this.boundEngine = undefined;\n }\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n /**\n * [#3461] Bridge the declarative authoring surface to the dispatcher:\n * materialize stack/connector-declared `webhook` metadata into `sys_webhook`\n * rows so the auto-enqueuer (and the Studio UI) can see them.\n *\n * Gated on the DATA ENGINE alone — deliberately independent of the\n * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing\n * rows is a pure write; a deployment that mounts the webhook plugin without\n * realtime must still get its declared webhooks into the table (and the\n * Setup UI), even if nothing dispatches them yet. Runs before\n * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.\n */\n private async bootDeclaredWebhooks(ctx: PluginContext): Promise<void> {\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available');\n return;\n }\n // Bind the provenance stamp so an admin edit freezes a seeded row.\n this.boundEngine = engine;\n bindWebhookProvenanceStamp(engine as any, ctx.logger as any);\n let metadataService: any;\n try { metadataService = ctx.getService('metadata'); } catch { /* optional */ }\n try {\n await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', {\n error: err?.message ?? String(err),\n });\n }\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: 'unauthenticated', message: 'Sign in to redeliver webhook deliveries.' },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: 'invalid_body', message: 'Request body must be JSON.' }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: 'missing_delivery_id', message: 'Body must include `deliveryId: string`.' },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'not_found') {\n return c.json({ success: false, error: 'not_found', message: err.message }, 404);\n }\n if (code === 'not_eligible') {\n return c.json({ success: false, error: 'not_eligible', message: err.message }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: 'internal_error', message: err?.message ?? String(err) },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -168,10 +168,25 @@ declare class WebhookOutboxPlugin implements Plugin {
168
168
  type: "standard";
169
169
  dependencies: string[];
170
170
  private autoEnqueuer;
171
+ /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */
172
+ private boundEngine;
171
173
  constructor(options?: WebhookOutboxPluginOptions);
172
174
  init(ctx: PluginContext): Promise<void>;
173
175
  dispose(): Promise<void>;
174
176
  private getMessaging;
177
+ /**
178
+ * [#3461] Bridge the declarative authoring surface to the dispatcher:
179
+ * materialize stack/connector-declared `webhook` metadata into `sys_webhook`
180
+ * rows so the auto-enqueuer (and the Studio UI) can see them.
181
+ *
182
+ * Gated on the DATA ENGINE alone — deliberately independent of the
183
+ * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing
184
+ * rows is a pure write; a deployment that mounts the webhook plugin without
185
+ * realtime must still get its declared webhooks into the table (and the
186
+ * Setup UI), even if nothing dispatches them yet. Runs before
187
+ * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.
188
+ */
189
+ private bootDeclaredWebhooks;
175
190
  private bootAutoEnqueue;
176
191
  private tryGetService;
177
192
  /**
package/dist/index.d.ts CHANGED
@@ -168,10 +168,25 @@ declare class WebhookOutboxPlugin implements Plugin {
168
168
  type: "standard";
169
169
  dependencies: string[];
170
170
  private autoEnqueuer;
171
+ /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */
172
+ private boundEngine;
171
173
  constructor(options?: WebhookOutboxPluginOptions);
172
174
  init(ctx: PluginContext): Promise<void>;
173
175
  dispose(): Promise<void>;
174
176
  private getMessaging;
177
+ /**
178
+ * [#3461] Bridge the declarative authoring surface to the dispatcher:
179
+ * materialize stack/connector-declared `webhook` metadata into `sys_webhook`
180
+ * rows so the auto-enqueuer (and the Studio UI) can see them.
181
+ *
182
+ * Gated on the DATA ENGINE alone — deliberately independent of the
183
+ * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing
184
+ * rows is a pure write; a deployment that mounts the webhook plugin without
185
+ * realtime must still get its declared webhooks into the table (and the
186
+ * Setup UI), even if nothing dispatches them yet. Runs before
187
+ * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.
188
+ */
189
+ private bootDeclaredWebhooks;
175
190
  private bootAutoEnqueue;
176
191
  private tryGetService;
177
192
  /**