@hogsend/engine 0.22.0 → 0.23.1

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.
@@ -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
- db
77
- .update(emailSends)
78
- .set({
79
- clickedAt: new Date(),
80
- updatedAt: new Date(),
81
- })
82
- .where(
83
- and(
84
- eq(emailSends.id, link.emailSendId),
85
- isNull(emailSends.clickedAt),
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
- if (link.event) {
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 identify the
114
- // session. This is the ONE path that needs the send context BEFORE the
115
- // redirect the awaited resolve is shared with the async chain below so
116
- // the read still happens once.
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
- preResolved = await resolveEmailSendContext(db, link.emailSendId);
124
- preResolvedSet = true;
125
- if (preResolved?.userId) {
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: preResolved.userId,
133
- emailSendId: link.emailSendId,
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
- // Resolve the send context ONCE (off the response path) and feed both the
145
- // re-ingest and the PER-HIT outbound emit avoiding a duplicate
146
- // `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
147
- // NULL dedupe key is distinct in Postgres, so every click creates a fresh
148
- // delivery to every subscribed destination (per-hit, not first-touch).
149
- const emailSendId = link.emailSendId;
150
- void (
151
- preResolvedSet
152
- ? Promise.resolve(preResolved)
153
- : resolveEmailSendContext(db, emailSendId)
154
- )
155
- .then(async (ctx) => {
156
- await pushTrackingEvent({
157
- db,
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: "email.clicked",
182
- payload: {
183
- emailSendId,
184
- messageId: ctx.messageId ?? null,
185
- templateKey: ctx.templateKey ?? null,
186
- userId: ctx.userId ?? null,
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
- .catch(logger.warn);
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. 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
  );