@pylonsync/webhooks 0.3.83

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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@pylonsync/webhooks",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.3.83",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json --noEmit",
12
+ "check": "tsc -p tsconfig.json --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@pylonsync/sdk": "0.3.81",
16
+ "@pylonsync/functions": "0.3.81"
17
+ },
18
+ "peerDependencies": {
19
+ "bun-types": "*"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "bun-types": {
23
+ "optional": true
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Dispatch path: app calls `dispatch(ctx, cfg, { type, data })`, the
3
+ * plugin finds every matching endpoint, schedules a delivery job
4
+ * for each.
5
+ *
6
+ * Jobs are scheduled via Pylon's `ctx.scheduler.runAfter(ms, fnName,
7
+ * args)` API — the framework's job worker picks them up + invokes
8
+ * the `_pylonWebhookDeliver` internal action.
9
+ *
10
+ * Caller's ctx.auth identity is propagated to the job (per v0.3.76
11
+ * fix), so the delivery handler executes with the same auth as the
12
+ * dispatcher — required because the worker reads the WebhookEndpoint
13
+ * + writes the WebhookAttempt under the same tenant scope.
14
+ */
15
+
16
+ import type { DispatchInput, WebhookConfig, WebhookCtx } from "./types";
17
+
18
+ export async function dispatch(
19
+ ctx: WebhookCtx,
20
+ cfg: WebhookConfig,
21
+ input: DispatchInput,
22
+ ): Promise<{ scheduled: number; eventId: string }> {
23
+ const eventId = input.id ?? generateEventId();
24
+ const occurredAt = input.occurredAt ?? new Date().toISOString();
25
+ const applicationId =
26
+ input.applicationId ??
27
+ (cfg.getApplicationId
28
+ ? await cfg.getApplicationId(ctx, {
29
+ id: eventId,
30
+ type: input.type,
31
+ occurredAt,
32
+ data: input.data,
33
+ })
34
+ : ctx.auth.tenantId);
35
+ if (!applicationId) {
36
+ throw ctx.error(
37
+ "NO_APPLICATION",
38
+ "webhooks dispatch needs an applicationId (no active tenant)",
39
+ );
40
+ }
41
+
42
+ const endpoints = await ctx.runQuery<
43
+ Array<{
44
+ id: string;
45
+ url: string;
46
+ eventTypes: string;
47
+ disabled?: boolean;
48
+ }>
49
+ >("_pylonWebhookListEndpoints", { applicationId });
50
+
51
+ let scheduled = 0;
52
+ for (const ep of endpoints) {
53
+ if (ep.disabled) continue;
54
+ const types = safeParseArray(ep.eventTypes);
55
+ if (types.length > 0 && !types.includes(input.type)) continue;
56
+ await ctx.scheduler.runAfter(0, "_pylonWebhookDeliver", {
57
+ endpointId: ep.id,
58
+ eventId,
59
+ eventType: input.type,
60
+ occurredAt,
61
+ data: JSON.stringify(input.data),
62
+ applicationId,
63
+ attempt: 1,
64
+ });
65
+ scheduled++;
66
+ }
67
+ return { scheduled, eventId };
68
+ }
69
+
70
+ function generateEventId(): string {
71
+ const bytes = new Uint8Array(16);
72
+ crypto.getRandomValues(bytes);
73
+ return `evt_${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
74
+ }
75
+
76
+ function safeParseArray(s: string): string[] {
77
+ try {
78
+ const out = JSON.parse(s);
79
+ return Array.isArray(out) ? (out as string[]) : [];
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
@@ -0,0 +1,215 @@
1
+ import { action, mutation, query, v } from "@pylonsync/functions";
2
+
3
+ import { signWebhook } from "./signature";
4
+ import type { WebhookConfig } from "./types";
5
+
6
+ /**
7
+ * Plugin-internal handlers. Apps wrap these as one-line files in
8
+ * `functions/` (same pattern as @pylonsync/stripe).
9
+ *
10
+ * Public surface:
11
+ * - none (dispatch is called from app code; delivery is server-
12
+ * internal). Apps add their own typed `createWebhookEndpoint`
13
+ * mutation that reads/writes the entity directly with whatever
14
+ * authorization rules they want.
15
+ *
16
+ * Internal:
17
+ * - _pylonWebhookListEndpoints — used by dispatch()
18
+ * - _pylonWebhookDeliver — the worker job
19
+ * - _pylonWebhookRecordAttempt — write the audit row
20
+ */
21
+ export function internalHandlers(
22
+ cfg: WebhookConfig,
23
+ ): Record<string, unknown> {
24
+ const endpointEnt = cfg.entities?.endpoint ?? "WebhookEndpoint";
25
+ const attemptEnt = cfg.entities?.attempt ?? "WebhookAttempt";
26
+ const retrySchedule = cfg.retrySchedule ?? [];
27
+
28
+ return {
29
+ _pylonWebhookListEndpoints: query({
30
+ args: { applicationId: v.string() },
31
+ internal: true,
32
+ async handler(ctx, args: { applicationId: string }) {
33
+ return ctx.db.query(endpointEnt, {
34
+ applicationId: args.applicationId,
35
+ });
36
+ },
37
+ }),
38
+
39
+ _pylonWebhookRecordAttempt: mutation({
40
+ args: {
41
+ applicationId: v.string(),
42
+ endpointId: v.string(),
43
+ eventId: v.string(),
44
+ eventType: v.string(),
45
+ url: v.string(),
46
+ attempt: v.number(),
47
+ status: v.string(),
48
+ httpStatus: v.optional(v.number()),
49
+ error: v.optional(v.string()),
50
+ scheduledAt: v.string(),
51
+ deliveredAt: v.optional(v.string()),
52
+ },
53
+ internal: true,
54
+ async handler(
55
+ ctx,
56
+ args: {
57
+ applicationId: string;
58
+ endpointId: string;
59
+ eventId: string;
60
+ eventType: string;
61
+ url: string;
62
+ attempt: number;
63
+ status: string;
64
+ httpStatus?: number;
65
+ error?: string;
66
+ scheduledAt: string;
67
+ deliveredAt?: string;
68
+ },
69
+ ) {
70
+ return ctx.db.insert(attemptEnt, {
71
+ applicationId: args.applicationId,
72
+ endpointId: args.endpointId,
73
+ eventId: args.eventId,
74
+ eventType: args.eventType,
75
+ url: args.url,
76
+ attempt: args.attempt,
77
+ status: args.status,
78
+ httpStatus: args.httpStatus ?? null,
79
+ error: args.error ?? null,
80
+ scheduledAt: args.scheduledAt,
81
+ deliveredAt: args.deliveredAt ?? null,
82
+ });
83
+ },
84
+ }),
85
+
86
+ _pylonWebhookDeliver: action({
87
+ args: {
88
+ endpointId: v.string(),
89
+ eventId: v.string(),
90
+ eventType: v.string(),
91
+ occurredAt: v.string(),
92
+ data: v.string(),
93
+ applicationId: v.string(),
94
+ attempt: v.number(),
95
+ },
96
+ internal: true,
97
+ async handler(
98
+ ctx,
99
+ args: {
100
+ endpointId: string;
101
+ eventId: string;
102
+ eventType: string;
103
+ occurredAt: string;
104
+ data: string;
105
+ applicationId: string;
106
+ attempt: number;
107
+ },
108
+ ) {
109
+ const endpoints = await ctx.runQuery<
110
+ Array<{
111
+ id: string;
112
+ url: string;
113
+ secret: string;
114
+ disabled?: boolean;
115
+ headers?: string | null;
116
+ }>
117
+ >("_pylonWebhookListEndpoints", {
118
+ applicationId: args.applicationId,
119
+ });
120
+ const ep = endpoints.find((e) => e.id === args.endpointId);
121
+ if (!ep || ep.disabled) {
122
+ return { skipped: true };
123
+ }
124
+
125
+ const payload = JSON.parse(args.data) as unknown;
126
+ const body = JSON.stringify({
127
+ id: args.eventId,
128
+ type: args.eventType,
129
+ occurredAt: args.occurredAt,
130
+ data: payload,
131
+ });
132
+ const ts = Math.floor(Date.now() / 1000);
133
+ const sig = await signWebhook({
134
+ id: args.eventId,
135
+ timestamp: ts,
136
+ body,
137
+ secrets: [ep.secret],
138
+ });
139
+
140
+ const headers: Record<string, string> = {
141
+ "Content-Type": "application/json",
142
+ "webhook-id": sig.id,
143
+ "webhook-timestamp": String(sig.timestamp),
144
+ "webhook-signature": sig.signature,
145
+ };
146
+ if (ep.headers) {
147
+ try {
148
+ const extra = JSON.parse(ep.headers) as Record<string, string>;
149
+ for (const [k, v] of Object.entries(extra)) headers[k] = v;
150
+ } catch {
151
+ /* malformed headers — ignore */
152
+ }
153
+ }
154
+
155
+ let httpStatus = 0;
156
+ let errorMsg: string | undefined;
157
+ let ok = false;
158
+ try {
159
+ const res = await fetch(ep.url, {
160
+ method: "POST",
161
+ headers,
162
+ body,
163
+ });
164
+ httpStatus = res.status;
165
+ ok = res.ok;
166
+ if (!ok) errorMsg = `HTTP ${res.status}`;
167
+ } catch (e) {
168
+ errorMsg = e instanceof Error ? e.message : String(e);
169
+ }
170
+
171
+ const now = new Date().toISOString();
172
+ await ctx.runMutation("_pylonWebhookRecordAttempt", {
173
+ applicationId: args.applicationId,
174
+ endpointId: ep.id,
175
+ eventId: args.eventId,
176
+ eventType: args.eventType,
177
+ url: ep.url,
178
+ attempt: args.attempt,
179
+ status: ok ? "succeeded" : "failed",
180
+ httpStatus,
181
+ error: errorMsg,
182
+ scheduledAt: now,
183
+ deliveredAt: ok ? now : undefined,
184
+ });
185
+
186
+ if (!ok) {
187
+ const nextOffset = retrySchedule[args.attempt - 1];
188
+ if (nextOffset === undefined) {
189
+ // Out of retries — write a `dead` row + bail.
190
+ await ctx.runMutation("_pylonWebhookRecordAttempt", {
191
+ applicationId: args.applicationId,
192
+ endpointId: ep.id,
193
+ eventId: args.eventId,
194
+ eventType: args.eventType,
195
+ url: ep.url,
196
+ attempt: args.attempt + 1,
197
+ status: "dead",
198
+ httpStatus,
199
+ error: errorMsg,
200
+ scheduledAt: now,
201
+ });
202
+ return { delivered: false, dead: true };
203
+ }
204
+ await ctx.scheduler.runAfter(
205
+ nextOffset * 1000,
206
+ "_pylonWebhookDeliver",
207
+ { ...args, attempt: args.attempt + 1 },
208
+ );
209
+ return { delivered: false, retryIn: nextOffset };
210
+ }
211
+ return { delivered: true, httpStatus };
212
+ },
213
+ }),
214
+ };
215
+ }
package/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `@pylonsync/webhooks` — outbound webhook delivery.
3
+ *
4
+ * Three primitives:
5
+ * - `signWebhook`/`verifyWebhook` — Svix-compatible HMAC-SHA256
6
+ * signature with timestamp + replay window. Receivers using
7
+ * Svix's reference verifier (Clerk, Resend, anyone with a
8
+ * `whsec_*` secret) work unchanged.
9
+ * - `dispatch(ctx, cfg, { type, data })` — fan out an event to
10
+ * every endpoint subscribed to `type`. Enqueues delivery jobs
11
+ * via `ctx.scheduler.runAfter`.
12
+ * - `deliverNow(ctx, cfg, attemptId)` — the worker side. Signs
13
+ * and POSTs, re-enqueues on failure with the next retry interval.
14
+ *
15
+ * Manifest fragment adds two entities:
16
+ * - `WebhookEndpoint` — customer-registered URLs
17
+ * - `WebhookAttempt` — delivery audit log (one row per attempt)
18
+ *
19
+ * Schema is intentionally minimal: apps that want richer
20
+ * application/event-type catalogs (Svix's full model) layer that on
21
+ * top, the plugin doesn't enforce a structure.
22
+ */
23
+
24
+ export { signWebhook, verifyWebhook } from "./signature";
25
+ export type {
26
+ SignOptions,
27
+ WebhookSignaturePayload,
28
+ SignatureError,
29
+ } from "./signature";
30
+ export type {
31
+ WebhookEvent,
32
+ WebhookEndpoint,
33
+ WebhookConfig,
34
+ WebhookCtx,
35
+ DispatchInput,
36
+ DeliveryAttempt,
37
+ } from "./types";
38
+
39
+ import { buildWebhookManifest, type WebhookManifestFragment } from "./manifest";
40
+ import { dispatch } from "./dispatch";
41
+ import { internalHandlers } from "./handlers";
42
+ import type { WebhookConfig } from "./types";
43
+
44
+ export { buildWebhookManifest } from "./manifest";
45
+ export type { WebhookManifestFragment } from "./manifest";
46
+ export { dispatch } from "./dispatch";
47
+
48
+ export const DEFAULT_RETRY_SCHEDULE_SECS = [
49
+ 5, // 5s
50
+ 300, // 5m
51
+ 1800, // 30m
52
+ 7200, // 2h
53
+ 18000, // 5h
54
+ 36000, // 10h
55
+ 50400, // 14h
56
+ ];
57
+
58
+ export interface WebhooksPlugin {
59
+ config: WebhookConfig;
60
+ manifest: WebhookManifestFragment;
61
+ handlers: Record<string, unknown>;
62
+ dispatch: typeof dispatch;
63
+ }
64
+
65
+ export function webhooks(cfg: WebhookConfig = {}): WebhooksPlugin {
66
+ const resolved: WebhookConfig = {
67
+ retrySchedule: cfg.retrySchedule ?? DEFAULT_RETRY_SCHEDULE_SECS,
68
+ replayToleranceSecs: cfg.replayToleranceSecs ?? 300,
69
+ ...cfg,
70
+ };
71
+ return {
72
+ config: resolved,
73
+ manifest: buildWebhookManifest(resolved),
74
+ handlers: internalHandlers(resolved),
75
+ dispatch,
76
+ };
77
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ type EntityDefinition,
3
+ type PolicyDefinition,
4
+ entity,
5
+ field,
6
+ policy,
7
+ } from "@pylonsync/sdk";
8
+
9
+ import type { WebhookConfig } from "./types";
10
+
11
+ export interface WebhookManifestFragment {
12
+ entities: EntityDefinition[];
13
+ policies: PolicyDefinition[];
14
+ }
15
+
16
+ export function buildWebhookManifest(
17
+ cfg: WebhookConfig,
18
+ ): WebhookManifestFragment {
19
+ const endpointName = cfg.entities?.endpoint ?? "WebhookEndpoint";
20
+ const attemptName = cfg.entities?.attempt ?? "WebhookAttempt";
21
+
22
+ const Endpoint = entity(endpointName, {
23
+ applicationId: field.string(),
24
+ url: field.string(),
25
+ secret: field.string(),
26
+ eventTypes: field.string(), // JSON array
27
+ headers: field.string().optional(), // JSON
28
+ disabled: field.boolean().optional(),
29
+ createdAt: field.string(),
30
+ });
31
+
32
+ const Attempt = entity(attemptName, {
33
+ applicationId: field.string(),
34
+ endpointId: field.string(),
35
+ eventId: field.string(),
36
+ eventType: field.string(),
37
+ url: field.string(),
38
+ attempt: field.number(),
39
+ status: field.string(),
40
+ httpStatus: field.number().optional(),
41
+ error: field.string().optional(),
42
+ scheduledAt: field.string(),
43
+ deliveredAt: field.string().optional(),
44
+ });
45
+
46
+ // Endpoints are tenant-scoped (app == tenant in the common
47
+ // multi-tenant SaaS case). Apps that want admin-only access
48
+ // override these in their own manifest.
49
+ const endpointPolicy = policy({
50
+ name: `${endpointName.toLowerCase()}_tenant_scoped`,
51
+ entity: endpointName,
52
+ allowRead: "auth.tenantId == data.applicationId or auth.is_admin == true",
53
+ allowInsert:
54
+ "auth.tenantId == data.applicationId or auth.is_admin == true",
55
+ allowUpdate:
56
+ "auth.tenantId == data.applicationId or auth.is_admin == true",
57
+ allowDelete:
58
+ "auth.tenantId == data.applicationId or auth.is_admin == true",
59
+ });
60
+
61
+ // Attempt rows are read-only — they're an internal audit trail.
62
+ // Tenant-scoped reads so the customer's dashboard can show
63
+ // delivery history; writes only via the plugin's internal
64
+ // mutation.
65
+ const attemptPolicy = policy({
66
+ name: `${attemptName.toLowerCase()}_read_only`,
67
+ entity: attemptName,
68
+ allowRead: "auth.tenantId == data.applicationId or auth.is_admin == true",
69
+ allowInsert: "false",
70
+ allowUpdate: "false",
71
+ allowDelete: "false",
72
+ });
73
+
74
+ return {
75
+ entities: [Endpoint, Attempt],
76
+ policies: [endpointPolicy, attemptPolicy],
77
+ };
78
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { signWebhook, verifyWebhook } from "./signature";
4
+
5
+ const SECRET = "whsec_dGVzdC1zZWNyZXQtdmFsdWU="; // base64('test-secret-value')
6
+ const ID = "evt_test_123";
7
+ const BODY = '{"id":"evt_test_123","type":"x.y","data":{}}';
8
+
9
+ describe("webhook signature", () => {
10
+ test("sign + verify round-trip succeeds", async () => {
11
+ const ts = 1_700_000_000;
12
+ const sig = await signWebhook({
13
+ id: ID,
14
+ timestamp: ts,
15
+ body: BODY,
16
+ secrets: [SECRET],
17
+ });
18
+ const result = await verifyWebhook(
19
+ SECRET,
20
+ { id: sig.id, timestamp: String(sig.timestamp), signature: sig.signature },
21
+ BODY,
22
+ { nowSecs: ts },
23
+ );
24
+ expect(result).toBe(true);
25
+ });
26
+
27
+ test("verify rejects a stale timestamp (replay window)", async () => {
28
+ const ts = 1_700_000_000;
29
+ const sig = await signWebhook({
30
+ id: ID,
31
+ timestamp: ts,
32
+ body: BODY,
33
+ secrets: [SECRET],
34
+ });
35
+ const result = await verifyWebhook(
36
+ SECRET,
37
+ { id: sig.id, timestamp: String(sig.timestamp), signature: sig.signature },
38
+ BODY,
39
+ { nowSecs: ts + 10 * 60 }, // 10 min later
40
+ );
41
+ expect(result).toBe("REPLAYED");
42
+ });
43
+
44
+ test("verify rejects a tampered body", async () => {
45
+ const ts = 1_700_000_000;
46
+ const sig = await signWebhook({
47
+ id: ID,
48
+ timestamp: ts,
49
+ body: BODY,
50
+ secrets: [SECRET],
51
+ });
52
+ const result = await verifyWebhook(
53
+ SECRET,
54
+ { id: sig.id, timestamp: String(sig.timestamp), signature: sig.signature },
55
+ `${BODY}x`, // tampered
56
+ { nowSecs: ts },
57
+ );
58
+ expect(result).toBe("INVALID_SIGNATURE");
59
+ });
60
+
61
+ test("verify accepts either signature during rotation", async () => {
62
+ const ts = 1_700_000_000;
63
+ const oldSecret = "whsec_b2xkLXNlY3JldA=="; // base64('old-secret')
64
+ const sig = await signWebhook({
65
+ id: ID,
66
+ timestamp: ts,
67
+ body: BODY,
68
+ secrets: [oldSecret, SECRET], // old + new
69
+ });
70
+ // Receiver using only the new secret still accepts.
71
+ expect(
72
+ await verifyWebhook(
73
+ SECRET,
74
+ { id: sig.id, timestamp: String(sig.timestamp), signature: sig.signature },
75
+ BODY,
76
+ { nowSecs: ts },
77
+ ),
78
+ ).toBe(true);
79
+ // Receiver still on the old secret also accepts.
80
+ expect(
81
+ await verifyWebhook(
82
+ oldSecret,
83
+ { id: sig.id, timestamp: String(sig.timestamp), signature: sig.signature },
84
+ BODY,
85
+ { nowSecs: ts },
86
+ ),
87
+ ).toBe(true);
88
+ });
89
+
90
+ test("verify reports missing headers", async () => {
91
+ expect(
92
+ await verifyWebhook(SECRET, { id: null, timestamp: "1", signature: "v1,x" }, BODY),
93
+ ).toBe("MISSING_ID");
94
+ expect(
95
+ await verifyWebhook(SECRET, { id: "x", timestamp: null, signature: "v1,x" }, BODY),
96
+ ).toBe("MISSING_TIMESTAMP");
97
+ expect(
98
+ await verifyWebhook(SECRET, { id: "x", timestamp: "1", signature: null }, BODY),
99
+ ).toBe("MISSING_SIGNATURE");
100
+ });
101
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Svix-compatible HMAC-SHA256 signing for outbound webhooks.
3
+ *
4
+ * Headers we attach:
5
+ * - `webhook-id`: stable event id (used by receivers for dedup)
6
+ * - `webhook-timestamp`: unix seconds
7
+ * - `webhook-signature`: `v1,<base64-sig> [v1,<base64-sig-2>...]`
8
+ * Multiple v1s appear during secret rotation — both old and new
9
+ * secrets are signed simultaneously for a configurable overlap
10
+ * window so receivers can update their secret without dropping
11
+ * deliveries.
12
+ *
13
+ * Signature input: `<webhook-id>.<webhook-timestamp>.<body>`
14
+ *
15
+ * Conformant with the Svix signature spec so existing receivers
16
+ * (Resend, Clerk, anyone using their reference verifier) work
17
+ * unchanged.
18
+ */
19
+
20
+ export interface SignOptions {
21
+ id: string;
22
+ timestamp: number;
23
+ body: string;
24
+ secrets: string[];
25
+ }
26
+
27
+ export interface WebhookSignaturePayload {
28
+ id: string;
29
+ timestamp: number;
30
+ signature: string;
31
+ }
32
+
33
+ export async function signWebhook(
34
+ opts: SignOptions,
35
+ ): Promise<WebhookSignaturePayload> {
36
+ const sigs: string[] = [];
37
+ for (const secret of opts.secrets) {
38
+ const sig = await hmacSha256B64(
39
+ normalizeSecret(secret),
40
+ `${opts.id}.${opts.timestamp}.${opts.body}`,
41
+ );
42
+ sigs.push(`v1,${sig}`);
43
+ }
44
+ return {
45
+ id: opts.id,
46
+ timestamp: opts.timestamp,
47
+ signature: sigs.join(" "),
48
+ };
49
+ }
50
+
51
+ export type SignatureError =
52
+ | "MISSING_ID"
53
+ | "MISSING_TIMESTAMP"
54
+ | "MISSING_SIGNATURE"
55
+ | "REPLAYED"
56
+ | "INVALID_SIGNATURE";
57
+
58
+ export async function verifyWebhook(
59
+ secret: string,
60
+ headers: {
61
+ id?: string | null;
62
+ timestamp?: string | null;
63
+ signature?: string | null;
64
+ },
65
+ body: string,
66
+ opts: { toleranceSecs?: number; nowSecs?: number } = {},
67
+ ): Promise<true | SignatureError> {
68
+ const id = headers.id;
69
+ const ts = headers.timestamp;
70
+ const sig = headers.signature;
71
+ if (!id) return "MISSING_ID";
72
+ if (!ts) return "MISSING_TIMESTAMP";
73
+ if (!sig) return "MISSING_SIGNATURE";
74
+
75
+ const tsNum = Number.parseInt(ts, 10);
76
+ if (Number.isNaN(tsNum)) return "MISSING_TIMESTAMP";
77
+ const now = opts.nowSecs ?? Math.floor(Date.now() / 1000);
78
+ const tolerance = opts.toleranceSecs ?? 300;
79
+ if (Math.abs(now - tsNum) > tolerance) return "REPLAYED";
80
+
81
+ const expected = await hmacSha256B64(
82
+ normalizeSecret(secret),
83
+ `${id}.${ts}.${body}`,
84
+ );
85
+ for (const part of sig.split(" ")) {
86
+ const [version, candidate] = part.split(",");
87
+ if (version !== "v1") continue;
88
+ if (constantTimeEqual(candidate, expected)) return true;
89
+ }
90
+ return "INVALID_SIGNATURE";
91
+ }
92
+
93
+ function normalizeSecret(secret: string): Uint8Array {
94
+ if (secret.startsWith("whsec_")) {
95
+ return base64Decode(secret.slice("whsec_".length));
96
+ }
97
+ return new TextEncoder().encode(secret);
98
+ }
99
+
100
+ async function hmacSha256B64(
101
+ secret: Uint8Array,
102
+ payload: string,
103
+ ): Promise<string> {
104
+ const secretBuf = new ArrayBuffer(secret.byteLength);
105
+ new Uint8Array(secretBuf).set(secret);
106
+ const bodyBytes = new TextEncoder().encode(payload);
107
+ const bodyBuf = new ArrayBuffer(bodyBytes.byteLength);
108
+ new Uint8Array(bodyBuf).set(bodyBytes);
109
+ const key = await crypto.subtle.importKey(
110
+ "raw",
111
+ secretBuf,
112
+ { name: "HMAC", hash: "SHA-256" },
113
+ false,
114
+ ["sign"],
115
+ );
116
+ const sig = await crypto.subtle.sign("HMAC", key, bodyBuf);
117
+ return base64Encode(new Uint8Array(sig));
118
+ }
119
+
120
+ function base64Decode(s: string): Uint8Array {
121
+ const bin = atob(s);
122
+ const out = new Uint8Array(bin.length);
123
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
124
+ return out;
125
+ }
126
+ function base64Encode(bytes: Uint8Array): string {
127
+ let s = "";
128
+ for (const b of bytes) s += String.fromCharCode(b);
129
+ return btoa(s);
130
+ }
131
+ function constantTimeEqual(a: string, b: string): boolean {
132
+ if (a.length !== b.length) return false;
133
+ let diff = 0;
134
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
135
+ return diff === 0;
136
+ }
package/src/types.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `@pylonsync/webhooks` — outbound webhook delivery for Pylon apps.
3
+ *
4
+ * Svix-style HMAC-SHA256 signed payloads with timestamp +
5
+ * replay-window protection, exponential-backoff retry schedule,
6
+ * secret rotation overlap, per-endpoint event filtering,
7
+ * idempotent delivery via stable event ids.
8
+ *
9
+ * Mental model:
10
+ * - Apps register **endpoints** at runtime: customer-supplied
11
+ * URLs that should receive certain event types.
12
+ * - The app's domain code calls `dispatch(ctx, { type, payload })`
13
+ * when something happens.
14
+ * - The plugin enqueues delivery to each matching endpoint via
15
+ * Pylon's job queue.
16
+ * - Worker pulls the job, signs the payload with the endpoint's
17
+ * secret, POSTs it. On failure, re-enqueues with the next
18
+ * retry interval (5s → 5m → 30m → 2h → 5h → 10h → 14h → dead).
19
+ */
20
+
21
+ export interface WebhookEvent<T = unknown> {
22
+ /** Stable id. Receivers dedup on this. */
23
+ id: string;
24
+ type: string;
25
+ occurredAt: string;
26
+ data: T;
27
+ }
28
+
29
+ export interface WebhookEndpoint {
30
+ id: string;
31
+ /** Logical bucket — usually the customer/org id. */
32
+ applicationId: string;
33
+ url: string;
34
+ /** Hex-encoded HMAC secret. Plaintext on the row by design — only
35
+ * the customer's app reads + uses it, and Pylon's at-rest crypto
36
+ * is the secret-encryption layer apps wire when they want it. */
37
+ secret: string;
38
+ /** Subset of event types this endpoint subscribes to. Empty = all. */
39
+ eventTypes: string[];
40
+ /**
41
+ * Headers to attach on every delivery. Useful for routing /
42
+ * authentication beyond the HMAC signature.
43
+ */
44
+ headers?: Record<string, string>;
45
+ /** Disabled endpoints don't receive deliveries. */
46
+ disabled?: boolean;
47
+ createdAt: string;
48
+ }
49
+
50
+ export interface WebhookConfig {
51
+ entities?: {
52
+ endpoint?: string;
53
+ attempt?: string;
54
+ };
55
+ /**
56
+ * Retry schedule (seconds offset from initial attempt). Default
57
+ * matches Svix's published schedule. After exhausting the
58
+ * schedule, the attempt is marked `dead` and the destination
59
+ * goes to the dead-letter queue.
60
+ */
61
+ retrySchedule?: number[];
62
+ /** Replay window for signature validation. Default 300s. */
63
+ replayToleranceSecs?: number;
64
+ /**
65
+ * App identifier resolver. For multi-tenant apps, this returns
66
+ * the application id (org id) from the dispatch context — used
67
+ * to filter endpoints to the right customer.
68
+ */
69
+ getApplicationId?: (
70
+ ctx: WebhookCtx,
71
+ payload: WebhookEvent,
72
+ ) => string | null | Promise<string | null>;
73
+ }
74
+
75
+ export interface WebhookCtx {
76
+ env: Record<string, string | undefined>;
77
+ auth: { userId?: string | null; tenantId?: string | null };
78
+ runQuery: <T>(name: string, args: Record<string, unknown>) => Promise<T>;
79
+ runMutation: <T = unknown>(
80
+ name: string,
81
+ args: Record<string, unknown>,
82
+ ) => Promise<T>;
83
+ scheduler: {
84
+ runAfter: (
85
+ ms: number,
86
+ fn: string,
87
+ args: Record<string, unknown>,
88
+ ) => Promise<string>;
89
+ };
90
+ error: (code: string, message: string) => Error;
91
+ }
92
+
93
+ export interface DispatchInput {
94
+ type: string;
95
+ data: unknown;
96
+ /**
97
+ * Stable event id. Apps that produce events idempotently (e.g.
98
+ * "subscription.updated" from a Stripe webhook) should pass
99
+ * the upstream event id here so re-deliveries dedup. Auto-
100
+ * generated when absent.
101
+ */
102
+ id?: string;
103
+ /** Override the auto-resolved application id. */
104
+ applicationId?: string;
105
+ occurredAt?: string;
106
+ }
107
+
108
+ export interface DeliveryAttempt {
109
+ id: string;
110
+ eventId: string;
111
+ endpointId: string;
112
+ url: string;
113
+ attempt: number;
114
+ status: "pending" | "succeeded" | "failed" | "dead";
115
+ httpStatus?: number;
116
+ error?: string;
117
+ scheduledAt: string;
118
+ deliveredAt?: string;
119
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
6
+ "include": [
7
+ "src"
8
+ ]
9
+ }