@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.
package/dist/mixin.d.ts CHANGED
@@ -32,7 +32,7 @@ export declare const _voltroWebhookDeliveriesTable: Table<"_voltro_webhook_deliv
32
32
  readonly eventId: ColumnBuilder<string | null, "text", boolean>;
33
33
  /** Attempt counter (1-indexed). */
34
34
  readonly attempt: ColumnBuilder<number, "integer", boolean>;
35
- readonly status: ColumnBuilder<"failed" | "pending" | "succeeded" | "inFlight" | "retryScheduled", "text", boolean>;
35
+ readonly status: ColumnBuilder<"failed" | "succeeded" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
36
36
  /** Payload as sent over the wire. Stored verbatim — re-rendering
37
37
  * from a referenced event row would lose the snapshot if the
38
38
  * source event was deleted. */
@@ -171,6 +171,13 @@ export declare const _voltroWebhookTargetsTable: Table<"_voltro_webhook_targets"
171
171
  * (a non-ciphertext value passes through); `voltro db encrypt-column
172
172
  * _voltro_webhook_targets.secret` converts them in place. */
173
173
  readonly secret: ColumnBuilder<string, "text", boolean>;
174
+ /** During a rotation (`rotateSecret`): the secret the receiver may still
175
+ * verify with. Every delivery until {@link previousSecretUntil} carries a
176
+ * signature under it beside the new one (Standard Webhooks' signature list),
177
+ * so the receiver switches when it is ready. Encrypted like `secret`. */
178
+ readonly previousSecret: ColumnBuilder<string | null, "text", boolean>;
179
+ /** When deliveries stop signing under `previousSecret`. */
180
+ readonly previousSecretUntil: ColumnBuilder<Date | null, "timestamp", boolean>;
174
181
  /** Serialised `SignatureScheme` discriminated union — see `signing.ts`.
175
182
  * Stored as JSON so future schemes don't require a schema migration. */
176
183
  readonly signing: ColumnBuilder<unknown, "json", boolean>;
@@ -287,6 +294,13 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
287
294
  * (a non-ciphertext value passes through); `voltro db encrypt-column
288
295
  * _voltro_webhook_targets.secret` converts them in place. */
289
296
  readonly secret: ColumnBuilder<string, "text", boolean>;
297
+ /** During a rotation (`rotateSecret`): the secret the receiver may still
298
+ * verify with. Every delivery until {@link previousSecretUntil} carries a
299
+ * signature under it beside the new one (Standard Webhooks' signature list),
300
+ * so the receiver switches when it is ready. Encrypted like `secret`. */
301
+ readonly previousSecret: ColumnBuilder<string | null, "text", boolean>;
302
+ /** When deliveries stop signing under `previousSecret`. */
303
+ readonly previousSecretUntil: ColumnBuilder<Date | null, "timestamp", boolean>;
290
304
  /** Serialised `SignatureScheme` discriminated union — see `signing.ts`.
291
305
  * Stored as JSON so future schemes don't require a schema migration. */
292
306
  readonly signing: ColumnBuilder<unknown, "json", boolean>;
@@ -392,7 +406,7 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
392
406
  readonly eventId: ColumnBuilder<string | null, "text", boolean>;
393
407
  /** Attempt counter (1-indexed). */
394
408
  readonly attempt: ColumnBuilder<number, "integer", boolean>;
395
- readonly status: ColumnBuilder<"failed" | "pending" | "succeeded" | "inFlight" | "retryScheduled", "text", boolean>;
409
+ readonly status: ColumnBuilder<"failed" | "succeeded" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
396
410
  /** Payload as sent over the wire. Stored verbatim — re-rendering
397
411
  * from a referenced event row would lose the snapshot if the
398
412
  * source event was deleted. */
