@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.
- package/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/env.ts +25 -0
- package/src/index.ts +34 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/lib/bucket-emit.ts +45 -0
- package/src/lib/contacts.ts +28 -6
- package/src/lib/mailer.ts +87 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +31 -0
- package/src/lib/tracked.ts +45 -3
- package/src/lib/tracking-events.ts +66 -1
- package/src/lib/webhook-signing.ts +151 -0
- package/src/routes/admin/contacts.ts +43 -3
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/contacts/index.ts +48 -5
- package/src/routes/lists/index.ts +41 -5
- package/src/routes/tracking/click.ts +59 -18
- package/src/routes/tracking/open.ts +62 -24
- 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 +399 -0
|
@@ -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,7 +52,10 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
48
52
|
null;
|
|
49
53
|
const userAgent = c.req.header("user-agent") ?? null;
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
// The `clickedAt` first-touch UPDATE is split OUT of the Promise.all so it can
|
|
56
|
+
// `.returning({ id })` — the `WHERE clickedAt IS NULL` makes a row come back
|
|
57
|
+
// ONLY on the first click, which gates the outbound `email.clicked` emit.
|
|
58
|
+
const [, , clicked] = await Promise.all([
|
|
52
59
|
db.insert(linkClicks).values({
|
|
53
60
|
trackedLinkId: link.id,
|
|
54
61
|
ipAddress: ip,
|
|
@@ -72,7 +79,8 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
72
79
|
eq(emailSends.id, link.emailSendId),
|
|
73
80
|
isNull(emailSends.clickedAt),
|
|
74
81
|
),
|
|
75
|
-
)
|
|
82
|
+
)
|
|
83
|
+
.returning({ id: emailSends.id }),
|
|
76
84
|
]);
|
|
77
85
|
|
|
78
86
|
const {
|
|
@@ -82,21 +90,54 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
82
90
|
analytics: posthog,
|
|
83
91
|
} = c.get("container");
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
// Resolve the send context ONCE (off the response path) and feed both the
|
|
94
|
+
// re-ingest (every click) and the first-touch outbound emit (first click
|
|
95
|
+
// only) — avoiding a duplicate `resolveEmailSendContext` read on the click
|
|
96
|
+
// hot path. `dedupeKey` = `email.clicked:<emailSendId>` is defence-in-depth
|
|
97
|
+
// alongside the first-touch gate (`clicked.length > 0`); first-party is the
|
|
98
|
+
// SINGLE emitter for `email.clicked` (the provider-webhook echo is suppressed).
|
|
99
|
+
const emailSendId = link.emailSendId;
|
|
100
|
+
const isFirstClick = clicked.length > 0;
|
|
101
|
+
void resolveEmailSendContext(db, emailSendId)
|
|
102
|
+
.then(async (ctx) => {
|
|
103
|
+
await pushTrackingEvent({
|
|
104
|
+
db,
|
|
105
|
+
hatchet,
|
|
106
|
+
registry,
|
|
107
|
+
logger,
|
|
108
|
+
posthog,
|
|
109
|
+
event: EMAIL_LINK_CLICKED,
|
|
110
|
+
emailSendId,
|
|
111
|
+
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
112
|
+
resolvedContext: ctx,
|
|
113
|
+
}).catch((err) => {
|
|
114
|
+
logger.warn("Failed to push click tracking event", {
|
|
115
|
+
linkId: link.id,
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (isFirstClick) {
|
|
121
|
+
await emitOutbound({
|
|
122
|
+
db,
|
|
123
|
+
hatchet,
|
|
124
|
+
logger,
|
|
125
|
+
event: "email.clicked",
|
|
126
|
+
dedupeKey: `email.clicked:${emailSendId}`,
|
|
127
|
+
payload: {
|
|
128
|
+
emailSendId,
|
|
129
|
+
resendId: ctx?.resendId ?? null,
|
|
130
|
+
templateKey: ctx?.templateKey ?? null,
|
|
131
|
+
userId: ctx?.userId ?? null,
|
|
132
|
+
to: ctx?.to ?? ctx?.userEmail ?? "",
|
|
133
|
+
at: new Date().toISOString(),
|
|
134
|
+
linkUrl: link.originalUrl,
|
|
135
|
+
linkId: link.id,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
.catch(logger.warn);
|
|
100
141
|
|
|
101
142
|
return c.redirect(link.originalUrl, 302);
|
|
102
143
|
},
|
|
@@ -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,37 +33,71 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
29
33
|
openRoute,
|
|
30
34
|
async (c) => {
|
|
31
35
|
const { id } = c.req.valid("param");
|
|
32
|
-
const {
|
|
36
|
+
const {
|
|
37
|
+
db,
|
|
38
|
+
hatchet,
|
|
39
|
+
registry,
|
|
40
|
+
logger,
|
|
41
|
+
analytics: posthog,
|
|
42
|
+
} = c.get("container");
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
// First-touch gate: the `WHERE openedAt IS NULL` makes this UPDATE return a
|
|
45
|
+
// row ONLY on the FIRST open. `.returning({ id })` lets the outbound emit fire
|
|
46
|
+
// exactly once — first-party is the SINGLE emitter for `email.opened` (the
|
|
47
|
+
// provider-webhook echo in the mailer is suppressed — risk 4).
|
|
48
|
+
const opened = await db
|
|
35
49
|
.update(emailSends)
|
|
36
50
|
.set({
|
|
37
51
|
openedAt: new Date(),
|
|
38
52
|
updatedAt: new Date(),
|
|
39
53
|
})
|
|
40
|
-
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)))
|
|
54
|
+
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)))
|
|
55
|
+
.returning({ id: emailSends.id });
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
// Resolve the send context ONCE (off the response path) and feed both the
|
|
58
|
+
// re-ingest (every open) and the first-touch outbound emit (first open
|
|
59
|
+
// only) — avoiding a duplicate `resolveEmailSendContext` read on the pixel
|
|
60
|
+
// hot path. `dedupeKey` = `email.opened:<id>` is defence-in-depth alongside
|
|
61
|
+
// the first-touch gate (`opened.length > 0`); first-party is the SINGLE
|
|
62
|
+
// emitter for `email.opened` (the provider-webhook echo is suppressed).
|
|
63
|
+
const isFirstOpen = opened.length > 0;
|
|
64
|
+
void resolveEmailSendContext(db, id)
|
|
65
|
+
.then(async (ctx) => {
|
|
66
|
+
await pushTrackingEvent({
|
|
67
|
+
db,
|
|
68
|
+
hatchet,
|
|
69
|
+
registry,
|
|
70
|
+
logger,
|
|
71
|
+
posthog,
|
|
72
|
+
event: EMAIL_OPENED,
|
|
73
|
+
emailSendId: id,
|
|
74
|
+
resolvedContext: ctx,
|
|
75
|
+
}).catch((err) => {
|
|
76
|
+
logger.warn("Failed to push open tracking event", {
|
|
77
|
+
emailSendId: id,
|
|
78
|
+
error: err instanceof Error ? err.message : String(err),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
48
81
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
if (isFirstOpen) {
|
|
83
|
+
await emitOutbound({
|
|
84
|
+
db,
|
|
85
|
+
hatchet,
|
|
86
|
+
logger,
|
|
87
|
+
event: "email.opened",
|
|
88
|
+
dedupeKey: `email.opened:${id}`,
|
|
89
|
+
payload: {
|
|
90
|
+
emailSendId: id,
|
|
91
|
+
resendId: ctx?.resendId ?? null,
|
|
92
|
+
templateKey: ctx?.templateKey ?? null,
|
|
93
|
+
userId: ctx?.userId ?? null,
|
|
94
|
+
to: ctx?.to ?? ctx?.userEmail ?? "",
|
|
95
|
+
at: new Date().toISOString(),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.catch(logger.warn);
|
|
63
101
|
|
|
64
102
|
return c.body(TRANSPARENT_GIF, 200, {
|
|
65
103
|
"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
|
+
});
|
|
@@ -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
|
+
}
|