@hogsend/engine 0.7.0 → 0.9.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.
Files changed (41) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/container.ts +80 -8
  4. package/src/destinations/define-destination.ts +104 -0
  5. package/src/destinations/presets/index.ts +94 -0
  6. package/src/destinations/presets/posthog.ts +71 -0
  7. package/src/destinations/presets/segment.ts +75 -0
  8. package/src/destinations/presets/slack.ts +66 -0
  9. package/src/destinations/presets/webhook.ts +37 -0
  10. package/src/destinations/registry-singleton.ts +78 -0
  11. package/src/env.ts +40 -0
  12. package/src/index.ts +59 -1
  13. package/src/journeys/define-journey.ts +26 -3
  14. package/src/journeys/journey-context.ts +1 -17
  15. package/src/lib/analytics-singleton.ts +7 -0
  16. package/src/lib/bucket-emit.ts +45 -0
  17. package/src/lib/contacts.ts +28 -6
  18. package/src/lib/mailer.ts +102 -0
  19. package/src/lib/outbound.ts +223 -0
  20. package/src/lib/preferences.ts +31 -0
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +45 -3
  23. package/src/lib/tracking-events.ts +77 -10
  24. package/src/lib/webhook-signing.ts +152 -0
  25. package/src/routes/admin/contacts.ts +43 -3
  26. package/src/routes/admin/index.ts +2 -0
  27. package/src/routes/admin/webhooks.ts +557 -0
  28. package/src/routes/contacts/index.ts +48 -5
  29. package/src/routes/lists/index.ts +41 -5
  30. package/src/routes/tracking/click.ts +58 -22
  31. package/src/routes/tracking/open.ts +53 -22
  32. package/src/routes/webhooks/sources.ts +69 -10
  33. package/src/webhook-sources/define-webhook-source.ts +57 -5
  34. package/src/webhook-sources/presets/clerk.ts +185 -0
  35. package/src/webhook-sources/presets/index.ts +80 -0
  36. package/src/webhook-sources/presets/segment.ts +120 -0
  37. package/src/webhook-sources/presets/stripe.ts +147 -0
  38. package/src/webhook-sources/presets/supabase.ts +131 -0
  39. package/src/webhook-sources/verify.ts +172 -0
  40. package/src/worker.ts +6 -0
  41. package/src/workflows/deliver-webhook.ts +484 -0