package/dist/mixin.js CHANGED
@@ -6,6 +6,8 @@ var u = o("_voltro_webhook_targets", {
6
6
  event: s(),
7
7
  url: s(),
8
8
  secret: s().encrypted().serverOnly(),
9
+ previousSecret: s().encrypted().serverOnly().nullable(),
10
+ previousSecretUntil: c().nullable(),
9
11
  signing: r(),
10
12
  retry: r(),
11
13
  scope: r().nullable(),
package/dist/plugin.js ADDED
@@ -0,0 +1,19 @@
1
+ import { Effect as e } from "effect";
2
+ import { definePlugin as t } from "@voltro/protocol";
3
+ //#region src/plugin.ts
4
+ var n = ["network:outbound:*"], r = [{
5
+ destination: "runtime:registered webhook targets (_voltro_webhook_targets)",
6
+ purpose: "event deliveries, HMAC-signed",
7
+ data: ["content", "metadata"],
8
+ disable: "remove or pause the targets; an emit then delivers nowhere"
9
+ }], i = (i = {}) => t({
10
+ name: i.name ? `@voltro/plugin-webhooks#${i.name}` : "@voltro/plugin-webhooks",
11
+ description: "Outgoing webhooks (multi-target, durable-workflow delivery, HMAC signing, retry policies, rate limits) + incoming (signature verification, idempotency, provider templates). Declares wildcard outbound network access because targets are user-configured URLs.",
12
+ permissions: n,
13
+ egress: r,
14
+ onActivate: (t) => e.sync(() => {
15
+ t.logger.info("webhooks active", { outbound: "network:outbound:* (subscriber-configured target URLs — audit visibility, not a restriction)" });
16
+ })
17
+ });
18
+ //#endregion
19
+ export { r as WEBHOOKS_EGRESS, n as WEBHOOKS_PLUGIN_PERMISSIONS, i as webhooksPlugin };
@@ -0,0 +1,19 @@
1
+ import { defineWebhookProvider as e } from "../dsl.js";
2
+ import { genericHmacSignature as t } from "../signing.js";
3
+ //#region src/providers/generic.ts
4
+ var n = (n) => e({
5
+ id: "generic",
6
+ name: "Generic",
7
+ signature: {
8
+ ...t(),
9
+ ...n?.signatureHeader === void 0 ? {} : { header: n.signatureHeader },
10
+ ...n?.replayWindowSeconds === void 0 ? {} : { replayWindowSeconds: n.replayWindowSeconds }
11
+ },
12
+ idempotency: {
13
+ from: "idempotency-key",
14
+ ttl: "7d"
15
+ },
16
+ bodyType: "json"
17
+ });
18
+ //#endregion
19
+ export { n as genericProvider };
@@ -0,0 +1,16 @@
1
+ import { defineWebhookProvider as e } from "../dsl.js";
2
+ import { githubSignature as t } from "../signing.js";
3
+ //#region src/providers/github.ts
4
+ var n = () => e({
5
+ id: "github",
6
+ name: "GitHub",
7
+ signature: t(),
8
+ idempotency: {
9
+ from: "x-github-delivery",
10
+ ttl: "7d"
11
+ },
12
+ bodyType: "json",
13
+ eventTypeFrom: (e) => {}
14
+ });
15
+ //#endregion
16
+ export { n as githubProvider };
@@ -1,86 +1,5 @@
1
- import { T as e, n as t, r as n, s as r } from "../signing-DeHObNv6.js";
2
- import { createHmac as i, timingSafeEqual as a } from "node:crypto";
3
- //#region src/providers/stripe.ts
4
- var o = (t) => e({
5
- id: "stripe",
6
- name: "Stripe",
7
- signature: {
8
- ...r(),
9
- ...t?.replayWindowSeconds === void 0 ? {} : { replayWindowSeconds: t.replayWindowSeconds }
10
- },
11
- idempotency: {
12
- from: (e, t) => {
13
- try {
14
- return JSON.parse(new TextDecoder().decode(t)).id;
15
- } catch {
16
- return;
17
- }
18
- },
19
- ttl: "30d"
20
- },
21
- bodyType: "json",
22
- eventTypeFrom: (e) => {
23
- if (typeof e == "object" && e && "type" in e) {
24
- let t = e.type;
25
- return typeof t == "string" ? t : void 0;
26
- }
27
- }
28
- }), s = () => e({
29
- id: "github",
30
- name: "GitHub",
31
- signature: n(),
32
- idempotency: {
33
- from: "x-github-delivery",
34
- ttl: "7d"
35
- },
36
- bodyType: "json",
37
- eventTypeFrom: (e) => {}
38
- }), c = () => ({
39
- _tag: "custom",
40
- header: "X-Slack-Signature",
41
- sign: (e, t) => {
42
- let n = `v0:${Math.floor(Date.now() / 1e3)}:${new TextDecoder().decode(e)}`;
43
- return `v0=${i("sha256", t).update(n).digest("hex")}`;
44
- },
45
- verify: (e, t, n) => {
46
- let r = n["x-slack-signature"], o = n["x-slack-request-timestamp"];
47
- if (!r || !o || !/^\d+$/.test(o)) return !1;
48
- let s = Math.floor(Date.now() / 1e3);
49
- if (Math.abs(s - Number(o)) > 300) return !1;
50
- let c = `v0:${o}:${new TextDecoder().decode(e)}`, l = `v0=${i("sha256", t).update(c).digest("hex")}`, u = Buffer.from(l, "utf8"), d = Buffer.from(r, "utf8");
51
- return u.length === d.length ? a(u, d) : (a(u, Buffer.alloc(u.length)), !1);
52
- }
53
- }), l = () => e({
54
- id: "slack",
55
- name: "Slack",
56
- signature: c(),
57
- idempotency: {
58
- from: "x-slack-request-timestamp",
59
- ttl: "5m"
60
- },
61
- bodyType: "json",
62
- eventTypeFrom: (e) => {
63
- if (typeof e == "object" && e && "event" in e) {
64
- let t = e.event;
65
- if (typeof t == "object" && t && "type" in t) {
66
- let e = t.type;
67
- return typeof e == "string" ? e : void 0;
68
- }
69
- }
70
- }
71
- }), u = (n) => e({
72
- id: "generic",
73
- name: "Generic",
74
- signature: {
75
- ...t(),
76
- ...n?.signatureHeader === void 0 ? {} : { header: n.signatureHeader },
77
- ...n?.replayWindowSeconds === void 0 ? {} : { replayWindowSeconds: n.replayWindowSeconds }
78
- },
79
- idempotency: {
80
- from: "idempotency-key",
81
- ttl: "7d"
82
- },
83
- bodyType: "json"
84
- });
85
- //#endregion
86
- export { u as genericProvider, s as githubProvider, l as slackProvider, o as stripeProvider };
1
+ import { stripeProvider as e } from "./stripe.js";
2
+ import { githubProvider as t } from "./github.js";
3
+ import { slackProvider as n } from "./slack.js";
4
+ import { genericProvider as r } from "./generic.js";
5
+ export { r as genericProvider, t as githubProvider, n as slackProvider, e as stripeProvider };
@@ -0,0 +1,39 @@
1
+ import { defineWebhookProvider as e } from "../dsl.js";
2
+ import { createHmac as t, timingSafeEqual as n } from "node:crypto";
3
+ //#region src/providers/slack.ts
4
+ var r = () => ({
5
+ _tag: "custom",
6
+ header: "X-Slack-Signature",
7
+ sign: (e, n) => {
8
+ let r = `v0:${Math.floor(Date.now() / 1e3)}:${new TextDecoder().decode(e)}`;
9
+ return `v0=${t("sha256", n).update(r).digest("hex")}`;
10
+ },
11
+ verify: (e, r, i) => {
12
+ let a = i["x-slack-signature"], o = i["x-slack-request-timestamp"];
13
+ if (!a || !o || !/^\d+$/.test(o)) return !1;
14
+ let s = Math.floor(Date.now() / 1e3);
15
+ if (Math.abs(s - Number(o)) > 300) return !1;
16
+ let c = `v0:${o}:${new TextDecoder().decode(e)}`, l = `v0=${t("sha256", r).update(c).digest("hex")}`, u = Buffer.from(l, "utf8"), d = Buffer.from(a, "utf8");
17
+ return u.length === d.length ? n(u, d) : (n(u, Buffer.alloc(u.length)), !1);
18
+ }
19
+ }), i = () => e({
20
+ id: "slack",
21
+ name: "Slack",
22
+ signature: r(),
23
+ idempotency: {
24
+ from: "x-slack-request-timestamp",
25
+ ttl: "5m"
26
+ },
27
+ bodyType: "json",
28
+ eventTypeFrom: (e) => {
29
+ if (typeof e == "object" && e && "event" in e) {
30
+ let t = e.event;
31
+ if (typeof t == "object" && t && "type" in t) {
32
+ let e = t.type;
33
+ return typeof e == "string" ? e : void 0;
34
+ }
35
+ }
36
+ }
37
+ });
38
+ //#endregion
39
+ export { i as slackProvider };
@@ -0,0 +1,30 @@
1
+ import { defineWebhookProvider as e } from "../dsl.js";
2
+ import { stripeSignature as t } from "../signing.js";
3
+ //#region src/providers/stripe.ts
4
+ var n = (n) => e({
5
+ id: "stripe",
6
+ name: "Stripe",
7
+ signature: {
8
+ ...t(),
9
+ ...n?.replayWindowSeconds === void 0 ? {} : { replayWindowSeconds: n.replayWindowSeconds }
10
+ },
11
+ idempotency: {
12
+ from: (e, t) => {
13
+ try {
14
+ return JSON.parse(new TextDecoder().decode(t)).id;
15
+ } catch {
16
+ return;
17
+ }
18
+ },
19
+ ttl: "30d"
20
+ },
21
+ bodyType: "json",
22
+ eventTypeFrom: (e) => {
23
+ if (typeof e == "object" && e && "type" in e) {
24
+ let t = e.type;
25
+ return typeof t == "string" ? t : void 0;
26
+ }
27
+ }
28
+ });
29
+ //#endregion
30
+ export { n as stripeProvider };
@@ -0,0 +1,61 @@
1
+ import { and as e, eq as t } from "@voltro/database";
2
+ //#region src/rateLimit.ts
3
+ var n = "_voltro_webhook_rate_windows", r = 6e4, i = (e, t) => `${e}@${t}`, a = async (n, r, i) => (await n.query({
4
+ table: "_voltro_webhook_rate_windows",
5
+ predicate: e(t("scope", r), t("bucket", i)),
6
+ order: [],
7
+ take: 1,
8
+ skip: void 0,
9
+ projection: void 0
10
+ }))[0] ?? null, o = 32, s = async (r, s, c) => {
11
+ for (let l = 0; l < o; l++) {
12
+ l > 0 && await new Promise((e) => setTimeout(e, l));
13
+ let o = await a(r, s.key, c);
14
+ if (o === null) await r.insertIgnore(n, {
15
+ id: i(s.key, c),
16
+ scope: s.key,
17
+ bucket: c,
18
+ count: 0,
19
+ createdAt: /* @__PURE__ */ new Date(),
20
+ updatedAt: /* @__PURE__ */ new Date()
21
+ }, { conflictColumns: ["id"] });
22
+ else {
23
+ if (o.count >= s.limit) return !1;
24
+ if (await r.updateMany("_voltro_webhook_rate_windows", {
25
+ count: o.count + 1,
26
+ updatedAt: /* @__PURE__ */ new Date()
27
+ }, { where: e(t("id", o.id), t("count", o.count)) }) === 1) return !0;
28
+ }
29
+ }
30
+ throw Error(`webhook rate window: contention on (${s.key}, ${c}) exceeded ${o} attempts`);
31
+ }, c = async (n, r, i) => {
32
+ for (let s = 0; s < o; s++) {
33
+ s > 0 && await new Promise((e) => setTimeout(e, s));
34
+ let o = await a(n, r, i);
35
+ if (o === null || o.count <= 0 || await n.updateMany("_voltro_webhook_rate_windows", {
36
+ count: o.count - 1,
37
+ updatedAt: /* @__PURE__ */ new Date()
38
+ }, { where: e(t("id", o.id), t("count", o.count)) }) === 1) return;
39
+ }
40
+ throw Error(`webhook rate window: release contention on (${r}) exceeded ${o} attempts`);
41
+ }, l = async (e, t, n, i = r) => {
42
+ if (t.length === 0) return {
43
+ acquired: !0,
44
+ retryInMs: 0
45
+ };
46
+ let a = Math.floor(n / i), o = [];
47
+ for (let r of t) if (await s(e, r, a)) o.push(r);
48
+ else {
49
+ for (let t of o) await c(e, t.key, a);
50
+ return {
51
+ acquired: !1,
52
+ retryInMs: (a + 1) * i - n
53
+ };
54
+ }
55
+ return {
56
+ acquired: !0,
57
+ retryInMs: 0
58
+ };
59
+ };
60
+ //#endregion
61
+ export { r as RATE_WINDOW_MS, n as RATE_WINDOW_TABLE, l as acquireRateSlots, s as consumeRateSlot, c as releaseRateSlot };
package/dist/retry.js ADDED
@@ -0,0 +1,65 @@
1
+ //#region src/retry.ts
2
+ var e = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, t = (t) => {
3
+ let n = e.exec(t);
4
+ if (!n) throw Error(`invalid duration literal: ${t}`);
5
+ let r = Number(n[1]), i = n[2] ?? "s";
6
+ switch (i) {
7
+ case "ms": return r;
8
+ case "s": return r * 1e3;
9
+ case "m": return r * 6e4;
10
+ case "h": return r * 36e5;
11
+ case "d": return r * 864e5;
12
+ default: throw Error(`unreachable unit: ${i}`);
13
+ }
14
+ }, n = [
15
+ 408,
16
+ 425,
17
+ 429,
18
+ 500,
19
+ 502,
20
+ 503,
21
+ 504
22
+ ], r = (e, r, i) => {
23
+ if (r >= e.maxAttempts || i?.status !== void 0 && !(e.retryOn ?? n).includes(i.status)) return null;
24
+ if (e.honourRetryAfter !== !1 && i?.retryAfterSeconds !== void 0) {
25
+ let n = i.retryAfterSeconds * 1e3, r = t(e.maxDelay), a = Math.min(n, r);
26
+ return {
27
+ delayMs: a,
28
+ baseDelayMs: a
29
+ };
30
+ }
31
+ let a = t(e.initialDelay), o = t(e.maxDelay), s;
32
+ switch (e.strategy) {
33
+ case "fixed":
34
+ s = a;
35
+ break;
36
+ case "linear":
37
+ s = a * r;
38
+ break;
39
+ case "exponential": s = a * 2 ** (r - 1);
40
+ }
41
+ s = Math.min(s, o);
42
+ let c = s;
43
+ return e.jitter === "full" && (c = Math.random() * s), {
44
+ delayMs: Math.max(1, Math.floor(c)),
45
+ baseDelayMs: s
46
+ };
47
+ }, i = () => ({
48
+ strategy: "exponential",
49
+ maxAttempts: 8,
50
+ initialDelay: "5s",
51
+ maxDelay: "1h",
52
+ retryOn: n,
53
+ honourRetryAfter: !0,
54
+ jitter: "full"
55
+ }), a = () => ({
56
+ strategy: "fixed",
57
+ maxAttempts: 120,
58
+ initialDelay: "30s",
59
+ maxDelay: "30s",
60
+ retryOn: n,
61
+ honourRetryAfter: !0,
62
+ jitter: "full"
63
+ });
64
+ //#endregion
65
+ export { i as defaultRetryPolicy, a as fastRetryPolicy, r as nextRetry, t as parseDuration };