@objectstack/plugin-webhooks 17.0.0 → 17.1.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
@@ -572,6 +572,71 @@ declare const WEBHOOK_SECRET_FIELD = "signing_secret";
572
572
  /** Column on `sys_webhook` holding the encrypted custom-header map. */
573
573
  declare const WEBHOOK_HEADERS_FIELD = "headers_secret";
574
574
 
575
+ /**
576
+ * ADR-0112 envelope for this refusal. `VALIDATION_ERROR`/400 is the standard
577
+ * catalog member for "the payload is not acceptable" — the SAME pair #8559's
578
+ * `EmptyCredentialWriteError` carries at the same door for the same class of
579
+ * verdict, so a client branching on `code`/`status` handles both malformed
580
+ * credential writes identically. A standard-catalog code needs no ledger entry.
581
+ */
582
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = "VALIDATION_ERROR";
583
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;
584
+ /**
585
+ * [#8566] Refusal to persist a `headers_secret` plaintext that is not a flat
586
+ * JSON object of string values.
587
+ *
588
+ * Carries the ADR-0112 pair plus the LOCATION (`object`/`field`) as fields, so
589
+ * a consumer branches on `code`/`status` rather than on message text — the same
590
+ * discipline {@link WebhookHeadersUnresolvableError} follows on the read side
591
+ * of this seam, and `EmptyCredentialWriteError` follows on the write side.
592
+ */
593
+ declare class WebhookHeadersShapeError extends Error {
594
+ readonly code = "VALIDATION_ERROR";
595
+ readonly status = 400;
596
+ readonly object: string;
597
+ readonly field: string;
598
+ constructor(object: string, field: string, diagnosis: string);
599
+ }
600
+ /**
601
+ * The verdict, as a pure function of the write payload — exported so the gate
602
+ * can be reasoned about and tested without booting an engine, and so any future
603
+ * caller uses the same one rule rather than restating it.
604
+ *
605
+ * Mutates nothing and returns nothing: it either passes or throws
606
+ * {@link WebhookHeadersShapeError}.
607
+ */
608
+ declare function assertWritableWebhookHeaders(data: Record<string, unknown> | null | undefined, object?: string, field?: string): void;
609
+ /** Minimal engine surface this binding needs — mirrors `webhook-provenance.ts`. */
610
+ interface MinimalEngine {
611
+ registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;
612
+ unregisterHooksByPackage(packageId: string): number;
613
+ }
614
+ interface MinimalLogger {
615
+ info?: (msg: string, meta?: Record<string, any>) => void;
616
+ }
617
+ /**
618
+ * Bind the shape gate to both write events on `sys_webhook`.
619
+ *
620
+ * ## Deliberately NOT exempt for `isSystem`
621
+ * The provenance stamp next door skips system writes because it is detecting an
622
+ * ADMIN edit; this is a validity verdict on a payload, and a malformed header
623
+ * map is exactly as unusable when a seeder writes it. Ruling item 2 says the
624
+ * plugin's own write paths inherit this validation through the hook, which is
625
+ * only true if system writes are covered. They pass by construction —
626
+ * `bootstrapDeclaredWebhooks` and the migration sweep both write
627
+ * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so
628
+ * covering them costs nothing and closes the door for a future write path that
629
+ * is less careful.
630
+ *
631
+ * Registered in CODE rather than from metadata, which also means
632
+ * `session.skipAutomations` (an import run with automations unchecked) cannot
633
+ * suppress it: the engine only skips metadata-bound entries. A validation door
634
+ * that an import could switch off would not be a door.
635
+ */
636
+ declare function bindWebhookHeadersShapeGate(engine: MinimalEngine, logger?: MinimalLogger): void;
637
+ /** Remove the gate — mirrors `unbindWebhookProvenanceStamp`, for `dispose()`. */
638
+ declare function unbindWebhookHeadersShapeGate(engine: MinimalEngine): void;
639
+
575
640
  /**
576
641
  * [#7799] One-shot boot sweep that moves already-persisted cleartext signing
577
642
  * secrets out of `sys_webhook.definition_json` and into the encrypted
@@ -625,4 +690,4 @@ interface MigrateWebhookSecretsResult {
625
690
  */
626
691
  declare function migrateLegacyWebhookSecrets(engine: IDataEngine, logger?: Logger, subscriptionsObject?: string): Promise<MigrateWebhookSecretsResult>;
627
692
 
