@voltro/plugin-webhooks 0.26.0 → 0.28.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
@@ -196,6 +196,31 @@ export declare interface DeliverySummary {
196
196
  * Covers ms / s / m / h / d. Default seconds (no suffix). */
197
197
  export declare type DurationLiteral = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` | `${number}`;
198
198
 
199
+ /**
200
+ * Per-emit scoping.
201
+ *
202
+ * **The outgoing fan-out was NOT tenant-scoped, and this is what closes it.**
203
+ * The service is built once at boot with the app-level store, so the `tenant()`
204
+ * mixin on `_voltro_webhook_targets` had no request subject to scope by — the
205
+ * target lookup was `eq('event', name)` and nothing else. Confinement therefore
206
+ * rested entirely on each target's own `filter`, i.e. on the app remembering.
207
+ *
208
+ * A consumer found this by reading the CLI, and named exactly why it had looked
209
+ * safe: target filters usually predicate on a globally unique app id, so a
210
+ * cross-tenant match is impossible **by accident**. It stopped being accidental
211
+ * for them the moment they introduced a value deliberately equal across teams
212
+ * (a tenant-wide audience marker).
213
+ *
214
+ * `ctx.webhooks` binds this from the request subject, so an emit inside a
215
+ * handler is scoped without the handler saying anything. Absent — a background
216
+ * job, a schedule, a replay — keeps the unscoped behaviour, because there is no
217
+ * tenant to scope BY and refusing would break every system emit.
218
+ */
219
+ export declare interface EmitOptions {
220
+ /** Only deliver to targets of this tenant. Absent ⇒ every target. */
221
+ readonly tenantId?: string | null;
222
+ }
223
+
199
224
  export declare interface EmitResult {
200
225
  /** Stable id matching the row(s) the deliverWebhook workflow
201
226
  * writes to `_voltro_webhook_deliveries`. Use as the foreign
@@ -223,6 +248,9 @@ export declare interface EncodedPayload {
223
248
  * be honestly represented in the requested format. */
224
249
  export declare const encodePayload: (format: WireFormat, payloadJson: string) => EncodedPayload;
225
250
 
251
+ /** The events a subscribe names, whichever spelling was used. */
252
+ export declare const eventsOf: (input: SubscribeInput) => ReadonlyArray<string>;
253
+
226
254
  /** Aggressive policy — retry every 30s for an hour. Use for a
227
255
  * downstream that's expected to come back quickly. */
228
256
  export declare const fastRetryPolicy: () => RetryPolicy;
@@ -418,7 +446,7 @@ export declare const isWebhookDescriptor: (value: unknown) => value is WebhookDe
418
446
  * when the target should receive this delivery. Supports the
419
447
  * simple key-path form `'payload.field': value` for v1; future
420
448
  * versions can grow operators ({ gt, lt, in, … }). */
421
- export declare const matchesFilter: (filter: Readonly<Record<string, unknown>> | null, payload: unknown) => boolean;
449
+ export declare const matchesFilter: (filter: WebhookFilter | Readonly<Record<string, unknown>> | null, payload: unknown) => boolean;
422
450
 
423
451
  /**
424
452
  * Build a request handler for a specific `IncomingWebhookDescriptor`.
@@ -542,7 +570,10 @@ export declare const releaseRateSlot: (store: DataStore, key: string, bucket: nu
542
570
  * value wins, then the event descriptor's per-event defaults
543
571
  * (`defaultSigning` / `defaultRetry` / `version` from
544
572
  * the declared event's `webhook:` block), then the package-global defaults. */
545
- export declare const resolveSubscribe: (input: SubscribeInput, event?: OutgoingEventDescriptor<unknown>) => {
573
+ export declare const resolveSubscribe: (input: SubscribeInput, event?: OutgoingEventDescriptor<unknown>,
574
+ /** The event this ROW is for, and the secret the group shares. Both default
575
+ * to the single-event behaviour, so existing callers are unchanged. */
576
+ forEvent?: string, sharedSecret?: string) => {
546
577
  readonly id: string;
547
578
  readonly event: string;
548
579
  readonly url: string;
@@ -621,7 +652,8 @@ export declare const slackSignature: () => CustomSignatureScheme;
621
652
  export declare const stripeSignature: (header?: string) => HmacSignatureScheme;
622
653
 
623
654
  export declare interface SubscribeInput {
624
- readonly event: string;
655
+ /** A single event. Use `events` for a multi-event subscription. */
656
+ readonly event?: string;
625
657
  readonly url: string;
626
658
  /** Secret used to sign deliveries to this target. Auto-generated
627
659
  * when omitted (32-byte hex). The caller receives it ONCE in the
@@ -629,10 +661,31 @@ export declare interface SubscribeInput {
629
661
  readonly secret?: string;
630
662
  readonly signing?: SignatureScheme;
631
663
  readonly retry?: RetryPolicy;
632
- /** Optional predicate filter — only emits whose payload matches
633
- * this predicate fan-out to this target. The shape mirrors the
634
- * schema-builder's `Predicate` from `@voltro/database`. */
635
- readonly filter?: Readonly<Record<string, unknown>>;
664
+ /**
665
+ * Optional routing filter only emits whose payload matches fan out to this
666
+ * target. See {@link WebhookFilter}: dotted paths into `{ payload }`, a bare
667
+ * value for equality, or `{ eq | in | gt | gte | lt | lte }`.
668
+ */
669
+ readonly filter?: WebhookFilter;
670
+ /**
671
+ * Subscribe ONE url to SEVERAL events at once.
672
+ *
673
+ * A subscription, as every webhook UI models it, is one URL with a list of
674
+ * event checkboxes — ours, Stripe's, GitHub's. The row is one event, so five
675
+ * checkboxes are five rows, and the gap between the two is where an app ends
676
+ * up hand-rolling every operation a user thinks of as single.
677
+ *
678
+ * The rows created here share ONE secret and one `scope`, which is what makes
679
+ * the group addressable afterwards — and it is not a convenience: the receiver
680
+ * verifies one signature for one URL, so N rows for one endpoint MUST sign
681
+ * identically. Without this the only way to say so was to read the secret
682
+ * column back out of `_voltro_webhook_targets`, which is exactly the coupling
683
+ * `listDeliveries` was added to remove, re-entered through another door.
684
+ *
685
+ * Mutually exclusive with `event`. Pass whichever reads better; one event is
686
+ * still one row.
687
+ */
688
+ readonly events?: ReadonlyArray<string>;
636
689
  /**
637
690
  * The APP's own scoping dimension — opaque, stored and returned verbatim.
638
691
  *
@@ -654,6 +707,12 @@ export declare interface SubscribeInput {
654
707
  }
655
708
 
656
709
  export declare interface SubscribeResult {
710
+ /** Every row created, when `events` named more than one. Absent for a
711
+ * single-event subscribe, where `id` and `event` already say it. */
712
+ readonly targets?: ReadonlyArray<{
713
+ readonly id: string;
714
+ readonly event: string;
715
+ }>;
657
716
  readonly id: string;
658
717
  readonly event: string;
659
718
  readonly url: string;
@@ -669,7 +728,7 @@ export declare interface SubscribeResult {
669
728
  export declare interface TargetPatch {
670
729
  readonly url?: string;
671
730
  readonly description?: string | null;
672
- readonly filter?: Readonly<Record<string, unknown>> | null;
731
+ readonly filter?: WebhookFilter | null;
673
732
  readonly scope?: Readonly<Record<string, unknown>> | null;
674
733
  readonly headers?: Readonly<Record<string, string>> | null;
675
734
  readonly rateLimitPerMinute?: number | null;
@@ -679,6 +738,23 @@ export declare interface TargetPatch {
679
738
 
680
739
  export declare const TARGETS_TABLE = "_voltro_webhook_targets";
681
740
 
741
+ /**
742
+ * WHICH target(s) an operation addresses.
743
+ *
744
+ * A string is one row. A `{ scope }` is the GROUP — every row whose opaque
745
+ * scope matches, which for a multi-event subscription is the whole endpoint.
746
+ *
747
+ * This exists because a subscription, as a user models it, is one URL with a
748
+ * list of event checkboxes, while a row is one event. Without a group selector
749
+ * every operation the user thinks of as single — pause the endpoint, fix its
750
+ * URL, rotate its secret, read its history — becomes a fan-out the app writes
751
+ * by hand, and `rotateSecret` in particular becomes delete + re-subscribe,
752
+ * which mints new ids and orphans the delivery history.
753
+ */
754
+ export declare type TargetSelector = string | {
755
+ readonly scope: Readonly<Record<string, unknown>>;
756
+ };
757
+
682
758
  export declare interface TargetSummary {
683
759
  readonly id: string;
684
760
  /** The app's own scoping dimension as written at subscribe time, verbatim.
@@ -723,8 +799,6 @@ export declare const useWebhooks: (ctx: {
723
799
 
724
800
  export declare const useWebhooksEffect: Effect.Effect<WebhooksServiceShape, never, WebhooksService>;
725
801
 
726
- /** Validate subscribe input. Throws on policy violations so the
727
- * caller's catch block can surface a 422-style error. */
728
802
  export declare const validateSubscribe: (input: SubscribeInput) => void;
729
803
 
730
804
  export declare type VerifyResult = {
@@ -972,6 +1046,24 @@ declare const WebhookDeliveryNotFound_base: Schema.TaggedErrorClass<WebhookDeliv
972
1046
 
973
1047
  export declare type WebhookDescriptor = OutgoingEventDescriptor<unknown> | IncomingWebhookDescriptor<unknown> | WebhookProviderDescriptor;
974
1048
 
1049
+ /**
1050
+ * A target's routing filter: dotted paths INTO the emitted envelope.
1051
+ *
1052
+ * The root is `{ payload }`, so every path starts `payload.` — a path that does
1053
+ * not resolve reads as `undefined` and the comparison fails, which is how a
1054
+ * typo'd path silently routes nothing.
1055
+ */
1056
+ export declare type WebhookFilter = Readonly<Record<string, WebhookFilterValue>>;
1057
+
1058
+ export declare type WebhookFilterValue = string | number | boolean | null | {
1059
+ readonly eq?: string | number | boolean | null;
1060
+ readonly in?: ReadonlyArray<string | number | boolean | null>;
1061
+ readonly gt?: number | string;
1062
+ readonly gte?: number | string;
1063
+ readonly lt?: number | string;
1064
+ readonly lte?: number | string;
1065
+ };
1066
+
975
1067
  /** Stable identifier for an event or webhook. Drives the database
976
1068
  * primary key, the inspect endpoint URL, the dashboard listing. Use
977
1069
  * dotted-camelCase (`order.completed`, `user.signedUp`). */
@@ -1068,7 +1160,7 @@ export declare interface WebhooksServiceShape {
1068
1160
  * discovered-events registry) the payload is DECODED against its
1069
1161
  * schema and a mismatch throws `WebhookPayloadInvalid` before any
1070
1162
  * delivery is created. */
1071
- readonly emit: <P>(event: string | OutgoingEventDescriptor<P> | DeclaredEventLike, payload: P) => Promise<EmitResult>;
1163
+ readonly emit: <P>(event: string | OutgoingEventDescriptor<P> | DeclaredEventLike, payload: P, options?: EmitOptions) => Promise<EmitResult>;
1072
1164
  /** Manual re-trigger for the dashboard's "Replay" button on a
1073
1165
  * failed delivery row. Re-runs the workflow at attempt 1 with
1074
1166
  * the original payload. */
@@ -1086,24 +1178,33 @@ export declare interface WebhooksServiceShape {
1086
1178
  * emits against this target queue under
1087
1179
  * `_voltro_webhook_deliveries` with status='pending' (no POST
1088
1180
  * happens); `resumeTarget` flushes them. */
1089
- readonly pauseTarget: (targetId: string) => Promise<void>;
1181
+ /** Every row a selector addresses. Refuses a scope that matches nothing —
1182
+ * an operation that silently affects zero rows is worse than an error. */
1183
+ readonly resolveTargets: (selector: TargetSelector) => Promise<ReadonlyArray<string>>;
1184
+ readonly pauseTarget: (target: TargetSelector) => Promise<void>;
1090
1185
  /** Re-enable a paused target AND flush its queued
1091
1186
  * `status='pending'` deliveries through the normal delivery
1092
1187
  * workflow, in emit order (`createdAt` ascending — millisecond
1093
1188
  * granularity) per target. */
1094
- readonly resumeTarget: (targetId: string) => Promise<void>;
1189
+ readonly resumeTarget: (target: TargetSelector) => Promise<void>;
1095
1190
  /** Hard-delete a target. Its queued `status='pending'` rows are
1096
1191
  * deleted with it (nothing left to flush); an in-flight workflow
1097
1192
  * run's `fetch-target` activity sees a null row and exits
1098
1193
  * cleanly. */
1099
- readonly deleteTarget: (targetId: string) => Promise<void>;
1194
+ readonly deleteTarget: (target: TargetSelector) => Promise<void>;
1100
1195
  /** Rotate the per-target signing secret. Returns the new secret
1101
1196
  * ONCE — store it client-side if you need to display it again.
1102
1197
  * In-flight retries continue with the OLD secret since signing
1103
1198
  * happens at attempt time using the row's then-current value
1104
1199
  * (acceptable: providers retry within seconds, the rotation
1105
1200
  * window is tight). */
1106
- readonly rotateSecret: (targetId: string) => Promise<{
1201
+ /**
1202
+ * Rotate the signing secret. Addressed by a SCOPE this rotates every row of
1203
+ * the endpoint to the SAME new value — which is the point: N rows for one URL
1204
+ * must sign identically, and the previous way to achieve it was delete +
1205
+ * re-subscribe, minting new ids and orphaning the delivery history.
1206
+ */
1207
+ readonly rotateSecret: (target: TargetSelector) => Promise<{
1107
1208
  readonly secret: string;
1108
1209
  }>;
1109
1210
  /** Re-pin a target's `payloadVersion` to the event's current
@@ -1124,7 +1225,7 @@ export declare interface WebhooksServiceShape {
1124
1225
  * makes it a different subscription, and the secret has `rotateSecret`, which
1125
1226
  * returns the new value once.
1126
1227
  */
1127
- readonly updateTarget: (targetId: string, patch: TargetPatch) => Promise<void>;
1228
+ readonly updateTarget: (target: TargetSelector, patch: TargetPatch) => Promise<void>;
1128
1229
  /**
1129
1230
  * Send one delivery to ONE target, bypassing fan-out and the filter.
1130
1231
  *
@@ -1151,6 +1252,9 @@ export declare interface WebhooksServiceShape {
1151
1252
  */
1152
1253
  readonly listDeliveries: (filter?: {
1153
1254
  readonly targetId?: string;
1255
+ /** Every row of an endpoint's group, so a history view needs no N-way
1256
+ * merge-and-re-sort in the app. */
1257
+ readonly scope?: Readonly<Record<string, unknown>>;
1154
1258
  readonly status?: DeliverySummary['status'];
1155
1259
  readonly since?: Date;
1156
1260
  readonly limit?: number;