@hogsend/engine 0.7.0 → 0.8.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,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,