628
- export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_SECRET_FIELD, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, migrateLegacyWebhookSecrets };
693
+ export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE, WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS, WEBHOOK_SECRET_FIELD, WebhookHeadersShapeError, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, assertWritableWebhookHeaders, bindWebhookHeadersShapeGate, migrateLegacyWebhookSecrets, unbindWebhookHeadersShapeGate };
package/dist/index.d.ts CHANGED
@@ -572,6 +572,71 @@ declare const WEBHOOK_SECRET_FIELD = "signing_secret";
572
572
  /** Column on `sys_webhook` holding the encrypted custom-header map. */
573
573
  declare const WEBHOOK_HEADERS_FIELD = "headers_secret";
574
574
 
575
+ /**
576
+ * ADR-0112 envelope for this refusal. `VALIDATION_ERROR`/400 is the standard
577
+ * catalog member for "the payload is not acceptable" — the SAME pair #8559's
578
+ * `EmptyCredentialWriteError` carries at the same door for the same class of
579
+ * verdict, so a client branching on `code`/`status` handles both malformed
580
+ * credential writes identically. A standard-catalog code needs no ledger entry.
581
+ */
582
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = "VALIDATION_ERROR";
583
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;
584
+ /**
585
+ * [#8566] Refusal to persist a `headers_secret` plaintext that is not a flat
586
+ * JSON object of string values.
587
+ *
588
+ * Carries the ADR-0112 pair plus the LOCATION (`object`/`field`) as fields, so
589
+ * a consumer branches on `code`/`status` rather than on message text — the same
590
+ * discipline {@link WebhookHeadersUnresolvableError} follows on the read side
591
+ * of this seam, and `EmptyCredentialWriteError` follows on the write side.
592
+ */
593
+ declare class WebhookHeadersShapeError extends Error {
594
+ readonly code = "VALIDATION_ERROR";
595
+ readonly status = 400;
596
+ readonly object: string;
597
+ readonly field: string;
598
+ constructor(object: string, field: string, diagnosis: string);
599
+ }
600
+ /**
601
+ * The verdict, as a pure function of the write payload — exported so the gate
602
+ * can be reasoned about and tested without booting an engine, and so any future
603
+ * caller uses the same one rule rather than restating it.
604
+ *
605
+ * Mutates nothing and returns nothing: it either passes or throws
606
+ * {@link WebhookHeadersShapeError}.
607
+ */
608
+ declare function assertWritableWebhookHeaders(data: Record<string, unknown> | null | undefined, object?: string, field?: string): void;
609
+ /** Minimal engine surface this binding needs — mirrors `webhook-provenance.ts`. */
610
+ interface MinimalEngine {
611
+ registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;
612
+ unregisterHooksByPackage(packageId: string): number;
613
+ }
614
+ interface MinimalLogger {
615
+ info?: (msg: string, meta?: Record<string, any>) => void;
616
+ }
617
+ /**
618
+ * Bind the shape gate to both write events on `sys_webhook`.
619
+ *
620
+ * ## Deliberately NOT exempt for `isSystem`
621
+ * The provenance stamp next door skips system writes because it is detecting an
622
+ * ADMIN edit; this is a validity verdict on a payload, and a malformed header
623
+ * map is exactly as unusable when a seeder writes it. Ruling item 2 says the
624
+ * plugin's own write paths inherit this validation through the hook, which is
625
+ * only true if system writes are covered. They pass by construction —
626
+ * `bootstrapDeclaredWebhooks` and the migration sweep both write
627
+ * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so
628
+ * covering them costs nothing and closes the door for a future write path that
629
+ * is less careful.
630
+ *
631
+ * Registered in CODE rather than from metadata, which also means
632
+ * `session.skipAutomations` (an import run with automations unchecked) cannot
633
+ * suppress it: the engine only skips metadata-bound entries. A validation door
634
+ * that an import could switch off would not be a door.
635
+ */
636
+ declare function bindWebhookHeadersShapeGate(engine: MinimalEngine, logger?: MinimalLogger): void;
637
+ /** Remove the gate — mirrors `unbindWebhookProvenanceStamp`, for `dispose()`. */
638
+ declare function unbindWebhookHeadersShapeGate(engine: MinimalEngine): void;
639
+
575
640
  /**
576
641
  * [#7799] One-shot boot sweep that moves already-persisted cleartext signing
577
642
  * secrets out of `sys_webhook.definition_json` and into the encrypted
@@ -625,4 +690,4 @@ interface MigrateWebhookSecretsResult {
625
690
  */