@@ -0,0 +1,80 @@
1
+ import type { env as engineEnv } from "../../env.js";
2
+ import type { DefinedWebhookSource } from "../define-webhook-source.js";
3
+ import { clerkSource } from "./clerk.js";
4
+ import { segmentSource } from "./segment.js";
5
+ import { stripeSource } from "./stripe.js";
6
+ import { supabaseSource } from "./supabase.js";
7
+
8
+ export { clerkSource } from "./clerk.js";
9
+ export { segmentSource } from "./segment.js";
10
+ export { stripeSource } from "./stripe.js";
11
+ export { supabaseSource } from "./supabase.js";
12
+
13
+ /**
14
+ * All shipped integration presets, keyed by their webhook-source id. The id is
15
+ * also the route segment: `PRESET_SOURCES.stripe` serves `POST /v1/webhooks/stripe`.
16
+ */
17
+ export const PRESET_SOURCES = {
18
+ clerk: clerkSource,
19
+ supabase: supabaseSource,
20
+ stripe: stripeSource,
21
+ segment: segmentSource,
22
+ } satisfies Record<string, DefinedWebhookSource>;
23
+
24
+ /** The stable id of a shipped preset (`"clerk" | "supabase" | "stripe" | "segment"`). */
25
+ export type PresetId = keyof typeof PRESET_SOURCES;
26
+
27
+ /** The slice of the validated env `presetsFromEnv` reads (preset secrets + override). */
28
+ type PresetEnv = Pick<
29
+ typeof engineEnv,
30
+ | "CLERK_WEBHOOK_SECRET"
31
+ | "SUPABASE_WEBHOOK_SECRET"
32
+ | "STRIPE_WEBHOOK_SECRET"
33
+ | "SEGMENT_WEBHOOK_SECRET"
34
+ | "ENABLED_WEBHOOK_PRESETS"
35
+ >;
36
+
37
+ /**
38
+ * Resolve which presets to enable from the validated env (decision #13).
39
+ *
40
+ * Resolution order:
41
+ * 1. `ENABLED_WEBHOOK_PRESETS === "none"` → no presets (hard off).
42
+ * 2. `ENABLED_WEBHOOK_PRESETS` is a csv of ids → exactly those, but ONLY when
43
+ * the preset's secret (`env[auth.envKey]`) is also set (a signature source
44
+ * with no secret fails closed at runtime, so enabling it is pointless).
45
+ * 3. `ENABLED_WEBHOOK_PRESETS === "*"` or absent → AUTO: every preset whose
46
+ * secret is present.
47
+ *
48
+ * In every branch a preset is only returned when its secret is configured, so a
49
+ * preset can never be mounted in an always-fail-closed state by accident.
50
+ */
51
+ export function presetsFromEnv(env: PresetEnv): DefinedWebhookSource[] {
52
+ const override = env.ENABLED_WEBHOOK_PRESETS?.trim();
53
+
54
+ if (override === "none") {
55
+ return [];
56
+ }
57
+
58
+ const hasSecret = (source: DefinedWebhookSource): boolean => {
59
+ const secret = env[source.auth.envKey as keyof PresetEnv];
60
+ return typeof secret === "string" && secret.length > 0;
61
+ };
62
+
63
+ // Explicit csv allow-list (anything other than "*"/empty).
64
+ if (override && override !== "*") {
65
+ const ids = new Set(
66
+ override
67
+ .split(",")
68
+ .map((id) => id.trim().toLowerCase())
69
+ .filter((id) => id.length > 0),
70
+ );
71
+ return (
72
+ Object.entries(PRESET_SOURCES) as [PresetId, DefinedWebhookSource][]
73
+ )
74
+ .filter(([id, source]) => ids.has(id) && hasSecret(source))
75
+ .map(([, source]) => source);
76
+ }
77
+
78
+ // AUTO ("*" or absent): every preset whose secret is set.
79
+ return Object.values(PRESET_SOURCES).filter(hasSecret);
80
+ }
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ import type { IngestEvent } from "../../lib/ingestion.js";
3
+ import { defineWebhookSource } from "../define-webhook-source.js";
4
+
5
+ /**
6
+ * Segment webhook preset.
7
+ *
8
+ * Auth: generic HMAC-hex over the raw body (`x-signature`), verified with
9
+ * `node:crypto`. Set `SEGMENT_WEBHOOK_SECRET` to auto-enable at
10
+ * `POST /v1/webhooks/segment`.
11
+ *
12
+ * Event mapping (decision #16):
13
+ * - `identify` → `contact.updated` (traits → `contactProperties` ONLY)
14
+ * - `track` → the literal `event` name (properties → `eventProperties` ONLY)
15
+ * - `page` / `screen` / `group` / `alias` → skipped (`null`)
16
+ *
17
+ * Identity: `userId = userId ?? anonymousId`; `email` lifted from
18
+ * `traits.email`/`context.traits.email`. `idempotencyKey = messageId` so
19
+ * Segment redelivery dedupes on `user_events.idempotencyKey`.
20
+ */
21
+
22
+ const segmentTraitsSchema = z.record(z.string(), z.unknown());
23
+
24
+ const segmentWebhookSchema = z
25
+ .object({
26
+ type: z.string(),
27
+ event: z.string().nullish(),
28
+ messageId: z.string().nullish(),
29
+ userId: z.string().nullish(),
30
+ anonymousId: z.string().nullish(),
31
+ traits: segmentTraitsSchema.nullish(),
32
+ properties: z.record(z.string(), z.unknown()).nullish(),
33
+ context: z
34
+ .object({
35
+ traits: segmentTraitsSchema.nullish(),
36
+ })
37
+ .catchall(z.unknown())
38
+ .nullish(),
39
+ })
40
+ .catchall(z.unknown());
41
+
42
+ type SegmentPayload = z.infer<typeof segmentWebhookSchema>;
43
+
44
+ /** Pull a string email out of a traits bag, if present. */
45
+ function emailFromTraits(
46
+ traits: Record<string, unknown> | null | undefined,
47
+ ): string | undefined {
48
+ const email = traits?.email;
49
+ return typeof email === "string" ? email : undefined;
50
+ }
51
+
52
+ export const segmentSource = defineWebhookSource({
53
+ meta: {
54
+ id: "segment",
55
+ name: "Segment",
56
+ description: "Receives Segment identify/track webhooks (HMAC-hex signed).",
57
+ },
58
+ auth: {
59
+ type: "signature",
60
+ scheme: "hmac-hex",
61
+ envKey: "SEGMENT_WEBHOOK_SECRET",
62
+ header: "x-signature",
63
+ },
64
+ schema: segmentWebhookSchema,
65
+ async transform(payload: SegmentPayload): Promise<IngestEvent | null> {
66
+ const userId = payload.userId ?? payload.anonymousId ?? undefined;
67
+ if (!userId) {
68
+ return null;
69
+ }
70
+
71
+ const traits = payload.traits ?? payload.context?.traits ?? undefined;
72
+ const userEmail =
73
+ emailFromTraits(payload.traits) ??
74
+ emailFromTraits(payload.context?.traits) ??
75
+ "";
76
+ const idempotencyKey = payload.messageId ?? undefined;
77
+
78
+ if (payload.type === "identify") {
79
+ // identify: traits are profile/identity → contactProperties ONLY.
80
+ const contactProperties: Record<string, unknown> = { ...(traits ?? {}) };
81
+ contactProperties.source = "segment";
82
+
83
+ return {
84
+ event: "contact.updated",
85
+ userId,
86
+ userEmail,
87
+ eventProperties: {
88
+ source: "segment",
89
+ _segmentType: "identify",
90
+ },
91
+ contactProperties,
92
+ ...(idempotencyKey ? { idempotencyKey } : {}),
93
+ };
94
+ }
95
+
96
+ if (payload.type === "track") {
97
+ const eventName = payload.event;
98
+ if (!eventName) {
99
+ return null;
100
+ }
101
+ // track: properties are behavioral → eventProperties ONLY.
102
+ const eventProperties: Record<string, unknown> = {
103
+ ...(payload.properties ?? {}),
104
+ };
105
+ eventProperties.source = "segment";
106
+
107
+ return {
108
+ event: eventName,
109
+ userId,
110
+ userEmail,
111
+ eventProperties,
112
+ contactProperties: {},
113
+ ...(idempotencyKey ? { idempotencyKey } : {}),
114
+ };
115
+ }
116
+
117
+ // page / screen / group / alias → skip.
118
+ return null;
119
+ },
120
+ });
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import type { IngestEvent } from "../../lib/ingestion.js";
3
+ import { defineWebhookSource } from "../define-webhook-source.js";
4
+
5
+ /**
6
+ * Stripe webhook preset.
7
+ *
8
+ * Auth: Stripe's `stripe-signature: t=<ts>,v1=<hex>` header, verified with
9
+ * `node:crypto` (NO `stripe` SDK — decision #14). Set `STRIPE_WEBHOOK_SECRET`
10
+ * (the `whsec_…` endpoint secret) to auto-enable at `POST /v1/webhooks/stripe`.
11
+ *
12
+ * Event mapping (decision #16, normalized to the outbound vocabulary):
13
+ * - `customer.created` → `contact.created`
14
+ * - `customer.updated` → `contact.updated`
15
+ * - `customer.deleted` → `contact.deleted` (EVENT only — decision #15)
16
+ * - `customer.subscription.<action>` → `subscription.<action>`
17
+ * - `invoice.<action>` → `invoice.<action>`
18
+ *
19
+ * Identity: `userId = obj.id` for customers, `obj.customer` for subscriptions /
20
+ * invoices. `idempotencyKey = payload.id` (the Stripe event id) so at-least-once
21
+ * redelivery dedupes on `user_events.idempotencyKey`.
22
+ *
23
+ * D2 split: customer profile (`name`, `phone`, `metadata`) → `contactProperties`
24
+ * ONLY; everything else → `eventProperties` ONLY.
25
+ */
26
+
27
+ const stripeObjectSchema = z
28
+ .object({
29
+ id: z.string().optional(),
30
+ object: z.string().optional(),
31
+ customer: z.string().nullish(),
32
+ email: z.string().nullish(),
33
+ name: z.string().nullish(),
34
+ phone: z.string().nullish(),
35
+ metadata: z.record(z.string(), z.unknown()).nullish(),
36
+ })
37
+ .catchall(z.unknown());
38
+
39
+ const stripeWebhookSchema = z
40
+ .object({
41
+ id: z.string(),
42
+ type: z.string(),
43
+ object: z.string().optional(),
44
+ data: z
45
+ .object({
46
+ object: stripeObjectSchema,
47
+ })
48
+ .catchall(z.unknown()),
49
+ })
50
+ .catchall(z.unknown());
51
+
52
+ type StripePayload = z.infer<typeof stripeWebhookSchema>;
53
+
54
+ export const stripeSource = defineWebhookSource({
55
+ meta: {
56
+ id: "stripe",
57
+ name: "Stripe",
58
+ description:
59
+ "Receives Stripe customer/subscription/invoice webhooks (signature-verified).",
60
+ },
61
+ auth: {
62
+ type: "signature",
63
+ scheme: "stripe",
64
+ envKey: "STRIPE_WEBHOOK_SECRET",
65
+ header: "stripe-signature",
66
+ },
67
+ schema: stripeWebhookSchema,
68
+ async transform(payload: StripePayload): Promise<IngestEvent | null> {
69
+ const type = payload.type;
70
+ const obj = payload.data.object;
71
+
72
+ // Normalize the Stripe event name → Hogsend vocabulary + resolve identity.
73
+ let event: string;
74
+ let userId: string | undefined;
75
+ let isCustomerLifecycle = false;
76
+ let isDelete = false;
77
+
78
+ if (type === "customer.created" || type === "customer.updated") {
79
+ event =
80
+ type === "customer.created" ? "contact.created" : "contact.updated";
81
+ userId = obj.id;
82
+ isCustomerLifecycle = true;
83
+ } else if (type === "customer.deleted") {
84
+ event = "contact.deleted";
85
+ userId = obj.id;
86
+ isDelete = true;
87
+ } else if (type.startsWith("customer.subscription.")) {
88
+ // customer.subscription.created/updated/deleted → subscription.<action>
89
+ const action = type.slice("customer.subscription.".length);
90
+ event = `subscription.${action}`;
91
+ userId = typeof obj.customer === "string" ? obj.customer : undefined;
92
+ } else if (type.startsWith("invoice.")) {
93
+ const action = type.slice("invoice.".length);
94
+ event = `invoice.${action}`;
95
+ userId = typeof obj.customer === "string" ? obj.customer : undefined;
96
+ } else {
97
+ return null;
98
+ }
99
+
100
+ if (!userId) {
101
+ return null;
102
+ }
103
+
104
+ const userEmail = typeof obj.email === "string" ? obj.email : "";
105
+
106
+ const eventProperties: Record<string, unknown> = {
107
+ source: "stripe",
108
+ stripeCustomerId: userId,
109
+ stripeEventId: payload.id,
110
+ _stripeEvent: type,
111
+ stripeObject: obj.object,
112
+ };
113
+
114
+ // Only the customer create/update lifecycle carries a profile to merge.
115
+ // Deletes (decision #15) and subscription/invoice events are event-only.
116
+ if (!isCustomerLifecycle || isDelete) {
117
+ return {
118
+ event,
119
+ userId,
120
+ userEmail,
121
+ eventProperties,
122
+ contactProperties: {},
123
+ idempotencyKey: payload.id,
124
+ };
125
+ }
126
+
127
+ const contactProperties: Record<string, unknown> = {
128
+ ...(obj.metadata ?? {}),
129
+ };
130
+ if (typeof obj.name === "string") {
131
+ contactProperties.name = obj.name;
132
+ }
133
+ if (typeof obj.phone === "string") {
134
+ contactProperties.phone = obj.phone;
135
+ }
136
+ contactProperties.stripeCustomerId = userId;
137
+
138
+ return {
139
+ event,
140
+ userId,
141
+ userEmail,
142
+ eventProperties,
143
+ contactProperties,
144
+ idempotencyKey: payload.id,
145
+ };
146
+ },
147
+ });
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+ import type { IngestEvent } from "../../lib/ingestion.js";
3
+ import { defineWebhookSource } from "../define-webhook-source.js";
4
+
5
+ /**
6
+ * Supabase `auth.users` webhook preset.
7
+ *
8
+ * Auth: Svix-signed when Supabase's "Send HTTP Request" hook is configured with
9
+ * a signing secret; falls back to the plain `x-supabase-webhook-secret` shared
10
+ * secret (via `fallbackMatchHeader`) for the database-webhook trigger path. Set
11
+ * `SUPABASE_WEBHOOK_SECRET` to auto-enable at `POST /v1/webhooks/supabase`.
12
+ *
13
+ * Only `schema === "auth" && table === "users"` rows are processed (other tables
14
+ * are skipped). Event mapping (decision #16, normalized):
15
+ * - `INSERT` → `contact.created`
16
+ * - `UPDATE` → `contact.updated`
17
+ * - `DELETE` → `contact.deleted` (EVENT only — decision #15)
18
+ *
19
+ * D2 split: profile fields → `contactProperties` ONLY; behavioral/source fields
20
+ * → `eventProperties` ONLY.
21
+ */
22
+
23
+ const supabaseUserRowSchema = z
24
+ .object({
25
+ id: z.string().optional(),
26
+ email: z.string().nullish(),
27
+ phone: z.string().nullish(),
28
+ email_confirmed_at: z.string().nullish(),
29
+ raw_user_meta_data: z.record(z.string(), z.unknown()).nullish(),
30
+ })
31
+ .catchall(z.unknown());
32
+
33
+ const supabaseWebhookSchema = z
34
+ .object({
35
+ type: z.enum(["INSERT", "UPDATE", "DELETE"]),
36
+ table: z.string(),
37
+ schema: z.string(),
38
+ record: supabaseUserRowSchema.nullish(),
39
+ old_record: supabaseUserRowSchema.nullish(),
40
+ })
41
+ .catchall(z.unknown());
42
+
43
+ type SupabasePayload = z.infer<typeof supabaseWebhookSchema>;
44
+
45
+ export const supabaseSource = defineWebhookSource({
46
+ meta: {
47
+ id: "supabase",
48
+ name: "Supabase",
49
+ description:
50
+ "Receives Supabase auth.users INSERT/UPDATE/DELETE webhooks (Svix-signed or shared-secret).",
51
+ },
52
+ auth: {
53
+ type: "signature",
54
+ scheme: "svix",
55
+ envKey: "SUPABASE_WEBHOOK_SECRET",
56
+ header: "svix-signature",
57
+ fallbackMatchHeader: "x-supabase-webhook-secret",
58
+ },
59
+ schema: supabaseWebhookSchema,
60
+ async transform(payload: SupabasePayload): Promise<IngestEvent | null> {
61
+ // Only auth.users mutations map to contacts; ignore everything else.
62
+ if (payload.schema !== "auth" || payload.table !== "users") {
63
+ return null;
64
+ }
65
+
66
+ let event: string;
67
+ switch (payload.type) {
68
+ case "INSERT":
69
+ event = "contact.created";
70
+ break;
71
+ case "UPDATE":
72
+ event = "contact.updated";
73
+ break;
74
+ case "DELETE":
75
+ event = "contact.deleted";
76
+ break;
77
+ default:
78
+ return null;
79
+ }
80
+
81
+ // DELETE carries the row in `old_record`; INSERT/UPDATE in `record`.
82
+ const row =
83
+ payload.type === "DELETE"
84
+ ? (payload.old_record ?? payload.record)
85
+ : (payload.record ?? payload.old_record);
86
+
87
+ if (!row) {
88
+ return null;
89
+ }
90
+
91
+ const userId = row.id;
92
+ if (!userId) {
93
+ return null;
94
+ }
95
+ const userEmail = typeof row.email === "string" ? row.email : "";
96
+
97
+ const eventProperties: Record<string, unknown> = {
98
+ source: "supabase",
99
+ supabaseUserId: userId,
100
+ _supabaseEvent: payload.type,
101
+ };
102
+
103
+ // Deletes carry no profile to merge — emit the event only (decision #15).
104
+ if (event === "contact.deleted") {
105
+ return {
106
+ event,
107
+ userId,
108
+ userEmail,
109
+ eventProperties,
110
+ contactProperties: {},
111
+ };
112
+ }
113
+
114
+ const contactProperties: Record<string, unknown> = {
115
+ ...(row.raw_user_meta_data ?? {}),
116
+ };
117
+ if (typeof row.phone === "string") {
118
+ contactProperties.phone = row.phone;
119
+ }
120
+ contactProperties.emailVerified = Boolean(row.email_confirmed_at);
121
+ contactProperties.supabaseUserId = userId;
122
+
123
+ return {
124
+ event,
125
+ userId,
126
+ userEmail,
127
+ eventProperties,
128
+ contactProperties,
129
+ };
130
+ },
131
+ });
@@ -0,0 +1,172 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Webhook } from "svix";
3
+
4
+ /**
5
+ * The signature verification schemes understood by `defineWebhookSource`'s
6
+ * `auth.type: "signature"` variant. Each preset (Clerk, Supabase, Stripe,
7
+ * Segment) maps to one of these; the route resolves the secret from
8
+ * `env[auth.envKey]` and calls {@link verifySignature} BEFORE parsing/handing
9
+ * the payload to `transform()`.
10
+ *
11
+ * - `"svix"` — Standard Webhooks / Svix header set (`svix-id` /
12
+ * `svix-timestamp` / `svix-signature`). Reuses
13
+ * `svix`'s `Webhook.verify` (the same machinery `plugin-resend`
14
+ * uses for inbound Resend webhooks).
15
+ * - `"stripe"` — `stripe-signature: t=<ts>,v1=<hex>[,v1=<hex>...]`. Computes
16
+ * `HMAC_SHA256(secret, `${t}.${rawBody}`)` with `node:crypto`
17
+ * (NO `stripe` SDK), constant-time compares each `v1` candidate,
18
+ * and enforces the 5-minute timestamp tolerance.
19
+ * - `"hmac-hex"` — Generic `HMAC_SHA256(secret, rawBody)` rendered as lowercase
20
+ * hex, constant-time compared against the header value (e.g.
21
+ * Segment's `x-signature`).
22
+ */
23
+ export type SignatureScheme = "svix" | "stripe" | "hmac-hex";
24
+
25
+ export interface VerifySignatureArgs {
26
+ rawBody: string;
27
+ headers: Record<string, string>;
28
+ secret: string;
29
+ }
30
+
31
+ const STRIPE_TOLERANCE_SECONDS = 5 * 60;
32
+
33
+ /**
34
+ * Lowercase every header key so callers can pass the raw (possibly Title-Case)
35
+ * header record and we still find `svix-id` / `stripe-signature` / `x-signature`.
36
+ */
37
+ function lowerHeaders(headers: Record<string, string>): Record<string, string> {
38
+ const lowered: Record<string, string> = {};
39
+ for (const [key, value] of Object.entries(headers)) {
40
+ lowered[key.toLowerCase()] = value;
41
+ }
42
+ return lowered;
43
+ }
44
+
45
+ /**
46
+ * Constant-time string comparison that never short-circuits on length. Returns
47
+ * `false` (rather than throwing) on a length mismatch so callers fail closed.
48
+ */
49
+ function safeEqual(a: string, b: string): boolean {
50
+ const bufA = Buffer.from(a, "utf8");
51
+ const bufB = Buffer.from(b, "utf8");
52
+ if (bufA.length !== bufB.length) {
53
+ return false;
54
+ }
55
+ return timingSafeEqual(bufA, bufB);
56
+ }
57
+
58
+ function verifySvix(args: VerifySignatureArgs): boolean {
59
+ const headers = lowerHeaders(args.headers);
60
+ const id = headers["svix-id"];
61
+ const timestamp = headers["svix-timestamp"];
62
+ const signature = headers["svix-signature"];
63
+
64
+ if (!id || !timestamp || !signature) {
65
+ return false;
66
+ }
67
+
68
+ try {
69
+ const wh = new Webhook(args.secret);
70
+ wh.verify(args.rawBody, {
71
+ "svix-id": id,
72
+ "svix-timestamp": timestamp,
73
+ "svix-signature": signature,
74
+ });
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function verifyStripe(args: VerifySignatureArgs): boolean {
82
+ const headers = lowerHeaders(args.headers);
83
+ const header = headers["stripe-signature"];
84
+ if (!header) {
85
+ return false;
86
+ }
87
+
88
+ // `t=1700000000,v1=<hex>,v1=<hex>` — there may be more than one v1 candidate
89
+ // during a secret rotation and forward-compat `v0`/scheme fields we ignore.
90
+ let timestamp: string | undefined;
91
+ const signatures: string[] = [];
92
+ for (const part of header.split(",")) {
93
+ const eq = part.indexOf("=");
94
+ if (eq === -1) {
95
+ continue;
96
+ }
97
+ const key = part.slice(0, eq).trim();
98
+ const value = part.slice(eq + 1).trim();
99
+ if (key === "t") {
100
+ timestamp = value;
101
+ } else if (key === "v1") {
102
+ signatures.push(value);
103
+ }
104
+ }
105
+
106
+ if (!timestamp || signatures.length === 0) {
107
+ return false;
108
+ }
109
+
110
+ const timestampSeconds = Number(timestamp);
111
+ if (!Number.isFinite(timestampSeconds)) {
112
+ return false;
113
+ }
114
+
115
+ const nowSeconds = Math.floor(Date.now() / 1000);
116
+ if (Math.abs(nowSeconds - timestampSeconds) > STRIPE_TOLERANCE_SECONDS) {
117
+ return false;
118
+ }
119
+
120
+ const expected = createHmac("sha256", args.secret)
121
+ .update(`${timestamp}.${args.rawBody}`)
122
+ .digest("hex");
123
+
124
+ return signatures.some((candidate) => safeEqual(candidate, expected));
125
+ }
126
+
127
+ function verifyHmacHex(args: VerifySignatureArgs, headerName: string): boolean {
128
+ const headers = lowerHeaders(args.headers);
129
+ const provided = headers[headerName.toLowerCase()];
130
+ if (!provided) {
131
+ return false;
132
+ }
133
+
134
+ const expected = createHmac("sha256", args.secret)
135
+ .update(args.rawBody)
136
+ .digest("hex");
137
+
138
+ return safeEqual(provided.trim(), expected);
139
+ }
140
+
141
+ /**
142
+ * Verify an inbound provider webhook signature for the given scheme.
143
+ *
144
+ * FAILS CLOSED: returns `false` (never throws) whenever a required header is
145
+ * missing or the signature does not match. The route enforces that the secret
146
+ * itself is present before calling this — an unset signature secret is a 401,
147
+ * NOT an open pass-through (deliberate divergence from the `"match"` variant,
148
+ * which stays open when unconfigured).
149
+ *
150
+ * For `"hmac-hex"` the header carrying the hex digest is passed via `headerName`
151
+ * (e.g. Segment's `x-signature`); `svix`/`stripe` read their own well-known
152
+ * headers and ignore `headerName`.
153
+ */
154
+ export function verifySignature(
155
+ scheme: SignatureScheme,
156
+ args: VerifySignatureArgs,
157
+ headerName?: string,
158
+ ): boolean {
159
+ switch (scheme) {
160
+ case "svix":
161
+ return verifySvix(args);
162
+ case "stripe":
163
+ return verifyStripe(args);
164
+ case "hmac-hex":
165
+ return verifyHmacHex(args, headerName ?? "x-signature");
166
+ default: {
167
+ // Exhaustiveness guard — an unknown scheme fails closed.
168
+ const _never: never = scheme;
169
+ return _never;
170
+ }
171
+ }
172
+ }
package/src/worker.ts CHANGED
@@ -16,6 +16,10 @@ import {
16
16
  } from "./workflows/bucket-backfill.js";
17
17
  import { bucketReconcileTask } from "./workflows/bucket-reconcile.js";
18
18
  import { checkAlertsTask } from "./workflows/check-alerts.js";
19
+ import {
20
+ deliverWebhookTask,
21
+ reapDueWebhookDeliveriesTask,
22
+ } from "./workflows/deliver-webhook.js";
19
23
  import { importContactsTask } from "./workflows/import-contacts.js";
20
24
  import {
21
25
  reapStuckCampaignsTask,
@@ -68,6 +72,8 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
68
72
  importContactsTask,
69
73
  sendCampaignTask,
70
74
  reapStuckCampaignsTask,
75
+ deliverWebhookTask,
76
+ reapDueWebhookDeliveriesTask,
71
77
  checkAlertsTask,
72
78
  bucketReconcileTask,
73
79
  bucketBackfillTask,