@objectstack/plugin-webhooks 17.0.0 → 17.2.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
  }
@@ -129,7 +137,22 @@ declare class AutoEnqueuer {
129
137
  private readonly subscriptions;
130
138
  private readonly subscriptionsObject;
131
139
  private readonly refreshIntervalMs;
132
- private readonly logger;
140
+ /**
141
+ * Optional, and deliberately NOT defaulted to `{}` (#10556).
142
+ *
143
+ * `OptionalLogger` guarantees a `warn` channel under #9754, so `{}` stopped being a
144
+ * legal value of the type — which is the gate working: an empty object is a
145
+ * sink that declares it can report and then discards everything. The repair
146
+ * is to say what is TRUE — there may be no logger at all — rather than to
147
+ * mint a sink that lies. Runtime behaviour is unchanged in both directions:
148
+ * absent logger and `{}` both printed nothing before, and print nothing now.
149
+ *
150
+ * ⛔ What this deliberately does NOT decide: whether an absent host sink should
151
+ * instead default to a `console`-backed one. That is the open design call the
152
+ * #9754 ledger records against `plugin-security`'s `= {}` field, and it is a
153
+ * maintainer decision — not something to settle here to make a checker green.
154
+ */
155
+ private readonly logger?;
133
156
  private subId;
134
157
  private subIdSelfHeal;
135
158
  private refreshTimer;
@@ -404,6 +427,28 @@ declare class WebhookOutboxPlugin implements Plugin {
404
427
  private boundEngine;
405
428
  constructor(options?: WebhookOutboxPluginOptions);
406
429
  init(ctx: PluginContext): Promise<void>;
430
+ /**
431
+ * Teardown — the kernel's ONLY teardown hook.
432
+ *
433
+ * [#10772] This body used to be spelled `dispose()`. `Plugin`
434
+ * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and
435
+ * `destroy?()` and no `dispose()`, and `ObjectKernel.performShutdown()` /
436
+ * `LiteKernel.destroy()` walk the plugins in reverse calling
437
+ * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED the
438
+ * auto-enqueuer was still running and both engine hooks were still bound.
439
+ * Measured on the same revision: `dispose()` had ZERO callers anywhere in
440
+ * the repo, so this teardown had never run in any process at all.
441
+ *
442
+ * Idempotent: `boundEngine` is cleared as it is unbound, so a second
443
+ * teardown is a no-op rather than a second unbind.
444
+ */
445
+ destroy(): Promise<void>;
446
+ /**
447
+ * Retained alias for {@link destroy}. Kept because it is public API of an
448
+ * exported class: an embedder may have learned to call it directly
449
+ * precisely BECAUSE the kernel never did, and deleting it would break them.
450
+ * Same signature, same return type — a direct caller sees no change.
451
+ */
407
452
  dispose(): Promise<void>;
408
453
  private getMessaging;
409
454
  /**
@@ -436,11 +481,35 @@ declare class WebhookOutboxPlugin implements Plugin {
436
481
  private tryGetService;
437
482
  /**
438
483
  * 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.
484
+ * available. Delegates to `messaging.redeliverHttp(deliveryId, …)`. Auth is
485
+ * the better-auth session cookie — every authenticated user counts.
486
+ *
487
+ * [#10740] Which is precisely why the caller's ACTIVE ORGANIZATION is
488
+ * resolved here and threaded into the call. `sys_http_delivery` is
489
+ * tenant-scoped, and this is the one door on it a request can reach: an
490
+ * unscoped replay from here is an authenticated user reaching another
491
+ * organization's delivery row on a walled deployment. With the tenant
492
+ * threaded, a row outside the caller's organization is simply not found.
493
+ *
494
+ * ⚠️ A session with no active organization threads `undefined`, and the
495
+ * driver's tenant-audit line then fires for that write. That is deliberate:
496
+ * the deployment could not tell us who is asking, and reporting the gap is
497
+ * the correct outcome. ⛔ It is never repaired with `bypassTenantAudit`,
498
+ * which would silence the report without closing anything.
441
499
  */
442
500
  private registerAdminRoutes;
443
- private resolveSessionUserId;
501
+ /**
502
+ * [#10740] The better-auth session envelope (`{ user, session }`) for this
503
+ * request, or `undefined`.
504
+ *
505
+ * Widened from the previous `resolveSessionUserId` because the route now
506
+ * needs two facts from ONE lookup: who is asking (`user.id`, the
507
+ * authentication gate) and which organization they are asking as
508
+ * (`session.activeOrganizationId`, the tenant threaded into the write).
509
+ * Resolving them separately would mean two `getSession` calls that can
510
+ * disagree.
511
+ */
512
+ private resolveSession;
444
513
  }
445
514
 
446
515
  /**
@@ -572,6 +641,71 @@ declare const WEBHOOK_SECRET_FIELD = "signing_secret";
572
641
  /** Column on `sys_webhook` holding the encrypted custom-header map. */
573
642
  declare const WEBHOOK_HEADERS_FIELD = "headers_secret";
574
643
 
644
+ /**
645
+ * ADR-0112 envelope for this refusal. `VALIDATION_ERROR`/400 is the standard
646
+ * catalog member for "the payload is not acceptable" — the SAME pair #8559's
647
+ * `EmptyCredentialWriteError` carries at the same door for the same class of
648
+ * verdict, so a client branching on `code`/`status` handles both malformed
649
+ * credential writes identically. A standard-catalog code needs no ledger entry.
650
+ */
651
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = "VALIDATION_ERROR";
652
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;
653
+ /**
654
+ * [#8566] Refusal to persist a `headers_secret` plaintext that is not a flat
655
+ * JSON object of string values.
656
+ *
657
+ * Carries the ADR-0112 pair plus the LOCATION (`object`/`field`) as fields, so
658
+ * a consumer branches on `code`/`status` rather than on message text — the same
659
+ * discipline {@link WebhookHeadersUnresolvableError} follows on the read side
660
+ * of this seam, and `EmptyCredentialWriteError` follows on the write side.
661
+ */
662
+ declare class WebhookHeadersShapeError extends Error {
663
+ readonly code = "VALIDATION_ERROR";
664
+ readonly status = 400;
665
+ readonly object: string;
666
+ readonly field: string;
667
+ constructor(object: string, field: string, diagnosis: string);
668
+ }
669
+ /**
670
+ * The verdict, as a pure function of the write payload — exported so the gate
671
+ * can be reasoned about and tested without booting an engine, and so any future
672
+ * caller uses the same one rule rather than restating it.
673
+ *
674
+ * Mutates nothing and returns nothing: it either passes or throws
675
+ * {@link WebhookHeadersShapeError}.
676
+ */
677
+ declare function assertWritableWebhookHeaders(data: Record<string, unknown> | null | undefined, object?: string, field?: string): void;
678
+ /** Minimal engine surface this binding needs — mirrors `webhook-provenance.ts`. */
679
+ interface MinimalEngine {
680
+ registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;
681
+ unregisterHooksByPackage(packageId: string): number;
682
+ }
683
+ interface MinimalLogger {
684
+ info?: (msg: string, meta?: Record<string, any>) => void;
685
+ }
686
+ /**
687
+ * Bind the shape gate to both write events on `sys_webhook`.
688
+ *
689
+ * ## Deliberately NOT exempt for `isSystem`
690
+ * The provenance stamp next door skips system writes because it is detecting an
691
+ * ADMIN edit; this is a validity verdict on a payload, and a malformed header
692
+ * map is exactly as unusable when a seeder writes it. Ruling item 2 says the
693
+ * plugin's own write paths inherit this validation through the hook, which is
694
+ * only true if system writes are covered. They pass by construction —
695
+ * `bootstrapDeclaredWebhooks` and the migration sweep both write
696
+ * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so
697
+ * covering them costs nothing and closes the door for a future write path that
698
+ * is less careful.
699
+ *
700
+ * Registered in CODE rather than from metadata, which also means
701
+ * `session.skipAutomations` (an import run with automations unchecked) cannot
702
+ * suppress it: the engine only skips metadata-bound entries. A validation door
703
+ * that an import could switch off would not be a door.
704
+ */
705
+ declare function bindWebhookHeadersShapeGate(engine: MinimalEngine, logger?: MinimalLogger): void;
706
+ /** Remove the gate — mirrors `unbindWebhookProvenanceStamp`, for `dispose()`. */
707
+ declare function unbindWebhookHeadersShapeGate(engine: MinimalEngine): void;
708
+
575
709
  /**
576
710
  * [#7799] One-shot boot sweep that moves already-persisted cleartext signing
577
711
  * secrets out of `sys_webhook.definition_json` and into the encrypted
@@ -625,4 +759,4 @@ interface MigrateWebhookSecretsResult {
625
759
  */
626
760
  declare function migrateLegacyWebhookSecrets(engine: IDataEngine, logger?: Logger, subscriptionsObject?: string): Promise<MigrateWebhookSecretsResult>;
627
761
 
628
- export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_SECRET_FIELD, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, migrateLegacyWebhookSecrets };
762
+ 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
@@ -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
  }