626
691
  declare function migrateLegacyWebhookSecrets(engine: IDataEngine, logger?: Logger, subscriptionsObject?: string): Promise<MigrateWebhookSecretsResult>;
627
692
 
628
- export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_SECRET_FIELD, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, migrateLegacyWebhookSecrets };
693
+ export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE, WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS, WEBHOOK_SECRET_FIELD, WebhookHeadersShapeError, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, assertWritableWebhookHeaders, bindWebhookHeadersShapeGate, migrateLegacyWebhookSecrets, unbindWebhookHeadersShapeGate };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SysWebhook
3
- } from "./chunk-GDCWDVDT.js";
3
+ } from "./chunk-XERWWQKN.js";
4
4
 
5
5
  // src/webhook-secret.ts
6
6
  var WEBHOOK_SECRET_FIELD = "signing_secret";
@@ -1062,6 +1062,98 @@ function unbindWebhookProvenanceStamp(engine) {
1062
1062
  }
1063
1063
  }
1064
1064
 
1065
+ // src/webhook-headers-gate.ts
1066
+ var WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = "VALIDATION_ERROR";
1067
+ var WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;
1068
+ var DECLARED_SHAPE = 'Custom HTTP headers sent with each delivery, as a JSON object ({"Authorization": "Bearer ..."})';
1069
+ var WebhookHeadersShapeError = class extends Error {
1070
+ constructor(object, field, diagnosis) {
1071
+ super(
1072
+ `Custom headers refused for "${object}.${field}": ${diagnosis}. The required shape is a FLAT JSON object of string values, which is what the field itself asks for \u2014 its description reads: "${DECLARED_SHAPE}". This is checked at the write door because one step later there is nothing left to check: the engine encrypts this value into sys_secret and every read path returns only the mask, so a stored value that can never be used is indistinguishable from one that works until the next delivery tries to send it \u2014 at which point the subscription parks and the report arrives an unbounded time later, in a different surface from the one it was typed into (#7986, #8558, #8566). ${HEADERS_REMEDY}`
1073
+ );
1074
+ this.code = WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE;
1075
+ this.status = WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS;
1076
+ this.name = "WebhookHeadersShapeError";
1077
+ this.object = object;
1078
+ this.field = field;
1079
+ }
1080
+ };
1081
+ function describeParsed(parsed) {
1082
+ if (parsed === null) return "null";
1083
+ if (Array.isArray(parsed)) return "a JSON array";
1084
+ if (typeof parsed !== "object") return `a JSON ${typeof parsed}`;
1085
+ const entries = Object.entries(parsed);
1086
+ if (entries.length === 0) {
1087
+ return 'an EMPTY JSON object, which is not the same thing as "send no custom headers"';
1088
+ }
1089
+ const bad = entries.filter(([, v]) => typeof v !== "string");
1090
+ if (bad.length > 0) {
1091
+ const named = bad.map(([k, v]) => `${JSON.stringify(k)} (${Array.isArray(v) ? "array" : v === null ? "null" : typeof v})`).join(", ");
1092
+ return `a JSON object, but the wire carries only strings and ${bad.length === 1 ? "this value is" : "these values are"} not a string: ${named}`;
1093
+ }
1094
+ return "a JSON object the header seam does not accept";
1095
+ }
1096
+ function describeRejected(value) {
1097
+ if (typeof value === "string") {
1098
+ let parsed;
1099
+ try {
1100
+ parsed = JSON.parse(value);
1101
+ } catch {
1102
+ return "the value is a string that is not valid JSON at all \u2014 check for unquoted keys or values ({X-Team: crm}), single quotes instead of double, or a trailing comma";
1103
+ }
1104
+ return `the value parses as JSON but is ${describeParsed(parsed)}`;
1105
+ }
1106
+ return `the value is ${describeParsed(value)}`;
1107
+ }
1108
+ function assertWritableWebhookHeaders(data, object = WEBHOOK_OBJECT, field = WEBHOOK_HEADERS_FIELD) {
1109
+ if (!data || typeof data !== "object") return;
1110
+ if (!Object.prototype.hasOwnProperty.call(data, field)) return;
1111
+ const value = data[field];
1112
+ if (value === null || typeof value === "undefined") return;
1113
+ if (value === "") return;
1114
+ if (isOpaqueSecretForm(value)) return;
1115
+ let serialized;
1116
+ if (typeof value === "string") {
1117
+ serialized = value;
1118
+ } else {
1119
+ try {
1120
+ serialized = JSON.stringify(value);
1121
+ } catch {
1122
+ throw new WebhookHeadersShapeError(
1123
+ object,
1124
+ field,
1125
+ "the value cannot be serialized to JSON at all (it contains a circular reference)"
1126
+ );
1127
+ }
1128
+ if (typeof serialized !== "string") {
1129
+ throw new WebhookHeadersShapeError(object, field, `the value is a ${typeof value}`);
1130
+ }
1131
+ }
1132
+ if (parseStoredHeaders(serialized)) return;
1133
+ throw new WebhookHeadersShapeError(object, field, describeRejected(value));
1134
+ }
1135
+ var WEBHOOK_HEADERS_GATE_PACKAGE = "plugin-webhooks:headers-shape-gate";
1136
+ var GATE_PRIORITY = 50;
1137
+ function bindWebhookHeadersShapeGate(engine, logger) {
1138
+ if (typeof engine?.registerHook !== "function") return;
1139
+ const handler = (ctx) => {
1140
+ assertWritableWebhookHeaders(ctx?.input?.data);
1141
+ };
1142
+ for (const event of ["beforeInsert", "beforeUpdate"]) {
1143
+ engine.registerHook(event, handler, {
1144
+ object: WEBHOOK_OBJECT,
1145
+ packageId: WEBHOOK_HEADERS_GATE_PACKAGE,
1146
+ priority: GATE_PRIORITY
1147
+ });
1148
+ }
1149
+ logger?.info?.("[webhook] headers_secret shape gate bound (refuses non-flat-string-map plaintext)");
1150
+ }
1151
+ function unbindWebhookHeadersShapeGate(engine) {
1152
+ if (typeof engine?.unregisterHooksByPackage === "function") {
1153
+ engine.unregisterHooksByPackage(WEBHOOK_HEADERS_GATE_PACKAGE);
1154
+ }
1155
+ }
1156
+
1065
1157
  // src/webhook-outbox-plugin.ts
