@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.
- package/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/container.ts +80 -8
- package/src/destinations/define-destination.ts +104 -0
- package/src/destinations/presets/index.ts +94 -0
- package/src/destinations/presets/posthog.ts +71 -0
- package/src/destinations/presets/segment.ts +75 -0
- package/src/destinations/presets/slack.ts +66 -0
- package/src/destinations/presets/webhook.ts +37 -0
- package/src/destinations/registry-singleton.ts +78 -0
- package/src/env.ts +40 -0
- package/src/index.ts +59 -1
- package/src/journeys/define-journey.ts +26 -3
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -0
- package/src/lib/bucket-emit.ts +45 -0
- package/src/lib/contacts.ts +28 -6
- package/src/lib/mailer.ts +102 -0
- package/src/lib/outbound.ts +223 -0
- package/src/lib/preferences.ts +31 -0
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracked.ts +45 -3
- package/src/lib/tracking-events.ts +77 -10
- package/src/lib/webhook-signing.ts +152 -0
- package/src/routes/admin/contacts.ts +43 -3
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/webhooks.ts +557 -0
- package/src/routes/contacts/index.ts +48 -5
- package/src/routes/lists/index.ts +41 -5
- package/src/routes/tracking/click.ts +58 -22
- package/src/routes/tracking/open.ts +53 -22
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +6 -0
- package/src/workflows/deliver-webhook.ts +484 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
1
2
|
import type { Database } from "@hogsend/db";
|
|
2
3
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
4
|
import type { AppEnv } from "../../app.js";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
resolveContact,
|
|
7
|
+
resolveOrCreateContact,
|
|
8
|
+
serializeContact,
|
|
9
|
+
} from "../../lib/contacts.js";
|
|
10
|
+
import type { Logger } from "../../lib/logger.js";
|
|
11
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
5
12
|
import { applyListMembership } from "../../lib/preferences.js";
|
|
6
13
|
import { errorSchema } from "../../lib/schemas.js";
|
|
7
14
|
import { getListRegistry } from "../../lists/registry-singleton.js";
|
|
@@ -122,6 +129,8 @@ const unsubscribeRoute = createRoute({
|
|
|
122
129
|
*/
|
|
123
130
|
async function applyListSubscription(opts: {
|
|
124
131
|
db: Database;
|
|
132
|
+
hatchet: HatchetClient;
|
|
133
|
+
logger: Logger;
|
|
125
134
|
id: string;
|
|
126
135
|
email?: string;
|
|
127
136
|
userId?: string;
|
|
@@ -132,7 +141,7 @@ async function applyListSubscription(opts: {
|
|
|
132
141
|
| { kind: "failed"; message: string }
|
|
133
142
|
| { kind: "ok" }
|
|
134
143
|
> {
|
|
135
|
-
const { db, id, email, userId, subscribed } = opts;
|
|
144
|
+
const { db, hatchet, logger, id, email, userId, subscribed } = opts;
|
|
136
145
|
|
|
137
146
|
if (!getListRegistry().has(id)) {
|
|
138
147
|
return { kind: "unknown_list" };
|
|
@@ -143,7 +152,30 @@ async function applyListSubscription(opts: {
|
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
try {
|
|
146
|
-
|
|
155
|
+
const { id: contactId, created } = await resolveOrCreateContact({
|
|
156
|
+
db,
|
|
157
|
+
userId,
|
|
158
|
+
email,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// INTENT-LAYER outbound emit (decision #3): the lists route emits
|
|
162
|
+
// `contact.created` ONLY on first creation (a list flip is not a contact
|
|
163
|
+
// property delta, so no `contact.updated`). Fire-and-forget after a read-back.
|
|
164
|
+
if (created) {
|
|
165
|
+
void resolveContact({ db, id: contactId })
|
|
166
|
+
.then((row) => {
|
|
167
|
+
if (!row) return;
|
|
168
|
+
return emitOutbound({
|
|
169
|
+
db,
|
|
170
|
+
hatchet,
|
|
171
|
+
logger,
|
|
172
|
+
event: "contact.created",
|
|
173
|
+
payload: serializeContact(row),
|
|
174
|
+
});
|
|
175
|
+
})
|
|
176
|
+
.catch(logger.warn);
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
await applyListMembership({
|
|
148
180
|
db,
|
|
149
181
|
userId,
|
|
@@ -175,12 +207,14 @@ export const listsRouter = new OpenAPIHono<AppEnv>()
|
|
|
175
207
|
return c.json({ lists }, 200);
|
|
176
208
|
})
|
|
177
209
|
.openapi(subscribeRoute, async (c) => {
|
|
178
|
-
const { db } = c.get("container");
|
|
210
|
+
const { db, hatchet, logger } = c.get("container");
|
|
179
211
|
const { id } = c.req.valid("param");
|
|
180
212
|
const { email, userId } = c.req.valid("json");
|
|
181
213
|
|
|
182
214
|
const result = await applyListSubscription({
|
|
183
215
|
db,
|
|
216
|
+
hatchet,
|
|
217
|
+
logger,
|
|
184
218
|
id,
|
|
185
219
|
email,
|
|
186
220
|
userId,
|
|
@@ -198,12 +232,14 @@ export const listsRouter = new OpenAPIHono<AppEnv>()
|
|
|
198
232
|
return c.json({ list: id, subscribed: true as const }, 200);
|
|
199
233
|
})
|
|
200
234
|
.openapi(unsubscribeRoute, async (c) => {
|
|
201
|
-
const { db } = c.get("container");
|
|
235
|
+
const { db, hatchet, logger } = c.get("container");
|
|
202
236
|
const { id } = c.req.valid("param");
|
|
203
237
|
const { email, userId } = c.req.valid("json");
|
|
204
238
|
|
|
205
239
|
const result = await applyListSubscription({
|
|
206
240
|
db,
|
|
241
|
+
hatchet,
|
|
242
|
+
logger,
|
|
207
243
|
id,
|
|
208
244
|
email,
|
|
209
245
|
userId,
|
|
@@ -2,8 +2,12 @@ import { emailSends, linkClicks, trackedLinks } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
5
6
|
import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
pushTrackingEvent,
|
|
9
|
+
resolveEmailSendContext,
|
|
10
|
+
} from "../../lib/tracking-events.js";
|
|
7
11
|
|
|
8
12
|
const clickRoute = createRoute({
|
|
9
13
|
method: "get",
|
|
@@ -48,6 +52,10 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
48
52
|
null;
|
|
49
53
|
const userAgent = c.req.header("user-agent") ?? null;
|
|
50
54
|
|
|
55
|
+
// First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
|
|
56
|
+
// exactly once (the first click), which is the row-level state we keep. The
|
|
57
|
+
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
58
|
+
// EVERY click (owner decision 1), so the emit below fires per-hit.
|
|
51
59
|
await Promise.all([
|
|
52
60
|
db.insert(linkClicks).values({
|
|
53
61
|
trackedLinkId: link.id,
|
|
@@ -75,28 +83,56 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
75
83
|
),
|
|
76
84
|
]);
|
|
77
85
|
|
|
78
|
-
const {
|
|
79
|
-
hatchet,
|
|
80
|
-
registry,
|
|
81
|
-
logger,
|
|
82
|
-
analytics: posthog,
|
|
83
|
-
} = c.get("container");
|
|
86
|
+
const { hatchet, registry, logger } = c.get("container");
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
// Resolve the send context ONCE (off the response path) and feed both the
|
|
89
|
+
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
90
|
+
// `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
|
|
91
|
+
// NULL dedupe key is distinct in Postgres, so every click creates a fresh
|
|
92
|
+
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
93
|
+
const emailSendId = link.emailSendId;
|
|
94
|
+
void resolveEmailSendContext(db, emailSendId)
|
|
95
|
+
.then(async (ctx) => {
|
|
96
|
+
await pushTrackingEvent({
|
|
97
|
+
db,
|
|
98
|
+
hatchet,
|
|
99
|
+
registry,
|
|
100
|
+
logger,
|
|
101
|
+
event: EMAIL_LINK_CLICKED,
|
|
102
|
+
emailSendId,
|
|
103
|
+
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
104
|
+
resolvedContext: ctx,
|
|
105
|
+
}).catch((err) => {
|
|
106
|
+
logger.warn("Failed to push click tracking event", {
|
|
107
|
+
linkId: link.id,
|
|
108
|
+
error: err instanceof Error ? err.message : String(err),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
113
|
+
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
114
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
115
|
+
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
116
|
+
if (ctx) {
|
|
117
|
+
await emitOutbound({
|
|
118
|
+
db,
|
|
119
|
+
hatchet,
|
|
120
|
+
logger,
|
|
121
|
+
event: "email.clicked",
|
|
122
|
+
payload: {
|
|
123
|
+
emailSendId,
|
|
124
|
+
resendId: ctx.resendId ?? null,
|
|
125
|
+
templateKey: ctx.templateKey ?? null,
|
|
126
|
+
userId: ctx.userId ?? null,
|
|
127
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
128
|
+
at: new Date().toISOString(),
|
|
129
|
+
linkUrl: link.originalUrl,
|
|
130
|
+
linkId: link.id,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.catch(logger.warn);
|
|
100
136
|
|
|
101
137
|
return c.redirect(link.originalUrl, 302);
|
|
102
138
|
},
|
|
@@ -2,8 +2,12 @@ import { emailSends } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, eq, isNull } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
5
6
|
import { EMAIL_OPENED } from "../../lib/tracking-event-names.js";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
pushTrackingEvent,
|
|
9
|
+
resolveEmailSendContext,
|
|
10
|
+
} from "../../lib/tracking-events.js";
|
|
7
11
|
|
|
8
12
|
const TRANSPARENT_GIF = Buffer.from(
|
|
9
13
|
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
|
@@ -29,8 +33,12 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
29
33
|
openRoute,
|
|
30
34
|
async (c) => {
|
|
31
35
|
const { id } = c.req.valid("param");
|
|
32
|
-
const { db } = c.get("container");
|
|
36
|
+
const { db, hatchet, registry, logger } = c.get("container");
|
|
33
37
|
|
|
38
|
+
// First-touch state UPDATE: the `WHERE openedAt IS NULL` sets `openedAt`
|
|
39
|
+
// exactly once (the first open), which is the row-level state we keep. The
|
|
40
|
+
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
41
|
+
// EVERY open (owner decision 1), so the emit below fires per-hit.
|
|
34
42
|
await db
|
|
35
43
|
.update(emailSends)
|
|
36
44
|
.set({
|
|
@@ -39,27 +47,50 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
39
47
|
})
|
|
40
48
|
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)));
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// Resolve the send context ONCE (off the response path) and feed both the
|
|
51
|
+
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
52
|
+
// `resolveEmailSendContext` read on the pixel hot path. NO `dedupeKey`: a
|
|
53
|
+
// NULL dedupe key is distinct in Postgres, so every open creates a fresh
|
|
54
|
+
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
55
|
+
void resolveEmailSendContext(db, id)
|
|
56
|
+
.then(async (ctx) => {
|
|
57
|
+
await pushTrackingEvent({
|
|
58
|
+
db,
|
|
59
|
+
hatchet,
|
|
60
|
+
registry,
|
|
61
|
+
logger,
|
|
62
|
+
event: EMAIL_OPENED,
|
|
63
|
+
emailSendId: id,
|
|
64
|
+
resolvedContext: ctx,
|
|
65
|
+
}).catch((err) => {
|
|
66
|
+
logger.warn("Failed to push open tracking event", {
|
|
67
|
+
emailSendId: id,
|
|
68
|
+
error: err instanceof Error ? err.message : String(err),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
73
|
+
// (orphaned tracked pixel / deleted send) has no userId or recipient to
|
|
74
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
75
|
+
// an empty distinct_id. A normal open always resolves a non-null userId.
|
|
76
|
+
if (ctx) {
|
|
77
|
+
await emitOutbound({
|
|
78
|
+
db,
|
|
79
|
+
hatchet,
|
|
80
|
+
logger,
|
|
81
|
+
event: "email.opened",
|
|
82
|
+
payload: {
|
|
83
|
+
emailSendId: id,
|
|
84
|
+
resendId: ctx.resendId ?? null,
|
|
85
|
+
templateKey: ctx.templateKey ?? null,
|
|
86
|
+
userId: ctx.userId ?? null,
|
|
87
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
88
|
+
at: new Date().toISOString(),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.catch(logger.warn);
|
|
63
94
|
|
|
64
95
|
return c.body(TRANSPARENT_GIF, 200, {
|
|
65
96
|
"Content-Type": "image/gif",
|
|
@@ -2,6 +2,7 @@ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
3
|
import { ingestEvent } from "../../lib/ingestion.js";
|
|
4
4
|
import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
|
|
5
|
+
import { verifySignature } from "../../webhook-sources/verify.js";
|
|
5
6
|
|
|
6
7
|
export function registerWebhookSourceRoutes(
|
|
7
8
|
app: OpenAPIHono<AppEnv>,
|
|
@@ -52,22 +53,75 @@ export function registerWebhookSourceRoutes(
|
|
|
52
53
|
|
|
53
54
|
const { db, logger, env, registry, hatchet } = c.get("container");
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
56
|
+
// Read the body ONCE as the EXACT received bytes — signature schemes verify
|
|
57
|
+
// over these bytes, so we must not re-stringify. JSON.parse only AFTER auth.
|
|
58
|
+
const rawBody = await c.req.text();
|
|
59
|
+
const headers: Record<string, string> = {};
|
|
60
|
+
for (const [key, value] of c.req.raw.headers.entries()) {
|
|
61
|
+
headers[key.toLowerCase()] = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
const secret = env[source.auth.envKey as keyof typeof env] as
|
|
58
65
|
| string
|
|
59
66
|
| undefined;
|
|
60
|
-
if (secret) {
|
|
61
|
-
const provided =
|
|
62
|
-
c.req.header(source.auth.header) ??
|
|
63
|
-
c.req.header("authorization")?.replace("Bearer ", "");
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
if (source.auth.type === "signature") {
|
|
69
|
+
// Signature sources FAIL CLOSED: an unset secret is a 401, never an open
|
|
70
|
+
// pass-through (deliberate divergence from the "match" variant).
|
|
71
|
+
if (!secret) {
|
|
72
|
+
logger.warn("Webhook signature secret not configured", {
|
|
73
|
+
source: sourceId,
|
|
74
|
+
});
|
|
75
|
+
return c.json({ error: "Webhook signature not configured" }, 401);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const auth = source.auth;
|
|
79
|
+
let verified = false;
|
|
80
|
+
|
|
81
|
+
if (auth.verify) {
|
|
82
|
+
verified = await auth.verify({ rawBody, headers, secret });
|
|
83
|
+
} else {
|
|
84
|
+
verified = verifySignature(
|
|
85
|
+
auth.scheme,
|
|
86
|
+
{ rawBody, headers, secret },
|
|
87
|
+
auth.header,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Optional plain shared-secret fallback (e.g. Supabase's
|
|
92
|
+
// `x-supabase-webhook-secret`) when the signature headers are absent.
|
|
93
|
+
if (!verified && auth.fallbackMatchHeader) {
|
|
94
|
+
const provided = headers[auth.fallbackMatchHeader.toLowerCase()];
|
|
95
|
+
verified = provided === secret;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!verified) {
|
|
99
|
+
return c.json({ error: "Invalid webhook signature" }, 401);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// "match": shared-secret equality. An unconfigured source stays OPEN
|
|
103
|
+
// (parity with the pre-engine route).
|
|
104
|
+
if (secret) {
|
|
105
|
+
const provided =
|
|
106
|
+
headers[source.auth.header.toLowerCase()] ??
|
|
107
|
+
headers.authorization?.replace("Bearer ", "");
|
|
108
|
+
|
|
109
|
+
if (provided !== secret) {
|
|
110
|
+
return c.json({ error: "Invalid webhook secret" }, 401);
|
|
111
|
+
}
|
|
67
112
|
}
|
|
68
113
|
}
|
|
69
114
|
|
|
70
|
-
let payload: unknown
|
|
115
|
+
let payload: unknown;
|
|
116
|
+
try {
|
|
117
|
+
payload = JSON.parse(rawBody);
|
|
118
|
+
} catch {
|
|
119
|
+
return c.json(
|
|
120
|
+
{ error: "Invalid payload", details: "Malformed JSON" },
|
|
121
|
+
400,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
71
125
|
if (source.schema) {
|
|
72
126
|
const parsed = source.schema.safeParse(payload);
|
|
73
127
|
if (!parsed.success) {
|
|
@@ -79,7 +133,12 @@ export function registerWebhookSourceRoutes(
|
|
|
79
133
|
payload = parsed.data;
|
|
80
134
|
}
|
|
81
135
|
|
|
82
|
-
const event = await source.transform(payload, {
|
|
136
|
+
const event = await source.transform(payload, {
|
|
137
|
+
db,
|
|
138
|
+
logger,
|
|
139
|
+
rawBody,
|
|
140
|
+
headers,
|
|
141
|
+
});
|
|
83
142
|
if (!event) {
|
|
84
143
|
logger.info("Webhook event skipped", { source: sourceId });
|
|
85
144
|
return c.json({ ok: true, skipped: true });
|
|
@@ -2,16 +2,62 @@ import type { Database } from "@hogsend/db";
|
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
import type { IngestEvent } from "../lib/ingestion.js";
|
|
4
4
|
import type { Logger } from "../lib/logger.js";
|
|
5
|
+
import type { SignatureScheme, VerifySignatureArgs } from "./verify.js";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
/**
|
|
8
|
+
* How a webhook source authenticates inbound requests.
|
|
9
|
+
*
|
|
10
|
+
* A discriminated union on `type`:
|
|
11
|
+
*
|
|
12
|
+
* - `"match"` — plain shared-secret equality. The route compares a configured
|
|
13
|
+
* secret against the request header (or `Authorization: Bearer`). When the
|
|
14
|
+
* secret is UNSET the source stays OPEN (parity with the pre-engine route);
|
|
15
|
+
* this variant is unchanged so PostHog + all consumer sources keep compiling.
|
|
16
|
+
*
|
|
17
|
+
* - `"signature"` — provider HMAC signature verification (Svix / Stripe /
|
|
18
|
+
* generic hex HMAC). The route resolves the secret from `env[envKey]`, reads
|
|
19
|
+
* the EXACT raw request body, and calls `verifySignature` (or the optional
|
|
20
|
+
* per-source `verify` override) over those bytes. Signature sources FAIL
|
|
21
|
+
* CLOSED (401) when their secret is unset — they are security-sensitive.
|
|
22
|
+
*/
|
|
23
|
+
export type WebhookSourceAuth =
|
|
24
|
+
| {
|
|
25
|
+
type: "match";
|
|
26
|
+
header: string;
|
|
27
|
+
envKey: string;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: "signature";
|
|
31
|
+
scheme: SignatureScheme;
|
|
32
|
+
envKey: string;
|
|
33
|
+
header: string;
|
|
34
|
+
/**
|
|
35
|
+
* For schemes (notably `"svix"`) whose providers may also send a plain
|
|
36
|
+
* shared-secret header: when the scheme's signature headers are absent but
|
|
37
|
+
* this header matches the secret verbatim, accept the request. Lets
|
|
38
|
+
* Supabase's `x-supabase-webhook-secret` plain-secret mode coexist with
|
|
39
|
+
* its Svix mode.
|
|
40
|
+
*/
|
|
41
|
+
fallbackMatchHeader?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional per-source override of the built-in scheme verification. When
|
|
44
|
+
* provided, the route calls this INSTEAD of `verifySignature(scheme, …)`.
|
|
45
|
+
* Receives the EXACT received bytes; must return (or resolve to) a boolean.
|
|
46
|
+
*/
|
|
47
|
+
verify?(args: VerifySignatureArgs): boolean | Promise<boolean>;
|
|
48
|
+
};
|
|
11
49
|
|
|
12
50
|
export interface WebhookSourceCtx {
|
|
13
51
|
db: Database;
|
|
14
52
|
logger: Logger;
|
|
53
|
+
/**
|
|
54
|
+
* The EXACT raw request body bytes (text), populated by the route. Required by
|
|
55
|
+
* signature schemes (the signature covers these bytes) and available to any
|
|
56
|
+
* `transform()` that needs provider-specific raw access.
|
|
57
|
+
*/
|
|
58
|
+
rawBody?: string;
|
|
59
|
+
/** The inbound request headers (lowercased keys), populated by the route. */
|
|
60
|
+
headers?: Record<string, string>;
|
|
15
61
|
}
|
|
16
62
|
|
|
17
63
|
export interface WebhookSourceMeta {
|
|
@@ -32,3 +78,9 @@ export function defineWebhookSource<T>(
|
|
|
32
78
|
): DefinedWebhookSource<T> {
|
|
33
79
|
return def;
|
|
34
80
|
}
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
type SignatureScheme,
|
|
84
|
+
type VerifySignatureArgs,
|
|
85
|
+
verifySignature,
|
|
86
|
+
} from "./verify.js";
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { IngestEvent } from "../../lib/ingestion.js";
|
|
3
|
+
import { defineWebhookSource } from "../define-webhook-source.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clerk webhook preset.
|
|
7
|
+
*
|
|
8
|
+
* Auth: Svix-signed (`svix-id`/`svix-timestamp`/`svix-signature`). Clerk's
|
|
9
|
+
* webhook signing secret is a `whsec_…` value — set `CLERK_WEBHOOK_SECRET` to
|
|
10
|
+
* auto-enable this source at `POST /v1/webhooks/clerk`. Signature sources FAIL
|
|
11
|
+
* CLOSED when the secret is unset.
|
|
12
|
+
*
|
|
13
|
+
* Event mapping (decision #16, normalized to the outbound vocabulary):
|
|
14
|
+
* - `user.created` → `contact.created`
|
|
15
|
+
* - `user.updated` → `contact.updated`
|
|
16
|
+
* - `user.deleted` → `contact.deleted` (EVENT only — decision #15)
|
|
17
|
+
* - `waitlistEntry.created` → `waitlist.joined`
|
|
18
|
+
*
|
|
19
|
+
* D2 split (decision, mirrors `webhook-sources/posthog.ts`): identity/profile
|
|
20
|
+
* fields → `contactProperties` ONLY; behavioral/source fields → `eventProperties`
|
|
21
|
+
* ONLY. The two bags are NEVER merged.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const clerkEmailAddressSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z.string().optional(),
|
|
27
|
+
email_address: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.catchall(z.unknown());
|
|
30
|
+
|
|
31
|
+
const clerkUserDataSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
id: z.string().optional(),
|
|
34
|
+
primary_email_address_id: z.string().nullish(),
|
|
35
|
+
email_addresses: z.array(clerkEmailAddressSchema).nullish(),
|
|
36
|
+
first_name: z.string().nullish(),
|
|
37
|
+
last_name: z.string().nullish(),
|
|
38
|
+
image_url: z.string().nullish(),
|
|
39
|
+
profile_image_url: z.string().nullish(),
|
|
40
|
+
public_metadata: z.record(z.string(), z.unknown()).nullish(),
|
|
41
|
+
})
|
|
42
|
+
.catchall(z.unknown());
|
|
43
|
+
|
|
44
|
+
const clerkWaitlistDataSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
id: z.string().optional(),
|
|
47
|
+
email_address: z.string().nullish(),
|
|
48
|
+
})
|
|
49
|
+
.catchall(z.unknown());
|
|
50
|
+
|
|
51
|
+
const clerkWebhookSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
type: z.string(),
|
|
54
|
+
object: z.string().optional(),
|
|
55
|
+
data: z.object({}).catchall(z.unknown()).nullish(),
|
|
56
|
+
})
|
|
57
|
+
.catchall(z.unknown());
|
|
58
|
+
|
|
59
|
+
type ClerkPayload = z.infer<typeof clerkWebhookSchema>;
|
|
60
|
+
|
|
61
|
+
/** Resolve the primary email address from Clerk's `email_addresses` array. */
|
|
62
|
+
function resolveClerkEmail(
|
|
63
|
+
data: z.infer<typeof clerkUserDataSchema>,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const addresses = data.email_addresses ?? [];
|
|
66
|
+
if (addresses.length === 0) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const primaryId = data.primary_email_address_id;
|
|
70
|
+
if (primaryId) {
|
|
71
|
+
const primary = addresses.find((a) => a.id === primaryId);
|
|
72
|
+
if (primary?.email_address) {
|
|
73
|
+
return primary.email_address;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return addresses[0]?.email_address ?? undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const clerkSource = defineWebhookSource({
|
|
80
|
+
meta: {
|
|
81
|
+
id: "clerk",
|
|
82
|
+
name: "Clerk",
|
|
83
|
+
description:
|
|
84
|
+
"Receives Clerk user lifecycle + waitlist webhooks (Svix-signed).",
|
|
85
|
+
},
|
|
86
|
+
auth: {
|
|
87
|
+
type: "signature",
|
|
88
|
+
scheme: "svix",
|
|
89
|
+
envKey: "CLERK_WEBHOOK_SECRET",
|
|
90
|
+
header: "svix-signature",
|
|
91
|
+
},
|
|
92
|
+
schema: clerkWebhookSchema,
|
|
93
|
+
async transform(payload: ClerkPayload): Promise<IngestEvent | null> {
|
|
94
|
+
const type = payload.type;
|
|
95
|
+
|
|
96
|
+
if (type === "waitlistEntry.created") {
|
|
97
|
+
const data = clerkWaitlistDataSchema.parse(payload.data ?? {});
|
|
98
|
+
const userEmail =
|
|
99
|
+
typeof data.email_address === "string" ? data.email_address : "";
|
|
100
|
+
const userId = data.id ?? userEmail;
|
|
101
|
+
if (!userId) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
event: "waitlist.joined",
|
|
106
|
+
userId,
|
|
107
|
+
userEmail,
|
|
108
|
+
eventProperties: {
|
|
109
|
+
source: "clerk",
|
|
110
|
+
_clerkEvent: type,
|
|
111
|
+
},
|
|
112
|
+
contactProperties: {},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let event: string;
|
|
117
|
+
switch (type) {
|
|
118
|
+
case "user.created":
|
|
119
|
+
event = "contact.created";
|
|
120
|
+
break;
|
|
121
|
+
case "user.updated":
|
|
122
|
+
event = "contact.updated";
|
|
123
|
+
break;
|
|
124
|
+
case "user.deleted":
|
|
125
|
+
event = "contact.deleted";
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const data = clerkUserDataSchema.parse(payload.data ?? {});
|
|
132
|
+
const userId = data.id;
|
|
133
|
+
if (!userId) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const userEmail = resolveClerkEmail(data) ?? "";
|
|
137
|
+
|
|
138
|
+
// Deletes carry no profile to merge — emit the event only (decision #15).
|
|
139
|
+
if (event === "contact.deleted") {
|
|
140
|
+
return {
|
|
141
|
+
event,
|
|
142
|
+
userId,
|
|
143
|
+
userEmail,
|
|
144
|
+
eventProperties: {
|
|
145
|
+
source: "clerk",
|
|
146
|
+
clerkUserId: userId,
|
|
147
|
+
_clerkEvent: type,
|
|
148
|
+
},
|
|
149
|
+
contactProperties: {},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const avatarUrl =
|
|
154
|
+
(typeof data.image_url === "string" ? data.image_url : undefined) ??
|
|
155
|
+
(typeof data.profile_image_url === "string"
|
|
156
|
+
? data.profile_image_url
|
|
157
|
+
: undefined);
|
|
158
|
+
|
|
159
|
+
const contactProperties: Record<string, unknown> = {
|
|
160
|
+
...(data.public_metadata ?? {}),
|
|
161
|
+
};
|
|
162
|
+
if (typeof data.first_name === "string") {
|
|
163
|
+
contactProperties.firstName = data.first_name;
|
|
164
|
+
}
|
|
165
|
+
if (typeof data.last_name === "string") {
|
|
166
|
+
contactProperties.lastName = data.last_name;
|
|
167
|
+
}
|
|
168
|
+
if (avatarUrl) {
|
|
169
|
+
contactProperties.avatarUrl = avatarUrl;
|
|
170
|
+
}
|
|
171
|
+
contactProperties.clerkUserId = userId;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
event,
|
|
175
|
+
userId,
|
|
176
|
+
userEmail,
|
|
177
|
+
eventProperties: {
|
|
178
|
+
source: "clerk",
|
|
179
|
+
clerkUserId: userId,
|
|
180
|
+
_clerkEvent: type,
|
|
181
|
+
},
|
|
182
|
+
contactProperties,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|