@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
|
@@ -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 {
|
|
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);
|