@voltro/plugin-webhooks 0.85.0 → 0.87.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.
@@ -0,0 +1,205 @@
1
+ import { verifyRequest as e } from "./signing.js";
2
+ import { getIdempotencyCache as t, parseTtl as n } from "./idempotency.js";
3
+ import { Schema as r } from "effect";
4
+ import { publishServerError as i } from "@voltro/protocol";
5
+ import { createLogger as a } from "@voltro/logger";
6
+ //#region src/incoming.ts
7
+ var o = (e, t) => ({
8
+ status: e,
9
+ contentType: "application/json; charset=utf-8",
10
+ body: JSON.stringify(t)
11
+ }), s = "voltroWebhookVerification", c = class extends Error {
12
+ webhookId;
13
+ name = "UnverifiedIncomingWebhook";
14
+ constructor(e) {
15
+ super(`incoming webhook '${e}' declares no verification. It would be mounted as a public, unauthenticated POST that runs your application code, so the framework refuses to mount it. Pick one on the descriptor:\n · provider: stripeWebhookProvider() — a preset (HMAC scheme + idempotency + body type)\n · signature: { _tag: 'hmac', algorithm: 'hmacSha256', header: 'X-Signature', encoding: 'hex', includeTimestamp: true }\n · verification: 'provider' — the handler verifies with the provider's own SDK\n · verification: 'none' — deliberately public; a gateway / IP allow-list owns it\nA signature-verified webhook reads its shared secret from VOLTRO_WEBHOOK_SECRET_${e.toUpperCase().replace(/[^A-Z0-9]/g, "_")}. The framework never invents that value — the sender holds the other half of it.`), this.webhookId = e;
16
+ }
17
+ }, l = (e) => `VOLTRO_WEBHOOK_SECRET_${e.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`, u = (e) => {
18
+ let t = e.signature ?? e.provider?.signature ?? null;
19
+ return (e.verification === void 0 || e.verification === "signature") && t === null ? null : e.verification ?? "signature";
20
+ }, d = (d, f) => {
21
+ let p = d.provider, m = d.signature ?? p?.signature ?? null, h = d.idempotency ?? p?.idempotency ?? null, g = d.bodyType ?? p?.bodyType ?? "json", _ = u(d);
22
+ if (_ === null) throw new c(d.id);
23
+ let v = _;
24
+ v === "none" && a({ scope: `webhook:${d.id}` }).warn("incoming webhook mounted WITHOUT verification — anyone who can reach this URL can run its handler", {
25
+ webhookId: d.id,
26
+ verification: "none"
27
+ });
28
+ let y = f.idempotencyCache ?? t(), b = n(h?.ttl), x = f.log ?? ((e) => {
29
+ a({ scope: `webhook:${e.webhookId}` }).debug("incoming webhook", {
30
+ status: e.status,
31
+ signatureOk: e.signatureOk,
32
+ idempotency: e.idempotency,
33
+ durationMs: e.durationMs,
34
+ ...e.errorMessage === void 0 ? {} : { errorMessage: e.errorMessage }
35
+ });
36
+ });
37
+ return Object.assign(async (t) => {
38
+ let n = Date.now();
39
+ if (t.method !== "POST") {
40
+ let e = o(405, {
41
+ error: "method not allowed",
42
+ expected: "POST"
43
+ });
44
+ return x({
45
+ webhookId: d.id,
46
+ status: 405,
47
+ signatureOk: "skipped",
48
+ idempotency: "skipped",
49
+ durationMs: Date.now() - n
50
+ }), e;
51
+ }
52
+ let a = "skipped";
53
+ if (m !== null && v === "signature") {
54
+ let r = await f.resolveSecret(d.id);
55
+ if (r === null) {
56
+ let e = `no signing secret configured — set ${l(d.id)}`, t = o(503, {
57
+ error: "webhook not configured",
58
+ reason: e
59
+ });
60
+ return x({
61
+ webhookId: d.id,
62
+ status: 503,
63
+ signatureOk: !1,
64
+ idempotency: "skipped",
65
+ durationMs: Date.now() - n,
66
+ errorMessage: e
67
+ }), t;
68
+ }
69
+ {
70
+ let i = e(m, {
71
+ rawBody: t.rawBody,
72
+ secret: r,
73
+ headers: t.headers
74
+ });
75
+ if (a = i.ok, !i.ok) {
76
+ let e = o(401, {
77
+ error: "invalid signature",
78
+ reason: i.reason
79
+ });
80
+ return x({
81
+ webhookId: d.id,
82
+ status: 401,
83
+ signatureOk: !1,
84
+ idempotency: "skipped",
85
+ durationMs: Date.now() - n,
86
+ errorMessage: i.reason
87
+ }), e;
88
+ }
89
+ }
90
+ }
91
+ let s;
92
+ try {
93
+ if (g === "json") {
94
+ let e = new TextDecoder().decode(t.rawBody);
95
+ s = e.length === 0 ? null : JSON.parse(e);
96
+ } else if (g === "form") {
97
+ let e = new TextDecoder().decode(t.rawBody), n = new URLSearchParams(e), r = {};
98
+ for (let [e, t] of n.entries()) r[e] = t;
99
+ s = r;
100
+ } else s = t.rawBody;
101
+ } catch (e) {
102
+ let t = o(400, {
103
+ error: "malformed body",
104
+ reason: e.message
105
+ });
106
+ return x({
107
+ webhookId: d.id,
108
+ status: 400,
109
+ signatureOk: a,
110
+ idempotency: "skipped",
111
+ durationMs: Date.now() - n,
112
+ errorMessage: e.message
113
+ }), t;
114
+ }
115
+ let c;
116
+ try {
117
+ c = r.decodeUnknownSync(d.payload)(s);
118
+ } catch (e) {
119
+ let t = o(422, {
120
+ error: "schema validation failed",
121
+ reason: e.message
122
+ });
123
+ return x({
124
+ webhookId: d.id,
125
+ status: 422,
126
+ signatureOk: a,
127
+ idempotency: "skipped",
128
+ durationMs: Date.now() - n,
129
+ errorMessage: e.message
130
+ }), t;
131
+ }
132
+ let u, p = "skipped";
133
+ if (h !== null) {
134
+ let e = h.from;
135
+ if (u = typeof e == "string" ? t.headers[e.toLowerCase()] : e(t.headers, t.rawBody), u !== void 0 && u.length > 0) {
136
+ let e = y.claim(u, b);
137
+ if (p = e, e === "duplicate") {
138
+ let e = o(200, {
139
+ received: !0,
140
+ duplicate: !0
141
+ });
142
+ return x({
143
+ webhookId: d.id,
144
+ status: 200,
145
+ signatureOk: a,
146
+ idempotency: "duplicate",
147
+ durationMs: Date.now() - n
148
+ }), e;
149
+ }
150
+ if (e === "inflight") {
151
+ let e = o(409, {
152
+ error: "request already in flight",
153
+ idempotencyKey: u
154
+ });
155
+ return x({
156
+ webhookId: d.id,
157
+ status: 409,
158
+ signatureOk: a,
159
+ idempotency: "inflight",
160
+ durationMs: Date.now() - n
161
+ }), e;
162
+ }
163
+ }
164
+ }
165
+ try {
166
+ let e = f.resolveWorkflows?.();
167
+ await d.handler({
168
+ body: c,
169
+ rawBody: t.rawBody,
170
+ headers: t.headers,
171
+ idempotencyKey: u ?? "",
172
+ ...e ? { workflows: e } : {}
173
+ }), u !== void 0 && y.commit(u);
174
+ let r = o(200, { received: !0 });
175
+ return x({
176
+ webhookId: d.id,
177
+ status: 200,
178
+ signatureOk: a,
179
+ idempotency: p,
180
+ durationMs: Date.now() - n
181
+ }), r;
182
+ } catch (e) {
183
+ u !== void 0 && y.release(u);
184
+ let t = o(500, {
185
+ error: "handler threw",
186
+ reason: e.message
187
+ });
188
+ return x({
189
+ webhookId: d.id,
190
+ status: 500,
191
+ signatureOk: a,
192
+ idempotency: p,
193
+ durationMs: Date.now() - n,
194
+ errorMessage: e.message
195
+ }), i({
196
+ error: e,
197
+ source: "webhook",
198
+ name: d.id,
199
+ fields: { status: 500 }
200
+ }), t;
201
+ }
202
+ }, { [s]: v });
203
+ }, f = (e) => `/webhooks/${e}`;
204
+ //#endregion
205
+ export { c as UnverifiedIncomingWebhook, s as WEBHOOK_VERIFICATION_PROPERTY, f as defaultIncomingPath, u as incomingWebhookVerification, d as mountIncomingWebhook };
package/dist/index.d.ts CHANGED
@@ -617,6 +617,9 @@ emitter: () => Pick<WebhooksServiceShape, "emit"> | undefined) => OutboxHandlerD
617
617
  */