@@ -129,7 +137,22 @@ declare class AutoEnqueuer {
129
137
  private readonly subscriptions;
130
138
  private readonly subscriptionsObject;
131
139
  private readonly refreshIntervalMs;
132
- private readonly logger;
140
+ /**
141
+ * Optional, and deliberately NOT defaulted to `{}` (#10556).
142
+ *
143
+ * `OptionalLogger` guarantees a `warn` channel under #9754, so `{}` stopped being a
144
+ * legal value of the type — which is the gate working: an empty object is a
145
+ * sink that declares it can report and then discards everything. The repair
146
+ * is to say what is TRUE — there may be no logger at all — rather than to
147
+ * mint a sink that lies. Runtime behaviour is unchanged in both directions:
148
+ * absent logger and `{}` both printed nothing before, and print nothing now.
149
+ *
150
+ * ⛔ What this deliberately does NOT decide: whether an absent host sink should
151
+ * instead default to a `console`-backed one. That is the open design call the
152
+ * #9754 ledger records against `plugin-security`'s `= {}` field, and it is a
153
+ * maintainer decision — not something to settle here to make a checker green.
154
+ */
155
+ private readonly logger?;
133
156
  private subId;
134
157
  private subIdSelfHeal;
135
158
  private refreshTimer;
@@ -404,6 +427,28 @@ declare class WebhookOutboxPlugin implements Plugin {
404
427
  private boundEngine;
405
428
  constructor(options?: WebhookOutboxPluginOptions);
406
429
  init(ctx: PluginContext): Promise<void>;
430
+ /**
431
+ * Teardown — the kernel's ONLY teardown hook.
432
+ *
433
+ * [#10772] This body used to be spelled `dispose()`. `Plugin`
434
+ * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and
435
+ * `destroy?()` and no `dispose()`, and `ObjectKernel.performShutdown()` /
436
+ * `LiteKernel.destroy()` walk the plugins in reverse calling
437
+ * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED the
438
+ * auto-enqueuer was still running and both engine hooks were still bound.
439
+ * Measured on the same revision: `dispose()` had ZERO callers anywhere in
440
+ * the repo, so this teardown had never run in any process at all.
441
+ *
442
+ * Idempotent: `boundEngine` is cleared as it is unbound, so a second
443
+ * teardown is a no-op rather than a second unbind.
444
+ */
445
+ destroy(): Promise<void>;
446
+ /**
447
+ * Retained alias for {@link destroy}. Kept because it is public API of an
448
+ * exported class: an embedder may have learned to call it directly
449
+ * precisely BECAUSE the kernel never did, and deleting it would break them.
450
+ * Same signature, same return type — a direct caller sees no change.
451
+ */
407
452
  dispose(): Promise<void>;
408
453
  private getMessaging;
409
454
  /**
@@ -436,11 +481,35 @@ declare class WebhookOutboxPlugin implements Plugin {
436
481
  private tryGetService;
437
482
  /**
438
483
  * 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.
484
+ * available. Delegates to `messaging.redeliverHttp(deliveryId, …)`. Auth is
485
+ * the better-auth session cookie — every authenticated user counts.
486
+ *
487
+ * [#10740] Which is precisely why the caller's ACTIVE ORGANIZATION is
488
+ * resolved here and threaded into the call. `sys_http_delivery` is
489
+ * tenant-scoped, and this is the one door on it a request can reach: an
490
+ * unscoped replay from here is an authenticated user reaching another
491
+ * organization's delivery row on a walled deployment. With the tenant
492
+ * threaded, a row outside the caller's organization is simply not found.
493
+ *
494
+ * ⚠️ A session with no active organization threads `undefined`, and the
495
+ * driver's tenant-audit line then fires for that write. That is deliberate:
496
+ * the deployment could not tell us who is asking, and reporting the gap is
497
+ * the correct outcome. ⛔ It is never repaired with `bypassTenantAudit`,
498
+ * which would silence the report without closing anything.
441
499
  */
442
500
  private registerAdminRoutes;
443
- private resolveSessionUserId;
501
+ /**
502
+ * [#10740] The better-auth session envelope (`{ user, session }`) for this
503
+ * request, or `undefined`.
504
+ *
505
+ * Widened from the previous `resolveSessionUserId` because the route now
506
+ * needs two facts from ONE lookup: who is asking (`user.id`, the
507
+ * authentication gate) and which organization they are asking as
508
+ * (`session.activeOrganizationId`, the tenant threaded into the write).
509
+ * Resolving them separately would mean two `getSession` calls that can
510
+ * disagree.
511
+ */
512
+ private resolveSession;
444
513
  }
445
514
 
446
515
  /**
@@ -572,6 +641,71 @@ declare const WEBHOOK_SECRET_FIELD = "signing_secret";
572
641
  /** Column on `sys_webhook` holding the encrypted custom-header map. */
573
642
  declare const WEBHOOK_HEADERS_FIELD = "headers_secret";
574
643
 
644
+ /**
645
+ * ADR-0112 envelope for this refusal. `VALIDATION_ERROR`/400 is the standard
646
+ * catalog member for "the payload is not acceptable" — the SAME pair #8559's
647
+ * `EmptyCredentialWriteError` carries at the same door for the same class of
648
+ * verdict, so a client branching on `code`/`status` handles both malformed
649
+ * credential writes identically. A standard-catalog code needs no ledger entry.
650
+ */
651
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = "VALIDATION_ERROR";
652
+ declare const WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;
653
+ /**
654
+ * [#8566] Refusal to persist a `headers_secret` plaintext that is not a flat
655
+ * JSON object of string values.
656
+ *
657
+ * Carries the ADR-0112 pair plus the LOCATION (`object`/`field`) as fields, so
658
+ * a consumer branches on `code`/`status` rather than on message text — the same
659
+ * discipline {@link WebhookHeadersUnresolvableError} follows on the read side
660
+ * of this seam, and `EmptyCredentialWriteError` follows on the write side.
661
+ */
662
+ declare class WebhookHeadersShapeError extends Error {
663
+ readonly code = "VALIDATION_ERROR";
664
+ readonly status = 400;
665
+ readonly object: string;
666
+ readonly field: string;
667
+ constructor(object: string, field: string, diagnosis: string);
668
+ }
669
+ /**
670
+ * The verdict, as a pure function of the write payload — exported so the gate
671
+ * can be reasoned about and tested without booting an engine, and so any future
672
+ * caller uses the same one rule rather than restating it.
673
+ *
674
+ * Mutates nothing and returns nothing: it either passes or throws
675
+ * {@link WebhookHeadersShapeError}.
676
+ */
677
+ declare function assertWritableWebhookHeaders(data: Record<string, unknown> | null | undefined, object?: string, field?: string): void;
678
+ /** Minimal engine surface this binding needs — mirrors `webhook-provenance.ts`. */
679
+ interface MinimalEngine {
680
+ registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;
681
+ unregisterHooksByPackage(packageId: string): number;
682
+ }
683
+ interface MinimalLogger {
684
+ info?: (msg: string, meta?: Record<string, any>) => void;
685
+ }
686
+ /**
687
+ * Bind the shape gate to both write events on `sys_webhook`.
688
+ *
689
+ * ## Deliberately NOT exempt for `isSystem`
690
+ * The provenance stamp next door skips system writes because it is detecting an
691
+ * ADMIN edit; this is a validity verdict on a payload, and a malformed header
692
+ * map is exactly as unusable when a seeder writes it. Ruling item 2 says the
693
+ * plugin's own write paths inherit this validation through the hook, which is
694
+ * only true if system writes are covered. They pass by construction —
695
+ * `bootstrapDeclaredWebhooks` and the migration sweep both write
696
+ * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so
697
+ * covering them costs nothing and closes the door for a future write path that
698
+ * is less careful.
699
+ *
700
+ * Registered in CODE rather than from metadata, which also means
701
+ * `session.skipAutomations` (an import run with automations unchecked) cannot
702
+ * suppress it: the engine only skips metadata-bound entries. A validation door
703
+ * that an import could switch off would not be a door.
704
+ */
705
+ declare function bindWebhookHeadersShapeGate(engine: MinimalEngine, logger?: MinimalLogger): void;
706
+ /** Remove the gate — mirrors `unbindWebhookProvenanceStamp`, for `dispose()`. */
707
+ declare function unbindWebhookHeadersShapeGate(engine: MinimalEngine): void;
708
+
575
709
  /**
576
710
  * [#7799] One-shot boot sweep that moves already-persisted cleartext signing
577
711
  * secrets out of `sys_webhook.definition_json` and into the encrypted
@@ -625,4 +759,4 @@ interface MigrateWebhookSecretsResult {
625
759
  */
626
760
  declare function migrateLegacyWebhookSecrets(engine: IDataEngine, logger?: Logger, subscriptionsObject?: string): Promise<MigrateWebhookSecretsResult>;
627
761
 
628
- export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_SECRET_FIELD, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, migrateLegacyWebhookSecrets };
762
+ 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 };