@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.
@@ -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. Called by
10
- * the LANDING SITE's frontend (CORS is open app-wide) after the user arrives
11
- * from a tracked email link; the site then calls `posthog.identify` (or its
12
- * analytics equivalent) with the result ideally gated behind whatever
13
- * analytics consent the site already operates under.
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 distinct id + send id.
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 for the distinct id",
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({ token: z.string().min(1).max(2048) }),
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
- const payload = validateIdentityToken({
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
  );