@hogsend/engine 0.14.0 → 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.14.0",
44
- "@hogsend/db": "^0.14.0",
45
- "@hogsend/email": "^0.14.0",
46
- "@hogsend/plugin-posthog": "^0.14.0",
47
- "@hogsend/plugin-resend": "^0.14.0"
43
+ "@hogsend/core": "^0.16.0",
44
+ "@hogsend/db": "^0.16.0",
45
+ "@hogsend/email": "^0.16.0",
46
+ "@hogsend/plugin-posthog": "^0.16.0",
47
+ "@hogsend/plugin-resend": "^0.16.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.14.0"
50
+ "@hogsend/plugin-postmark": "^0.16.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
package/src/env.ts CHANGED
@@ -141,6 +141,13 @@ export const env = createEnv({
141
141
  // Default OFF to avoid a surprise double-emit alongside the existing
142
142
  // fire-and-forget PostHog capture path.
143
143
  ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
144
+ /**
145
+ * Append a short-lived signed identity token (`hs_t`) to tracked-link
146
+ * redirect destinations, so the landing site can stitch the email click
147
+ * to its web session (`POST /v1/t/identify` + posthog.identify). Opt-in:
148
+ * it changes outbound URLs, which can break pre-signed destinations.
149
+ */
150
+ TRACKING_IDENTITY_TOKEN: z.coerce.boolean().default(false),
144
151
  RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
145
152
  ADMIN_API_KEY: z.string().min(1).optional(),
146
153
  API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
package/src/index.ts CHANGED
@@ -205,6 +205,12 @@ export { checkEmailPreferences } from "./lib/enrollment-guards.js";
205
205
  export { isFrequencyCapped } from "./lib/frequency-cap.js";
206
206
  export { addrSpecOf, hostOfFromAddress } from "./lib/from-address.js";
207
207
  export { hatchet } from "./lib/hatchet.js";
208
+ export {
209
+ generateIdentityToken,
210
+ type IdentityTokenPayload,
211
+ InvalidIdentityTokenError,
212
+ validateIdentityToken,
213
+ } from "./lib/identity-token.js";
208
214
  // --- Ingestion pipeline ---
209
215
  export {
210
216
  type IngestEvent,
@@ -1,9 +1,12 @@
1
1
  import type { JsonValue } from "@hatchet-dev/typescript-sdk/v1/types.js";
2
- import { evaluatePropertyConditions } from "@hogsend/core";
2
+ import { criteriaBuilder, evaluatePropertyConditions } from "@hogsend/core";
3
3
  import type {
4
4
  JourneyMeta,
5
+ JourneyMetaInput,
5
6
  JourneyRunFn,
6
7
  JourneyUser,
8
+ JourneyWhere,
9
+ PropertyCondition,
7
10
  } from "@hogsend/core/types";
8
11
  import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
9
12
  import { and, eq, inArray, notInArray } from "drizzle-orm";
@@ -37,11 +40,45 @@ export interface DefinedJourney {
37
40
  task: ReturnType<typeof hatchet.durableTask>;
38
41
  }
39
42
 
43
+ /**
44
+ * Resolve a builder-function `where` to its `PropertyCondition[]` — a
45
+ * one-shot, definition-time call mirroring `defineBucket`'s criteria
46
+ * normalization. A declarative array passes straight through, so existing
47
+ * journeys are unaffected; downstream (registry parse, checkExits, admin
48
+ * routes, Studio) only ever sees plain data.
49
+ */
50
+ function normalizeWhere(
51
+ where: JourneyWhere | undefined,
52
+ ): PropertyCondition[] | undefined {
53
+ if (typeof where !== "function") return where;
54
+ const resolved = where(criteriaBuilder);
55
+ return Array.isArray(resolved) ? resolved : [resolved];
56
+ }
57
+
40
58
  export function defineJourney(options: {
41
- meta: JourneyMeta;
59
+ meta: JourneyMetaInput;
42
60
  run: JourneyRunFn;
43
61
  }): DefinedJourney {
44
- const { meta } = options;
62
+ const { trigger, exitOn, ...rest } = options.meta;
63
+ const triggerWhere = normalizeWhere(trigger.where);
64
+ const meta: JourneyMeta = {
65
+ ...rest,
66
+ trigger: {
67
+ event: trigger.event,
68
+ ...(triggerWhere ? { where: triggerWhere } : {}),
69
+ },
70
+ ...(exitOn
71
+ ? {
72
+ exitOn: exitOn.map((exit) => {
73
+ const exitWhere = normalizeWhere(exit.where);
74
+ return {
75
+ event: exit.event,
76
+ ...(exitWhere ? { where: exitWhere } : {}),
77
+ };
78
+ }),
79
+ }
80
+ : {}),
81
+ };
45
82
 
46
83
  const task = hatchet.durableTask({
47
84
  name: `journey-${meta.id}`,
@@ -0,0 +1,112 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ } from "node:crypto";
7
+
8
+ /**
9
+ * Short-lived identity token appended to tracked-link redirects as `hs_t`
10
+ * (opt-in via TRACKING_IDENTITY_TOKEN). The landing site exchanges it at
11
+ * `POST /v1/t/identify` for the distinct id and calls `posthog.identify` —
12
+ * stitching the email click to the web session.
13
+ *
14
+ * ENCRYPTED (AES-256-GCM keyed off BETTER_AUTH_SECRET), not merely signed:
15
+ * the distinct id can fall back to an email address, and a signed-but-
16
+ * readable token would put a base64-decodable email in the URL — into
17
+ * browser history, referrers, and any script on the landing page. The GCM
18
+ * auth tag also covers integrity, so tampering fails decryption.
19
+ */
20
+
21
+ export interface IdentityTokenPayload {
22
+ /** The distinct id the landing site should identify as. */
23
+ distinctId: string;
24
+ emailSendId: string;
25
+ exp: number;
26
+ }
27
+
28
+ export class InvalidIdentityTokenError extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "InvalidIdentityTokenError";
32
+ }
33
+ }
34
+
35
+ const DEFAULT_EXPIRY_SECONDS = 60 * 60; // 1 hour — a click-to-landing hop
36
+ const IV_LENGTH = 12;
37
+ const TAG_LENGTH = 16;
38
+
39
+ function deriveKey(secret: string): Buffer {
40
+ return createHash("sha256").update(secret).digest();
41
+ }
42
+
43
+ export function generateIdentityToken(opts: {
44
+ secret: string;
45
+ distinctId: string;
46
+ emailSendId: string;
47
+ expiresInSeconds?: number;
48
+ }): string {
49
+ const payload: IdentityTokenPayload = {
50
+ distinctId: opts.distinctId,
51
+ emailSendId: opts.emailSendId,
52
+ exp:
53
+ Math.floor(Date.now() / 1000) +
54
+ (opts.expiresInSeconds ?? DEFAULT_EXPIRY_SECONDS),
55
+ };
56
+ const iv = randomBytes(IV_LENGTH);
57
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(opts.secret), iv);
58
+ const ciphertext = Buffer.concat([
59
+ cipher.update(JSON.stringify(payload), "utf-8"),
60
+ cipher.final(),
61
+ ]);
62
+ return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
63
+ "base64url",
64
+ );
65
+ }
66
+
67
+ export function validateIdentityToken(opts: {
68
+ token: string;
69
+ secret: string;
70
+ }): IdentityTokenPayload {
71
+ let raw: Buffer;
72
+ try {
73
+ raw = Buffer.from(opts.token, "base64url");
74
+ } catch {
75
+ throw new InvalidIdentityTokenError("Malformed token");
76
+ }
77
+ if (raw.length <= IV_LENGTH + TAG_LENGTH) {
78
+ throw new InvalidIdentityTokenError("Malformed token");
79
+ }
80
+
81
+ const iv = raw.subarray(0, IV_LENGTH);
82
+ const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
83
+ const tag = raw.subarray(raw.length - TAG_LENGTH);
84
+
85
+ let payload: IdentityTokenPayload;
86
+ try {
87
+ const decipher = createDecipheriv(
88
+ "aes-256-gcm",
89
+ deriveKey(opts.secret),
90
+ iv,
91
+ );
92
+ decipher.setAuthTag(tag);
93
+ const plaintext = Buffer.concat([
94
+ decipher.update(ciphertext),
95
+ decipher.final(),
96
+ ]).toString("utf-8");
97
+ payload = JSON.parse(plaintext);
98
+ } catch {
99
+ throw new InvalidIdentityTokenError("Bad token");
100
+ }
101
+
102
+ if (
103
+ typeof payload.distinctId !== "string" ||
104
+ typeof payload.exp !== "number"
105
+ ) {
106
+ throw new InvalidIdentityTokenError("Invalid payload shape");
107
+ }
108
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
109
+ throw new InvalidIdentityTokenError("Token expired");
110
+ }
111
+ return payload;
112
+ }
@@ -4,10 +4,12 @@ import { trackedLinks } from "@hogsend/db";
4
4
  import {
5
5
  EMAIL_ACTION_EVENT_ATTR,
6
6
  EMAIL_ACTION_PROPS_ATTR,
7
+ HOSTED_ANSWER_HREF,
7
8
  } from "@hogsend/email";
