@hogsend/engine 0.6.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.
Files changed (60) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/buckets/check-membership.ts +34 -15
  4. package/src/container.ts +33 -0
  5. package/src/env.ts +29 -0
  6. package/src/index.ts +47 -1
  7. package/src/journeys/define-journey.ts +26 -2
  8. package/src/journeys/journey-context.ts +5 -1
  9. package/src/lib/boot.ts +1 -1
  10. package/src/lib/bucket-emit.ts +47 -2
  11. package/src/lib/contacts.ts +1105 -18
  12. package/src/lib/email-service-types.ts +8 -0
  13. package/src/lib/ingestion.ts +63 -33
  14. package/src/lib/mailer.ts +88 -0
  15. package/src/lib/outbound.ts +216 -0
  16. package/src/lib/preferences.ts +137 -0
  17. package/src/lib/tracked.ts +204 -37
  18. package/src/lib/tracking-events.ts +67 -2
  19. package/src/lib/webhook-signing.ts +151 -0
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/bulk.ts +7 -3
  28. package/src/routes/admin/contacts.ts +108 -59
  29. package/src/routes/admin/events.ts +65 -0
  30. package/src/routes/admin/index.ts +2 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/admin/webhooks.ts +466 -0
  36. package/src/routes/campaigns/index.ts +252 -0
  37. package/src/routes/contacts/index.ts +231 -0
  38. package/src/routes/email/preferences.ts +27 -3
  39. package/src/routes/email/unsubscribe.ts +7 -49
  40. package/src/routes/emails/index.ts +133 -0
  41. package/src/routes/events/index.ts +119 -0
  42. package/src/routes/index.ts +52 -2
  43. package/src/routes/lists/index.ts +258 -0
  44. package/src/routes/tracking/click.ts +59 -18
  45. package/src/routes/tracking/open.ts +62 -24
  46. package/src/routes/webhooks/sources.ts +69 -10
  47. package/src/webhook-sources/define-webhook-source.ts +57 -5
  48. package/src/webhook-sources/presets/clerk.ts +185 -0
  49. package/src/webhook-sources/presets/index.ts +80 -0
  50. package/src/webhook-sources/presets/segment.ts +120 -0
  51. package/src/webhook-sources/presets/stripe.ts +147 -0
  52. package/src/webhook-sources/presets/supabase.ts +131 -0
  53. package/src/webhook-sources/verify.ts +172 -0
  54. package/src/worker.ts +12 -0
  55. package/src/workflows/bucket-backfill.ts +32 -21
  56. package/src/workflows/bucket-reconcile.ts +20 -5
  57. package/src/workflows/deliver-webhook.ts +399 -0
  58. package/src/workflows/import-contacts.ts +28 -20
  59. package/src/workflows/send-campaign.ts +589 -0
  60. package/src/routes/ingest.ts +0 -71
@@ -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 { pushTrackingEvent } from "../../lib/tracking-events.js";
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 { db } = c.get("container");
36
+ const {
37
+ db,
38
+ hatchet,
39
+ registry,
40
+ logger,
41
+ analytics: posthog,
42
+ } = c.get("container");
33
43
 
34
- await db
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
- const {
43
- hatchet,
44
- registry,
45
- logger,
46
- analytics: posthog,
47
- } = c.get("container");
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
- pushTrackingEvent({
50
- db,
51
- hatchet,
52
- registry,
53
- logger,
54
- posthog,
55
- event: EMAIL_OPENED,
56
- emailSendId: id,
57
- }).catch((err) => {
58
- logger.warn("Failed to push open tracking event", {
59
- emailSendId: id,
60
- error: err instanceof Error ? err.message : String(err),
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
- // Auth is enforced only when the source's secret is configured. An
56
- // unconfigured source is treated as open (parity with the pre-engine route).
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
- if (provided !== secret) {
66
- return c.json({ error: "Invalid webhook secret" }, 401);
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 = await c.req.json();
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, { db, logger });
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
- export interface WebhookSourceAuth {
7
- header: string;
8
- envKey: string;
9
- type: "match";
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
+ }
@@ -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
+ });