@hogsend/engine 0.22.0 → 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/container.ts +21 -0
- package/src/index.ts +13 -0
- package/src/lib/analytics-identity.ts +112 -0
- package/src/lib/contacts.ts +113 -19
- package/src/lib/identity-service.ts +107 -0
- package/src/lib/identity-token.ts +65 -5
- package/src/lib/ingestion.ts +52 -2
- package/src/lib/outbound.ts +17 -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/contacts/index.ts +7 -0
- package/src/routes/events/index.ts +16 -1
- package/src/routes/tracking/answer.ts +11 -4
- package/src/routes/tracking/click.ts +130 -71
- package/src/routes/tracking/identify.ts +62 -15
|
@@ -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
|
);
|