618
618
  export declare const matchesFilter: (filter: WebhookFilter | Readonly<Record<string, unknown>> | null, payload: unknown) => boolean;
619
619
 
620
+ /** The longest overlap `rotateSecret` accepts, and its default: 30 days, unless ended earlier. */
621
+ export declare const MAX_SECRET_OVERLAP_SECONDS: number;
622
+
620
623
  /** A mounted handler, carrying its verification declaration. */
621
624
  export declare type MountedIncomingWebhook = ((request: IncomingRequest) => Promise<IncomingResponse>) & {
622
625
  readonly [WEBHOOK_VERIFICATION_PROPERTY]: IncomingWebhookVerification;
@@ -822,6 +825,28 @@ export declare interface RetryPolicy {
822
825
 
823
826
  export declare type RetryStrategy = 'fixed' | 'linear' | 'exponential';
824
827
 
828
+ /** What `rotateSecret` returns. */
829
+ export declare interface RotatedSecret {
830
+ /** The new secret — returned once; hand it to the receiver. */
831
+ readonly secret: string;
832
+ /** Until when deliveries are signed under the previous secret too. `null`:
833
+ * no overlap — asked for none, or a scheme with one signature per message. */
834
+ readonly previousSignedUntil: Date | null;
835
+ }
836
+
837
+ /** How long a rotated webhook secret keeps signing beside the new one. */
838
+ export declare interface RotateSecretOptions {
839
+ /**
840
+ * Seconds every delivery still carries a signature under the previous
841
+ * secret, so the receiver can switch when it is ready. The length is the
842
+ * RECEIVER's switch-over time — nothing on the sending side can measure it —
843
+ * so by default the overlap lasts until {@link WebhooksServiceShape.endSecretOverlap}
844
+ * ends it (once the receiver has switched), and at most 30 days. Pass a
845
+ * number to bound it yourself; `0` switches at once (after a leak).
846
+ */
847
+ readonly overlapSeconds?: number;
848
+ }
849
+
825
850
  export declare type SignatureScheme = HmacSignatureScheme | CustomSignatureScheme | StandardWebhooksSignatureScheme;
826
851
 
827
852
  export declare interface SignedRequest {
@@ -1250,7 +1275,7 @@ export declare const _voltroWebhookDeliveriesTable: Table<"_voltro_webhook_deliv
1250
1275
  readonly eventId: ColumnBuilder<string | null, "text", boolean>;
1251
1276
  /** Attempt counter (1-indexed). */
1252
1277
  readonly attempt: ColumnBuilder<number, "integer", boolean>;
1253
- readonly status: ColumnBuilder<"failed" | "pending" | "succeeded" | "inFlight" | "retryScheduled", "text", boolean>;
1278
+ readonly status: ColumnBuilder<"failed" | "succeeded" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
1254
1279
  /** Payload as sent over the wire. Stored verbatim — re-rendering
1255
1280
  * from a referenced event row would lose the snapshot if the
1256
1281
  * source event was deleted. */
@@ -1389,6 +1414,13 @@ export declare const _voltroWebhookTargetsTable: Table<"_voltro_webhook_targets"
1389
1414
  * (a non-ciphertext value passes through); `voltro db encrypt-column
1390
1415
  * _voltro_webhook_targets.secret` converts them in place. */
1391
1416
  readonly secret: ColumnBuilder<string, "text", boolean>;
1417
+ /** During a rotation (`rotateSecret`): the secret the receiver may still
1418
+ * verify with. Every delivery until {@link previousSecretUntil} carries a
1419
+ * signature under it beside the new one (Standard Webhooks' signature list),
1420
+ * so the receiver switches when it is ready. Encrypted like `secret`. */
1421
+ readonly previousSecret: ColumnBuilder<string | null, "text", boolean>;
1422
+ /** When deliveries stop signing under `previousSecret`. */
1423
+ readonly previousSecretUntil: ColumnBuilder<Date | null, "timestamp", boolean>;
1392
1424
  /** Serialised `SignatureScheme` discriminated union — see `signing.ts`.
1393
1425
  * Stored as JSON so future schemes don't require a schema migration. */
1394
1426
  readonly signing: ColumnBuilder<unknown, "json", boolean>;
@@ -1702,8 +1734,11 @@ export declare interface WebhooksServiceShape {
1702
1734
  * must sign identically, and the previous way to achieve it was delete +
1703
1735
  * re-subscribe, minting new ids and orphaning the delivery history.
1704
1736
  */
1705
- readonly rotateSecret: (target: TargetSelector) => Promise<{
1706
- readonly secret: string;
1737
+ readonly rotateSecret: (target: TargetSelector, options?: RotateSecretOptions) => Promise<RotatedSecret>;
1738
+ /** End a rotation's overlap once the receiver verifies with the new secret: the
1739
+ * previous secret stops signing at once. Returns how many targets were overlapping. */
1740
+ readonly endSecretOverlap: (target: TargetSelector) => Promise<{
1741
+ readonly ended: number;
1707
1742
  }>;
1708
1743
  /** Re-pin a target's `payloadVersion` to the event's current
1709
1744
  * version. Called from the dashboard's "Re-pin" action after
@@ -1805,6 +1840,13 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
1805
1840
  * (a non-ciphertext value passes through); `voltro db encrypt-column
1806
1841
  * _voltro_webhook_targets.secret` converts them in place. */
1807
1842
  readonly secret: ColumnBuilder<string, "text", boolean>;
1843
+ /** During a rotation (`rotateSecret`): the secret the receiver may still
1844
+ * verify with. Every delivery until {@link previousSecretUntil} carries a
1845
+ * signature under it beside the new one (Standard Webhooks' signature list),
1846
+ * so the receiver switches when it is ready. Encrypted like `secret`. */
1847
+ readonly previousSecret: ColumnBuilder<string | null, "text", boolean>;
1848
+ /** When deliveries stop signing under `previousSecret`. */
1849
+ readonly previousSecretUntil: ColumnBuilder<Date | null, "timestamp", boolean>;
1808
1850
  /** Serialised `SignatureScheme` discriminated union — see `signing.ts`.
1809
1851
  * Stored as JSON so future schemes don't require a schema migration. */
1810
1852
  readonly signing: ColumnBuilder<unknown, "json", boolean>;
@@ -1910,7 +1952,7 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
1910
1952
  readonly eventId: ColumnBuilder<string | null, "text", boolean>;
1911
1953
  /** Attempt counter (1-indexed). */
1912
1954
  readonly attempt: ColumnBuilder<number, "integer", boolean>;
1913
- readonly status: ColumnBuilder<"failed" | "pending" | "succeeded" | "inFlight" | "retryScheduled", "text", boolean>;
1955
+ readonly status: ColumnBuilder<"failed" | "succeeded" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
1914
1956
  /** Payload as sent over the wire. Stored verbatim — re-rendering
1915
1957
  * from a referenced event row would lose the snapshot if the
1916
1958
  * source event was deleted. */