@objectstack/plugin-webhooks 17.1.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -32,7 +32,15 @@ type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;
32
32
  */
33
33
  interface OptionalLogger {
34
34
  info?(msg: string, meta?: unknown): void;
35
- warn?(msg: string, meta?: unknown): void;
35
+ /**
36
+ * The GUARANTEED fallback channel (#9754). `error` stays optional — hosts do
37
+ * inject reduced sinks — so `warn` is where a durability report lands when
38
+ * `error` is absent, and a fallback that may itself be missing is not a
39
+ * fallback. Call sites keep the `logger?.warn?.(…)` spelling as the backstop
40
+ * for hosts the TYPE cannot reach; `SweepLogger` in plugin-email's
41
+ * `outbox-sweep.ts` carries the full reasoning and the measurement.
42
+ */
43
+ warn(msg: string, meta?: unknown): void;
36
44
  debug?(msg: string, meta?: unknown): void;
37
45
  error?(msg: string, err?: unknown, meta?: unknown): void;
38
46
  }
@@ -51,6 +59,21 @@ interface CachedSubscription {
51
59
  headers?: Record<string, string>;
52
60
  secret?: string;
53
61
  timeoutMs?: number;
62
+ /**
63
+ * [#13546] The subscription's own organization — `sys_webhook` is
64
+ * organization-scoped (#8554), so each row carries the tenant that authored
65
+ * it. Stamped onto every delivery row this subscription produces
66
+ * (`EnqueueHttpInput.organizationId`), which is what makes the
67
+ * cross-organization wall on `redeliver()` (#10740) actually exclude other
68
+ * tenants' rows: a row enqueued without it lands `organization_id = NULL`,
69
+ * the driver's global-row arm, visible to every organization. There is no
70
+ * request context to read here — the enqueuer runs fire-and-forget off the
71
+ * write path — so the subscription row is the one honest source. Absent
72
+ * when the row itself carries no organization (a `single`-posture install):
73
+ * the delivery then lands NULL, which is honest for a subscription that
74
+ * belongs to no organization. Threaded, never fabricated (#11303's rule).
75
+ */
76
+ organizationId?: string;
54
77
  /**
55
78
  * [#8069] Set when a credential this subscription needs could not be
56
79
  * recovered. The subscription stays CACHED — that is the change — but every
@@ -129,7 +152,22 @@ declare class AutoEnqueuer {
129
152
  private readonly subscriptions;
130
153
  private readonly subscriptionsObject;
131
154
  private readonly refreshIntervalMs;
132
- private readonly logger;
155
+ /**
156
+ * Optional, and deliberately NOT defaulted to `{}` (#10556).
157
+ *
158
+ * `OptionalLogger` guarantees a `warn` channel under #9754, so `{}` stopped being a
159
+ * legal value of the type — which is the gate working: an empty object is a
160
+ * sink that declares it can report and then discards everything. The repair
161
+ * is to say what is TRUE — there may be no logger at all — rather than to
162
+ * mint a sink that lies. Runtime behaviour is unchanged in both directions:
163
+ * absent logger and `{}` both printed nothing before, and print nothing now.
164
+ *
165
+ * ⛔ What this deliberately does NOT decide: whether an absent host sink should
166
+ * instead default to a `console`-backed one. That is the open design call the
167
+ * #9754 ledger records against `plugin-security`'s `= {}` field, and it is a
168
+ * maintainer decision — not something to settle here to make a checker green.
169
+ */
170
+ private readonly logger?;
133
171
  private subId;
134
172
  private subIdSelfHeal;
135
173
  private refreshTimer;
@@ -404,6 +442,28 @@ declare class WebhookOutboxPlugin implements Plugin {
404
442
  private boundEngine;
405
443
  constructor(options?: WebhookOutboxPluginOptions);
406
444
  init(ctx: PluginContext): Promise<void>;
445
+ /**
446
+ * Teardown — the kernel's ONLY teardown hook.
447
+ *
448
+ * [#10772] This body used to be spelled `dispose()`. `Plugin`
449
+ * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and
450
+ * `destroy?()` and no `dispose()`, and `ObjectKernel.performShutdown()` /
451
+ * `LiteKernel.destroy()` walk the plugins in reverse calling
452
+ * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED the
453
+ * auto-enqueuer was still running and both engine hooks were still bound.
454
+ * Measured on the same revision: `dispose()` had ZERO callers anywhere in
455
+ * the repo, so this teardown had never run in any process at all.
456
+ *
457
+ * Idempotent: `boundEngine` is cleared as it is unbound, so a second
458
+ * teardown is a no-op rather than a second unbind.
459
+ */
460
+ destroy(): Promise<void>;
461
+ /**
462
+ * Retained alias for {@link destroy}. Kept because it is public API of an
463
+ * exported class: an embedder may have learned to call it directly
464
+ * precisely BECAUSE the kernel never did, and deleting it would break them.
465
+ * Same signature, same return type — a direct caller sees no change.
466
+ */
407
467
  dispose(): Promise<void>;
408
468
  private getMessaging;
409
469
  /**
@@ -436,11 +496,35 @@ declare class WebhookOutboxPlugin implements Plugin {
436
496
  private tryGetService;
437
497
  /**
438
498
  * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
439
- * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the
440
- * better-auth session cookie — every authenticated user counts.
499
+ * available. Delegates to `messaging.redeliverHttp(deliveryId, …)`. Auth is
500
+ * the better-auth session cookie — every authenticated user counts.
501
+ *
502
+ * [#10740] Which is precisely why the caller's ACTIVE ORGANIZATION is
503
+ * resolved here and threaded into the call. `sys_http_delivery` is
504
+ * tenant-scoped, and this is the one door on it a request can reach: an
505
+ * unscoped replay from here is an authenticated user reaching another
506
+ * organization's delivery row on a walled deployment. With the tenant
507
+ * threaded, a row outside the caller's organization is simply not found.
508
+ *
509
+ * ⚠️ A session with no active organization threads `undefined`, and the
510
+ * driver's tenant-audit line then fires for that write. That is deliberate:
511
+ * the deployment could not tell us who is asking, and reporting the gap is
512
+ * the correct outcome. ⛔ It is never repaired with `bypassTenantAudit`,
513
+ * which would silence the report without closing anything.
441
514
  */
442
515
  private registerAdminRoutes;
443
- private resolveSessionUserId;
516
+ /**
517
+ * [#10740] The better-auth session envelope (`{ user, session }`) for this
518
+ * request, or `undefined`.
519
+ *
520
+ * Widened from the previous `resolveSessionUserId` because the route now
521
+ * needs two facts from ONE lookup: who is asking (`user.id`, the
522
+ * authentication gate) and which organization they are asking as
523
+ * (`session.activeOrganizationId`, the tenant threaded into the write).
524
+ * Resolving them separately would mean two `getSession` calls that can
525
+ * disagree.
526
+ */
527
+ private resolveSession;
444
528
  }
445
529
 
446
530
  /**
package/dist/index.d.ts CHANGED
@@ -32,7 +32,15 @@ type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;
32
32
  */
33
33
  interface OptionalLogger {
34
34
  info?(msg: string, meta?: unknown): void;
35
- warn?(msg: string, meta?: unknown): void;
35
+ /**
36
+ * The GUARANTEED fallback channel (#9754). `error` stays optional — hosts do
37
+ * inject reduced sinks — so `warn` is where a durability report lands when
38
+ * `error` is absent, and a fallback that may itself be missing is not a
39
+ * fallback. Call sites keep the `logger?.warn?.(…)` spelling as the backstop
40
+ * for hosts the TYPE cannot reach; `SweepLogger` in plugin-email's
41
+ * `outbox-sweep.ts` carries the full reasoning and the measurement.
42
+ */
43
+ warn(msg: string, meta?: unknown): void;
36
44
  debug?(msg: string, meta?: unknown): void;
37
45
  error?(msg: string, err?: unknown, meta?: unknown): void;
38
46
  }
@@ -51,6 +59,21 @@ interface CachedSubscription {
51
59
  headers?: Record<string, string>;
52
60
  secret?: string;
53
61
  timeoutMs?: number;
62
+ /**
63
+ * [#13546] The subscription's own organization — `sys_webhook` is
64
+ * organization-scoped (#8554), so each row carries the tenant that authored
65
+ * it. Stamped onto every delivery row this subscription produces
66
+ * (`EnqueueHttpInput.organizationId`), which is what makes the
67
+ * cross-organization wall on `redeliver()` (#10740) actually exclude other
68
+ * tenants' rows: a row enqueued without it lands `organization_id = NULL`,
69
+ * the driver's global-row arm, visible to every organization. There is no
70
+ * request context to read here — the enqueuer runs fire-and-forget off the
71
+ * write path — so the subscription row is the one honest source. Absent
72
+ * when the row itself carries no organization (a `single`-posture install):
73
+ * the delivery then lands NULL, which is honest for a subscription that
74
+ * belongs to no organization. Threaded, never fabricated (#11303's rule).
75
+ */
76
+ organizationId?: string;
54
77
  /**
55
78
  * [#8069] Set when a credential this subscription needs could not be
56
79
  * recovered. The subscription stays CACHED — that is the change — but every
@@ -129,7 +152,22 @@ declare class AutoEnqueuer {
129
152
  private readonly subscriptions;
130
153
  private readonly subscriptionsObject;
131
154
  private readonly refreshIntervalMs;
132
- private readonly logger;
155
+ /**
156
+ * Optional, and deliberately NOT defaulted to `{}` (#10556).
157
+ *
158
+ * `OptionalLogger` guarantees a `warn` channel under #9754, so `{}` stopped being a
159
+ * legal value of the type — which is the gate working: an empty object is a
160
+ * sink that declares it can report and then discards everything. The repair
161
+ * is to say what is TRUE — there may be no logger at all — rather than to
162
+ * mint a sink that lies. Runtime behaviour is unchanged in both directions:
163
+ * absent logger and `{}` both printed nothing before, and print nothing now.
164
+ *
165
+ * ⛔ What this deliberately does NOT decide: whether an absent host sink should
166
+ * instead default to a `console`-backed one. That is the open design call the
167
+ * #9754 ledger records against `plugin-security`'s `= {}` field, and it is a
168
+ * maintainer decision — not something to settle here to make a checker green.
169
+ */
170
+ private readonly logger?;
133
171
  private subId;
134
172
  private subIdSelfHeal;
135
173
  private refreshTimer;
@@ -404,6 +442,28 @@ declare class WebhookOutboxPlugin implements Plugin {
404
442
  private boundEngine;
405
443
  constructor(options?: WebhookOutboxPluginOptions);
406
444
  init(ctx: PluginContext): Promise<void>;
445
+ /**
446
+ * Teardown — the kernel's ONLY teardown hook.
447
+ *
448
+ * [#10772] This body used to be spelled `dispose()`. `Plugin`
449
+ * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and
450
+ * `destroy?()` and no `dispose()`, and `ObjectKernel.performShutdown()` /
451
+ * `LiteKernel.destroy()` walk the plugins in reverse calling
452
+ * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED the
453
+ * auto-enqueuer was still running and both engine hooks were still bound.
454
+ * Measured on the same revision: `dispose()` had ZERO callers anywhere in
455
+ * the repo, so this teardown had never run in any process at all.
456
+ *
457
+ * Idempotent: `boundEngine` is cleared as it is unbound, so a second
458
+ * teardown is a no-op rather than a second unbind.
459
+ */
460
+ destroy(): Promise<void>;
461
+ /**
462
+ * Retained alias for {@link destroy}. Kept because it is public API of an
463
+ * exported class: an embedder may have learned to call it directly
464
+ * precisely BECAUSE the kernel never did, and deleting it would break them.
465
+ * Same signature, same return type — a direct caller sees no change.
466
+ */
407
467
  dispose(): Promise<void>;
408
468
  private getMessaging;
409
469
  /**
@@ -436,11 +496,35 @@ declare class WebhookOutboxPlugin implements Plugin {
436
496
  private tryGetService;
437
497
  /**
438
498
  * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
439
- * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the
440
- * better-auth session cookie — every authenticated user counts.
499
+ * available. Delegates to `messaging.redeliverHttp(deliveryId, …)`. Auth is
500
+ * the better-auth session cookie — every authenticated user counts.
501
+ *
502
+ * [#10740] Which is precisely why the caller's ACTIVE ORGANIZATION is
503
+ * resolved here and threaded into the call. `sys_http_delivery` is
504
+ * tenant-scoped, and this is the one door on it a request can reach: an
505
+ * unscoped replay from here is an authenticated user reaching another
506
+ * organization's delivery row on a walled deployment. With the tenant
507
+ * threaded, a row outside the caller's organization is simply not found.
508
+ *
509
+ * ⚠️ A session with no active organization threads `undefined`, and the
510
+ * driver's tenant-audit line then fires for that write. That is deliberate:
511
+ * the deployment could not tell us who is asking, and reporting the gap is
512
+ * the correct outcome. ⛔ It is never repaired with `bypassTenantAudit`,
513
+ * which would silence the report without closing anything.
441
514
  */
442
515
  private registerAdminRoutes;
443
- private resolveSessionUserId;
516
+ /**
517
+ * [#10740] The better-auth session envelope (`{ user, session }`) for this
518
+ * request, or `undefined`.
519
+ *
520
+ * Widened from the previous `resolveSessionUserId` because the route now
521
+ * needs two facts from ONE lookup: who is asking (`user.id`, the
522
+ * authentication gate) and which organization they are asking as
523
+ * (`session.activeOrganizationId`, the tenant threaded into the write).
524
+ * Resolving them separately would mean two `getSession` calls that can
525
+ * disagree.
526
+ */
527
+ private resolveSession;
444
528
  }
445
529
 
446
530
  /**
package/dist/index.js CHANGED
@@ -194,7 +194,7 @@ var AutoEnqueuer = class {
194
194
  this.droppedForSecret = /* @__PURE__ */ new Set();
195
195
  this.subscriptionsObject = opts.subscriptionsObject ?? "sys_webhook";
196
196
  this.refreshIntervalMs = opts.refreshIntervalMs ?? 6e4;
197
- this.logger = opts.logger ?? {};
197
+ this.logger = opts.logger;
198
198
  }
199
199
  /**
200
200
  * Load the subscription cache and start listening for events.
@@ -219,7 +219,7 @@ var AutoEnqueuer = class {
219
219
  if (this.refreshIntervalMs > 0) {
220
220
  this.refreshTimer = setInterval(() => {
221
221
  this.refresh().catch(
222
- (err) => this.logger.warn?.("[webhook-auto-enqueuer] periodic refresh failed", err)
222
+ (err) => this.logger?.warn?.("[webhook-auto-enqueuer] periodic refresh failed", err)
223
223
  );
224
224
  }, this.refreshIntervalMs);
225
225
  this.refreshTimer.unref?.();
@@ -252,7 +252,7 @@ var AutoEnqueuer = class {
252
252
  rearmAfterCryptoRegistered() {
253
253
  const inFlight = this.refreshing ?? Promise.resolve();
254
254
  void inFlight.catch(() => void 0).then(() => this.running ? this.refresh() : void 0).catch(
255
- (err) => this.logger.warn?.(
255
+ (err) => this.logger?.warn?.(
256
256
  "[webhook-auto-enqueuer] re-arm after CryptoProvider registration failed",
257
257
  err
258
258
  )
@@ -276,7 +276,7 @@ var AutoEnqueuer = class {
276
276
  where: { active: true }
277
277
  });
278
278
  } catch (err) {
279
- this.logger.warn?.(
279
+ this.logger?.warn?.(
280
280
  `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,
281
281
  err
282
282
  );
@@ -300,7 +300,7 @@ var AutoEnqueuer = class {
300
300
  if (!live.has(id)) this.droppedForSecret.delete(id);
301
301
  }
302
302
  }
303
- this.logger.debug?.("[webhook-auto-enqueuer] cache refreshed", {
303
+ this.logger?.debug?.("[webhook-auto-enqueuer] cache refreshed", {
304
304
  objects: this.subscriptions.size,
305
305
  rows: rows.length
306
306
  });
@@ -371,14 +371,14 @@ var AutoEnqueuer = class {
371
371
  reportWriteFailure(sub, eventId, err, verb) {
372
372
  const meta = { webhook: sub.name, eventId, err: err?.message ?? err };
373
373
  if (!sub.parkedReason) {
374
- this.logger.warn?.(`[webhook-auto-enqueuer] ${verb} failed`, meta);
374
+ this.logger?.warn?.(`[webhook-auto-enqueuer] ${verb} failed`, meta);
375
375
  return;
376
376
  }
377
377
  const message = `[webhook-auto-enqueuer] could not record the undeliverable event for webhook '${sub.name}' \u2014 the subscription is parked for an unresolvable credential, and this event is now DISCARDED WITH NO TRACE in sys_http_delivery, which is the durability gap #8069 closes. Most likely cause: the enqueue callback was wired directly to IHttpOutbox.enqueue instead of MessagingService.enqueueHttp \u2014 only the messaging seam routes a parked event to recordUndeliverable(), and the delivery door refuses it rather than minting a pending row that would be sent UNSIGNED.`;
378
- if (typeof this.logger.error === "function") {
379
- this.logger.error(message, err, meta);
378
+ if (typeof this.logger?.error === "function") {
379
+ this.logger?.error(message, err, meta);
380
380
  } else {
381
- this.logger.warn?.(message, meta);
381
+ this.logger?.warn?.(message, meta);
382
382
  }
383
383
  }
384
384
  park(sub, err, credential) {
@@ -430,7 +430,7 @@ var AutoEnqueuer = class {
430
430
  }
431
431
  const legacy = readLegacySecret(row?.definition_json);
432
432
  if (legacy) {
433
- this.logger.warn?.(
433
+ this.logger?.warn?.(
434
434
  `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its signing secret as CLEARTEXT in definition_json, readable over the data API (#7799). Signing continues from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired to move it into sys_secret.`,
435
435
  { id: sub.id }
436
436
  );
@@ -480,7 +480,7 @@ var AutoEnqueuer = class {
480
480
  }
481
481
  const legacy = readLegacyHeaders(row?.definition_json);
482
482
  if (legacy) {
483
- this.logger.warn?.(
483
+ this.logger?.warn?.(
484
484
  `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its custom headers as CLEARTEXT in definition_json, readable over the data API (#7986) \u2014 that map is the ordinary place an Authorization header goes. Delivery continues from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired to move them into sys_secret.`,
485
485
  { id: sub.id }
486
486
  );
@@ -523,7 +523,7 @@ var AutoEnqueuer = class {
523
523
  err: err?.message ?? err
524
524
  };
525
525
  if (this.droppedForSecret.has(sub.id)) {
526
- this.logger.debug?.(
526
+ this.logger?.debug?.(
527
527
  `[webhook-auto-enqueuer] webhook '${sub.name}' is still dropped for an unresolvable ${credential.noun} (${credential.issues})`,
528
528
  meta
529
529
  );
@@ -531,10 +531,10 @@ var AutoEnqueuer = class {
531
531
  }
532
532
  this.droppedForSecret.add(sub.id);
533
533
  const message = `[webhook-auto-enqueuer] webhook '${sub.name}' holds ${credential.article} that could not be decrypted \u2014 the subscription is PARKED rather than ${credential.ratherThan} (${credential.issue}), so every matching record change is discarded with NO delivery, while the row keeps reading active:true in Setup. Each discarded event IS recorded in sys_http_delivery as a dead row with 0 attempts carrying this cause (#8069) \u2014 look there for the backlog; those rows can never be sent or redelivered, because a parked row has no HMAC signature. Fix: register a CryptoProvider (engine.setCryptoProvider \u2014 LocalCryptoProvider in dev, KMS/Vault in production) with the same key the ${credential.noun} was written under, and make sure the sys_secret row is reachable; the subscription re-arms on registration (#8022) and at the next periodic refresh.`;
534
- if (typeof this.logger.error === "function") {
535
- this.logger.error(message, err, meta);
534
+ if (typeof this.logger?.error === "function") {
535
+ this.logger?.error(message, err, meta);
536
536
  } else {
537
- this.logger.warn?.(message, meta);
537
+ this.logger?.warn?.(message, meta);
538
538
  }
539
539
  }
540
540
  parseRow(row) {
@@ -559,7 +559,7 @@ var AutoEnqueuer = class {
559
559
  const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);
560
560
  const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));
561
561
  if (unknown.length > 0) {
562
- this.logger.warn?.(
562
+ this.logger?.warn?.(
563
563
  `[webhook-auto-enqueuer] webhook '${row.name ?? row.id}' declares trigger(s) the engine never emits: ${unknown.join(", ")} \u2014 ignored. Dispatchable triggers: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(", ")}.`,
564
564
  { id: row.id, unknown }
565
565
  );
@@ -568,7 +568,7 @@ var AutoEnqueuer = class {
568
568
  normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t))
569
569
  );
570
570
  if (triggers.size === 0) {
571
- this.logger.warn?.(
571
+ this.logger?.warn?.(
572
572
  `[webhook-auto-enqueuer] webhook '${row.name ?? row.id}' has no dispatchable triggers \u2014 it will NEVER fire (rule webhook/without-triggers): there is no manual fire path (#3196), so this row is dead while looking armed in Setup. Declare one of: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(", ")}, or set it inactive if it should be off.`,
573
573
  { id: row.id }
574
574
  );
@@ -596,7 +596,11 @@ var AutoEnqueuer = class {
596
596
  // `headers` and `secret` are both filled by attachCredentials()
597
597
  // from their encrypted columns, NOT read off the row — see #7799
598
598
  // (secret) and #7986 (headers).
599
- timeoutMs: defn.timeoutMs
599
+ timeoutMs: defn.timeoutMs,
600
+ // [#13546] The tenant column the kernel provisions on sys_webhook.
601
+ // This cache read is a dispatcher-side unscoped find, so the column
602
+ // comes back for every organization's rows.
603
+ organizationId: row.organization_id ? String(row.organization_id) : void 0
600
604
  };
601
605
  }
602
606
  /**
@@ -625,7 +629,7 @@ var AutoEnqueuer = class {
625
629
  const payload = event.payload ?? {};
626
630
  const recordId = payload.recordId;
627
631
  if (typeof recordId !== "string" || recordId === "") {
628
- this.logger.warn?.(
632
+ this.logger?.warn?.(
629
633
  "[webhook-auto-enqueuer] dropping off-contract data event: payload is not a DataEvent (no top-level string `recordId`) \u2014 fix the producer",
630
634
  { type: event.type, object: event.object }
631
635
  );
@@ -650,6 +654,12 @@ var AutoEnqueuer = class {
650
654
  // subscription, so the delivery path is byte-identical to before.
651
655
  undeliverableReason: sub.parkedReason,
652
656
  timeoutMs: sub.timeoutMs,
657
+ // [#13546] The delivery row belongs to the SUBSCRIPTION's
658
+ // organization — the one honest tenant in scope on this
659
+ // fire-and-forget path (no request context exists here).
660
+ // Absent for an org-less subscription; the row then lands
661
+ // NULL, the global-row shape.
662
+ organizationId: sub.organizationId,
653
663
  // [#3946] Envelope keys are written LAST so the event payload
654
664
  // cannot rewrite them. Behaviour-neutral for the engine's own
655
665
  // publishers — since #4626 a `data.record.*` payload is a
@@ -694,7 +704,7 @@ var AutoEnqueuer = class {
694
704
  const payload = event.payload ?? {};
695
705
  const matched = payload.matched;
696
706
  if (typeof matched !== "number" || !Number.isInteger(matched) || matched < 0) {
697
- this.logger.warn?.(
707
+ this.logger?.warn?.(
698
708
  "[webhook-auto-enqueuer] dropping off-contract bulk data event: payload is not a BulkDataEvent (no top-level non-negative integer `matched`) \u2014 fix the producer",
699
709
  { type: event.type, object: event.object }
700
710
  );
@@ -702,7 +712,7 @@ var AutoEnqueuer = class {
702
712
  }
703
713
  const eventUuid = payload.id;
704
714
  if (typeof eventUuid !== "string" || eventUuid === "") {
705
- this.logger.warn?.(
715
+ this.logger?.warn?.(
706
716
  "[webhook-auto-enqueuer] dropping off-contract bulk data event: payload has no top-level string `id` to dedup on \u2014 fix the producer",
707
717
  { type: event.type, object: event.object }
708
718
  );
@@ -724,6 +734,9 @@ var AutoEnqueuer = class {
724
734
  // an undeliverable row instead of enqueuing a delivery.
725
735
  undeliverableReason: sub.parkedReason,
726
736
  timeoutMs: sub.timeoutMs,
737
+ // [#13546] See the per-record path — the subscription's own
738
+ // organization, absent for an org-less subscription.
739
+ organizationId: sub.organizationId,
727
740
  // [#3946] Envelope keys last so the payload cannot rewrite them.
728
741
  payload: {
729
742
  ...payload,
@@ -739,7 +752,7 @@ var AutoEnqueuer = class {
739
752
  if (event.object !== this.subscriptionsObject) return;
740
753
  if (!event.type?.startsWith("data.record.") && !event.type?.startsWith("data.records.")) return;
741
754
  this.refresh().catch(
742
- (err) => this.logger.warn?.("[webhook-auto-enqueuer] self-heal refresh failed", err)
755
+ (err) => this.logger?.warn?.("[webhook-auto-enqueuer] self-heal refresh failed", err)
743
756
  );
744
757
  }
745
758
  /** Test / admin accessor. */
@@ -1207,7 +1220,7 @@ var WebhookOutboxPlugin = class {
1207
1220
  try {
1208
1221
  const i18n = ctx.getService("i18n");
1209
1222
  if (i18n && typeof i18n.loadTranslations === "function") {
1210
- const { WebhooksTranslations } = await import("./translations-IAKF6NAP.js");
1223
+ const { WebhooksTranslations } = await import("./translations-CPXQB2NM.js");
1211
1224
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
1212
1225
  i18n.loadTranslations(locale, data);
1213
1226
  }
@@ -1228,7 +1241,22 @@ var WebhookOutboxPlugin = class {
1228
1241
  autoEnqueue: autoEnqueueOpt !== false
1229
1242
  });
1230
1243
  }
1231
- async dispose() {
1244
+ /**
1245
+ * Teardown — the kernel's ONLY teardown hook.
1246
+ *
1247
+ * [#10772] This body used to be spelled `dispose()`. `Plugin`
1248
+ * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and
1249
+ * `destroy?()` and no `dispose()`, and `ObjectKernel.performShutdown()` /
1250
+ * `LiteKernel.destroy()` walk the plugins in reverse calling
1251
+ * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED the
1252
+ * auto-enqueuer was still running and both engine hooks were still bound.
1253
+ * Measured on the same revision: `dispose()` had ZERO callers anywhere in
1254
+ * the repo, so this teardown had never run in any process at all.
1255
+ *
1256
+ * Idempotent: `boundEngine` is cleared as it is unbound, so a second
1257
+ * teardown is a no-op rather than a second unbind.
1258
+ */
1259
+ async destroy() {
1232
1260
  await this.autoEnqueuer?.stop();
1233
1261
  if (this.boundEngine) {
1234
1262
  try {
@@ -1242,6 +1270,15 @@ var WebhookOutboxPlugin = class {
1242
1270
  this.boundEngine = void 0;
1243
1271
  }
1244
1272
  }
1273
+ /**
1274
+ * Retained alias for {@link destroy}. Kept because it is public API of an
1275
+ * exported class: an embedder may have learned to call it directly
1276
+ * precisely BECAUSE the kernel never did, and deleting it would break them.
1277
+ * Same signature, same return type — a direct caller sees no change.
1278
+ */
1279
+ async dispose() {
1280
+ await this.destroy();
1281
+ }
1245
1282
  getMessaging(ctx) {
1246
1283
  const svc = this.tryGetService(ctx, ["messaging"]);
1247
1284
  return svc && typeof svc.enqueueHttp === "function" ? svc : void 0;
@@ -1353,8 +1390,21 @@ var WebhookOutboxPlugin = class {
1353
1390
  }
1354
1391
  /**
1355
1392
  * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
1356
- * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the
1357
- * better-auth session cookie — every authenticated user counts.
1393
+ * available. Delegates to `messaging.redeliverHttp(deliveryId, …)`. Auth is
1394
+ * the better-auth session cookie — every authenticated user counts.
1395
+ *
1396
+ * [#10740] Which is precisely why the caller's ACTIVE ORGANIZATION is
1397
+ * resolved here and threaded into the call. `sys_http_delivery` is
1398
+ * tenant-scoped, and this is the one door on it a request can reach: an
1399
+ * unscoped replay from here is an authenticated user reaching another
1400
+ * organization's delivery row on a walled deployment. With the tenant
1401
+ * threaded, a row outside the caller's organization is simply not found.
1402
+ *
1403
+ * ⚠️ A session with no active organization threads `undefined`, and the
1404
+ * driver's tenant-audit line then fires for that write. That is deliberate:
1405
+ * the deployment could not tell us who is asking, and reporting the gap is
1406
+ * the correct outcome. ⛔ It is never repaired with `bypassTenantAudit`,
1407
+ * which would silence the report without closing anything.
1358
1408
  */
1359
1409
  registerAdminRoutes(ctx) {
1360
1410
  const http = this.tryGetService(ctx, ["http-server"]);
@@ -1366,8 +1416,9 @@ var WebhookOutboxPlugin = class {
1366
1416
  const messaging = this.getMessaging(ctx);
1367
1417
  if (!rawApp || !messaging) return;
1368
1418
  rawApp.post("/api/v1/webhooks/redeliver", async (c) => {
1369
- const userId = await this.resolveSessionUserId(ctx, c);
1370
- if (!userId) {
1419
+ const session = await this.resolveSession(ctx, c);
1420
+ const userId = session?.user?.id;
1421
+ if (typeof userId !== "string" || userId.length === 0) {
1371
1422
  return c.json(
1372
1423
  { success: false, error: { code: "UNAUTHENTICATED", message: "Sign in to redeliver webhook deliveries." } },
1373
1424
  401
@@ -1387,8 +1438,10 @@ var WebhookOutboxPlugin = class {
1387
1438
  );
1388
1439
  }
1389
1440
  try {
1390
- const row = await messaging.redeliverHttp(deliveryId);
1391
- ctx.logger.info?.("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId });
1441
+ const activeOrg = session?.session?.activeOrganizationId;
1442
+ const tenantId = typeof activeOrg === "string" && activeOrg.length > 0 ? activeOrg : void 0;
1443
+ const row = await messaging.redeliverHttp(deliveryId, { tenantId });
1444
+ ctx.logger.info?.("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId, tenantId });
1392
1445
  return c.json({ success: true, data: { id: row.id, status: row.status } });
1393
1446
  } catch (err) {
1394
1447
  const code = err?.code;
@@ -1407,7 +1460,18 @@ var WebhookOutboxPlugin = class {
1407
1460
  });
1408
1461
  ctx.logger.info?.("[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver");
1409
1462
  }
1410
- async resolveSessionUserId(ctx, c) {
1463
+ /**
1464
+ * [#10740] The better-auth session envelope (`{ user, session }`) for this
1465
+ * request, or `undefined`.
1466
+ *
1467
+ * Widened from the previous `resolveSessionUserId` because the route now
1468
+ * needs two facts from ONE lookup: who is asking (`user.id`, the
1469
+ * authentication gate) and which organization they are asking as
1470
+ * (`session.activeOrganizationId`, the tenant threaded into the write).
1471
+ * Resolving them separately would mean two `getSession` calls that can
1472
+ * disagree.
1473
+ */
1474
+ async resolveSession(ctx, c) {
1411
1475
  try {
1412
1476
  const authService = this.tryGetService(ctx, ["auth"]);
1413
1477
  if (!authService) return void 0;
@@ -1416,9 +1480,7 @@ var WebhookOutboxPlugin = class {
1416
1480
  api = await authService.getApi();
1417
1481
  }
1418
1482
  if (!api?.getSession) return void 0;
1419
- const session = await api.getSession({ headers: c.req.raw.headers });
1420
- const uid2 = session?.user?.id;
1421
- return typeof uid2 === "string" && uid2.length > 0 ? uid2 : void 0;
1483
+ return await api.getSession({ headers: c.req.raw.headers });
1422
1484
  } catch {
1423
1485
  return void 0;
1424
1486
  }