@voltro/plugin-webhooks 0.25.0 → 0.27.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
@@ -169,6 +169,29 @@ declare interface DeliverWorkflowOptions {
169
169
  readonly rateWindowMs?: number;
170
170
  }
171
171
 
172
+ /** `getDelivery` adds the two heavy columns `listDeliveries` omits. */
173
+ export declare interface DeliveryDetail extends DeliverySummary {
174
+ readonly payload: unknown;
175
+ readonly responseBody: string | null;
176
+ }
177
+
178
+ /** One delivery ATTEMPT, as the management UI reads it. */
179
+ export declare interface DeliverySummary {
180
+ readonly id: string;
181
+ readonly deliveryId: string;
182
+ readonly targetId: string;
183
+ readonly event: string;
184
+ readonly eventId: string | null;
185
+ readonly attempt: number;
186
+ readonly status: 'pending' | 'inFlight' | 'succeeded' | 'failed' | 'retryScheduled';
187
+ readonly responseStatus: number | null;
188
+ readonly errorMessage: string | null;
189
+ readonly latencyMs: number | null;
190
+ readonly scheduledAt: string | null;
191
+ readonly nextAttemptAt: string | null;
192
+ readonly createdAt: string | null;
193
+ }
194
+
172
195
  /** Compact duration literal — parsed without a deps-pulling library.
173
196
  * Covers ms / s / m / h / d. Default seconds (no suffix). */
