@hogsend/engine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/container.ts +80 -8
  4. package/src/destinations/define-destination.ts +104 -0
  5. package/src/destinations/presets/index.ts +94 -0
  6. package/src/destinations/presets/posthog.ts +71 -0
  7. package/src/destinations/presets/segment.ts +75 -0
  8. package/src/destinations/presets/slack.ts +66 -0
  9. package/src/destinations/presets/webhook.ts +37 -0
  10. package/src/destinations/registry-singleton.ts +78 -0
  11. package/src/env.ts +40 -0
  12. package/src/index.ts +59 -1
  13. package/src/journeys/define-journey.ts +26 -3
  14. package/src/journeys/journey-context.ts +1 -17
  15. package/src/lib/analytics-singleton.ts +7 -0
  16. package/src/lib/bucket-emit.ts +45 -0
  17. package/src/lib/contacts.ts +28 -6
  18. package/src/lib/mailer.ts +102 -0
  19. package/src/lib/outbound.ts +223 -0
  20. package/src/lib/preferences.ts +31 -0
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +45 -3
  23. package/src/lib/tracking-events.ts +77 -10
  24. package/src/lib/webhook-signing.ts +152 -0
  25. package/src/routes/admin/contacts.ts +43 -3
  26. package/src/routes/admin/index.ts +2 -0
  27. package/src/routes/admin/webhooks.ts +557 -0
  28. package/src/routes/contacts/index.ts +48 -5
  29. package/src/routes/lists/index.ts +41 -5
  30. package/src/routes/tracking/click.ts +58 -22
  31. package/src/routes/tracking/open.ts +53 -22
  32. package/src/routes/webhooks/sources.ts +69 -10
  33. package/src/webhook-sources/define-webhook-source.ts +57 -5
  34. package/src/webhook-sources/presets/clerk.ts +185 -0
  35. package/src/webhook-sources/presets/index.ts +80 -0
  36. package/src/webhook-sources/presets/segment.ts +120 -0
  37. package/src/webhook-sources/presets/stripe.ts +147 -0
  38. package/src/webhook-sources/presets/supabase.ts +131 -0
  39. package/src/webhook-sources/verify.ts +172 -0
  40. package/src/worker.ts +6 -0
  41. package/src/workflows/deliver-webhook.ts +484 -0