8
9
 
9
10
  const ANCHOR_RE = /<a\b[^>]*>/gi;
10
11
  const HREF_RE = /\bhref="(https?:\/\/[^"]+)"/i;
12
+ const SENTINEL_HREF_RE = new RegExp(`\\bhref="${HOSTED_ANSWER_HREF}"`, "i");
11
13
  const EVENT_ATTR_RE = new RegExp(
12
14
  `\\b${EMAIL_ACTION_EVENT_ATTR}="([^"]*)"`,
13
15
  "i",
@@ -137,9 +139,30 @@ export async function rewriteLinks(opts: {
137
139
 
138
140
  for (const match of html.matchAll(ANCHOR_RE)) {
139
141
  const tag = match[0];
140
- const url = tag.match(HREF_RE)?.[1];
141
142
  const semantic = parseSemanticAttrs(tag);
142
143
 
144
+ // Sentinel destination: the engine-hosted answer page. Only meaningful on
145
+ // a semantic link, and resolvable only here (the page URL embeds the
146
+ // tracked link's own id, generated client-side below).
147
+ if (SENTINEL_HREF_RE.test(tag)) {
148
+ if (!semantic) {
149
+ throw new Error(
150
+ `href="${HOSTED_ANSWER_HREF}" is only valid on a semantic link (EmailAction)`,
151
+ );
152
+ }
153
+ const key = linkKey(HOSTED_ANSWER_HREF, semantic);
154
+ if (!pending.has(key)) {
155
+ const id = randomUUID();
156
+ pending.set(key, {
157
+ id,
158
+ url: `${baseUrl}/v1/t/a/${id}`,
159
+ semantic,
160
+ });
161
+ }
162
+ continue;
163
+ }
164
+
165
+ const url = tag.match(HREF_RE)?.[1];
143
166
  if (!url || shouldSkipUrl(url)) {
144
167
  if (semantic) {
145
168
  throw new Error(
@@ -168,6 +191,15 @@ export async function rewriteLinks(opts: {
168
191
  );
169
192
 
170
193
  return html.replace(ANCHOR_RE, (tag) => {
194
+ if (SENTINEL_HREF_RE.test(tag)) {
195
+ const link = pending.get(
196
+ linkKey(HOSTED_ANSWER_HREF, parseSemanticAttrs(tag)),
197
+ );
198
+ if (!link) return tag;
199
+ return tag
200
+ .replace(SENTINEL_HREF_RE, `href="${baseUrl}/v1/t/c/${link.id}"`)
201
+ .replace(STRIP_SEMANTIC_ATTRS_RE, "");
202
+ }
171
203
  const url = tag.match(HREF_RE)?.[1];
172
204
  if (!url || shouldSkipUrl(url)) return tag;
173
205
  const link = pending.get(linkKey(url, parseSemanticAttrs(tag)));
@@ -0,0 +1,187 @@
1
+ import { trackedLinks } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { eq } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { htmlPage } from "../../lib/html.js";
6
+ import {
7
+ pushTrackingEvent,
8
+ resolveEmailSendContext,
9
+ } from "../../lib/tracking-events.js";
10
+
11
+ /**
12
+ * The hosted answer page — the engine-served landing for a semantic link
13
+ * whose author has no page of their own (`href={HOSTED_ANSWER_HREF}` in
14
+ * `EmailAction`). Possession of the unguessable link id is the auth, the
15
+ * same trust model as unsubscribe.
16
+ *
17
+ * GET shows the recorded answer and an optional free-text box; POST ingests
18
+ * the comment as `<event>.comment` — a real consumer event journeys and
19
+ * destinations can react to. ONE comment per (send, event): the
20
+ * `semc:` idempotency key mirrors first-answer-wins, which also caps what a
21
+ * forwarded link can inject.
22
+ */
23
+
24
+ const COMMENT_MAX = 2000;
25
+
26
+ function escapeHtml(value: string): string {
27
+ return value
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&#x27;");
33
+ }
34
+
35
+ function describeAnswer(properties: Record<string, unknown> | null): string {
36
+ if (!properties) return "";
37
+ const parts = Object.entries(properties)
38
+ .filter(([, v]) => v !== null && v !== undefined)
39
+ .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`);
40
+ return parts.join(" · ");
41
+ }
42
+
43
+ async function loadSemanticLink(
44
+ db: AppEnv["Variables"]["container"]["db"],
45
+ id: string,
46
+ ) {
47
+ const rows = await db
48
+ .select({
49
+ id: trackedLinks.id,
50
+ emailSendId: trackedLinks.emailSendId,
51
+ event: trackedLinks.event,
52
+ eventProperties: trackedLinks.eventProperties,
53
+ })
54
+ .from(trackedLinks)
55
+ .where(eq(trackedLinks.id, id))
56
+ .limit(1);
57
+ const link = rows[0];
58
+ return link?.event ? link : null;
59
+ }
60
+
61
+ const answerPageRoute = createRoute({
62
+ method: "get",
63
+ path: "/a/:id",
64
+ tags: ["Tracking"],
65
+ summary: "Hosted answer page for a semantic link",
66
+ request: {
67
+ params: z.object({ id: z.string().uuid() }),
68
+ },
69
+ responses: {
70
+ 200: {
71
+ description: "Answer page",
72
+ content: { "text/html": { schema: z.string() } },
73
+ },
74
+ 404: { description: "Not a semantic link" },
75
+ },
76
+ });
77
+
78
+ const answerCommentRoute = createRoute({
79
+ method: "post",
80
+ path: "/a/:id",
81
+ tags: ["Tracking"],
82
+ summary: "Attach a free-text comment to a semantic answer",
83
+ request: {
84
+ params: z.object({ id: z.string().uuid() }),
85
+ body: {
86
+ content: {
87
+ "application/x-www-form-urlencoded": {
88
+ schema: z.object({ comment: z.string().min(1).max(COMMENT_MAX) }),
89
+ },
90
+ },
91
+ },
92
+ },
93
+ responses: {
94
+ 200: {
95
+ description: "Comment recorded",
96
+ content: { "text/html": { schema: z.string() } },
97
+ },
98
+ 404: { description: "Not a semantic link" },
99
+ },
100
+ });
101
+
102
+ export const answerRouter = new OpenAPIHono<AppEnv>()
103
+ .openapi(answerPageRoute, async (c) => {
104
+ const { id } = c.req.valid("param");
105
+ const { db } = c.get("container");
106
+
107
+ const link = await loadSemanticLink(db, id);
108
+ if (!link) {
109
+ return c.html(
110
+ htmlPage({
111
+ title: "Not found",
112
+ body: "<h1>Nothing here</h1><p>This link doesn't lead anywhere.</p>",
113
+ }),
114
+ 404,
115
+ );
116
+ }
117
+
118
+ const answer = describeAnswer(link.eventProperties);
119
+ return c.html(
120
+ htmlPage({
121
+ title: "Thanks — answer recorded",
122
+ body: `
123
+ <h1>Thanks — that's recorded.</h1>
124
+ ${answer ? `<p>Your answer: <strong>${answer}</strong></p>` : ""}
125
+ <p>Anything you'd like to add? A sentence here goes straight to the team.</p>
126
+ <form method="post" action="">
127
+ <textarea name="comment" rows="4" maxlength="${COMMENT_MAX}" required
128
+ style="width:100%;padding:10px;border:1px solid #e5e7eb;border-radius:8px;font:inherit"></textarea>
129
+ <button type="submit"
130
+ style="margin-top:12px;padding:10px 18px;border:0;border-radius:8px;background:#1a1a1a;color:#fff;font:inherit;cursor:pointer">
131
+ Send
132
+ </button>
133
+ </form>`,
134
+ }),
135
+ );
136
+ })
137
+ .openapi(answerCommentRoute, async (c) => {
138
+ const { id } = c.req.valid("param");
139
+ const { comment } = c.req.valid("form");
140
+ const { db, hatchet, registry, logger } = c.get("container");
141
+
142
+ const link = await loadSemanticLink(db, id);
143
+ if (!link) {
144
+ return c.html(
145
+ htmlPage({
146
+ title: "Not found",
147
+ body: "<h1>Nothing here</h1><p>This link doesn't lead anywhere.</p>",
148
+ }),
149
+ 404,
150
+ );
151
+ }
152
+
153
+ const ctx = await resolveEmailSendContext(db, link.emailSendId);
154
+ if (ctx) {
155
+ // `<event>.comment` is a consumer-namespace event — journeys can wait
156
+ // on it and destinations receive it like any other. First comment per
157
+ // (send, event) wins; repeats are no-ops.
158
+ await pushTrackingEvent({
159
+ db,
160
+ hatchet,
161
+ registry,
162
+ logger,
163
+ event: `${link.event}.comment`,
164
+ emailSendId: link.emailSendId,
165
+ properties: {
166
+ comment,
167
+ parentEvent: link.event,
168
+ ...(link.eventProperties ?? {}),
169
+ linkId: link.id,
170
+ },
171
+ resolvedContext: ctx,
172
+ idempotencyKey: `semc:${link.emailSendId}:${link.event}`,
173
+ }).catch((err) => {
174
+ logger.warn("Failed to ingest answer comment", {
175
+ linkId: link.id,
176
+ error: err instanceof Error ? err.message : String(err),
177
+ });
178
+ });
179
+ }
180
+
181
+ return c.html(
182
+ htmlPage({
183
+ title: "Thank you",
184
+ body: "<h1>Thank you.</h1><p>Your note is on its way to the team. You can close this tab.</p>",
185
+ }),
186
+ );
187
+ });
@@ -2,6 +2,7 @@ import { emailSends, linkClicks, trackedLinks } from "@hogsend/db";
2
2
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
3
  import { and, eq, isNull, sql } from "drizzle-orm";
4
4
  import type { AppEnv } from "../../app.js";
5
+ import { generateIdentityToken } from "../../lib/identity-token.js";
5
6
  import { emitOutbound } from "../../lib/outbound.js";
6
7
  import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
7
8
  import {
@@ -108,13 +109,49 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
108
109
  });
109
110
  }
110
111
 
112
+ // 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.
117
+ let redirectUrl = link.originalUrl;
118
+ let preResolved: Awaited<
119
+ ReturnType<typeof resolveEmailSendContext>
120
+ > | null = null;
121
+ let preResolvedSet = false;
122
+ if (env.TRACKING_IDENTITY_TOKEN) {
123
+ preResolved = await resolveEmailSendContext(db, link.emailSendId);
124
+ preResolvedSet = true;
125
+ if (preResolved?.userId) {
126
+ try {
127
+ const url = new URL(link.originalUrl);
128
+ url.searchParams.set(
129
+ "hs_t",
130
+ generateIdentityToken({
131
+ secret: env.BETTER_AUTH_SECRET,
132
+ distinctId: preResolved.userId,
133
+ emailSendId: link.emailSendId,
134
+ }),
135
+ );
136
+ redirectUrl = url.toString();
137
+ } catch {
138
+ // Unparseable destination — redirect untouched rather than break it.
139
+ redirectUrl = link.originalUrl;
140
+ }
141
+ }
142
+ }
143
+
111
144
  // Resolve the send context ONCE (off the response path) and feed both the
112
145
  // re-ingest and the PER-HIT outbound emit — avoiding a duplicate
113
146
  // `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
114
147
  // NULL dedupe key is distinct in Postgres, so every click creates a fresh
115
148
  // delivery to every subscribed destination (per-hit, not first-touch).
116
149
  const emailSendId = link.emailSendId;
117
- void resolveEmailSendContext(db, emailSendId)
150
+ void (
151
+ preResolvedSet
152
+ ? Promise.resolve(preResolved)
153
+ : resolveEmailSendContext(db, emailSendId)
154
+ )
118
155
  .then(async (ctx) => {
119
156
  await pushTrackingEvent({
120
157
  db,
@@ -157,6 +194,6 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
157
194
  })
158
195
  .catch(logger.warn);
159
196
 
160
- return c.redirect(link.originalUrl, 302);
197
+ return c.redirect(redirectUrl, 302);
161
198
  },
162
199
  );
@@ -0,0 +1,71 @@
1
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import {
4
+ InvalidIdentityTokenError,
5
+ validateIdentityToken,
6
+ } from "../../lib/identity-token.js";
7
+
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.
14
+ *
15
+ * Possession of a fresh signed token IS the authorization (the same trust
16
+ * 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
+ */
19
+ const identifyRoute = createRoute({
20
+ method: "post",
21
+ path: "/identify",
22
+ tags: ["Tracking"],
23
+ summary: "Exchange a redirect identity token for the distinct id",
24
+ request: {
25
+ body: {
26
+ content: {
27
+ "application/json": {
28
+ schema: z.object({ token: z.string().min(1).max(2048) }),
29
+ },
30
+ },
31
+ },
32
+ },
33
+ responses: {
34
+ 200: {
35
+ description: "Resolved identity",
36
+ content: {
37
+ "application/json": {
38
+ schema: z.object({
39
+ distinctId: z.string(),
40
+ emailSendId: z.string().optional(),
41
+ }),
42
+ },
43
+ },
44
+ },
45
+ 400: { description: "Invalid or expired token" },
46
+ },
47
+ });
48
+
49
+ export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
50
+ identifyRoute,
51
+ async (c) => {
52
+ const { token } = c.req.valid("json");
53
+ const { env } = c.get("container");
54
+
55
+ try {
56
+ const payload = validateIdentityToken({
57
+ token,
58
+ secret: env.BETTER_AUTH_SECRET,
59
+ });
60
+ return c.json(
61
+ { distinctId: payload.distinctId, emailSendId: payload.emailSendId },
62
+ 200,
63
+ );
64
+ } catch (err) {
65
+ if (err instanceof InvalidIdentityTokenError) {
66
+ return c.body(null, 400);
67
+ }
68
+ throw err;
69
+ }
70
+ },
71
+ );
@@ -1,9 +1,13 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
+ import { answerRouter } from "./answer.js";
3
4
  import { clickRouter } from "./click.js";
5
+ import { identifyRouter } from "./identify.js";
4
6
  import { openRouter } from "./open.js";
5
7
 
6
8
  export const trackingRouter = new OpenAPIHono<AppEnv>();
7
9
 
8
10
  trackingRouter.route("/", clickRouter);
9
11
  trackingRouter.route("/", openRouter);
12
+ trackingRouter.route("/", answerRouter);
13
+ trackingRouter.route("/", identifyRouter);