174
197
  export declare type DurationLiteral = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` | `${number}`;
@@ -200,6 +223,9 @@ export declare interface EncodedPayload {
200
223
  * be honestly represented in the requested format. */
201
224
  export declare const encodePayload: (format: WireFormat, payloadJson: string) => EncodedPayload;
202
225
 
226
+ /** The events a subscribe names, whichever spelling was used. */
227
+ export declare const eventsOf: (input: SubscribeInput) => ReadonlyArray<string>;
228
+
203
229
  /** Aggressive policy — retry every 30s for an hour. Use for a
204
230
  * downstream that's expected to come back quickly. */
205
231
  export declare const fastRetryPolicy: () => RetryPolicy;
@@ -519,7 +545,10 @@ export declare const releaseRateSlot: (store: DataStore, key: string, bucket: nu
519
545
  * value wins, then the event descriptor's per-event defaults
520
546
  * (`defaultSigning` / `defaultRetry` / `version` from
521
547
  * the declared event's `webhook:` block), then the package-global defaults. */
522
- export declare const resolveSubscribe: (input: SubscribeInput, event?: OutgoingEventDescriptor<unknown>) => {
548
+ export declare const resolveSubscribe: (input: SubscribeInput, event?: OutgoingEventDescriptor<unknown>,
549
+ /** The event this ROW is for, and the secret the group shares. Both default
550
+ * to the single-event behaviour, so existing callers are unchanged. */
551
+ forEvent?: string, sharedSecret?: string) => {
523
552
  readonly id: string;
524
553
  readonly event: string;
525
554
  readonly url: string;
@@ -527,6 +556,7 @@ export declare const resolveSubscribe: (input: SubscribeInput, event?: OutgoingE
527
556
  readonly signing: SignatureScheme;
528
557
  readonly retry: RetryPolicy;
529
558
  readonly filter: Readonly<Record<string, unknown>> | null;
559
+ readonly scope: Readonly<Record<string, unknown>> | null;
530
560
  readonly headers: Readonly<Record<string, string>> | null;
531
561
  readonly rateLimitPerMinute: number | null;
532
562
  readonly active: boolean;
@@ -597,7 +627,8 @@ export declare const slackSignature: () => CustomSignatureScheme;
597
627
  export declare const stripeSignature: (header?: string) => HmacSignatureScheme;
598
628
 
599
629
  export declare interface SubscribeInput {
600
- readonly event: string;
630
+ /** A single event. Use `events` for a multi-event subscription. */
631
+ readonly event?: string;
601
632
  readonly url: string;
602
633
  /** Secret used to sign deliveries to this target. Auto-generated
603
634
  * when omitted (32-byte hex). The caller receives it ONCE in the
@@ -609,6 +640,34 @@ export declare interface SubscribeInput {
609
640
  * this predicate fan-out to this target. The shape mirrors the
610
641
  * schema-builder's `Predicate` from `@voltro/database`. */
611
642
  readonly filter?: Readonly<Record<string, unknown>>;
643
+ /**
644
+ * Subscribe ONE url to SEVERAL events at once.
645
+ *
646
+ * A subscription, as every webhook UI models it, is one URL with a list of
647
+ * event checkboxes — ours, Stripe's, GitHub's. The row is one event, so five
648
+ * checkboxes are five rows, and the gap between the two is where an app ends
649
+ * up hand-rolling every operation a user thinks of as single.
650
+ *
651
+ * The rows created here share ONE secret and one `scope`, which is what makes
652
+ * the group addressable afterwards — and it is not a convenience: the receiver
653
+ * verifies one signature for one URL, so N rows for one endpoint MUST sign
654
+ * identically. Without this the only way to say so was to read the secret
655
+ * column back out of `_voltro_webhook_targets`, which is exactly the coupling
656
+ * `listDeliveries` was added to remove, re-entered through another door.
657
+ *
658
+ * Mutually exclusive with `event`. Pass whichever reads better; one event is
659
+ * still one row.
660
+ */
661
+ readonly events?: ReadonlyArray<string>;
662
+ /**
663
+ * The APP's own scoping dimension — opaque, stored and returned verbatim.
664
+ *
665
+ * `.with(tenant())` is one level too coarse for real deployments: endpoints
666
+ * are commonly scoped to a team, a project or a workspace, and a tenant has
667
+ * many of those. Pass whatever identifies yours; `listTargets({ scope })`
668
+ * filters on equality against it. The framework never interprets it.
669
+ */
670
+ readonly scope?: Readonly<Record<string, unknown>>;
612
671
  readonly headers?: Readonly<Record<string, string>>;
613
672
  readonly rateLimitPerMinute?: number;
614
673
  readonly format?: 'json' | 'form' | 'xml';
@@ -621,6 +680,12 @@ export declare interface SubscribeInput {
621
680
  }
622
681
 
623
682
  export declare interface SubscribeResult {
683
+ /** Every row created, when `events` named more than one. Absent for a
684
+ * single-event subscribe, where `id` and `event` already say it. */
685
+ readonly targets?: ReadonlyArray<{
686
+ readonly id: string;
687
+ readonly event: string;
688
+ }>;
624
689
  readonly id: string;
625
690
  readonly event: string;
626
691
  readonly url: string;
@@ -632,10 +697,42 @@ export declare interface SubscribeResult {
632
697
  readonly retry: RetryPolicy;
633
698
  }
634
699
 
700
+ /** The patchable subset of a target. Absent keys are left alone. */
701
+ export declare interface TargetPatch {
702
+ readonly url?: string;
703
+ readonly description?: string | null;
704
+ readonly filter?: Readonly<Record<string, unknown>> | null;
705
+ readonly scope?: Readonly<Record<string, unknown>> | null;
706
+ readonly headers?: Readonly<Record<string, string>> | null;
707
+ readonly rateLimitPerMinute?: number | null;
708
+ readonly autoDisableAfter?: number | null;
709
+ readonly format?: 'json' | 'form' | 'xml';
710
+ }
711
+
635
712
  export declare const TARGETS_TABLE = "_voltro_webhook_targets";
636
713
 
714
+ /**
715
+ * WHICH target(s) an operation addresses.
716
+ *
717
+ * A string is one row. A `{ scope }` is the GROUP — every row whose opaque
718
+ * scope matches, which for a multi-event subscription is the whole endpoint.
719
+ *
720
+ * This exists because a subscription, as a user models it, is one URL with a
721
+ * list of event checkboxes, while a row is one event. Without a group selector
722
+ * every operation the user thinks of as single — pause the endpoint, fix its
723
+ * URL, rotate its secret, read its history — becomes a fan-out the app writes
724
+ * by hand, and `rotateSecret` in particular becomes delete + re-subscribe,
725
+ * which mints new ids and orphans the delivery history.
726
+ */
727
+ export declare type TargetSelector = string | {
728
+ readonly scope: Readonly<Record<string, unknown>>;
729
+ };
730
+
637
731
  export declare interface TargetSummary {
638
732
  readonly id: string;
733
+ /** The app's own scoping dimension as written at subscribe time, verbatim.
734
+ * `null` when the app stored none. */
735
+ readonly scope: Readonly<Record<string, unknown>> | null;
639
736
  readonly event: string;
640
737
  readonly url: string;
641
738
  readonly active: boolean;
@@ -675,8 +772,6 @@ export declare const useWebhooks: (ctx: {
675
772
 
676
773
  export declare const useWebhooksEffect: Effect.Effect<WebhooksServiceShape, never, WebhooksService>;
677
774
 
678
- /** Validate subscribe input. Throws on policy violations so the
679
- * caller's catch block can surface a 422-style error. */
680
775
  export declare const validateSubscribe: (input: SubscribeInput) => void;
681
776
 
682
777
  export declare type VerifyResult = {
@@ -821,6 +916,30 @@ export declare const _voltroWebhookTargetsTable: Table<"_voltro_webhook_targets"
821
916
  readonly signing: ColumnBuilder<unknown, "json", boolean>;
822
917
  /** Serialised `RetryPolicy` — see `retry.ts`. */
823
918
  readonly retry: ColumnBuilder<unknown, "json", boolean>;
919
+ /**
920
+ * The APP's own scoping dimension. Opaque to the framework.
921
+ *
922
+ * Stored and returned verbatim, never interpreted — the framework does not
923
+ * know what a team, a project or a workspace is, and does not need to. Reads
924
+ * filter on equality against the json you wrote:
925
+ *
926
+ * subscribe({ event, url, scope: { teamId: 'q970…' } })
927
+ * listTargets({ scope: { teamId } })
928
+ *
929
+ * It exists because `.with(tenant())` is one level too coarse for real
930
+ * deployments. A consumer's endpoints are scoped to a TEAM and a tenant has
931
+ * many teams; every read filters by it and every write guards on it, so
932
+ * without this column the plugin cannot hold their rows at all and they keep
933
+ * a parallel table.
934
+ *
935
+ * The precedent is `_voltro_presence.meta`, and it is worth stating because
936
+ * it decided a migration: that column is json the framework stores and never
937
+ * interprets, and it is the ONLY reason the same consumer's presence
938
+ * migration was lossless — their three denormalised columns went straight in.
939
+ * The general form: a plugin that stores rows in an app's database on the
940
+ * app's behalf needs one place for the app's own dimension.
941
+ */
942
+ readonly scope: ColumnBuilder<unknown, "json", boolean>;
824
943
  /** Optional predicate filter (subset of `Predicate`) — the engine
825
944
  * evaluates this against each emit's payload to decide whether
826
945
  * this target receives the delivery. */
@@ -1004,29 +1123,43 @@ export declare interface WebhooksServiceShape {
1004
1123
  /** List currently-subscribed targets for an event, or all when
1005
1124
  * `event` is omitted. The dashboard's "Targets" page consumes
1006
1125
  * this. */
1007
- readonly listTargets: (event?: string) => Promise<ReadonlyArray<TargetSummary>>;
1126
+ readonly listTargets: (event?: string,
1127
+ /** Filter to targets whose opaque `scope` equals this, key for key. The
1128
+ * match is exact on the keys you pass — a target with extra keys still
1129
+ * matches, so `{ teamId }` finds targets scoped to that team regardless of
1130
+ * what else the app stored beside it. */
1131
+ scope?: Readonly<Record<string, unknown>>) => Promise<ReadonlyArray<TargetSummary>>;
1008
1132
  /** Soft-disable a target without deleting the row. While paused,
1009
1133
  * emits against this target queue under
1010
1134
  * `_voltro_webhook_deliveries` with status='pending' (no POST
1011
1135
  * happens); `resumeTarget` flushes them. */
1012
- readonly pauseTarget: (targetId: string) => Promise<void>;
1136
+ /** Every row a selector addresses. Refuses a scope that matches nothing —
1137
+ * an operation that silently affects zero rows is worse than an error. */
1138
+ readonly resolveTargets: (selector: TargetSelector) => Promise<ReadonlyArray<string>>;
1139
+ readonly pauseTarget: (target: TargetSelector) => Promise<void>;
1013
1140
  /** Re-enable a paused target AND flush its queued
1014
1141
  * `status='pending'` deliveries through the normal delivery
1015
1142
  * workflow, in emit order (`createdAt` ascending — millisecond
1016
1143
  * granularity) per target. */
1017
- readonly resumeTarget: (targetId: string) => Promise<void>;
1144
+ readonly resumeTarget: (target: TargetSelector) => Promise<void>;
1018
1145
  /** Hard-delete a target. Its queued `status='pending'` rows are
1019
1146
  * deleted with it (nothing left to flush); an in-flight workflow
1020
1147
  * run's `fetch-target` activity sees a null row and exits
1021
1148
  * cleanly. */
1022
- readonly deleteTarget: (targetId: string) => Promise<void>;
1149
+ readonly deleteTarget: (target: TargetSelector) => Promise<void>;
1023
1150
  /** Rotate the per-target signing secret. Returns the new secret
1024
1151
  * ONCE — store it client-side if you need to display it again.
1025
1152
  * In-flight retries continue with the OLD secret since signing
1026
1153
  * happens at attempt time using the row's then-current value
1027
1154
  * (acceptable: providers retry within seconds, the rotation
1028
1155
  * window is tight). */
1029
- readonly rotateSecret: (targetId: string) => Promise<{
1156
+ /**
1157
+ * Rotate the signing secret. Addressed by a SCOPE this rotates every row of
1158
+ * the endpoint to the SAME new value — which is the point: N rows for one URL
1159
+ * must sign identically, and the previous way to achieve it was delete +
1160
+ * re-subscribe, minting new ids and orphaning the delivery history.
1161
+ */
1162
+ readonly rotateSecret: (target: TargetSelector) => Promise<{
1030
1163
  readonly secret: string;
1031
1164
  }>;
1032
1165
  /** Re-pin a target's `payloadVersion` to the event's current
@@ -1034,6 +1167,55 @@ export declare interface WebhooksServiceShape {
1034
1167
  * the consumer has updated their handler to accept the new
1035
1168
  * payload shape. */
1036
1169
  readonly updateTargetPayloadVersion: (targetId: string, version: number) => Promise<void>;
1170
+ /**
1171
+ * Edit a target in place.
1172
+ *
1173
+ * Without it, changing a URL, description, filter, headers or rate limit
1174
+ * means delete + re-subscribe — which ROTATES the secret (every receiver has
1175
+ * to be reconfigured) and ORPHANS the delivery history (the rows point at a
1176
+ * target id that no longer exists). Neither is what "I fixed a typo in the
1177
+ * URL" should cost.
1178
+ *
1179
+ * `event` and `secret` are deliberately not patchable: changing the event
1180
+ * makes it a different subscription, and the secret has `rotateSecret`, which
1181
+ * returns the new value once.
1182
+ */
1183
+ readonly updateTarget: (target: TargetSelector, patch: TargetPatch) => Promise<void>;
1184
+ /**
1185
+ * Send one delivery to ONE target, bypassing fan-out and the filter.
1186
+ *
1187
+ * `emit` fans out to every matching target, so there is no way to answer "is
1188
+ * THIS endpoint reachable" — the first button in every webhook UI. The
1189
+ * delivery is real: it is signed, recorded in the delivery log and retried
1190
+ * like any other, so what it proves is what production will do.
1191
+ */
1192
+ readonly testTarget: (targetId: string, payload?: unknown) => Promise<{
1193
+ readonly deliveryId: string;
1194
+ }>;
1195
+ /**
1196
+ * Read the delivery log.
1197
+ *
1198
+ * There was no service method for it, so the only way to build a management
1199
+ * view was to query `_voltro_webhook_deliveries` directly — and a consumer
1200
+ * declined, correctly: the 0.24.0 `agent_messages` rename taught them what
1201
+ * app code coupled to a framework table name costs. That one was survivable
1202
+ * because it was a rename; a column change would not be.
1203
+ *
1204
+ * The two heavy columns (`payload`, `responseBody`) are omitted here and
1205
+ * available from `getDelivery`, so a list view does not pull response bodies
1206
+ * for 200 rows.
1207
+ */
1208
+ readonly listDeliveries: (filter?: {
1209
+ readonly targetId?: string;
1210
+ /** Every row of an endpoint's group, so a history view needs no N-way
1211
+ * merge-and-re-sort in the app. */
1212
+ readonly scope?: Readonly<Record<string, unknown>>;
1213
+ readonly status?: DeliverySummary['status'];
1214
+ readonly since?: Date;
1215
+ readonly limit?: number;
1216
+ }) => Promise<ReadonlyArray<DeliverySummary>>;
1217
+ /** One delivery attempt including its payload and response body. */
1218
+ readonly getDelivery: (id: string) => Promise<DeliveryDetail | null>;
1037
1219
  }
1038
1220
 
1039
1221
  /**
@@ -1082,6 +1264,30 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
1082
1264
  readonly signing: ColumnBuilder<unknown, "json", boolean>;
1083
1265
  /** Serialised `RetryPolicy` — see `retry.ts`. */
1084
1266
  readonly retry: ColumnBuilder<unknown, "json", boolean>;
1267
+ /**
1268
+ * The APP's own scoping dimension. Opaque to the framework.
1269
+ *
1270
+ * Stored and returned verbatim, never interpreted — the framework does not
1271
+ * know what a team, a project or a workspace is, and does not need to. Reads
1272
+ * filter on equality against the json you wrote:
1273
+ *
1274
+ * subscribe({ event, url, scope: { teamId: 'q970…' } })
1275
+ * listTargets({ scope: { teamId } })
1276
+ *
1277
+ * It exists because `.with(tenant())` is one level too coarse for real
1278
+ * deployments. A consumer's endpoints are scoped to a TEAM and a tenant has
1279
+ * many teams; every read filters by it and every write guards on it, so
1280
+ * without this column the plugin cannot hold their rows at all and they keep
1281
+ * a parallel table.
1282
+ *
1283
+ * The precedent is `_voltro_presence.meta`, and it is worth stating because
1284
+ * it decided a migration: that column is json the framework stores and never
1285
+ * interprets, and it is the ONLY reason the same consumer's presence
1286
+ * migration was lossless — their three denormalised columns went straight in.
1287
+ * The general form: a plugin that stores rows in an app's database on the
1288
+ * app's behalf needs one place for the app's own dimension.
1289
+ */
1290
+ readonly scope: ColumnBuilder<unknown, "json", boolean>;
1085
1291
  /** Optional predicate filter (subset of `Predicate`) — the engine
1086
1292
  * evaluates this against each emit's payload to decide whether
1087
1293
  * this target receives the delivery. */