@objectstack/plugin-webhooks 17.2.0 → 17.4.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.ts CHANGED
@@ -59,6 +59,30 @@ interface CachedSubscription {
59
59
  headers?: Record<string, string>;
60
60
  secret?: string;
61
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
+ * [#13566] ALSO the subscription half of the organization MATCH — compared
77
+ * against the organization the producer stamps on the event
78
+ * (`DataEvent.organizationId`, #14970; `BulkDataEvent.organizationId`,
79
+ * #15225 / #15813) before anything is enqueued. See
80
+ * {@link AutoEnqueuer.admitsOrganization} for the matrix; the short form
81
+ * is that a subscription with no organization ownership does not receive
82
+ * an organization-walled event, and a subscription with one receives only
83
+ * its own organization's.
84
+ */
85
+ organizationId?: string;
62
86
  /**
63
87
  * [#8069] Set when a credential this subscription needs could not be
64
88
  * recovered. The subscription stays CACHED — that is the change — but every
@@ -101,11 +125,17 @@ interface AutoEnqueuerOptions {
101
125
  * The handler:
102
126
  * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`
103
127
  * — O(1) per event, no DB hit on the write path.
104
- * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The
128
+ * 2. [#13566] Compares each candidate's own organization with the one the
129
+ * producer stamped on the event — one equality per candidate, no
130
+ * lookup (see {@link admitsOrganization}). On a walled deployment this
131
+ * is what keeps organization A's record events out of organization B's
132
+ * webhook endpoints.
133
+ * 3. Calls `outbox.enqueue()` fire-and-forget for each match. The
105
134
  * enqueue itself is a single INSERT, which runs *after* the user's
106
135
  * request has already returned.
107
136
  *
108
- * Net cost on the write path: one synchronous Map lookup (~microseconds).
137
+ * Net cost on the write path: one synchronous Map lookup (~microseconds)
138
+ * plus one string comparison per candidate subscription.
109
139
  *
110
140
  * ## Cache freshness
111
141
  * The cache is rebuilt:
@@ -172,6 +202,17 @@ declare class AutoEnqueuer {
172
202
  * refresh, forever.
173
203
  */
174
204
  private readonly droppedForSecret;
205
+ /**
206
+ * [#13566] Webhook ids whose FIRST organization-dimension refusal has been
207
+ * said out loud (see {@link admitsOrganization}). Same say-once shape as
208
+ * {@link droppedForSecret}, for the same reason: a subscription that
209
+ * cannot receive a class of events must be reported once, with the
210
+ * remedy, and not once per event forever — an org-less `'*'` subscription
211
+ * on a walled deployment would otherwise warn on every write of every
212
+ * organization. Pruned on refresh to the rows still live, so a row that
213
+ * is deleted and re-created reports again.
214
+ */
215
+ private readonly organizationRefusalReported;
175
216
  constructor(engine: IDataEngine, realtime: IRealtimeService, enqueue: HttpEnqueueFn, opts?: AutoEnqueuerOptions);
176
217
  /**
177
218
  * Load the subscription cache and start listening for events.
@@ -365,6 +406,68 @@ declare class AutoEnqueuer {
365
406
  * webhook opts in with `bulk_update` / `bulk_delete`.
366
407
  */
367
408
  private handleBulkEvent;
409
+ /**
410
+ * [#13566] The organization dimension of the match — ONE comparison
411
+ * between the subscription's own organization (cached off its
412
+ * `sys_webhook` row, #13546) and the organization the producer stamped on
413
+ * the event (#14970 per record; #15225 / #15813 per batch). ⛔ No lookup:
414
+ * the enqueuer exists to keep this path O(1), and both halves are already
415
+ * in hand — the filter is a comparison, never a resolution.
416
+ *
417
+ * The cells, and why each falls where it does:
418
+ *
419
+ * | subscription | event | verdict |
420
+ * |--------------|--------|------------------------------------------------|
421
+ * | org A | org A | deliver |
422
+ * | org A | org B | not a match — the routine outcome of the new |
423
+ * | | | term, silent like an object-name mismatch |
424
+ * | org A | absent | REFUSE (fail-closed), said once per sub |
425
+ * | none | org A | REFUSE, said once per sub — the ruling's case |
426
+ * | none | absent | deliver — no wall on either side |
427
+ *
428
+ * **`none` × `org A` — the ruling.** A `sys_webhook` row with no
429
+ * organization on a walled deployment (a package-declared row bootstrapped
430
+ * under `isSystem`, a row authored before the column was provisioned)
431
+ * would otherwise receive EVERY organization's records at its URL, signed
432
+ * with its secret — the leak this card is. Maintainer's ruling
433
+ * (2026-09-07), verbatim: *a subscription with no organisation ownership
434
+ * does not fan out — loud refusal, never a silent cross-organisation
435
+ * delivery.* ADR-0131 D1 is why there is no third reading — NULL is not a
436
+ * state, so "no organization" is never "every organization".
437
+ *
438
+ * **`org A` × `absent` — fail-closed, on BOTH paths.** The two event
439
+ * families spell absence with the same key and mean different things by
440
+ * it (`packages/spec/src/api/events.zod.ts`, both members' docs). On a
441
+ * `DataEvent` it is "this record belongs to no organization" — an
442
+ * environment-wide row, an object outside the wall, or (the per-record
443
+ * producer's own docblock, `eventOrganizationId` in
444
+ * `packages/objectql/src/engine.ts`) no row in hand at the publish site,
445
+ * published absent rather than substituting the caller's organization.
446
+ * On a `BulkDataEvent` it is "the producer did not assert one
447
+ * organization for this batch" — a system or cross-membership predicate
448
+ * write — and the contract says outright that a tenant-scoped consumer
449
+ * must not deliver it inside an organization wall. Neither reading names
450
+ * organization A, so a subscription that belongs to A delivers on
451
+ * neither. ⛔ Absent is never "no tenancy concern": delivering on it
452
+ * would turn each of those cases into a cross-organization delivery. The
453
+ * cost is an environment-wide record not reaching an organization's
454
+ * webhook — accepted, and said once so the subscription is not dead while
455
+ * looking armed.
456
+ *
457
+ * **`none` × `absent` — deliver.** A `single`-posture deployment stamps
458
+ * nothing on either side (`postureStampsOrganization` is false there), so
459
+ * this cell is every event on every non-walled install; on a walled one
460
+ * it is an environment-wide subscription taking an environment-wide
461
+ * event. No organization is named anywhere, so there is no wall to cross.
462
+ *
463
+ * Both refusals are said ONCE per subscription (the #8022 say-once rule,
464
+ * ledger {@link organizationRefusalReported}): the first refused event is
465
+ * a `warn` naming the consequence and the remedy, later ones are
466
+ * debug-level. Without that a refused subscription reads active:true in
467
+ * Setup with nothing ever arriving — the "dead while looking armed" shape
468
+ * ADR-0078 refuses.
469
+ */
470
+ private admitsOrganization;
368
471
  private handleSelfHealEvent;
369
472
  /** Test / admin accessor. */
370
473
  snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>>;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SysWebhook
3
- } from "./chunk-XERWWQKN.js";
3
+ } from "./chunk-MEX2VC7T.js";
4
4
 
5
5
  // src/webhook-secret.ts
6
6
  var WEBHOOK_SECRET_FIELD = "signing_secret";
@@ -192,6 +192,17 @@ var AutoEnqueuer = class {
192
192
  * refresh, forever.
193
193
  */
194
194
  this.droppedForSecret = /* @__PURE__ */ new Set();
195
+ /**
196
+ * [#13566] Webhook ids whose FIRST organization-dimension refusal has been
197
+ * said out loud (see {@link admitsOrganization}). Same say-once shape as
198
+ * {@link droppedForSecret}, for the same reason: a subscription that
199
+ * cannot receive a class of events must be reported once, with the
200
+ * remedy, and not once per event forever — an org-less `'*'` subscription
201
+ * on a walled deployment would otherwise warn on every write of every
202
+ * organization. Pruned on refresh to the rows still live, so a row that
203
+ * is deleted and re-created reports again.
204
+ */
205
+ this.organizationRefusalReported = /* @__PURE__ */ new Set();
195
206
  this.subscriptionsObject = opts.subscriptionsObject ?? "sys_webhook";
196
207
  this.refreshIntervalMs = opts.refreshIntervalMs ?? 6e4;
197
208
  this.logger = opts.logger;
@@ -294,11 +305,14 @@ var AutoEnqueuer = class {
294
305
  }
295
306
  this.subscriptions.clear();
296
307
  for (const [k, v] of next) this.subscriptions.set(k, v);
297
- if (this.droppedForSecret.size > 0) {
308
+ if (this.droppedForSecret.size > 0 || this.organizationRefusalReported.size > 0) {
298
309
  const live = new Set(rows.map((r) => String(r?.id)));
299
310
  for (const id of this.droppedForSecret) {
300
311
  if (!live.has(id)) this.droppedForSecret.delete(id);
301
312
  }
313
+ for (const id of this.organizationRefusalReported) {
314
+ if (!live.has(id)) this.organizationRefusalReported.delete(id);
315
+ }
302
316
  }
303
317
  this.logger?.debug?.("[webhook-auto-enqueuer] cache refreshed", {
304
318
  objects: this.subscriptions.size,
@@ -596,7 +610,11 @@ var AutoEnqueuer = class {
596
610
  // `headers` and `secret` are both filled by attachCredentials()
597
611
  // from their encrypted columns, NOT read off the row — see #7799
598
612
  // (secret) and #7986 (headers).
599
- timeoutMs: defn.timeoutMs
613
+ timeoutMs: defn.timeoutMs,
614
+ // [#13546] The tenant column the kernel provisions on sys_webhook.
615
+ // This cache read is a dispatcher-side unscoped find, so the column
616
+ // comes back for every organization's rows.
617
+ organizationId: row.organization_id ? String(row.organization_id) : void 0
600
618
  };
601
619
  }
602
620
  /**
@@ -631,9 +649,18 @@ var AutoEnqueuer = class {
631
649
  );
632
650
  return;
633
651
  }
652
+ const organization = readEventOrganizationId(payload);
653
+ if (!organization) {
654
+ this.logger?.warn?.(
655
+ '[webhook-auto-enqueuer] dropping off-contract data event: `organizationId` is present but not a non-empty string (DataEventSchema refuses that at the publish site) \u2014 fix the producer; never coerced and never read as "no organization"',
656
+ { type: event.type, object: event.object }
657
+ );
658
+ return;
659
+ }
634
660
  const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
635
661
  for (const sub of subs) {
636
662
  if (!sub.triggers.has(trigger)) continue;
663
+ if (!this.admitsOrganization(sub, organization.organizationId, event)) continue;
637
664
  void this.enqueue({
638
665
  source: "webhook",
639
666
  refId: sub.id,
@@ -650,6 +677,12 @@ var AutoEnqueuer = class {
650
677
  // subscription, so the delivery path is byte-identical to before.
651
678
  undeliverableReason: sub.parkedReason,
652
679
  timeoutMs: sub.timeoutMs,
680
+ // [#13546] The delivery row belongs to the SUBSCRIPTION's
681
+ // organization — the one honest tenant in scope on this
682
+ // fire-and-forget path (no request context exists here).
683
+ // Absent for an org-less subscription; the row then lands
684
+ // NULL, the global-row shape.
685
+ organizationId: sub.organizationId,
653
686
  // [#3946] Envelope keys are written LAST so the event payload
654
687
  // cannot rewrite them. Behaviour-neutral for the engine's own
655
688
  // publishers — since #4626 a `data.record.*` payload is a
@@ -709,8 +742,17 @@ var AutoEnqueuer = class {
709
742
  return;
710
743
  }
711
744
  const eventId = `${event.object}:${event.type}:${eventUuid}`;
745
+ const organization = readEventOrganizationId(payload);
746
+ if (!organization) {
747
+ this.logger?.warn?.(
748
+ '[webhook-auto-enqueuer] dropping off-contract bulk data event: `organizationId` is present but not a non-empty string (BulkDataEventSchema refuses that at the publish site) \u2014 fix the producer; never coerced and never read as "not asserted"',
749
+ { type: event.type, object: event.object }
750
+ );
751
+ return;
752
+ }
712
753
  for (const sub of subs) {
713
754
  if (!sub.triggers.has(trigger)) continue;
755
+ if (!this.admitsOrganization(sub, organization.organizationId, event)) continue;
714
756
  void this.enqueue({
715
757
  source: "webhook",
716
758
  refId: sub.id,
@@ -724,6 +766,9 @@ var AutoEnqueuer = class {
724
766
  // an undeliverable row instead of enqueuing a delivery.
725
767
  undeliverableReason: sub.parkedReason,
726
768
  timeoutMs: sub.timeoutMs,
769
+ // [#13546] See the per-record path — the subscription's own
770
+ // organization, absent for an org-less subscription.
771
+ organizationId: sub.organizationId,
727
772
  // [#3946] Envelope keys last so the payload cannot rewrite them.
728
773
  payload: {
729
774
  ...payload,
@@ -735,6 +780,91 @@ var AutoEnqueuer = class {
735
780
  }).catch((err) => this.reportWriteFailure(sub, eventId, err, "bulk enqueue"));
736
781
  }
737
782
  }
783
+ /**
784
+ * [#13566] The organization dimension of the match — ONE comparison
785
+ * between the subscription's own organization (cached off its
786
+ * `sys_webhook` row, #13546) and the organization the producer stamped on
787
+ * the event (#14970 per record; #15225 / #15813 per batch). ⛔ No lookup:
788
+ * the enqueuer exists to keep this path O(1), and both halves are already
789
+ * in hand — the filter is a comparison, never a resolution.
790
+ *
791
+ * The cells, and why each falls where it does:
792
+ *
793
+ * | subscription | event | verdict |
794
+ * |--------------|--------|------------------------------------------------|
795
+ * | org A | org A | deliver |
796
+ * | org A | org B | not a match — the routine outcome of the new |
797
+ * | | | term, silent like an object-name mismatch |
798
+ * | org A | absent | REFUSE (fail-closed), said once per sub |
799
+ * | none | org A | REFUSE, said once per sub — the ruling's case |
800
+ * | none | absent | deliver — no wall on either side |
801
+ *
802
+ * **`none` × `org A` — the ruling.** A `sys_webhook` row with no
803
+ * organization on a walled deployment (a package-declared row bootstrapped
804
+ * under `isSystem`, a row authored before the column was provisioned)
805
+ * would otherwise receive EVERY organization's records at its URL, signed
806
+ * with its secret — the leak this card is. Maintainer's ruling
807
+ * (2026-09-07), verbatim: *a subscription with no organisation ownership
808
+ * does not fan out — loud refusal, never a silent cross-organisation
809
+ * delivery.* ADR-0131 D1 is why there is no third reading — NULL is not a
810
+ * state, so "no organization" is never "every organization".
811
+ *
812
+ * **`org A` × `absent` — fail-closed, on BOTH paths.** The two event
813
+ * families spell absence with the same key and mean different things by
814
+ * it (`packages/spec/src/api/events.zod.ts`, both members' docs). On a
815
+ * `DataEvent` it is "this record belongs to no organization" — an
816
+ * environment-wide row, an object outside the wall, or (the per-record
817
+ * producer's own docblock, `eventOrganizationId` in
818
+ * `packages/objectql/src/engine.ts`) no row in hand at the publish site,
819
+ * published absent rather than substituting the caller's organization.
820
+ * On a `BulkDataEvent` it is "the producer did not assert one
821
+ * organization for this batch" — a system or cross-membership predicate
822
+ * write — and the contract says outright that a tenant-scoped consumer
823
+ * must not deliver it inside an organization wall. Neither reading names
824
+ * organization A, so a subscription that belongs to A delivers on
825
+ * neither. ⛔ Absent is never "no tenancy concern": delivering on it
826
+ * would turn each of those cases into a cross-organization delivery. The
827
+ * cost is an environment-wide record not reaching an organization's
828
+ * webhook — accepted, and said once so the subscription is not dead while
829
+ * looking armed.
830
+ *
831
+ * **`none` × `absent` — deliver.** A `single`-posture deployment stamps
832
+ * nothing on either side (`postureStampsOrganization` is false there), so
833
+ * this cell is every event on every non-walled install; on a walled one
834
+ * it is an environment-wide subscription taking an environment-wide
835
+ * event. No organization is named anywhere, so there is no wall to cross.
836
+ *
837
+ * Both refusals are said ONCE per subscription (the #8022 say-once rule,
838
+ * ledger {@link organizationRefusalReported}): the first refused event is
839
+ * a `warn` naming the consequence and the remedy, later ones are
840
+ * debug-level. Without that a refused subscription reads active:true in
841
+ * Setup with nothing ever arriving — the "dead while looking armed" shape
842
+ * ADR-0078 refuses.
843
+ */
844
+ admitsOrganization(sub, eventOrganizationId, event) {
845
+ if (sub.organizationId === eventOrganizationId) return true;
846
+ if (sub.organizationId !== void 0 && eventOrganizationId !== void 0) return false;
847
+ const orgless = sub.organizationId === void 0;
848
+ const meta = {
849
+ id: sub.id,
850
+ webhook: sub.name,
851
+ type: event.type,
852
+ object: event.object,
853
+ subscriptionOrganizationId: sub.organizationId,
854
+ eventNamesOrganization: eventOrganizationId !== void 0
855
+ };
856
+ if (this.organizationRefusalReported.has(sub.id)) {
857
+ this.logger?.debug?.(
858
+ `[webhook-auto-enqueuer] webhook '${sub.name}' still refused on the organization dimension`,
859
+ meta
860
+ );
861
+ return false;
862
+ }
863
+ this.organizationRefusalReported.add(sub.id);
864
+ const message = orgless ? `[webhook-auto-enqueuer] webhook '${sub.name}' belongs to NO organization, but this ${event.type} event on '${event.object}' is organization-walled (the producer stamped organizationId) \u2014 refusing to fan out: a subscription with no organization ownership does not receive an organization's records. It will receive NO organization-walled event while reading active:true in Setup; author the webhook inside the organization that should receive these events. Said once per subscription.` : `[webhook-auto-enqueuer] webhook '${sub.name}' belongs to organization '${sub.organizationId}', but this ${event.type} event on '${event.object}' names no organization \u2014 refusing to deliver it inside an organization wall: on the per-record path an absent organizationId is an environment-wide row or an object outside the wall; on the bulk path it is a batch the producer could not attribute to one organization (a system or cross-membership predicate write). A tenant-scoped subscription receives only events attributable to its own organization. Said once per subscription.`;
865
+ this.logger?.warn?.(message, meta);
866
+ return false;
867
+ }
738
868
  handleSelfHealEvent(event) {
739
869
  if (event.object !== this.subscriptionsObject) return;
740
870
  if (!event.type?.startsWith("data.record.") && !event.type?.startsWith("data.records.")) return;
@@ -759,6 +889,12 @@ function mapActionToTrigger(action) {
759
889
  return null;
760
890
  }
761
891
  }
892
+ function readEventOrganizationId(payload) {
893
+ const value = payload.organizationId;
894
+ if (value === void 0) return { organizationId: void 0 };
895
+ if (typeof value !== "string" || value === "") return void 0;
896
+ return { organizationId: value };
897
+ }
762
898
  function mapBulkActionToTrigger(action) {
763
899
  switch (action) {
764
900
  case "updated":
@@ -1022,34 +1158,18 @@ function createWebhookRedeliverGuard(engine, subscriptionsObject = WEBHOOK_OBJEC
1022
1158
 
1023
1159
  // src/webhook-provenance.ts
1024
1160
  var WEBHOOK_PROVENANCE_PACKAGE = "plugin-webhooks:provenance";
1025
- var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
1026
1161
  function bindWebhookProvenanceStamp(engine, logger) {
1027
1162
  if (typeof engine?.registerHook !== "function") return;
1028
1163
  engine.registerHook(
1029
1164
  "beforeUpdate",
1030
1165
  async (ctx) => {
1031
1166
  if (ctx?.session?.isSystem) return;
1032
- const id = ctx?.input?.id ?? ctx?.input?.data?.id;
1033
- if (!id) return;
1034
1167
  const data = ctx?.input?.data;
1035
1168
  if (!data || typeof data !== "object") return;
1036
- try {
1037
- const rows = await engine.find("sys_webhook", {
1038
- where: { id },
1039
- fields: ["id", "managed_by", "customized"],
1040
- limit: 1,
1041
- context: SYSTEM_CTX3
1042
- });
1043
- const row = Array.isArray(rows) ? rows[0] : void 0;
1044
- if (!row) return;
1045
- if ((row.managed_by === "package" || row.managed_by === "platform") && row.customized !== true) {
1046
- data.customized = true;
1047
- }
1048
- } catch (err) {
1049
- logger?.warn?.("[webhook] provenance stamp failed (edit proceeds unstamped)", {
1050
- id,
1051
- error: err?.message
1052
- });
1169
+ const previous = ctx?.previous;
1170
+ if (!previous || typeof previous !== "object") return;
1171
+ if ((previous.managed_by === "package" || previous.managed_by === "platform") && previous.customized !== true) {
1172
+ data.customized = true;
1053
1173
  }
1054
1174
  },
1055
1175
  { object: "sys_webhook", packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 }
@@ -1207,7 +1327,7 @@ var WebhookOutboxPlugin = class {
1207
1327
  try {
1208
1328
  const i18n = ctx.getService("i18n");
1209
1329
  if (i18n && typeof i18n.loadTranslations === "function") {
1210
- const { WebhooksTranslations } = await import("./translations-IAKF6NAP.js");
1330
+ const { WebhooksTranslations } = await import("./translations-MNRJO53N.js");
1211
1331
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
1212
1332
  i18n.loadTranslations(locale, data);
1213
1333
  }