@objectstack/plugin-webhooks 17.3.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
@@ -72,6 +72,15 @@ interface CachedSubscription {
72
72
  * when the row itself carries no organization (a `single`-posture install):
73
73
  * the delivery then lands NULL, which is honest for a subscription that
74
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.
75
84
  */
76
85
  organizationId?: string;
77
86
  /**
@@ -116,11 +125,17 @@ interface AutoEnqueuerOptions {
116
125
  * The handler:
117
126
  * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`
118
127
  * — O(1) per event, no DB hit on the write path.
119
- * 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
120
134
  * enqueue itself is a single INSERT, which runs *after* the user's
121
135
  * request has already returned.
122
136
  *
123
- * 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.
124
139
  *
125
140
  * ## Cache freshness
126
141
  * The cache is rebuilt:
@@ -187,6 +202,17 @@ declare class AutoEnqueuer {
187
202
  * refresh, forever.
188
203
  */
189
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;
190
216
  constructor(engine: IDataEngine, realtime: IRealtimeService, enqueue: HttpEnqueueFn, opts?: AutoEnqueuerOptions);
191
217
  /**
192
218
  * Load the subscription cache and start listening for events.
@@ -380,6 +406,68 @@ declare class AutoEnqueuer {
380
406
  * webhook opts in with `bulk_update` / `bulk_delete`.
381
407
  */
382
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;
383
471
  private handleSelfHealEvent;
384
472
  /** Test / admin accessor. */
385
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,
@@ -635,9 +649,18 @@ var AutoEnqueuer = class {
635
649
  );
636
650
  return;
637
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
+ }
638
660
  const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
639
661
  for (const sub of subs) {
640
662
  if (!sub.triggers.has(trigger)) continue;
663
+ if (!this.admitsOrganization(sub, organization.organizationId, event)) continue;
641
664
  void this.enqueue({
642
665
  source: "webhook",
643
666
  refId: sub.id,
@@ -719,8 +742,17 @@ var AutoEnqueuer = class {
719
742
  return;
720
743
  }
721
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
+ }
722
753
  for (const sub of subs) {
723
754
  if (!sub.triggers.has(trigger)) continue;
755
+ if (!this.admitsOrganization(sub, organization.organizationId, event)) continue;
724
756
  void this.enqueue({
725
757
  source: "webhook",
726
758
  refId: sub.id,
@@ -748,6 +780,91 @@ var AutoEnqueuer = class {
748
780
  }).catch((err) => this.reportWriteFailure(sub, eventId, err, "bulk enqueue"));
749
781
  }
750
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
+ }
751
868
  handleSelfHealEvent(event) {
752
869
  if (event.object !== this.subscriptionsObject) return;
753
870
  if (!event.type?.startsWith("data.record.") && !event.type?.startsWith("data.records.")) return;
@@ -772,6 +889,12 @@ function mapActionToTrigger(action) {
772
889
  return null;
773
890
  }
774
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
+ }
775
898
  function mapBulkActionToTrigger(action) {
776
899
  switch (action) {
777
900
  case "updated":
@@ -1035,34 +1158,18 @@ function createWebhookRedeliverGuard(engine, subscriptionsObject = WEBHOOK_OBJEC
1035
1158
 
1036
1159
  // src/webhook-provenance.ts
1037
1160
  var WEBHOOK_PROVENANCE_PACKAGE = "plugin-webhooks:provenance";
1038
- var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
1039
1161
  function bindWebhookProvenanceStamp(engine, logger) {
1040
1162
  if (typeof engine?.registerHook !== "function") return;
1041
1163
  engine.registerHook(
1042
1164
  "beforeUpdate",
1043
1165
  async (ctx) => {
1044
1166
  if (ctx?.session?.isSystem) return;
1045
- const id = ctx?.input?.id ?? ctx?.input?.data?.id;
1046
- if (!id) return;
1047
1167
  const data = ctx?.input?.data;
1048
1168
  if (!data || typeof data !== "object") return;
1049
- try {
1050
- const rows = await engine.find("sys_webhook", {
1051
- where: { id },
1052
- fields: ["id", "managed_by", "customized"],
1053
- limit: 1,
1054
- context: SYSTEM_CTX3
1055
- });
1056
- const row = Array.isArray(rows) ? rows[0] : void 0;
1057
- if (!row) return;
1058
- if ((row.managed_by === "package" || row.managed_by === "platform") && row.customized !== true) {
1059
- data.customized = true;
1060
- }
1061
- } catch (err) {
1062
- logger?.warn?.("[webhook] provenance stamp failed (edit proceeds unstamped)", {
1063
- id,
1064
- error: err?.message
1065
- });
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;
1066
1173
  }
1067
1174
  },
1068
1175
  { object: "sys_webhook", packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 }
@@ -1220,7 +1327,7 @@ var WebhookOutboxPlugin = class {
1220
1327
  try {
1221
1328
  const i18n = ctx.getService("i18n");
1222
1329
  if (i18n && typeof i18n.loadTranslations === "function") {
1223
- const { WebhooksTranslations } = await import("./translations-CPXQB2NM.js");
1330
+ const { WebhooksTranslations } = await import("./translations-MNRJO53N.js");
1224
1331
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
1225
1332
  i18n.loadTranslations(locale, data);
1226
1333
  }