@hogsend/engine 0.21.1 → 0.23.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 -7
- package/src/app.ts +37 -30
- package/src/connectors/define-connector.ts +205 -0
- package/src/connectors/presets/index.ts +31 -0
- package/src/connectors/registry-singleton.ts +79 -0
- package/src/container.ts +94 -0
- package/src/env.ts +5 -0
- package/src/index.ts +69 -0
- package/src/lib/analytics-identity.ts +112 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +233 -26
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/identity-service.ts +107 -0
- package/src/lib/identity-token.ts +65 -5
- package/src/lib/ingestion.ts +58 -2
- package/src/lib/outbound.ts +17 -0
- package/src/lib/provider-credentials.ts +11 -0
- package/src/lib/semantic-click.ts +15 -6
- package/src/lib/tracking-events.ts +5 -1
- package/src/lib/tracking.ts +37 -0
- package/src/lib/webhook-signing.ts +7 -1
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/connectors/index.ts +279 -0
- package/src/routes/contacts/index.ts +7 -0
- package/src/routes/events/index.ts +16 -1
- package/src/routes/index.ts +17 -4
- package/src/routes/tracking/answer.ts +11 -4
- package/src/routes/tracking/click.ts +130 -71
- package/src/routes/tracking/identify.ts +62 -15
- package/src/routes/webhooks/index.ts +3 -3
- package/src/routes/webhooks/sources.ts +20 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
|
@@ -38,6 +38,8 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
38
38
|
id: trackedLinks.id,
|
|
39
39
|
originalUrl: trackedLinks.originalUrl,
|
|
40
40
|
emailSendId: trackedLinks.emailSendId,
|
|
41
|
+
distinctId: trackedLinks.distinctId,
|
|
42
|
+
source: trackedLinks.source,
|
|
41
43
|
event: trackedLinks.event,
|
|
42
44
|
eventProperties: trackedLinks.eventProperties,
|
|
43
45
|
})
|
|
@@ -56,10 +58,17 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
56
58
|
null;
|
|
57
59
|
const userAgent = c.req.header("user-agent") ?? null;
|
|
58
60
|
|
|
61
|
+
// The linkClicks insert + clickCount increment stay UNCONDITIONAL — every
|
|
62
|
+
// tracked link counts clicks, email or not.
|
|
63
|
+
//
|
|
59
64
|
// First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
|
|
60
65
|
// exactly once (the first click), which is the row-level state we keep. The
|
|
61
66
|
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
62
|
-
// EVERY click (owner decision 1), so the emit below fires per-hit.
|
|
67
|
+
// EVERY click (owner decision 1), so the emit below fires per-hit. The
|
|
68
|
+
// emailSends update is GATED on `emailSendId != null` (MF-6): a non-email
|
|
69
|
+
// link has no send row to mark. This was previously safe-by-accident
|
|
70
|
+
// (`WHERE id = NULL` matches nothing) — the gate makes it explicit.
|
|
71
|
+
const emailSendId = link.emailSendId;
|
|
63
72
|
await Promise.all([
|
|
64
73
|
db.insert(linkClicks).values({
|
|
65
74
|
trackedLinkId: link.id,
|
|
@@ -73,18 +82,22 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
73
82
|
updatedAt: new Date(),
|
|
74
83
|
})
|
|
75
84
|
.where(eq(trackedLinks.id, link.id)),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
...(emailSendId
|
|
86
|
+
? [
|
|
87
|
+
db
|
|
88
|
+
.update(emailSends)
|
|
89
|
+
.set({
|
|
90
|
+
clickedAt: new Date(),
|
|
91
|
+
updatedAt: new Date(),
|
|
92
|
+
})
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(emailSends.id, emailSendId),
|
|
96
|
+
isNull(emailSends.clickedAt),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
]
|
|
100
|
+
: []),
|
|
88
101
|
]);
|
|
89
102
|
|
|
90
103
|
const { hatchet, registry, logger } = c.get("container");
|
|
@@ -93,8 +106,11 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
93
106
|
// deferred past the scanner-burst window (a Hatchet task) so the gate can
|
|
94
107
|
// see the WHOLE burst — an inline check could never suppress a scanner's
|
|
95
108
|
// first click. The task claims the send's answer slot (first answer wins)
|
|
96
|
-
// and emits the consumer event + email.action outbound.
|
|
97
|
-
|
|
109
|
+
// and emits the consumer event + email.action outbound. GATED on
|
|
110
|
+
// `emailSendId != null` (MF-6): the confirm task is email-semantic (it
|
|
111
|
+
// claims a send's answer slot + emits `email.action`), so a non-email
|
|
112
|
+
// semantic link would have no send to confirm against.
|
|
113
|
+
if (link.event && emailSendId) {
|
|
98
114
|
void confirmSemanticClickTask
|
|
99
115
|
.runNoWait({
|
|
100
116
|
trackedLinkId: link.id,
|
|
@@ -110,27 +126,48 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
// Cross-device identity stitch (opt-in): append a short-lived signed
|
|
113
|
-
// `hs_t` token to the destination so the landing site can
|
|
114
|
-
// session
|
|
115
|
-
//
|
|
116
|
-
//
|
|
129
|
+
// `hs_t` token to the destination so the landing site can fold its own anon
|
|
130
|
+
// session INTO the subject at `/v1/t/identify`. Two mint sources by link
|
|
131
|
+
// type (§6.5): a stitch-bearing NON-email link mints from its own
|
|
132
|
+
// `distinct_id` (`src: "<source>:<id>"`); an EMAIL link mints from the
|
|
133
|
+
// resolved send context; a BROADCAST link (no `distinct_id`, no send) mints
|
|
134
|
+
// nothing. The token is minted at CLICK time only — never stored on the
|
|
135
|
+
// shareable `/v1/t/c/:id` artifact (§6.3). The awaited send resolve is
|
|
136
|
+
// shared with the async chain below so the read still happens once.
|
|
117
137
|
let redirectUrl = link.originalUrl;
|
|
118
138
|
let preResolved: Awaited<
|
|
119
139
|
ReturnType<typeof resolveEmailSendContext>
|
|
120
140
|
> | null = null;
|
|
121
141
|
let preResolvedSet = false;
|
|
122
142
|
if (env.TRACKING_IDENTITY_TOKEN) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
143
|
+
let tokenDistinctId: string | null = null;
|
|
144
|
+
let tokenSrc: string | null = null;
|
|
145
|
+
if (link.distinctId) {
|
|
146
|
+
// Stitch-bearing non-email link: the subject is the link's own
|
|
147
|
+
// `distinct_id` (canonical key). No send resolve needed.
|
|
148
|
+
tokenDistinctId = link.distinctId;
|
|
149
|
+
tokenSrc = `${link.source ?? "link"}:${link.id}`;
|
|
150
|
+
} else if (emailSendId) {
|
|
151
|
+
// Email link: resolve the recipient's canonical key from the send row.
|
|
152
|
+
preResolved = await resolveEmailSendContext(db, emailSendId);
|
|
153
|
+
preResolvedSet = true;
|
|
154
|
+
if (preResolved?.userId) {
|
|
155
|
+
tokenDistinctId = preResolved.userId;
|
|
156
|
+
tokenSrc = `email:${emailSendId}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// else: broadcast link (no distinctId, no send) — mint nothing.
|
|
160
|
+
|
|
161
|
+
if (tokenDistinctId && tokenSrc) {
|
|
126
162
|
try {
|
|
127
163
|
const url = new URL(link.originalUrl);
|
|
128
164
|
url.searchParams.set(
|
|
129
165
|
"hs_t",
|
|
130
166
|
generateIdentityToken({
|
|
131
167
|
secret: env.BETTER_AUTH_SECRET,
|
|
132
|
-
distinctId:
|
|
133
|
-
|
|
168
|
+
distinctId: tokenDistinctId,
|
|
169
|
+
src: tokenSrc,
|
|
170
|
+
emailSendId: emailSendId ?? undefined,
|
|
134
171
|
}),
|
|
135
172
|
);
|
|
136
173
|
redirectUrl = url.toString();
|
|
@@ -141,58 +178,80 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
141
178
|
}
|
|
142
179
|
}
|
|
143
180
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
// `
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
hatchet,
|
|
159
|
-
registry,
|
|
160
|
-
logger,
|
|
161
|
-
event: EMAIL_LINK_CLICKED,
|
|
162
|
-
emailSendId,
|
|
163
|
-
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
164
|
-
resolvedContext: ctx,
|
|
165
|
-
}).catch((err) => {
|
|
166
|
-
logger.warn("Failed to push click tracking event", {
|
|
167
|
-
linkId: link.id,
|
|
168
|
-
error: err instanceof Error ? err.message : String(err),
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Only emit when the send-context resolved. A missing emailSends row
|
|
173
|
-
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
174
|
-
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
175
|
-
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
176
|
-
if (ctx) {
|
|
177
|
-
await emitOutbound({
|
|
181
|
+
// PER-HIT outbound emit, off the response path. EMAIL links re-ingest the
|
|
182
|
+
// first-party `email.link_clicked` event (journey routing + userEvents) and
|
|
183
|
+
// emit `email.clicked`; NON-email links emit `link.clicked` instead — never
|
|
184
|
+
// a malformed `email.clicked` (MF-missing #3). NO `dedupeKey`: a NULL dedupe
|
|
185
|
+
// key is distinct in Postgres, so every click creates a fresh delivery to
|
|
186
|
+
// every subscribed destination (per-hit, not first-touch).
|
|
187
|
+
if (emailSendId) {
|
|
188
|
+
void (
|
|
189
|
+
preResolvedSet
|
|
190
|
+
? Promise.resolve(preResolved)
|
|
191
|
+
: resolveEmailSendContext(db, emailSendId)
|
|
192
|
+
)
|
|
193
|
+
.then(async (ctx) => {
|
|
194
|
+
await pushTrackingEvent({
|
|
178
195
|
db,
|
|
179
196
|
hatchet,
|
|
197
|
+
registry,
|
|
180
198
|
logger,
|
|
181
|
-
event:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
to: ctx.to ?? ctx.userEmail ?? "",
|
|
188
|
-
at: new Date().toISOString(),
|
|
189
|
-
linkUrl: link.originalUrl,
|
|
199
|
+
event: EMAIL_LINK_CLICKED,
|
|
200
|
+
emailSendId,
|
|
201
|
+
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
202
|
+
resolvedContext: ctx,
|
|
203
|
+
}).catch((err) => {
|
|
204
|
+
logger.warn("Failed to push click tracking event", {
|
|
190
205
|
linkId: link.id,
|
|
191
|
-
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
});
|
|
192
208
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
209
|
+
|
|
210
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
211
|
+
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
212
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
213
|
+
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
214
|
+
if (ctx) {
|
|
215
|
+
await emitOutbound({
|
|
216
|
+
db,
|
|
217
|
+
hatchet,
|
|
218
|
+
logger,
|
|
219
|
+
event: "email.clicked",
|
|
220
|
+
payload: {
|
|
221
|
+
emailSendId,
|
|
222
|
+
messageId: ctx.messageId ?? null,
|
|
223
|
+
templateKey: ctx.templateKey ?? null,
|
|
224
|
+
userId: ctx.userId ?? null,
|
|
225
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
226
|
+
at: new Date().toISOString(),
|
|
227
|
+
linkUrl: link.originalUrl,
|
|
228
|
+
linkId: link.id,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
.catch(logger.warn);
|
|
234
|
+
} else {
|
|
235
|
+
// Non-email tracked link: emit the catalogued `link.clicked` (NOT
|
|
236
|
+
// `email.clicked`). `userId` is the link's stitch subject when
|
|
237
|
+
// identity-bearing, else null for a broadcast link. No re-ingest — the
|
|
238
|
+
// first-party `email.link_clicked` bus event is email-semantic.
|
|
239
|
+
void emitOutbound({
|
|
240
|
+
db,
|
|
241
|
+
hatchet,
|
|
242
|
+
logger,
|
|
243
|
+
event: "link.clicked",
|
|
244
|
+
payload: {
|
|
245
|
+
linkId: link.id,
|
|
246
|
+
source: link.source ?? null,
|
|
247
|
+
userId: link.distinctId ?? null,
|
|
248
|
+
emailSendId: null,
|
|
249
|
+
messageId: null,
|
|
250
|
+
linkUrl: link.originalUrl,
|
|
251
|
+
at: new Date().toISOString(),
|
|
252
|
+
},
|
|
253
|
+
}).catch(logger.warn);
|
|
254
|
+
}
|
|
196
255
|
|
|
197
256
|
return c.redirect(redirectUrl, 302);
|
|
198
257
|
},
|
|
@@ -6,26 +6,40 @@ import {
|
|
|
6
6
|
} from "../../lib/identity-token.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Exchange a redirect identity token (`hs_t`) for the distinct id
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Exchange a redirect identity token (`hs_t`) for the distinct id, AND — when
|
|
10
|
+
* the caller supplies its own browser anon id (`currentDistinctId`) and the
|
|
11
|
+
* active analytics provider can merge — fire a SERVER-SIDE `alias` folding the
|
|
12
|
+
* caller's own anon session INTO the token's canonical subject. Called by the
|
|
13
|
+
* LANDING SITE's frontend (CORS is open app-wide) after the user arrives from a
|
|
14
|
+
* tracked link.
|
|
14
15
|
*
|
|
15
16
|
* Possession of a fresh signed token IS the authorization (the same trust
|
|
16
17
|
* model as unsubscribe links): tokens are signed with BETTER_AUTH_SECRET,
|
|
17
|
-
* expire after an hour, and resolve to nothing but the
|
|
18
|
+
* expire after an hour, and resolve to nothing but the canonical key + src.
|
|
19
|
+
*
|
|
20
|
+
* ANTI-HIJACK (MF-4): the route NEVER passes `currentDistinctId` as the
|
|
21
|
+
* survivor and NEVER server-identifies it. A forwarded-token holder can, at
|
|
22
|
+
* worst, fold THEIR OWN anon session into the subject — never overwrite the
|
|
23
|
+
* subject, never become the subject, never name a victim's anon id (they don't
|
|
24
|
+
* know it). A scanner following the redirect runs no posthog-js, so it supplies
|
|
25
|
+
* no `currentDistinctId` and the merge no-ops — the exchange is inert for
|
|
26
|
+
* headless prefetch.
|
|
18
27
|
*/
|
|
19
28
|
const identifyRoute = createRoute({
|
|
20
29
|
method: "post",
|
|
21
30
|
path: "/identify",
|
|
22
31
|
tags: ["Tracking"],
|
|
23
|
-
summary: "Exchange a redirect identity token
|
|
32
|
+
summary: "Exchange a redirect identity token + optionally alias the caller",
|
|
24
33
|
request: {
|
|
25
34
|
body: {
|
|
26
35
|
content: {
|
|
27
36
|
"application/json": {
|
|
28
|
-
schema: z.object({
|
|
37
|
+
schema: z.object({
|
|
38
|
+
token: z.string().min(1).max(2048),
|
|
39
|
+
// The caller's OWN browser anon distinct id, to be folded INTO the
|
|
40
|
+
// token subject. Optional — absent = legacy resolve-only behaviour.
|
|
41
|
+
currentDistinctId: z.string().min(1).max(200).optional(),
|
|
42
|
+
}),
|
|
29
43
|
},
|
|
30
44
|
},
|
|
31
45
|
},
|
|
@@ -35,8 +49,11 @@ const identifyRoute = createRoute({
|
|
|
35
49
|
description: "Resolved identity",
|
|
36
50
|
content: {
|
|
37
51
|
"application/json": {
|
|
52
|
+
// ONE response schema across §6 (MF-5): `src` is the new field,
|
|
53
|
+
// `emailSendId` retained for the one-minor deprecation window.
|
|
38
54
|
schema: z.object({
|
|
39
55
|
distinctId: z.string(),
|
|
56
|
+
src: z.string(),
|
|
40
57
|
emailSendId: z.string().optional(),
|
|
41
58
|
}),
|
|
42
59
|
},
|
|
@@ -49,23 +66,53 @@ const identifyRoute = createRoute({
|
|
|
49
66
|
export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
50
67
|
identifyRoute,
|
|
51
68
|
async (c) => {
|
|
52
|
-
const { token } = c.req.valid("json");
|
|
53
|
-
const { env } = c.get("container");
|
|
69
|
+
const { token, currentDistinctId } = c.req.valid("json");
|
|
70
|
+
const { env, analytics, logger } = c.get("container");
|
|
54
71
|
|
|
72
|
+
let payload: ReturnType<typeof validateIdentityToken>;
|
|
55
73
|
try {
|
|
56
|
-
|
|
74
|
+
payload = validateIdentityToken({
|
|
57
75
|
token,
|
|
58
76
|
secret: env.BETTER_AUTH_SECRET,
|
|
59
77
|
});
|
|
60
|
-
return c.json(
|
|
61
|
-
{ distinctId: payload.distinctId, emailSendId: payload.emailSendId },
|
|
62
|
-
200,
|
|
63
|
-
);
|
|
64
78
|
} catch (err) {
|
|
65
79
|
if (err instanceof InvalidIdentityTokenError) {
|
|
66
80
|
return c.body(null, 400);
|
|
67
81
|
}
|
|
68
82
|
throw err;
|
|
69
83
|
}
|
|
84
|
+
|
|
85
|
+
// MF-5 — fire the alias FIRE-AND-FORGET (never await on the response path)
|
|
86
|
+
// and respond synchronously. The token-proven canonical key is the survivor;
|
|
87
|
+
// the caller's own session is the absorbed (anonymous) side. A provider
|
|
88
|
+
// without `identityMerge` (or no provider) skips the merge cleanly — the
|
|
89
|
+
// client falls back to its existing best-effort `posthog.identify`.
|
|
90
|
+
if (
|
|
91
|
+
currentDistinctId &&
|
|
92
|
+
analytics?.capabilities.identityMerge &&
|
|
93
|
+
analytics.mergeIdentities &&
|
|
94
|
+
currentDistinctId !== payload.distinctId
|
|
95
|
+
) {
|
|
96
|
+
try {
|
|
97
|
+
analytics.mergeIdentities({
|
|
98
|
+
distinctId: payload.distinctId,
|
|
99
|
+
alias: currentDistinctId,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Best-effort — a provider error must never fail the exchange.
|
|
103
|
+
logger.warn("identify: mergeIdentities failed", {
|
|
104
|
+
error: err instanceof Error ? err.message : String(err),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return c.json(
|
|
110
|
+
{
|
|
111
|
+
distinctId: payload.distinctId,
|
|
112
|
+
src: payload.src,
|
|
113
|
+
emailSendId: payload.emailSendId,
|
|
114
|
+
},
|
|
115
|
+
200,
|
|
116
|
+
);
|
|
70
117
|
},
|
|
71
118
|
);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { DefinedConnector } from "../../connectors/define-connector.js";
|
|
4
4
|
import { registerEmailProviderRoutes } from "./email-provider.js";
|
|
5
5
|
import { resendWebhookRouter } from "./resend.js";
|
|
6
6
|
import { registerWebhookSourceRoutes } from "./sources.js";
|
|
7
7
|
|
|
8
8
|
export interface RegisterWebhookRoutesOptions {
|
|
9
|
-
|
|
9
|
+
webhookConnectors: DefinedConnector[]; // pre-filtered to transport "webhook"
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function registerWebhookRoutes(
|
|
@@ -20,5 +20,5 @@ export function registerWebhookRoutes(
|
|
|
20
20
|
// 3. the `/v1/webhooks/:sourceId` consumer-source catch-all (LAST).
|
|
21
21
|
app.route("/v1/webhooks", resendWebhookRouter);
|
|
22
22
|
registerEmailProviderRoutes(app);
|
|
23
|
-
registerWebhookSourceRoutes(app, opts.
|
|
23
|
+
registerWebhookSourceRoutes(app, opts.webhookConnectors);
|
|
24
24
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Database } from "@hogsend/db";
|
|
2
2
|
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import type { AppEnv } from "../../app.js";
|
|
4
|
+
import type { DefinedConnector } from "../../connectors/define-connector.js";
|
|
4
5
|
import { headersToRecord } from "../../lib/headers.js";
|
|
5
6
|
import { ingestEvent } from "../../lib/ingestion.js";
|
|
6
7
|
import type { Logger } from "../../lib/logger.js";
|
|
7
8
|
import { getDerivedCredential } from "../../lib/provider-credentials.js";
|
|
8
|
-
import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
|
|
9
9
|
import { verifySignature } from "../../webhook-sources/verify.js";
|
|
10
10
|
|
|
11
11
|
/** Negative-cache window for the stored PostHog secret (mirrors the token
|
|
@@ -64,7 +64,7 @@ export function invalidateStoredPosthogSecret(): void {
|
|
|
64
64
|
|
|
65
65
|
export function registerWebhookSourceRoutes(
|
|
66
66
|
app: OpenAPIHono<AppEnv>,
|
|
67
|
-
sources:
|
|
67
|
+
sources: DefinedConnector[], // already filtered to transport === "webhook"
|
|
68
68
|
) {
|
|
69
69
|
// Reserve `email` for the email-provider route
|
|
70
70
|
// (`POST /v1/webhooks/email/:providerId`). A source with `meta.id === "email"`
|
|
@@ -122,6 +122,12 @@ export function registerWebhookSourceRoutes(
|
|
|
122
122
|
return c.json({ error: "Unknown webhook source" }, 404);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Webhook-transport connectors always carry inboundVerify (defineConnector
|
|
126
|
+
// enforces it at authoring time). Narrow once so the rest of the auth ladder
|
|
127
|
+
// is byte-identical to the pre-connector source dispatch.
|
|
128
|
+
const auth = source.inboundVerify;
|
|
129
|
+
if (!auth) return c.json({ error: "Unknown webhook source" }, 404);
|
|
130
|
+
|
|
125
131
|
const { db, logger, env, registry, hatchet } = c.get("container");
|
|
126
132
|
|
|
127
133
|
// Read the body ONCE as the EXACT received bytes — signature schemes verify
|
|
@@ -129,9 +135,7 @@ export function registerWebhookSourceRoutes(
|
|
|
129
135
|
const rawBody = await c.req.text();
|
|
130
136
|
const headers = headersToRecord(c.req.raw.headers);
|
|
131
137
|
|
|
132
|
-
let secret = env[
|
|
133
|
-
| string
|
|
134
|
-
| undefined;
|
|
138
|
+
let secret = env[auth.envKey as keyof typeof env] as string | undefined;
|
|
135
139
|
|
|
136
140
|
// For the inbound PostHog source, fall back to the secret minted by
|
|
137
141
|
// `hogsend connect` (kind="derived" store) when env has none — so an
|
|
@@ -139,13 +143,13 @@ export function registerWebhookSourceRoutes(
|
|
|
139
143
|
// neither env nor the store has a secret (current behavior preserved).
|
|
140
144
|
if (
|
|
141
145
|
!secret &&
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
auth.type === "match" &&
|
|
147
|
+
auth.envKey === "POSTHOG_WEBHOOK_SECRET"
|
|
144
148
|
) {
|
|
145
149
|
secret = await resolveStoredPosthogSecret(db, logger);
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
if (
|
|
152
|
+
if (auth.type === "signature") {
|
|
149
153
|
// Signature sources FAIL CLOSED: an unset secret is a 401, never an open
|
|
150
154
|
// pass-through (deliberate divergence from the "match" variant).
|
|
151
155
|
if (!secret) {
|
|
@@ -155,7 +159,6 @@ export function registerWebhookSourceRoutes(
|
|
|
155
159
|
return c.json({ error: "Webhook signature not configured" }, 401);
|
|
156
160
|
}
|
|
157
161
|
|
|
158
|
-
const auth = source.auth;
|
|
159
162
|
let verified = false;
|
|
160
163
|
|
|
161
164
|
if (auth.verify) {
|
|
@@ -183,7 +186,7 @@ export function registerWebhookSourceRoutes(
|
|
|
183
186
|
// (parity with the pre-engine route).
|
|
184
187
|
if (secret) {
|
|
185
188
|
const provided =
|
|
186
|
-
headers[
|
|
189
|
+
headers[auth.header.toLowerCase()] ??
|
|
187
190
|
headers.authorization?.replace("Bearer ", "");
|
|
188
191
|
|
|
189
192
|
if (provided !== secret) {
|
|
@@ -216,6 +219,7 @@ export function registerWebhookSourceRoutes(
|
|
|
216
219
|
const event = await source.transform(payload, {
|
|
217
220
|
db,
|
|
218
221
|
logger,
|
|
222
|
+
transport: "webhook",
|
|
219
223
|
rawBody,
|
|
220
224
|
headers,
|
|
221
225
|
});
|
|
@@ -230,6 +234,12 @@ export function registerWebhookSourceRoutes(
|
|
|
230
234
|
ok: true,
|
|
231
235
|
event: event.event,
|
|
232
236
|
userId: event.userId,
|
|
237
|
+
// INTENTIONALLY the ExitResult[] ARRAY (not `.length`) — preserved
|
|
238
|
+
// byte-for-byte for back-compat. The OpenAPI schema declares
|
|
239
|
+
// `exits: z.number().optional()`, but this route has always returned the
|
|
240
|
+
// array; the NEW `/v1/connectors/:id/ingress` route returns
|
|
241
|
+
// `result.exits.length` as a deliberate divergence. Do NOT "tidy" either
|
|
242
|
+
// to match the other.
|
|
233
243
|
exits: result.exits,
|
|
234
244
|
});
|
|
235
245
|
});
|
|
@@ -1,52 +1,36 @@
|
|
|
1
1
|
import type { Database } from "@hogsend/db";
|
|
2
2
|
import type { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
type ConnectorCtx,
|
|
5
|
+
type DefinedConnector,
|
|
6
|
+
defineConnector,
|
|
7
|
+
type InboundVerifyAuth,
|
|
8
|
+
} from "../connectors/define-connector.js";
|
|
3
9
|
import type { IngestEvent } from "../lib/ingestion.js";
|
|
4
10
|
import type { Logger } from "../lib/logger.js";
|
|
5
|
-
import type { SignatureScheme, VerifySignatureArgs } from "./verify.js";
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
|
-
*
|
|
13
|
+
* @deprecated naming only — `defineWebhookSource` is now the
|
|
14
|
+
* `transport: "webhook"` specialization of {@link defineConnector}, kept as a
|
|
15
|
+
* behavior- and signature-identical alias. NO migration is required.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
17
|
+
* `WebhookSourceAuth` is an alias of the connector's inbound-verify union —
|
|
18
|
+
* IDENTICAL shape today, so every existing source's `auth` keeps type-checking.
|
|
11
19
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
20
|
+
* SURFACE PIN: this alias must stay byte-for-byte equal to the frozen
|
|
21
|
+
* `{ match | signature }` webhook auth shape. `__tests__/connectors.test.ts`
|
|
22
|
+
* has a type-level assertion (`expectTypeOf<WebhookSourceAuth>()...`) that fails
|
|
23
|
+
* the build if `InboundVerifyAuth` ever gains a third variant — so a future
|
|
24
|
+
* additive change to the connector union can never silently widen this frozen
|
|
25
|
+
* public webhook-source surface.
|
|
22
26
|
*/
|
|
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
|
-
};
|
|
27
|
+
export type WebhookSourceAuth = InboundVerifyAuth;
|
|
49
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Unchanged public shape. `ctx` stays the narrow webhook-only context —
|
|
31
|
+
* `ConnectorCtx` minus `transport` — so consumer transforms typed against this
|
|
32
|
+
* are byte-for-byte source-compatible.
|
|
33
|
+
*/
|
|
50
34
|
export interface WebhookSourceCtx {
|
|
51
35
|
db: Database;
|
|
52
36
|
logger: Logger;
|
|
@@ -73,9 +57,30 @@ export interface DefinedWebhookSource<T = unknown> {
|
|
|
73
57
|
transform(payload: T, ctx: WebhookSourceCtx): Promise<IngestEvent | null>;
|
|
74
58
|
}
|
|
75
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Lift a `DefinedWebhookSource` onto the connector umbrella as a
|
|
62
|
+
* `transport: "webhook"` connector: `auth` → `inboundVerify`, transform `ctx`
|
|
63
|
+
* widened to {@link ConnectorCtx} (the webhook route always sets
|
|
64
|
+
* `transport: "webhook"` + `rawBody`/`headers`). Used by the container to
|
|
65
|
+
* register webhook sources into the unified {@link ConnectorRegistry}.
|
|
66
|
+
*/
|
|
67
|
+
export function webhookSourceToConnector<T>(
|
|
68
|
+
source: DefinedWebhookSource<T>,
|
|
69
|
+
): DefinedConnector<T> {
|
|
70
|
+
return defineConnector<T>({
|
|
71
|
+
meta: { ...source.meta, transport: "webhook" },
|
|
72
|
+
inboundVerify: source.auth,
|
|
73
|
+
schema: source.schema,
|
|
74
|
+
transform: (payload: T, ctx: ConnectorCtx) =>
|
|
75
|
+
source.transform(payload, ctx),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
76
79
|
export function defineWebhookSource<T>(
|
|
77
80
|
def: DefinedWebhookSource<T>,
|
|
78
81
|
): DefinedWebhookSource<T> {
|
|
82
|
+
// Unchanged contract: returns its argument. The container converts it via
|
|
83
|
+
// webhookSourceToConnector when building the registry.
|
|
79
84
|
return def;
|
|
80
85
|
}
|
|
81
86
|
|
|
@@ -45,8 +45,11 @@ function lowerHeaders(headers: Record<string, string>): Record<string, string> {
|
|
|
45
45
|
/**
|
|
46
46
|
* Constant-time string comparison that never short-circuits on length. Returns
|
|
47
47
|
* `false` (rather than throwing) on a length mismatch so callers fail closed.
|
|
48
|
+
* Exported so the connector ingress route reuses ONE hardened compare rather
|
|
49
|
+
* than re-implementing `Buffer.from` + `timingSafeEqual` inline (where a later
|
|
50
|
+
* refactor could drop the length guard and reintroduce the throw-on-mismatch).
|
|
48
51
|
*/
|
|
49
|
-
function safeEqual(a: string, b: string): boolean {
|
|
52
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
50
53
|
const bufA = Buffer.from(a, "utf8");
|
|
51
54
|
const bufB = Buffer.from(b, "utf8");
|
|
52
55
|
if (bufA.length !== bufB.length) {
|