@@ -0,0 +1,152 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Webhook } from "svix";
3
+
4
+ /**
5
+ * Outbound webhook signing core.
6
+ *
7
+ * Hogsend emits a Svix-style HMAC-SHA256 signed event stream. The signing
8
+ * scheme is the Standard Webhooks spec (the same one `svix` implements and that
9
+ * `plugin-resend` consumes for inbound Resend webhooks):
10
+ *
11
+ * signedContent = `${id}.${timestampSeconds}.${body}`
12
+ * signature = base64( HMAC_SHA256( base64decode(secret without `whsec_`), signedContent ) )
13
+ * header value = `v1,${signature}`
14
+ *
15
+ * Pure `node:crypto` equivalent (documented for the SDK / spec consumers and
16
+ * subscriber-side verification without a `svix` dependency):
17
+ *
18
+ * import { createHmac, timingSafeEqual } from "node:crypto";
19
+ * const key = Buffer.from(secret.slice(6), "base64"); // drop the `whsec_` prefix
20
+ * const sig = createHmac("sha256", key)
21
+ * .update(`${id}.${ts}.${body}`)
22
+ * .digest("base64");
23
+ * const header = `v1,${sig}`;
24
+ * // compare each space-delimited `v1,<sig>` candidate with timingSafeEqual.
25
+ */
26
+
27
+ /**
28
+ * The 13-event catalog — the SINGLE source of truth (schema, routes, client,
29
+ * CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
30
+ * member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
31
+ */
32
+ export const WEBHOOK_EVENT_TYPES = [
33
+ "contact.created",
34
+ "contact.updated",
35
+ "contact.deleted",
36
+ "contact.unsubscribed",
37
+ "email.sent",
38
+ "email.delivered",
39
+ "email.opened",
40
+ "email.clicked",
41
+ "email.bounced",
42
+ "email.complained",
43
+ "journey.completed",
44
+ "bucket.entered",
45
+ "bucket.left",
46
+ ] as const;
47
+
48
+ export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
49
+
50
+ /**
51
+ * Generate a new `whsec_<base64(32 bytes)>` signing secret plus its display
52
+ * prefix (safe to surface on list/get responses).
53
+ *
54
+ * NOTE: the secret body is STANDARD base64, not base64url. svix (via
55
+ * standardwebhooks → @stablelib/base64) strips the `whsec_` prefix and decodes
56
+ * the remainder with a STRICT standard-base64 decoder that rejects the `-`/`_`
57
+ * characters base64url emits (~74% of base64url secrets contain one and fail
58
+ * `new Webhook(secret)`). Standard base64 round-trips cleanly through both svix
59
+ * and the `node:crypto` fallback (`Buffer.from(secret.slice(6), "base64")`).
60
+ */
61
+ export function generateWebhookSecret(): {
62
+ secret: string;
63
+ secretPrefix: string;
64
+ } {
65
+ const secret = `whsec_${randomBytes(32).toString("base64")}`;
66
+ return { secret, secretPrefix: secret.slice(0, 12) };
67
+ }
68
+
69
+ export interface SignedWebhook {
70
+ headers: {
71
+ "Webhook-Id": string;
72
+ "Webhook-Timestamp": string;
73
+ "Webhook-Signature": string;
74
+ "Content-Type": "application/json";
75
+ };
76
+ /**
77
+ * The EXACT bytes that were signed AND must be sent. Never re-stringify the
78
+ * payload between signing and sending — the signature covers these bytes.
79
+ */
80
+ body: string;
81
+ }
82
+
83
+ /**
84
+ * Sign an outbound webhook payload, producing the Standard Webhooks header set
85
+ * (`Webhook-Id` / `Webhook-Timestamp` / `Webhook-Signature`) plus the exact
86
+ * `body` bytes that were signed.
87
+ *
88
+ * `timestamp` is unix SECONDS — the caller passes `Math.floor(Date.now()/1000)`.
89
+ * `payload` is JSON-stringified when an object; a string is used verbatim.
90
+ */
91
+ export function signWebhook(opts: {
92
+ id: string;
93
+ timestamp: number;
94
+ payload: unknown;
95
+ secret: string;
96
+ }): SignedWebhook {
97
+ const body =
98
+ typeof opts.payload === "string"
99
+ ? opts.payload
100
+ : JSON.stringify(opts.payload);
101
+
102
+ const wh = new Webhook(opts.secret);
103
+ // svix's `sign` takes the timestamp as a Date and floors it to seconds
104
+ // internally; pass the canonical seconds back through a Date to keep the exact
105
+ // value the caller intended.
106
+ const signature = wh.sign(opts.id, new Date(opts.timestamp * 1000), body);
107
+
108
+ return {
109
+ headers: {
110
+ "Webhook-Id": opts.id,
111
+ "Webhook-Timestamp": String(opts.timestamp),
112
+ "Webhook-Signature": signature,
113
+ "Content-Type": "application/json",
114
+ },
115
+ body,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Consumer/test-facing verification of an inbound Hogsend webhook. Enforces the
121
+ * 5-minute timestamp tolerance and uses a constant-time signature compare (both
122
+ * inside svix). Throws on a bad signature or stale timestamp.
123
+ *
124
+ * Accepts either Title-Case (`Webhook-Id`) or lowercase (`webhook-id`) header
125
+ * keys — and the `svix-*` aliases — by normalizing the header map first.
126
+ */
127
+ export function verifyWebhookSignature(opts: {
128
+ payload: string;
129
+ headers: Record<string, string>;
130
+ secret: string;
131
+ }): unknown {
132
+ const lowered: Record<string, string> = {};
133
+ for (const [key, value] of Object.entries(opts.headers)) {
134
+ lowered[key.toLowerCase()] = value;
135
+ }
136
+
137
+ // Coalesce to "" so a genuinely-absent header reaches svix as an empty
138
+ // string — svix then throws its own clear "Missing required header" rather
139
+ // than a type error here.
140
+ const id = lowered["webhook-id"] ?? lowered["svix-id"] ?? "";
141
+ const timestamp =
142
+ lowered["webhook-timestamp"] ?? lowered["svix-timestamp"] ?? "";
143
+ const signature =
144
+ lowered["webhook-signature"] ?? lowered["svix-signature"] ?? "";
145
+
146
+ const wh = new Webhook(opts.secret);
147
+ return wh.verify(opts.payload, {
148
+ "webhook-id": id,
149
+ "webhook-timestamp": timestamp,
150
+ "webhook-signature": signature,
151
+ });
152
+ }
@@ -9,6 +9,7 @@ import {
9
9
  serializeContact as serializeContactRow,
10
10
  serializePrefs,
11
11
  } from "../../lib/contacts.js";
12
+ import { emitOutbound } from "../../lib/outbound.js";
12
13
 
13
14
  const contactSchema = z.object({
14
15
  id: z.string(),
@@ -262,13 +263,18 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
262
263
  );
263
264
  })
264
265
  .openapi(createRoute_, async (c) => {
265
- const { db } = c.get("container");
266
+ const { db, hatchet, logger } = c.get("container");
266
267
  const body = c.req.valid("json");
267
268
 
268
269
  // Delegate to the identity resolver (D1): it upserts/merges on the provided
269
270
  // identity keys (externalId and/or email), so the hand-rolled existence
270
271
  // check + raw insert + 409 are gone (§5). Read the row back to serialize.
271
- const { id } = await resolveOrCreateContact({
272
+ const {
273
+ id,
274
+ created: wasCreated,
275
+ linked,
276
+ merged,
277
+ } = await resolveOrCreateContact({
272
278
  db,
273
279
  userId: body.externalId,
274
280
  email: body.email,
@@ -280,10 +286,26 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
280
286
  throw new Error("Failed to create contact");
281
287
  }
282
288
 
289
+ // INTENT-LAYER outbound emit (decision #3): admin upsert mirrors the public
290
+ // route — `contact.created` on a real creation, `contact.updated` when an
291
+ // existing contact was linked/merged with a non-empty property delta.
292
+ const hadPropertyDelta = Boolean(
293
+ body.properties && Object.keys(body.properties).length > 0,
294
+ );
295
+ if (wasCreated || (linked || merged ? hadPropertyDelta : false)) {
296
+ void emitOutbound({
297
+ db,
298
+ hatchet,
299
+ logger,
300
+ event: wasCreated ? "contact.created" : "contact.updated",
301
+ payload: serializeContactRow(created),
302
+ }).catch(logger.warn);
303
+ }
304
+
283
305
  return c.json({ contact: serializeContact(created) }, 201);
284
306
  })
285
307
  .openapi(updateRoute, async (c) => {
286
- const { db } = c.get("container");
308
+ const { db, hatchet, logger } = c.get("container");
287
309
  const { id } = c.req.valid("param");
288
310
  const body = c.req.valid("json");
289
311
 
@@ -331,6 +353,24 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
331
353
  throw new Error("Failed to update contact");
332
354
  }
333
355
 
356
+ // INTENT-LAYER outbound emit (decision #3): the admin update is an explicit
357
+ // edit — emit `contact.updated` on a non-empty property delta or a filled
358
+ // email (a newly-attached identity). Fire-and-forget; the serialized updated
359
+ // row is the catalog payload.
360
+ const hadPropertyDelta = Boolean(
361
+ body.properties && Object.keys(body.properties).length > 0,
362
+ );
363
+ const filledEmail = Boolean(body.email && body.email !== current.email);
364
+ if (hadPropertyDelta || filledEmail) {
365
+ void emitOutbound({
366
+ db,
367
+ hatchet,
368
+ logger,
369
+ event: "contact.updated",
370
+ payload: serializeContactRow(updated),
371
+ }).catch(logger.warn);
372
+ }
373
+
334
374
  return c.json({ contact: serializeContact(updated) }, 200);
335
375
  })
336
376
  .openapi(deleteRoute, async (c) => {
@@ -20,6 +20,7 @@ import { reportingRouter } from "./reporting.js";
20
20
  import { suppressionsRouter } from "./suppressions.js";
21
21
  import { templatesRouter } from "./templates.js";
22
22
  import { timelineRouter } from "./timeline.js";
23
+ import { webhooksRouter } from "./webhooks.js";
23
24
 
24
25
  export const adminRouter = new OpenAPIHono<AppEnv>();
25
26
  adminRouter.use("*", requireAdmin);
@@ -39,6 +40,7 @@ adminRouter.route("/reporting", reportingRouter);
39
40
  adminRouter.route("/templates", templatesRouter);
40
41
  adminRouter.route("/suppressions", suppressionsRouter);
41
42
  adminRouter.route("/api-keys", apiKeysRouter);
43
+ adminRouter.route("/webhooks", webhooksRouter);
42
44
  adminRouter.route("/audit-logs", auditLogsRouter);
43
45
  adminRouter.route("/alerts", alertsRouter);
44
46
  adminRouter.route("/dlq", dlqRouter);