1066
1158
  var WebhookOutboxPlugin = class {
1067
1159
  constructor(options = {}) {
@@ -1115,7 +1207,7 @@ var WebhookOutboxPlugin = class {
1115
1207
  try {
1116
1208
  const i18n = ctx.getService("i18n");
1117
1209
  if (i18n && typeof i18n.loadTranslations === "function") {
1118
- const { WebhooksTranslations } = await import("./translations-U32QIEPB.js");
1210
+ const { WebhooksTranslations } = await import("./translations-IAKF6NAP.js");
1119
1211
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
1120
1212
  i18n.loadTranslations(locale, data);
1121
1213
  }
@@ -1143,6 +1235,10 @@ var WebhookOutboxPlugin = class {
1143
1235
  unbindWebhookProvenanceStamp(this.boundEngine);
1144
1236
  } catch {
1145
1237
  }
1238
+ try {
1239
+ unbindWebhookHeadersShapeGate(this.boundEngine);
1240
+ } catch {
1241
+ }
1146
1242
  this.boundEngine = void 0;
1147
1243
  }
1148
1244
  }
@@ -1170,6 +1266,7 @@ var WebhookOutboxPlugin = class {
1170
1266
  }
1171
1267
  this.boundEngine = engine;
1172
1268
  bindWebhookProvenanceStamp(engine, ctx.logger);
1269
+ bindWebhookHeadersShapeGate(engine, ctx.logger);
1173
1270
  let metadataService;
1174
1271
  try {
1175
1272
  metadataService = ctx.getService("metadata");
@@ -1331,8 +1428,14 @@ export {
1331
1428
  AutoEnqueuer,
1332
1429
  SysWebhook,
1333
1430
  WEBHOOK_HEADERS_FIELD,
1431
+ WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE,
1432
+ WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS,
1334
1433
  WEBHOOK_SECRET_FIELD,
1434
+ WebhookHeadersShapeError,
1335
1435
  WebhookOutboxPlugin,
1336
- migrateLegacyWebhookSecrets
1436
+ assertWritableWebhookHeaders,
1437
+ bindWebhookHeadersShapeGate,
1438
+ migrateLegacyWebhookSecrets,
1439
+ unbindWebhookHeadersShapeGate
1337
1440
  };
1338
1441
  //# sourceMappingURL=index.js.map