@hogsend/engine 0.25.0 → 0.27.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.25.0",
3
+ "version": "0.27.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.25.0",
44
- "@hogsend/db": "^0.25.0",
45
- "@hogsend/email": "^0.25.0",
46
- "@hogsend/plugin-posthog": "^0.25.0",
47
- "@hogsend/plugin-resend": "^0.25.0"
43
+ "@hogsend/core": "^0.27.0",
44
+ "@hogsend/db": "^0.27.0",
45
+ "@hogsend/email": "^0.27.0",
46
+ "@hogsend/plugin-posthog": "^0.27.0",
47
+ "@hogsend/plugin-resend": "^0.27.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.25.0"
50
+ "@hogsend/plugin-postmark": "^0.27.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
@@ -72,6 +72,9 @@ export interface ConnectorRuntimesHandle {
72
72
  const LEASE_TTL_MS = 30_000;
73
73
  const RENEW_MS = 10_000;
74
74
  const ELECT_MS = 5_000;
75
+ // ~30s of failed elections (6 * ELECT_MS) before warning loudly that a
76
+ // configured runtime still can't acquire its lease (Redis down or contended).
77
+ const LEASE_MISS_WARN_AT = 6;
75
78
 
76
79
  /** Build the in-process dispatch→transform→ingest sink for one connector. */
77
80
  function makeIngest(client: HogsendClient, connector: DefinedConnector) {
@@ -150,6 +153,7 @@ function startController(
150
153
  let leading = false;
151
154
  let token = "";
152
155
  let stopped = false;
156
+ let leaseMisses = 0;
153
157
  let timer: ReturnType<typeof setTimeout> | undefined;
154
158
 
155
159
  /** Drop leadership: heartbeat key deleted FIRST (immediate Offline), then socket. */
@@ -178,6 +182,7 @@ function startController(
178
182
  ttlMs: LEASE_TTL_MS,
179
183
  });
180
184
  if (won) {
185
+ leaseMisses = 0;
181
186
  leading = true;
182
187
  heartbeat = startConnectorHeartbeat(connectorId, logger);
183
188
  heartbeat.state.setMetadata(rt.getMetadata());
@@ -198,6 +203,22 @@ function startController(
198
203
  leading = false;
199
204
  await releaseLeaderLease({ key: leaseKey, token });
200
205
  }
206
+ } else {
207
+ // Lease not acquired: another replica holds it (benign, normal during
208
+ // rollout) OR Redis is unreachable (the gateway can NEVER connect) —
209
+ // indistinguishable from the boolean. Warn LOUDLY once after ~30s of
210
+ // misses so a genuinely stuck runtime surfaces instead of silently
211
+ // never connecting (which Studio otherwise mis-reads as "intents off").
212
+ leaseMisses++;
213
+ if (leaseMisses === LEASE_MISS_WARN_AT) {
214
+ logger.error(
215
+ "Connector runtime has not acquired its leader lease after ~30s — " +
216
+ "Redis unreachable (check REDIS_URL points at the SAME instance " +
217
+ "as the API) or another replica holds it; if none does, the " +
218
+ "gateway will not connect.",
219
+ { connectorId },
220
+ );
221
+ }
201
222
  }
202
223
  } else {
203
224
  const renewed = await renewLeaderLease({
package/src/index.ts CHANGED
@@ -327,6 +327,13 @@ export {
327
327
  releaseLeaderLease,
328
328
  renewLeaderLease,
329
329
  } from "./lib/leader-lease.js";
330
+ // --- Managed tracked links (channel-agnostic mint — Studio/Discord/share) ---
331
+ export {
332
+ type LinkType,
333
+ type MintedLink,
334
+ type MintLinkOptions,
335
+ mintLink,
336
+ } from "./lib/links.js";
330
337
  // --- Logging ---
331
338
  export { createLogger, type Logger } from "./lib/logger.js";
332
339
  export { createTrackedMailer } from "./lib/mailer.js";
@@ -64,6 +64,12 @@ export class InvalidIdentityTokenError extends Error {
64
64
  }
65
65
 
66
66
  const DEFAULT_EXPIRY_SECONDS = 60 * 60; // 1 hour — a click-to-landing hop
67
+ /**
68
+ * The single-use burn sentinel (`POST /v1/t/identify`) lives in Redis for the
69
+ * token's full validity window, so a reshared token can't replay a merge while
70
+ * it would still validate. Kept equal to the token lifetime.
71
+ */
72
+ export const IDENTITY_TOKEN_TTL_SECONDS = DEFAULT_EXPIRY_SECONDS;
67
73
  const IV_LENGTH = 12;
68
74
  const TAG_LENGTH = 16;
69
75
 
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { type Database, links, trackedLinks } from "@hogsend/db";
3
+
4
+ /**
5
+ * The channel-agnostic MANAGED tracked-link mint — the counterpart to the email
6
+ * HTML-rewrite path (`prepareTrackedHtml`). Any non-email channel (the Studio
7
+ * Links UI, a Discord DM/channel post, SMS, a share link) mints through here.
8
+ *
9
+ * It inserts a durable `links` row (the operator/campaign identity that the
10
+ * Studio lists + manages) plus a `tracked_links` click-counter row pointing back
11
+ * at it, and returns the `/v1/t/c/:id` redirect URL. Email does NOT use this — it
12
+ * rewrites HTML at send time and keeps `tracked_links.link_id` NULL, so the two
13
+ * remain independent consumers of the same click spine.
14
+ *
15
+ * SHARE-SAFE INVARIANT: a link is identity-bearing (carries a `distinctId` the
16
+ * click can stitch + may mint a single-use `hs_t`) ONLY when `type: "personal"`
17
+ * AND an explicit `distinctId` is passed. A `"public"` link NEVER carries a
18
+ * person token — a shared/reshared public link attributes by campaign only.
19
+ */
20
+ export type LinkType = "personal" | "public";
21
+
22
+ export interface MintLinkOptions {
23
+ db: Database;
24
+ /** The destination URL the redirect 302s to. Must be http(s). */
25
+ url: string;
26
+ /** Public base URL of this instance (the tracking host) — the redirect prefix. */
27
+ baseUrl: string;
28
+ /** Originating channel: "studio" | "discord" | "sms" | "referral" | … (open). */
29
+ source: string;
30
+ /** "personal" (1:1, identity-bearing) | "public" (shareable). Default "public". */
31
+ type?: LinkType;
32
+ /** Operator-facing name (Studio list). */
33
+ label?: string;
34
+ /** UTM-style campaign grouping (public links). */
35
+ campaign?: string;
36
+ /**
37
+ * The canonical contact key a click should stitch — honoured ONLY for
38
+ * `type: "personal"`; dropped for public links (the share-safe invariant).
39
+ */
40
+ distinctId?: string;
41
+ /** The admin actor who minted it (Studio). */
42
+ createdBy?: string;
43
+ }
44
+
45
+ export interface MintedLink {
46
+ /** The `links` row id (the managed identity). */
47
+ linkId: string;
48
+ /** The `tracked_links` row id — the `:id` in the redirect URL. */
49
+ trackedLinkId: string;
50
+ /** The short redirect URL: `${baseUrl}/v1/t/c/:id`. */
51
+ url: string;
52
+ }
53
+
54
+ /**
55
+ * Reject a non-http(s) destination at mint time. The click route 302s to the
56
+ * stored URL verbatim, so giving operators a UI to mint these would otherwise
57
+ * widen the latent open-redirect into `javascript:`/`data:` territory.
58
+ */
59
+ function assertHttpUrl(url: string): void {
60
+ let parsed: URL;
61
+ try {
62
+ parsed = new URL(url);
63
+ } catch {
64
+ throw new Error(`mintLink: invalid destination URL: ${url}`);
65
+ }
66
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
67
+ throw new Error(
68
+ `mintLink: destination must be http(s), got "${parsed.protocol}"`,
69
+ );
70
+ }
71
+ }
72
+
73
+ export async function mintLink(opts: MintLinkOptions): Promise<MintedLink> {
74
+ assertHttpUrl(opts.url);
75
+ const type: LinkType = opts.type ?? "public";
76
+ // A public link must NEVER carry a person token — drop any distinctId.
77
+ const distinctId = type === "personal" ? (opts.distinctId ?? null) : null;
78
+
79
+ const linkId = randomUUID();
80
+ const trackedLinkId = randomUUID();
81
+
82
+ await opts.db.insert(links).values({
83
+ id: linkId,
84
+ originalUrl: opts.url,
85
+ type,
86
+ label: opts.label ?? null,
87
+ campaign: opts.campaign ?? null,
88
+ source: opts.source,
89
+ distinctId,
90
+ createdBy: opts.createdBy ?? null,
91
+ });
92
+ await opts.db.insert(trackedLinks).values({
93
+ id: trackedLinkId,
94
+ linkId,
95
+ emailSendId: null,
96
+ distinctId,
97
+ source: opts.source,
98
+ originalUrl: opts.url,
99
+ });
100
+
101
+ return {
102
+ linkId,
103
+ trackedLinkId,
104
+ url: `${opts.baseUrl}/v1/t/c/${trackedLinkId}`,
105
+ };
106
+ }
@@ -97,7 +97,10 @@ const connectInfoSchema = z.object({
97
97
  apiPublicUrl: z.string(),
98
98
  redirectUri: z.string(),
99
99
  interactionsUrl: z.string(),
100
- ingressSecretConfigured: z.boolean(),
100
+ // @deprecated legacy standalone-gateway signal (Boolean(CONNECTOR_INGRESS_SECRET)).
101
+ // The default inline runtime never uses the ingress secret; readiness is
102
+ // `workerOnline` (the owned Redis heartbeat). Kept one minor; NOT a precondition.
103
+ legacyIngressSecretConfigured: z.boolean(),
101
104
  credentialStored: z.boolean(),
102
105
  guildId: z.string().nullable(),
103
106
  // Tri-state — null = unknown (no guild from worker or derived credential).
@@ -420,7 +423,7 @@ export const adminConnectorsRouter = new OpenAPIHono<AppEnv>()
420
423
  apiPublicUrl,
421
424
  redirectUri,
422
425
  interactionsUrl: `${apiPublicUrl}/v1/connectors/discord/interactions`,
423
- ingressSecretConfigured: Boolean(env.CONNECTOR_INGRESS_SECRET),
426
+ legacyIngressSecretConfigured: Boolean(env.CONNECTOR_INGRESS_SECRET),
424
427
  credentialStored: derived !== null,
425
428
  guildId,
426
429
  // Tri-state — a guild id (live or derived) confirms install; else null.
@@ -17,6 +17,7 @@ import { emailsRouter } from "./emails.js";
17
17
  import { eventsRouter } from "./events.js";
18
18
  import { journeyLogsRouter } from "./journey-logs.js";
19
19
  import { journeysRouter } from "./journeys.js";
20
+ import { linksRouter } from "./links.js";
20
21
  import { metricsRouter } from "./metrics.js";
21
22
  import { preferencesRouter } from "./preferences.js";
22
23
  import { providerCredentialsRouter } from "./provider-credentials.js";
@@ -38,6 +39,7 @@ adminRouter.route("/journeys", journeysRouter);
38
39
  adminRouter.route("/buckets", bucketsRouter);
39
40
  adminRouter.route("/events", eventsRouter);
40
41
  adminRouter.route("/emails", emailsRouter);
42
+ adminRouter.route("/links", linksRouter);
41
43
  adminRouter.route("/journey-logs", journeyLogsRouter);
42
44
  adminRouter.route("/metrics", metricsRouter);
43
45
  adminRouter.route("/reporting", reportingRouter);
@@ -0,0 +1,453 @@
1
+ import { linkClicks, links, trackedLinks } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, count, desc, eq, inArray, isNull, sql, sum } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { mintLink } from "../../lib/links.js";
6
+ import { errorSchema } from "../../lib/schemas.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Admin CRUD for managed (operator-owned) tracked links — the surface behind
10
+ // the Studio "Links" view. A `links` row is the durable, named identity of a
11
+ // tracked link; the click counter + per-hit `link_clicks` live in
12
+ // `tracked_links` (which back-references via `link_id`). Email's per-send
13
+ // rewritten links are a SEPARATE consumer of the same click spine (they keep
14
+ // `tracked_links.link_id` NULL) and are NOT listed here — this view is the
15
+ // managed/standalone surface only.
16
+ //
17
+ // The click count is computed ON READ by summing `tracked_links.click_count`
18
+ // for the link's `tracked_links` rows — there is deliberately NO denormalized
19
+ // counter on `links`, so a click never has to write back to this table.
20
+ //
21
+ // One FLAT `Link` shape is returned everywhere: `url` is the short redirect URL
22
+ // and `clickCount` is the computed count, both baked onto the row. `GET /:id`
23
+ // adds a `clicks` array. Archive returns the (now-archived) flat link.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ // Resolves the minting actor across the two admin auth paths (mirrors the audit
27
+ // middleware): an API key carries a `name`; a Better-Auth session carries a
28
+ // `user` whose email we record. Stored verbatim on `links.created_by`.
29
+ function resolveActor(c: {
30
+ get: (k: "apiKey" | "user") => unknown;
31
+ }): string | null {
32
+ const apiKey = c.get("apiKey") as { name?: string } | undefined;
33
+ if (apiKey?.name) return apiKey.name;
34
+ const user = c.get("user") as { email?: string } | null | undefined;
35
+ return user?.email ?? null;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Shared response shapes
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const linkSchema = z.object({
43
+ id: z.string(),
44
+ // The link's redirect tracked-row id (one per managed link). Absent only if a
45
+ // link somehow has no tracked row — kept nullable to stay total.
46
+ trackedLinkId: z.string().nullable(),
47
+ originalUrl: z.string(),
48
+ type: z.enum(["personal", "public"]),
49
+ label: z.string().nullable(),
50
+ campaign: z.string().nullable(),
51
+ source: z.string(),
52
+ distinctId: z.string().nullable(),
53
+ createdBy: z.string().nullable(),
54
+ // Computed on read (summed across the link's tracked_links rows).
55
+ clickCount: z.number(),
56
+ // The short redirect URL: `${API_PUBLIC_URL}/v1/t/c/:trackedLinkId`.
57
+ url: z.string(),
58
+ archivedAt: z.string().nullable(),
59
+ createdAt: z.string(),
60
+ updatedAt: z.string(),
61
+ });
62
+
63
+ const clickSchema = z.object({
64
+ id: z.string(),
65
+ trackedLinkId: z.string(),
66
+ ipAddress: z.string().nullable(),
67
+ userAgent: z.string().nullable(),
68
+ clickedAt: z.string(),
69
+ });
70
+
71
+ const linkDetailSchema = linkSchema.extend({
72
+ clicks: z.array(clickSchema),
73
+ });
74
+
75
+ type LinkRow = typeof links.$inferSelect;
76
+ type ClickAgg = { clicks: number; trackedLinkId: string | null };
77
+
78
+ // The short redirect URL for a link's tracked row, or a bare prefix if the link
79
+ // has no tracked row (should not happen for a minted link, but keep it total).
80
+ function shortUrlFor(baseUrl: string, trackedLinkId: string | null): string {
81
+ return `${baseUrl}/v1/t/c/${trackedLinkId ?? ""}`;
82
+ }
83
+
84
+ function serializeLink(
85
+ row: LinkRow,
86
+ agg: ClickAgg | undefined,
87
+ baseUrl: string,
88
+ ): z.infer<typeof linkSchema> {
89
+ const trackedLinkId = agg?.trackedLinkId ?? null;
90
+ return {
91
+ id: row.id,
92
+ trackedLinkId,
93
+ originalUrl: row.originalUrl,
94
+ // The column is a free text; mintLink only ever writes these two values.
95
+ type: row.type === "personal" ? "personal" : "public",
96
+ label: row.label,
97
+ campaign: row.campaign,
98
+ source: row.source,
99
+ distinctId: row.distinctId,
100
+ createdBy: row.createdBy,
101
+ clickCount: agg?.clicks ?? 0,
102
+ url: shortUrlFor(baseUrl, trackedLinkId),
103
+ archivedAt: row.archivedAt ? row.archivedAt.toISOString() : null,
104
+ createdAt: row.createdAt.toISOString(),
105
+ updatedAt: row.updatedAt.toISOString(),
106
+ };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Routes
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const createLinkRoute = createRoute({
114
+ method: "post",
115
+ path: "/",
116
+ tags: ["Admin — Links"],
117
+ summary: "Mint a managed tracked link",
118
+ request: {
119
+ body: {
120
+ content: {
121
+ "application/json": {
122
+ schema: z.object({
123
+ url: z.string().url(),
124
+ type: z.enum(["personal", "public"]).default("public"),
125
+ label: z.string().optional(),
126
+ campaign: z.string().optional(),
127
+ // Honoured ONLY for personal links (the share-safe invariant in
128
+ // mintLink drops it for public). A canonical contact key the click
129
+ // should stitch the visitor's anon session into.
130
+ distinctId: z.string().optional(),
131
+ }),
132
+ },
133
+ },
134
+ },
135
+ },
136
+ responses: {
137
+ 200: {
138
+ content: { "application/json": { schema: linkSchema } },
139
+ description: "Minted link",
140
+ },
141
+ 400: {
142
+ content: { "application/json": { schema: errorSchema } },
143
+ description: "Invalid destination URL",
144
+ },
145
+ },
146
+ });
147
+
148
+ const listLinksRoute = createRoute({
149
+ method: "get",
150
+ path: "/",
151
+ tags: ["Admin — Links"],
152
+ summary: "List managed links (newest first)",
153
+ request: {
154
+ query: z.object({
155
+ limit: z.coerce.number().min(1).max(200).default(50),
156
+ offset: z.coerce.number().min(0).default(0),
157
+ type: z.enum(["personal", "public"]).optional(),
158
+ includeArchived: z.coerce.boolean().default(false),
159
+ }),
160
+ },
161
+ responses: {
162
+ 200: {
163
+ content: {
164
+ "application/json": {
165
+ schema: z.object({
166
+ links: z.array(linkSchema),
167
+ total: z.number(),
168
+ limit: z.number(),
169
+ offset: z.number(),
170
+ }),
171
+ },
172
+ },
173
+ description: "Managed link list",
174
+ },
175
+ },
176
+ });
177
+
178
+ const getLinkRoute = createRoute({
179
+ method: "get",
180
+ path: "/{id}",
181
+ tags: ["Admin — Links"],
182
+ summary: "Get a managed link with recent clicks",
183
+ request: { params: z.object({ id: z.string().uuid() }) },
184
+ responses: {
185
+ 200: {
186
+ content: { "application/json": { schema: linkDetailSchema } },
187
+ description: "Link detail",
188
+ },
189
+ 404: {
190
+ content: { "application/json": { schema: errorSchema } },
191
+ description: "Link not found",
192
+ },
193
+ },
194
+ });
195
+
196
+ const updateLinkRoute = createRoute({
197
+ method: "patch",
198
+ path: "/{id}",
199
+ tags: ["Admin — Links"],
200
+ summary: "Update a managed link (label + campaign)",
201
+ request: {
202
+ params: z.object({ id: z.string().uuid() }),
203
+ body: {
204
+ content: {
205
+ "application/json": {
206
+ schema: z.object({
207
+ label: z.string().nullable().optional(),
208
+ campaign: z.string().nullable().optional(),
209
+ }),
210
+ },
211
+ },
212
+ },
213
+ },
214
+ responses: {
215
+ 200: {
216
+ content: { "application/json": { schema: linkSchema } },
217
+ description: "Updated link",
218
+ },
219
+ 404: {
220
+ content: { "application/json": { schema: errorSchema } },
221
+ description: "Link not found",
222
+ },
223
+ },
224
+ });
225
+
226
+ const archiveLinkRoute = createRoute({
227
+ method: "delete",
228
+ path: "/{id}",
229
+ tags: ["Admin — Links"],
230
+ summary: "Archive a managed link (soft-delete)",
231
+ request: { params: z.object({ id: z.string().uuid() }) },
232
+ responses: {
233
+ 200: {
234
+ content: { "application/json": { schema: linkSchema } },
235
+ description: "Archived link",
236
+ },
237
+ 404: {
238
+ content: { "application/json": { schema: errorSchema } },
239
+ description: "Link not found",
240
+ },
241
+ },
242
+ });
243
+
244
+ type Db = AppEnv["Variables"]["container"]["db"];
245
+
246
+ // Aggregates each link's tracked_links rows in ONE grouped query: the summed
247
+ // click_count (computed on read — no denormalized counter on `links`) and the
248
+ // link's redirect id (a managed link has exactly one tracked row, minted
249
+ // alongside it). Returns a map keyed by link id; links with no tracked rows are
250
+ // simply absent (callers default to 0 / a bare prefix).
251
+ async function aggregateFor(
252
+ db: Db,
253
+ linkIds: string[],
254
+ ): Promise<Map<string, ClickAgg>> {
255
+ const map = new Map<string, ClickAgg>();
256
+ if (linkIds.length === 0) return map;
257
+ const rows = await db
258
+ .select({
259
+ linkId: trackedLinks.linkId,
260
+ clicks: sql<number>`coalesce(${sum(trackedLinks.clickCount)}, 0)`.mapWith(
261
+ Number,
262
+ ),
263
+ trackedLinkId: sql<string>`min(${trackedLinks.id}::text)`,
264
+ })
265
+ .from(trackedLinks)
266
+ .where(inArray(trackedLinks.linkId, linkIds))
267
+ .groupBy(trackedLinks.linkId);
268
+ for (const r of rows) {
269
+ if (r.linkId) {
270
+ map.set(r.linkId, {
271
+ clicks: r.clicks,
272
+ trackedLinkId: r.trackedLinkId ?? null,
273
+ });
274
+ }
275
+ }
276
+ return map;
277
+ }
278
+
279
+ export const linksRouter = new OpenAPIHono<AppEnv>()
280
+ .openapi(createLinkRoute, async (c) => {
281
+ const { db, env } = c.get("container");
282
+ const body = c.req.valid("json");
283
+
284
+ try {
285
+ const minted = await mintLink({
286
+ db,
287
+ url: body.url,
288
+ baseUrl: env.API_PUBLIC_URL,
289
+ source: "studio",
290
+ type: body.type,
291
+ label: body.label,
292
+ campaign: body.campaign,
293
+ distinctId: body.distinctId,
294
+ createdBy: resolveActor(c) ?? undefined,
295
+ });
296
+
297
+ const [row] = await db
298
+ .select()
299
+ .from(links)
300
+ .where(eq(links.id, minted.linkId))
301
+ .limit(1);
302
+
303
+ if (!row) {
304
+ return c.json({ error: "Mint succeeded but link not found" }, 400);
305
+ }
306
+
307
+ return c.json(
308
+ serializeLink(
309
+ row,
310
+ { clicks: 0, trackedLinkId: minted.trackedLinkId },
311
+ env.API_PUBLIC_URL,
312
+ ),
313
+ 200,
314
+ );
315
+ } catch (err) {
316
+ const message = err instanceof Error ? err.message : "Mint failed";
317
+ return c.json({ error: message }, 400);
318
+ }
319
+ })
320
+ .openapi(listLinksRoute, async (c) => {
321
+ const { db, env } = c.get("container");
322
+ const { limit, offset, type, includeArchived } = c.req.valid("query");
323
+
324
+ const where = and(
325
+ includeArchived ? undefined : isNull(links.archivedAt),
326
+ type ? eq(links.type, type) : undefined,
327
+ );
328
+
329
+ const [rows, totalRows] = await Promise.all([
330
+ db
331
+ .select()
332
+ .from(links)
333
+ .where(where)
334
+ .orderBy(desc(links.createdAt))
335
+ .limit(limit)
336
+ .offset(offset),
337
+ db.select({ value: count() }).from(links).where(where),
338
+ ]);
339
+
340
+ const agg = await aggregateFor(
341
+ db,
342
+ rows.map((r) => r.id),
343
+ );
344
+
345
+ return c.json(
346
+ {
347
+ links: rows.map((row) =>
348
+ serializeLink(row, agg.get(row.id), env.API_PUBLIC_URL),
349
+ ),
350
+ total: totalRows[0]?.value ?? 0,
351
+ limit,
352
+ offset,
353
+ },
354
+ 200,
355
+ );
356
+ })
357
+ .openapi(getLinkRoute, async (c) => {
358
+ const { db, env } = c.get("container");
359
+ const { id } = c.req.valid("param");
360
+
361
+ const [row] = await db
362
+ .select()
363
+ .from(links)
364
+ .where(eq(links.id, id))
365
+ .limit(1);
366
+
367
+ if (!row) {
368
+ return c.json({ error: "Link not found" }, 404);
369
+ }
370
+
371
+ // Recent clicks joined via the link's tracked_links rows, newest first,
372
+ // capped. The aggregate gives the summed count + the redirect id.
373
+ const [agg, clickRows] = await Promise.all([
374
+ aggregateFor(db, [id]),
375
+ db
376
+ .select({
377
+ id: linkClicks.id,
378
+ trackedLinkId: linkClicks.trackedLinkId,
379
+ ipAddress: linkClicks.ipAddress,
380
+ userAgent: linkClicks.userAgent,
381
+ clickedAt: linkClicks.clickedAt,
382
+ })
383
+ .from(linkClicks)
384
+ .innerJoin(trackedLinks, eq(linkClicks.trackedLinkId, trackedLinks.id))
385
+ .where(eq(trackedLinks.linkId, id))
386
+ .orderBy(desc(linkClicks.clickedAt))
387
+ .limit(50),
388
+ ]);
389
+
390
+ return c.json(
391
+ {
392
+ ...serializeLink(row, agg.get(id), env.API_PUBLIC_URL),
393
+ clicks: clickRows.map((cl) => ({
394
+ id: cl.id,
395
+ trackedLinkId: cl.trackedLinkId,
396
+ ipAddress: cl.ipAddress,
397
+ userAgent: cl.userAgent,
398
+ clickedAt: cl.clickedAt.toISOString(),
399
+ })),
400
+ },
401
+ 200,
402
+ );
403
+ })
404
+ .openapi(updateLinkRoute, async (c) => {
405
+ const { db, env } = c.get("container");
406
+ const { id } = c.req.valid("param");
407
+ const body = c.req.valid("json");
408
+
409
+ const patch: Partial<Pick<LinkRow, "label" | "campaign">> & {
410
+ updatedAt: Date;
411
+ } = { updatedAt: new Date() };
412
+ if (body.label !== undefined) patch.label = body.label;
413
+ if (body.campaign !== undefined) patch.campaign = body.campaign;
414
+
415
+ const [updated] = await db
416
+ .update(links)
417
+ .set(patch)
418
+ .where(eq(links.id, id))
419
+ .returning();
420
+
421
+ if (!updated) {
422
+ return c.json({ error: "Link not found" }, 404);
423
+ }
424
+
425
+ const agg = await aggregateFor(db, [updated.id]);
426
+ return c.json(
427
+ serializeLink(updated, agg.get(updated.id), env.API_PUBLIC_URL),
428
+ 200,
429
+ );
430
+ })
431
+ .openapi(archiveLinkRoute, async (c) => {
432
+ const { db, env } = c.get("container");
433
+ const { id } = c.req.valid("param");
434
+
435
+ const archivedAt = new Date();
436
+ // Archive only if not already archived — a second DELETE is a 404, not a
437
+ // silent re-archive. History (link_clicks via tracked_links) survives.
438
+ const [archived] = await db
439
+ .update(links)
440
+ .set({ archivedAt, updatedAt: archivedAt })
441
+ .where(and(eq(links.id, id), isNull(links.archivedAt)))
442
+ .returning();
443
+
444
+ if (!archived) {
445
+ return c.json({ error: "Link not found" }, 404);
446
+ }
447
+
448
+ const agg = await aggregateFor(db, [archived.id]);
449
+ return c.json(
450
+ serializeLink(archived, agg.get(archived.id), env.API_PUBLIC_URL),
451
+ 200,
452
+ );
453
+ });
@@ -1,9 +1,12 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
3
  import type { AppEnv } from "../../app.js";
3
4
  import {
5
+ IDENTITY_TOKEN_TTL_SECONDS,
4
6
  InvalidIdentityTokenError,
5
7
  validateIdentityToken,
6
8
  } from "../../lib/identity-token.js";
9
+ import { getRedis } from "../../lib/redis.js";
7
10
 
8
11
  /**
9
12
  * Exchange a redirect identity token (`hs_t`) for the distinct id, AND — when
@@ -82,6 +85,50 @@ export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
82
85
  throw err;
83
86
  }
84
87
 
88
+ // SINGLE-USE BURN (anti-reshare): a tracked link can be forwarded, so the
89
+ // `hs_t` token rides into other inboxes. The control is single-use — the
90
+ // FIRST exchange of a token wins; a second exchange of the SAME token must
91
+ // NOT fire another identify/merge (a reshared link can't keep folding the
92
+ // subject around). Key on a sha256 of the RAW token (never the plaintext id)
93
+ // and claim it atomically with SET … NX; if the claim loses (`null`), the
94
+ // token is already spent → return a 200 no-op (never error the landing
95
+ // page). TTL matches the token lifetime so the sentinel covers the whole
96
+ // window in which the token would still validate.
97
+ // The resolve-only response (no server merge) — returned when the token is
98
+ // already spent.
99
+ const resolveOnly = {
100
+ distinctId: payload.distinctId,
101
+ src: payload.src,
102
+ emailSendId: payload.emailSendId,
103
+ };
104
+ const burnKey = `hs_t:burn:${createHash("sha256").update(token).digest("hex")}`;
105
+ try {
106
+ const claimed = await getRedis().set(
107
+ burnKey,
108
+ "1",
109
+ "EX",
110
+ IDENTITY_TOKEN_TTL_SECONDS,
111
+ "NX",
112
+ );
113
+ if (claimed === null) {
114
+ // Token already spent — no-op (no identify/merge), still 200 the page.
115
+ logger.info("identify: token already spent (single-use burn)", {
116
+ src: payload.src,
117
+ });
118
+ return c.json(resolveOnly, 200);
119
+ }
120
+ } catch (err) {
121
+ // Redis unavailable — degrade to the normal identify. The burn works when
122
+ // Redis is up (≈ always); a Redis-down window restores the pre-burn merge
123
+ // behaviour rather than coupling the exchange to Redis liveness or dropping
124
+ // legitimate first-time merges. Never fail the exchange. (A stricter deploy
125
+ // could fail CLOSED here — skip the merge — to also close the narrow
126
+ // reshare-during-outage replay window; we accept it as best-effort.)
127
+ logger.warn("identify: single-use burn unavailable (degrading)", {
128
+ error: err instanceof Error ? err.message : String(err),
129
+ });
130
+ }
131
+
85
132
  // MF-5 — fire the alias FIRE-AND-FORGET (never await on the response path)
86
133
  // and respond synchronously. The token-proven canonical key is the survivor;
87
134
  // the caller's own session is the absorbed (anonymous) side. A provider