@shware/workflow 5.0.1 → 6.0.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.
Files changed (178) hide show
  1. package/README.md +165 -42
  2. package/dist/aws/index.cjs +1 -1
  3. package/dist/aws/index.mjs +1 -1
  4. package/dist/client.cjs +45 -4
  5. package/dist/client.cjs.map +1 -1
  6. package/dist/client.d.cts +25 -3
  7. package/dist/client.d.cts.map +1 -1
  8. package/dist/client.d.mts +25 -3
  9. package/dist/client.d.mts.map +1 -1
  10. package/dist/client.mjs +45 -4
  11. package/dist/client.mjs.map +1 -1
  12. package/dist/cloudflare/index.cjs +2 -2
  13. package/dist/cloudflare/index.mjs +2 -2
  14. package/dist/cloudflare/runner.cjs +1 -1
  15. package/dist/cloudflare/runner.d.cts +1 -1
  16. package/dist/cloudflare/runner.d.mts +1 -1
  17. package/dist/cloudflare/runner.mjs +1 -1
  18. package/dist/config.cjs +24 -3
  19. package/dist/config.cjs.map +1 -1
  20. package/dist/config.d.cts +15 -9
  21. package/dist/config.d.cts.map +1 -1
  22. package/dist/config.d.mts +15 -9
  23. package/dist/config.d.mts.map +1 -1
  24. package/dist/config.mjs +25 -4
  25. package/dist/config.mjs.map +1 -1
  26. package/dist/contract.cjs +11 -0
  27. package/dist/contract.cjs.map +1 -1
  28. package/dist/contract.d.cts +28 -7
  29. package/dist/contract.d.cts.map +1 -1
  30. package/dist/contract.d.mts +28 -7
  31. package/dist/contract.d.mts.map +1 -1
  32. package/dist/contract.mjs +11 -0
  33. package/dist/contract.mjs.map +1 -1
  34. package/dist/dsl/bundle.cjs +4 -3
  35. package/dist/dsl/bundle.cjs.map +1 -1
  36. package/dist/dsl/bundle.d.cts +5 -1
  37. package/dist/dsl/bundle.d.cts.map +1 -1
  38. package/dist/dsl/bundle.d.mts +5 -1
  39. package/dist/dsl/bundle.d.mts.map +1 -1
  40. package/dist/dsl/bundle.mjs +4 -3
  41. package/dist/dsl/bundle.mjs.map +1 -1
  42. package/dist/dsl/flow.cjs +2 -1
  43. package/dist/dsl/flow.cjs.map +1 -1
  44. package/dist/dsl/flow.d.cts +1 -1
  45. package/dist/dsl/flow.d.cts.map +1 -1
  46. package/dist/dsl/flow.d.mts +1 -1
  47. package/dist/dsl/flow.d.mts.map +1 -1
  48. package/dist/dsl/flow.mjs +2 -1
  49. package/dist/dsl/flow.mjs.map +1 -1
  50. package/dist/dsl/workflow.cjs +2 -2
  51. package/dist/dsl/workflow.mjs +2 -2
  52. package/dist/email-sender.cjs +1 -1
  53. package/dist/email-sender.cjs.map +1 -1
  54. package/dist/email-sender.d.cts +1 -1
  55. package/dist/email-sender.d.mts +1 -1
  56. package/dist/email-sender.mjs +1 -1
  57. package/dist/email-sender.mjs.map +1 -1
  58. package/dist/email.cjs +32 -5
  59. package/dist/email.cjs.map +1 -1
  60. package/dist/email.d.cts +38 -9
  61. package/dist/email.d.cts.map +1 -1
  62. package/dist/email.d.mts +38 -9
  63. package/dist/email.d.mts.map +1 -1
  64. package/dist/email.mjs +32 -6
  65. package/dist/email.mjs.map +1 -1
  66. package/dist/engine/condition.cjs.map +1 -1
  67. package/dist/engine/condition.d.cts.map +1 -1
  68. package/dist/engine/condition.d.mts.map +1 -1
  69. package/dist/engine/condition.mjs.map +1 -1
  70. package/dist/engine/index.cjs +1 -1
  71. package/dist/engine/index.mjs +1 -1
  72. package/dist/engine/interpreter.cjs +21 -4
  73. package/dist/engine/interpreter.cjs.map +1 -1
  74. package/dist/engine/interpreter.mjs +21 -4
  75. package/dist/engine/interpreter.mjs.map +1 -1
  76. package/dist/engine/ports.d.cts +8 -2
  77. package/dist/engine/ports.d.cts.map +1 -1
  78. package/dist/engine/ports.d.mts +8 -2
  79. package/dist/engine/ports.d.mts.map +1 -1
  80. package/dist/host/channels.cjs +1 -1
  81. package/dist/host/channels.mjs +1 -1
  82. package/dist/host/execute.cjs +2 -2
  83. package/dist/host/execute.d.cts +1 -1
  84. package/dist/host/execute.d.mts +1 -1
  85. package/dist/host/execute.mjs +2 -2
  86. package/dist/host/http.cjs +38 -3
  87. package/dist/host/http.cjs.map +1 -1
  88. package/dist/host/http.d.cts +2 -2
  89. package/dist/host/http.d.cts.map +1 -1
  90. package/dist/host/http.d.mts +2 -2
  91. package/dist/host/http.d.mts.map +1 -1
  92. package/dist/host/http.mjs +39 -4
  93. package/dist/host/http.mjs.map +1 -1
  94. package/dist/host/ingest.cjs +128 -4
  95. package/dist/host/ingest.cjs.map +1 -1
  96. package/dist/host/ingest.d.cts +71 -0
  97. package/dist/host/ingest.d.cts.map +1 -1
  98. package/dist/host/ingest.d.mts +71 -0
  99. package/dist/host/ingest.d.mts.map +1 -1
  100. package/dist/host/ingest.mjs +125 -5
  101. package/dist/host/ingest.mjs.map +1 -1
  102. package/dist/host/journey-host.cjs +10 -8
  103. package/dist/host/journey-host.cjs.map +1 -1
  104. package/dist/host/journey-host.mjs +10 -8
  105. package/dist/host/journey-host.mjs.map +1 -1
  106. package/dist/index.cjs +5 -2
  107. package/dist/index.d.cts +5 -5
  108. package/dist/index.d.mts +5 -5
  109. package/dist/index.mjs +5 -5
  110. package/dist/ir.cjs +11 -2
  111. package/dist/ir.cjs.map +1 -1
  112. package/dist/ir.d.cts +17 -0
  113. package/dist/ir.d.cts.map +1 -1
  114. package/dist/ir.d.mts +17 -0
  115. package/dist/ir.d.mts.map +1 -1
  116. package/dist/ir.mjs +11 -3
  117. package/dist/ir.mjs.map +1 -1
  118. package/dist/reporting/observer.cjs +1 -0
  119. package/dist/reporting/observer.cjs.map +1 -1
  120. package/dist/reporting/observer.mjs +1 -0
  121. package/dist/reporting/observer.mjs.map +1 -1
  122. package/dist/reporting/tracking.cjs +28 -10
  123. package/dist/reporting/tracking.cjs.map +1 -1
  124. package/dist/reporting/tracking.d.cts +13 -6
  125. package/dist/reporting/tracking.d.cts.map +1 -1
  126. package/dist/reporting/tracking.d.mts +13 -6
  127. package/dist/reporting/tracking.d.mts.map +1 -1
  128. package/dist/reporting/tracking.mjs +28 -10
  129. package/dist/reporting/tracking.mjs.map +1 -1
  130. package/dist/store/facts.cjs +3 -3
  131. package/dist/store/facts.cjs.map +1 -1
  132. package/dist/store/facts.d.cts +2 -2
  133. package/dist/store/facts.d.cts.map +1 -1
  134. package/dist/store/facts.d.mts +2 -2
  135. package/dist/store/facts.d.mts.map +1 -1
  136. package/dist/store/facts.mjs +3 -3
  137. package/dist/store/facts.mjs.map +1 -1
  138. package/dist/store/index.cjs +1 -1
  139. package/dist/store/index.cjs.map +1 -1
  140. package/dist/store/index.d.cts +44 -13
  141. package/dist/store/index.d.cts.map +1 -1
  142. package/dist/store/index.d.mts +44 -13
  143. package/dist/store/index.d.mts.map +1 -1
  144. package/dist/store/index.mjs +1 -1
  145. package/dist/store/index.mjs.map +1 -1
  146. package/dist/store/postgres-reporting.cjs +30 -10
  147. package/dist/store/postgres-reporting.cjs.map +1 -1
  148. package/dist/store/postgres-reporting.d.cts +4 -2
  149. package/dist/store/postgres-reporting.d.cts.map +1 -1
  150. package/dist/store/postgres-reporting.d.mts +4 -2
  151. package/dist/store/postgres-reporting.d.mts.map +1 -1
  152. package/dist/store/postgres-reporting.mjs +30 -10
  153. package/dist/store/postgres-reporting.mjs.map +1 -1
  154. package/dist/store/postgres.cjs +39 -3
  155. package/dist/store/postgres.cjs.map +1 -1
  156. package/dist/store/postgres.d.cts +4 -2
  157. package/dist/store/postgres.d.cts.map +1 -1
  158. package/dist/store/postgres.d.mts +4 -2
  159. package/dist/store/postgres.d.mts.map +1 -1
  160. package/dist/store/postgres.mjs +40 -4
  161. package/dist/store/postgres.mjs.map +1 -1
  162. package/dist/store/tables.cjs +75 -11
  163. package/dist/store/tables.cjs.map +1 -1
  164. package/dist/store/tables.d.cts +17 -1
  165. package/dist/store/tables.d.cts.map +1 -1
  166. package/dist/store/tables.d.mts +17 -1
  167. package/dist/store/tables.d.mts.map +1 -1
  168. package/dist/store/tables.mjs +74 -12
  169. package/dist/store/tables.mjs.map +1 -1
  170. package/dist/templates.cjs +60 -7
  171. package/dist/templates.cjs.map +1 -1
  172. package/dist/templates.d.cts +101 -13
  173. package/dist/templates.d.cts.map +1 -1
  174. package/dist/templates.d.mts +101 -13
  175. package/dist/templates.d.mts.map +1 -1
  176. package/dist/templates.mjs +61 -9
  177. package/dist/templates.mjs.map +1 -1
  178. package/package.json +1 -1
@@ -15,7 +15,8 @@ function requester(request) {
15
15
  }
16
16
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
17
17
  async function handleTracking(request, reporting, options = {}) {
18
- const path = new URL(request.url).pathname.split("/");
18
+ const url = new URL(request.url);
19
+ const path = url.pathname.split("/");
19
20
  const root = path.at(1);
20
21
  const kind = path.at(2);
21
22
  const id = path.at(3);
@@ -33,10 +34,10 @@ async function handleTracking(request, reporting, options = {}) {
33
34
  if ((kind === "l" || kind === "u") && position !== void 0 && read) {
34
35
  const target = await recordClick(reporting, id, Number(position), who, options);
35
36
  if (target === void 0) return page(options, "This link has expired.", 404);
36
- if (kind === "u") return confirmUnsubscribe(options, id);
37
+ if (kind === "u") return confirmUnsubscribe(reporting, options, id);
37
38
  return Response.redirect(target, 302);
38
39
  }
39
- if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
40
+ if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(reporting, options, id);
40
41
  if (kind === "d" && who.method === "POST") {
41
42
  const known = await reporting.recordDeviceDelivered(id, Date.now());
42
43
  return new Response(null, { status: known ? 204 : 404 });
@@ -66,23 +67,40 @@ async function handleTracking(request, reporting, options = {}) {
66
67
  }
67
68
  if (kind === "u" && who.method === "POST") {
68
69
  const at = Date.now();
69
- const unsubscribed = await reporting.recordUnsubscribe(id, at);
70
+ const all = url.searchParams.get("scope") === "all";
71
+ const unsubscribed = await reporting.recordUnsubscribe(id, at, all ? "all" : "topic");
70
72
  if (unsubscribed === void 0) return page(options, "You won't get these emails anymore.");
73
+ if (unsubscribed.topic === null) return page(options, "This link has expired.", 404);
71
74
  if (options.onUnsubscribed !== void 0) try {
72
75
  await options.onUnsubscribed({
73
76
  messageId: id,
74
77
  userId: unsubscribed.userId,
75
- at
78
+ at,
79
+ topic: unsubscribed.topic,
80
+ all
76
81
  });
77
82
  } catch (error) {
78
83
  console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);
79
84
  }
80
- return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
85
+ const from = options.product === void 0 ? "" : ` from ${escape(options.product)}`;
86
+ return page(options, all ? `You won't get emails${from} anymore.` : `You won't get ${escape(topicName(unsubscribed.topic))} emails${from} anymore.`);
81
87
  }
82
88
  }
83
- /** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */
84
- function confirmUnsubscribe(options, id) {
85
- return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
89
+ /** A topic id as the page shows it: `product_updates` reads as "product updates". */
90
+ function topicName(topic) {
91
+ return topic.replace(/[_-]+/g, " ");
92
+ }
93
+ /**
94
+ * The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks: the
95
+ * email's topic (the default), or every email. A copy nobody knows, or a transactional one (the
96
+ * link was never in it), is an expired link.
97
+ */
98
+ async function confirmUnsubscribe(reporting, options, id) {
99
+ const message = await reporting.lookupMessage(id);
100
+ if (message?.channel !== "email" || message.topic === null) return page(options, "This link has expired.", 404);
101
+ const product = options.product === void 0 ? "" : ` from ${escape(options.product)}`;
102
+ const topic = escape(topicName(message.topic));
103
+ return page(options, `Unsubscribe from ${topic} emails${product}?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe from ${topic}</button></form><form method="post" action="/t/u/${id}?scope=all"><button type="submit" class="secondary">Unsubscribe from all emails</button></form>`);
86
104
  }
87
105
  /**
88
106
  * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
@@ -150,7 +168,7 @@ function escape(text) {
150
168
  return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
151
169
  }
152
170
  function page(options, message, status = 200, extra = "") {
153
- const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
171
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}form{margin:12px 0}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}button.secondary{background:#e4e4e7;color:#18181b}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
154
172
  return new Response(html, {
155
173
  status,
156
174
  headers: { "content-type": "text/html; charset=utf-8" }
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email\n * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is\n * logged, never a reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n const unsubscribed = await reporting.recordUnsubscribe(id, at);\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({ messageId: id, userId: unsubscribed.userId, at });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here):\n * opts out of the email's topic; `?scope=all` (the page's\n * second button) of every email\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. The page offers the email's topic (the default, and what\n * a mail client's one-click POST does) and every email. Changing a template's or the config's\n * unsubscribe URL, or removing the template, changes nothing for emails already sent. A\n * transactional email (no topic) never carried the link: every `/t/u` variant answers 404 for it.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): from the email's `topic`, or with `all` from every\n * email — then the host wakes the user's waits that read email reachability, so an\n * `exitWhen: not(reachable('email'))` takes effect at once. Its failure is logged, never a\n * reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: {\n messageId: string;\n userId: string;\n at: number;\n topic: string;\n all: boolean;\n }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(reporting, options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read)\n return confirmUnsubscribe(reporting, options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n // The page's \"all emails\" button; a mail client's one-click POST carries no scope\n const all = url.searchParams.get('scope') === 'all';\n const unsubscribed = await reporting.recordUnsubscribe(id, at, all ? 'all' : 'topic');\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n // A transactional email never carried the link\n if (unsubscribed.topic === null) return page(options, 'This link has expired.', 404);\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({\n messageId: id,\n userId: unsubscribed.userId,\n at,\n topic: unsubscribed.topic,\n all,\n });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n const from = options.product === undefined ? '' : ` from ${escape(options.product)}`;\n return page(\n options,\n all\n ? `You won't get emails${from} anymore.`\n : `You won't get ${escape(topicName(unsubscribed.topic))} emails${from} anymore.`\n );\n }\n return undefined;\n}\n\n/** A topic id as the page shows it: `product_updates` reads as \"product updates\". */\nfunction topicName(topic: string): string {\n return topic.replace(/[_-]+/g, ' ');\n}\n\n/**\n * The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks: the\n * email's topic (the default), or every email. A copy nobody knows, or a transactional one (the\n * link was never in it), is an expired link.\n */\nasync function confirmUnsubscribe(\n reporting: JourneyReporting,\n options: TrackingOptions,\n id: string\n): Promise<Response> {\n const message = await reporting.lookupMessage(id);\n if (message?.channel !== 'email' || message.topic === null) {\n return page(options, 'This link has expired.', 404);\n }\n const product = options.product === undefined ? '' : ` from ${escape(options.product)}`;\n const topic = escape(topicName(message.topic));\n return page(\n options,\n `Unsubscribe from ${topic} emails${product}?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe from ${topic}</button></form>` +\n `<form method=\"post\" action=\"/t/u/${id}?scope=all\"><button type=\"submit\" class=\"secondary\">Unsubscribe from all emails</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}form{margin:12px 0}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}button.secondary{background:#e4e4e7;color:#18181b}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
@@ -8,7 +8,9 @@ import { JourneyReporting } from "../store/index.cjs";
8
8
  * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm
9
9
  * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm
10
10
  * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click
11
- * unsubscribe (RFC 8058: a mail client's button posts here)
11
+ * unsubscribe (RFC 8058: a mail client's button posts here):
12
+ * opts out of the email's topic; `?scope=all` (the page's
13
+ * second button) of every email
12
14
  * POST /t/d/<message> a push the device received (iOS's notification service
13
15
  * extension, a browser's service worker)
14
16
  * POST /t/p/<message> a push opened, reported by a browser's service worker
@@ -18,8 +20,10 @@ import { JourneyReporting } from "../store/index.cjs";
18
20
  * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An
19
21
  * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was
20
22
  * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,
21
- * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or
22
- * removing the template, changes nothing for emails already sent.
23
+ * which no link scanner sends — counts. The page offers the email's topic (the default, and what
24
+ * a mail client's one-click POST does) and every email. Changing a template's or the config's
25
+ * unsubscribe URL, or removing the template, changes nothing for emails already sent. A
26
+ * transactional email (no topic) never carried the link: every `/t/u` variant answers 404 for it.
23
27
  */
24
28
  export interface TrackingOptions {
25
29
  /** The name the confirmation pages show (the config's `title`). */
@@ -33,14 +37,17 @@ export interface TrackingOptions {
33
37
  at: number;
34
38
  }) => Promise<void>) | undefined;
35
39
  /**
36
- * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email
37
- * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is
38
- * logged, never a reason to fail the unsubscribe.
40
+ * An unsubscribe was recorded (`POST /t/u`): from the email's `topic`, or with `all` from every
41
+ * email — then the host wakes the user's waits that read email reachability, so an
42
+ * `exitWhen: not(reachable('email'))` takes effect at once. Its failure is logged, never a
43
+ * reason to fail the unsubscribe.
39
44
  */
40
45
  onUnsubscribed?: ((unsubscribe: {
41
46
  messageId: string;
42
47
  userId: string;
43
48
  at: number;
49
+ topic: string;
50
+ all: boolean;
44
51
  }) => Promise<void>) | undefined;
45
52
  }
46
53
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;iBA0BiB;;EAEf;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;;;;EAOlE,mBACM;IAAe;IAAmB;IAAgB;QAAiB;;;wBAgCrD,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAoFW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
1
+ {"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BiB;;EAEf;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;;;;;EAQlE,mBACM;IACA;IACA;IACA;IACA;IACA;QACI;;;wBAgCU,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAoHW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
@@ -8,7 +8,9 @@ import { JourneyReporting } from "../store/index.mjs";
8
8
  * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm
9
9
  * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm
10
10
  * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click
11
- * unsubscribe (RFC 8058: a mail client's button posts here)
11
+ * unsubscribe (RFC 8058: a mail client's button posts here):
12
+ * opts out of the email's topic; `?scope=all` (the page's
13
+ * second button) of every email
12
14
  * POST /t/d/<message> a push the device received (iOS's notification service
13
15
  * extension, a browser's service worker)
14
16
  * POST /t/p/<message> a push opened, reported by a browser's service worker
@@ -18,8 +20,10 @@ import { JourneyReporting } from "../store/index.mjs";
18
20
  * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An
19
21
  * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was
20
22
  * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,
21
- * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or
22
- * removing the template, changes nothing for emails already sent.
23
+ * which no link scanner sends — counts. The page offers the email's topic (the default, and what
24
+ * a mail client's one-click POST does) and every email. Changing a template's or the config's
25
+ * unsubscribe URL, or removing the template, changes nothing for emails already sent. A
26
+ * transactional email (no topic) never carried the link: every `/t/u` variant answers 404 for it.
23
27
  */
24
28
  export interface TrackingOptions {
25
29
  /** The name the confirmation pages show (the config's `title`). */
@@ -33,14 +37,17 @@ export interface TrackingOptions {
33
37
  at: number;
34
38
  }) => Promise<void>) | undefined;
35
39
  /**
36
- * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email
37
- * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is
38
- * logged, never a reason to fail the unsubscribe.
40
+ * An unsubscribe was recorded (`POST /t/u`): from the email's `topic`, or with `all` from every
41
+ * email — then the host wakes the user's waits that read email reachability, so an
42
+ * `exitWhen: not(reachable('email'))` takes effect at once. Its failure is logged, never a
43
+ * reason to fail the unsubscribe.
39
44
  */
40
45
  onUnsubscribed?: ((unsubscribe: {
41
46
  messageId: string;
42
47
  userId: string;
43
48
  at: number;
49
+ topic: string;
50
+ all: boolean;
44
51
  }) => Promise<void>) | undefined;
45
52
  }
46
53
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;iBA0BiB;;EAEf;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;;;;EAOlE,mBACM;IAAe;IAAmB;IAAgB;QAAiB;;;wBAgCrD,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAoFW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
1
+ {"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BiB;;EAEf;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;;;;;EAQlE,mBACM;IACA;IACA;IACA;IACA;IACA;QACI;;;wBAgCU,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAoHW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
@@ -14,7 +14,8 @@ function requester(request) {
14
14
  }
15
15
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
16
16
  async function handleTracking(request, reporting, options = {}) {
17
- const path = new URL(request.url).pathname.split("/");
17
+ const url = new URL(request.url);
18
+ const path = url.pathname.split("/");
18
19
  const root = path.at(1);
19
20
  const kind = path.at(2);
20
21
  const id = path.at(3);
@@ -32,10 +33,10 @@ async function handleTracking(request, reporting, options = {}) {
32
33
  if ((kind === "l" || kind === "u") && position !== void 0 && read) {
33
34
  const target = await recordClick(reporting, id, Number(position), who, options);
34
35
  if (target === void 0) return page(options, "This link has expired.", 404);
35
- if (kind === "u") return confirmUnsubscribe(options, id);
36
+ if (kind === "u") return confirmUnsubscribe(reporting, options, id);
36
37
  return Response.redirect(target, 302);
37
38
  }
38
- if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
39
+ if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(reporting, options, id);
39
40
  if (kind === "d" && who.method === "POST") {
40
41
  const known = await reporting.recordDeviceDelivered(id, Date.now());
41
42
  return new Response(null, { status: known ? 204 : 404 });
@@ -65,23 +66,40 @@ async function handleTracking(request, reporting, options = {}) {
65
66
  }
66
67
  if (kind === "u" && who.method === "POST") {
67
68
  const at = Date.now();
68
- const unsubscribed = await reporting.recordUnsubscribe(id, at);
69
+ const all = url.searchParams.get("scope") === "all";
70
+ const unsubscribed = await reporting.recordUnsubscribe(id, at, all ? "all" : "topic");
69
71
  if (unsubscribed === void 0) return page(options, "You won't get these emails anymore.");
72
+ if (unsubscribed.topic === null) return page(options, "This link has expired.", 404);
70
73
  if (options.onUnsubscribed !== void 0) try {
71
74
  await options.onUnsubscribed({
72
75
  messageId: id,
73
76
  userId: unsubscribed.userId,
74
- at
77
+ at,
78
+ topic: unsubscribed.topic,
79
+ all
75
80
  });
76
81
  } catch (error) {
77
82
  console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);
78
83
  }
79
- return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
84
+ const from = options.product === void 0 ? "" : ` from ${escape(options.product)}`;
85
+ return page(options, all ? `You won't get emails${from} anymore.` : `You won't get ${escape(topicName(unsubscribed.topic))} emails${from} anymore.`);
80
86
  }
81
87
  }
82
- /** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */
83
- function confirmUnsubscribe(options, id) {
84
- return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
88
+ /** A topic id as the page shows it: `product_updates` reads as "product updates". */
89
+ function topicName(topic) {
90
+ return topic.replace(/[_-]+/g, " ");
91
+ }
92
+ /**
93
+ * The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks: the
94
+ * email's topic (the default), or every email. A copy nobody knows, or a transactional one (the
95
+ * link was never in it), is an expired link.
96
+ */
97
+ async function confirmUnsubscribe(reporting, options, id) {
98
+ const message = await reporting.lookupMessage(id);
99
+ if (message?.channel !== "email" || message.topic === null) return page(options, "This link has expired.", 404);
100
+ const product = options.product === void 0 ? "" : ` from ${escape(options.product)}`;
101
+ const topic = escape(topicName(message.topic));
102
+ return page(options, `Unsubscribe from ${topic} emails${product}?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe from ${topic}</button></form><form method="post" action="/t/u/${id}?scope=all"><button type="submit" class="secondary">Unsubscribe from all emails</button></form>`);
85
103
  }
86
104
  /**
87
105
  * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
@@ -149,7 +167,7 @@ function escape(text) {
149
167
  return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
150
168
  }
151
169
  function page(options, message, status = 200, extra = "") {
152
- const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
170
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}form{margin:12px 0}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}button.secondary{background:#e4e4e7;color:#18181b}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
153
171
  return new Response(html, {
154
172
  status,
155
173
  headers: { "content-type": "text/html; charset=utf-8" }
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email\n * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is\n * logged, never a reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n const unsubscribed = await reporting.recordUnsubscribe(id, at);\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({ messageId: id, userId: unsubscribed.userId, at });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here):\n * opts out of the email's topic; `?scope=all` (the page's\n * second button) of every email\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. The page offers the email's topic (the default, and what\n * a mail client's one-click POST does) and every email. Changing a template's or the config's\n * unsubscribe URL, or removing the template, changes nothing for emails already sent. A\n * transactional email (no topic) never carried the link: every `/t/u` variant answers 404 for it.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): from the email's `topic`, or with `all` from every\n * email — then the host wakes the user's waits that read email reachability, so an\n * `exitWhen: not(reachable('email'))` takes effect at once. Its failure is logged, never a\n * reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: {\n messageId: string;\n userId: string;\n at: number;\n topic: string;\n all: boolean;\n }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(reporting, options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read)\n return confirmUnsubscribe(reporting, options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n // The page's \"all emails\" button; a mail client's one-click POST carries no scope\n const all = url.searchParams.get('scope') === 'all';\n const unsubscribed = await reporting.recordUnsubscribe(id, at, all ? 'all' : 'topic');\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n // A transactional email never carried the link\n if (unsubscribed.topic === null) return page(options, 'This link has expired.', 404);\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({\n messageId: id,\n userId: unsubscribed.userId,\n at,\n topic: unsubscribed.topic,\n all,\n });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n const from = options.product === undefined ? '' : ` from ${escape(options.product)}`;\n return page(\n options,\n all\n ? `You won't get emails${from} anymore.`\n : `You won't get ${escape(topicName(unsubscribed.topic))} emails${from} anymore.`\n );\n }\n return undefined;\n}\n\n/** A topic id as the page shows it: `product_updates` reads as \"product updates\". */\nfunction topicName(topic: string): string {\n return topic.replace(/[_-]+/g, ' ');\n}\n\n/**\n * The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks: the\n * email's topic (the default), or every email. A copy nobody knows, or a transactional one (the\n * link was never in it), is an expired link.\n */\nasync function confirmUnsubscribe(\n reporting: JourneyReporting,\n options: TrackingOptions,\n id: string\n): Promise<Response> {\n const message = await reporting.lookupMessage(id);\n if (message?.channel !== 'email' || message.topic === null) {\n return page(options, 'This link has expired.', 404);\n }\n const product = options.product === undefined ? '' : ` from ${escape(options.product)}`;\n const topic = escape(topicName(message.topic));\n return page(\n options,\n `Unsubscribe from ${topic} emails${product}?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe from ${topic}</button></form>` +\n `<form method=\"post\" action=\"/t/u/${id}?scope=all\"><button type=\"submit\" class=\"secondary\">Unsubscribe from all emails</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}form{margin:12px 0}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}button.secondary{background:#e4e4e7;color:#18181b}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
@@ -45,9 +45,9 @@ var JourneyFactSource = class {
45
45
  getSegmentCondition(name) {
46
46
  return this.store.getSegmentCondition(name);
47
47
  }
48
- /** The user's registered devices; none, and push skips while `reachable('push')` is false. */
49
- async isSuppressed(channel) {
50
- return this.store.isSuppressed(this.userId, channel);
48
+ /** The user's opt-outs: the channel's, or with a topic that topic's as well. */
49
+ async isSuppressed(channel, topic) {
50
+ return this.store.isSuppressed(this.userId, channel, topic);
51
51
  }
52
52
  listDevices() {
53
53
  return this.store.listDevices(this.userId);
@@ -1 +1 @@
1
- {"version":3,"file":"facts.cjs","names":["matchesWhere"],"sources":["../../src/store/facts.ts"],"sourcesContent":["import { matchesWhere } from '../engine/condition';\nimport type { FactSource, PushDevice } from '../engine/ports';\nimport type { ConditionIR, ScalarIR } from '../ir';\nimport type { JourneyStore } from './index';\n\n/** The profile path that reads the user's own id (`u.userId`). */\nexport const USER_ID_PROPERTY = 'userId';\n\n/** Single-user fact source over a JourneyStore: evaluation lives in engine/condition.ts, this only reads. */\nexport class JourneyFactSource implements FactSource {\n constructor(\n private readonly store: JourneyStore,\n private readonly userId: string\n ) {}\n\n async countEvents(\n event: string,\n opts?: { sinceMs?: number; untilMs?: number; where?: ConditionIR }\n ): Promise<number> {\n const window = {\n ...(opts?.sinceMs === undefined ? {} : { sinceMs: opts.sinceMs }),\n ...(opts?.untilMs === undefined ? {} : { untilMs: opts.untilMs }),\n };\n if (opts?.where === undefined) return this.store.countEvents(this.userId, event, window);\n // Payload filtering happens in JS via the shared evaluator — one set of\n // where semantics with the in-memory FactSource; per-user row counts are small.\n const where = opts.where;\n const payloads = await this.store.listEventPayloads(this.userId, event, window);\n return payloads.filter((payload) => matchesWhere(payload, where)).length;\n }\n\n /**\n * The scalar boundary. Everything downstream — comparisons, subject\n * placeholders, resolved message props — is typed ScalarIR, but nothing so\n * far has checked that the stored JSON is one: /identify validates only that\n * `props` is a plain object, and the merge stores whatever nesting it is\n * handed. Left unchecked, `gt(score, 3)` on `['5']` coerces its way to true\n * while `eq(score, 5)` is false, and `String(recipient)` on an object\n * addresses an email to `[object Object]`.\n *\n * So a non-scalar value reads as absent, matching how matchesWhere treats a\n * non-scalar payload leaf. The port cannot say \"present but not a scalar\",\n * and guessing at a coercion is what produced the wrong branch in the first\n * place.\n */\n async getProperty(path: string): Promise<ScalarIR | undefined> {\n // The system field: the request's identity, never stored in the profile (contract.user.userId)\n if (path === USER_ID_PROPERTY) return this.userId;\n const props: Record<string, unknown> | undefined = await this.store.getProfile(this.userId);\n if (props === undefined) return undefined;\n // Own keys only: `props[path]` would otherwise find Object.prototype, so\n // `exists(u.toString)` held for every user with a profile row and a subject\n // placeholder rendered native-code text into the email.\n if (!Object.hasOwn(props, path)) return undefined;\n const value = props[path];\n // An explicit null means \"unset\" and behaves as not_exists. /identify\n // itself clears a property by removing the key (the merge drops nulls), so\n // this covers profiles written by other means — a direct SQL fixup, a\n // migration, an import.\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n return value;\n }\n return undefined;\n }\n\n getSegmentCondition(name: string): Promise<ConditionIR | undefined> {\n return this.store.getSegmentCondition(name);\n }\n\n /** The user's registered devices; none, and push skips while `reachable('push')` is false. */\n async isSuppressed(channel: string): Promise<boolean> {\n return this.store.isSuppressed(this.userId, channel);\n }\n\n listDevices(): Promise<PushDevice[]> {\n return this.store.listDevices(this.userId);\n }\n}\n"],"mappings":";;;;AAMA,MAAa,mBAAmB;;AAGhC,IAAa,oBAAb,MAAqD;CAEhC;CACA;CAFnB,YACE,OACA,QACA;EAFiB,KAAA,QAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,YACJ,OACA,MACiB;EACjB,MAAM,SAAS;GACb,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;GAC/D,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;EACjE;EACA,IAAI,MAAM,UAAU,KAAA,GAAW,OAAO,KAAK,MAAM,YAAY,KAAK,QAAQ,OAAO,MAAM;EAGvF,MAAM,QAAQ,KAAK;EAEnB,QAAO,MADgB,KAAK,MAAM,kBAAkB,KAAK,QAAQ,OAAO,MAAM,EAAA,CAC9D,QAAQ,YAAYA,yBAAAA,aAAa,SAAS,KAAK,CAAC,CAAC,CAAC;CACpE;;;;;;;;;;;;;;;CAgBA,MAAM,YAAY,MAA6C;EAE7D,IAAI,SAAA,UAA2B,OAAO,KAAK;EAC3C,MAAM,QAA6C,MAAM,KAAK,MAAM,WAAW,KAAK,MAAM;EAC1F,IAAI,UAAU,KAAA,GAAW,OAAO,KAAA;EAIhC,IAAI,CAAC,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO,KAAA;EACxC,MAAM,QAAQ,MAAM;EAKpB,IAAI,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,WAC7E,OAAO;CAGX;CAEA,oBAAoB,MAAgD;EAClE,OAAO,KAAK,MAAM,oBAAoB,IAAI;CAC5C;;CAGA,MAAM,aAAa,SAAmC;EACpD,OAAO,KAAK,MAAM,aAAa,KAAK,QAAQ,OAAO;CACrD;CAEA,cAAqC;EACnC,OAAO,KAAK,MAAM,YAAY,KAAK,MAAM;CAC3C;AACF"}
1
+ {"version":3,"file":"facts.cjs","names":["matchesWhere"],"sources":["../../src/store/facts.ts"],"sourcesContent":["import { matchesWhere } from '../engine/condition';\nimport type { FactSource, PushDevice } from '../engine/ports';\nimport type { ConditionIR, ScalarIR } from '../ir';\nimport type { JourneyStore } from './index';\n\n/** The profile path that reads the user's own id (`u.userId`). */\nexport const USER_ID_PROPERTY = 'userId';\n\n/** Single-user fact source over a JourneyStore: evaluation lives in engine/condition.ts, this only reads. */\nexport class JourneyFactSource implements FactSource {\n constructor(\n private readonly store: JourneyStore,\n private readonly userId: string\n ) {}\n\n async countEvents(\n event: string,\n opts?: { sinceMs?: number; untilMs?: number; where?: ConditionIR }\n ): Promise<number> {\n const window = {\n ...(opts?.sinceMs === undefined ? {} : { sinceMs: opts.sinceMs }),\n ...(opts?.untilMs === undefined ? {} : { untilMs: opts.untilMs }),\n };\n if (opts?.where === undefined) return this.store.countEvents(this.userId, event, window);\n // Payload filtering happens in JS via the shared evaluator — one set of\n // where semantics with the in-memory FactSource; per-user row counts are small.\n const where = opts.where;\n const payloads = await this.store.listEventPayloads(this.userId, event, window);\n return payloads.filter((payload) => matchesWhere(payload, where)).length;\n }\n\n /**\n * The scalar boundary. Everything downstream — comparisons, subject\n * placeholders, resolved message props — is typed ScalarIR, but nothing so\n * far has checked that the stored JSON is one: /identify validates only that\n * `props` is a plain object, and the merge stores whatever nesting it is\n * handed. Left unchecked, `gt(score, 3)` on `['5']` coerces its way to true\n * while `eq(score, 5)` is false, and `String(recipient)` on an object\n * addresses an email to `[object Object]`.\n *\n * So a non-scalar value reads as absent, matching how matchesWhere treats a\n * non-scalar payload leaf. The port cannot say \"present but not a scalar\",\n * and guessing at a coercion is what produced the wrong branch in the first\n * place.\n */\n async getProperty(path: string): Promise<ScalarIR | undefined> {\n // The system field: the request's identity, never stored in the profile (contract.user.userId)\n if (path === USER_ID_PROPERTY) return this.userId;\n const props: Record<string, unknown> | undefined = await this.store.getProfile(this.userId);\n if (props === undefined) return undefined;\n // Own keys only: `props[path]` would otherwise find Object.prototype, so\n // `exists(u.toString)` held for every user with a profile row and a subject\n // placeholder rendered native-code text into the email.\n if (!Object.hasOwn(props, path)) return undefined;\n const value = props[path];\n // An explicit null means \"unset\" and behaves as not_exists. /identify\n // itself clears a property by removing the key (the merge drops nulls), so\n // this covers profiles written by other means — a direct SQL fixup, a\n // migration, an import.\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n return value;\n }\n return undefined;\n }\n\n getSegmentCondition(name: string): Promise<ConditionIR | undefined> {\n return this.store.getSegmentCondition(name);\n }\n\n /** The user's opt-outs: the channel's, or with a topic that topic's as well. */\n async isSuppressed(channel: string, topic?: string): Promise<boolean> {\n return this.store.isSuppressed(this.userId, channel, topic);\n }\n\n listDevices(): Promise<PushDevice[]> {\n return this.store.listDevices(this.userId);\n }\n}\n"],"mappings":";;;;AAMA,MAAa,mBAAmB;;AAGhC,IAAa,oBAAb,MAAqD;CAEhC;CACA;CAFnB,YACE,OACA,QACA;EAFiB,KAAA,QAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,YACJ,OACA,MACiB;EACjB,MAAM,SAAS;GACb,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;GAC/D,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;EACjE;EACA,IAAI,MAAM,UAAU,KAAA,GAAW,OAAO,KAAK,MAAM,YAAY,KAAK,QAAQ,OAAO,MAAM;EAGvF,MAAM,QAAQ,KAAK;EAEnB,QAAO,MADgB,KAAK,MAAM,kBAAkB,KAAK,QAAQ,OAAO,MAAM,EAAA,CAC9D,QAAQ,YAAYA,yBAAAA,aAAa,SAAS,KAAK,CAAC,CAAC,CAAC;CACpE;;;;;;;;;;;;;;;CAgBA,MAAM,YAAY,MAA6C;EAE7D,IAAI,SAAA,UAA2B,OAAO,KAAK;EAC3C,MAAM,QAA6C,MAAM,KAAK,MAAM,WAAW,KAAK,MAAM;EAC1F,IAAI,UAAU,KAAA,GAAW,OAAO,KAAA;EAIhC,IAAI,CAAC,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO,KAAA;EACxC,MAAM,QAAQ,MAAM;EAKpB,IAAI,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,WAC7E,OAAO;CAGX;CAEA,oBAAoB,MAAgD;EAClE,OAAO,KAAK,MAAM,oBAAoB,IAAI;CAC5C;;CAGA,MAAM,aAAa,SAAiB,OAAkC;EACpE,OAAO,KAAK,MAAM,aAAa,KAAK,QAAQ,SAAS,KAAK;CAC5D;CAEA,cAAqC;EACnC,OAAO,KAAK,MAAM,YAAY,KAAK,MAAM;CAC3C;AACF"}
@@ -29,8 +29,8 @@ export declare class JourneyFactSource implements FactSource {
29
29
  */
30
30
  getProperty(path: string): Promise<ScalarIR | undefined>;
31
31
  getSegmentCondition(name: string): Promise<ConditionIR | undefined>;
32
- /** The user's registered devices; none, and push skips while `reachable('push')` is false. */
33
- isSuppressed(channel: string): Promise<boolean>;
32
+ /** The user's opt-outs: the channel's, or with a topic that topic's as well. */
33
+ isSuppressed(channel: string, topic?: string): Promise<boolean>;
34
34
  listDevices(): Promise<PushDevice[]>;
35
35
  }
36
36
  //# sourceMappingURL=facts.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"facts.d.cts","names":[],"sources":["../../src/store/facts.ts"],"mappings":";;;;qBAMa;;qBAGA,6BAA6B;mBAErB;mBACA;EAFnB,YACmB,OAAO,cACP;EAGb,YACJ,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;;;;;;;;;;;;;;;EA2BG,YAAY,eAAe,QAAQ;EAoBzC,oBAAoB,eAAe,QAAQ;;EAKrC,aAAa,kBAAkB;EAIrC,eAAe,QAAQ"}
1
+ {"version":3,"file":"facts.d.cts","names":[],"sources":["../../src/store/facts.ts"],"mappings":";;;;qBAMa;;qBAGA,6BAA6B;mBAErB;mBACA;EAFnB,YACmB,OAAO,cACP;EAGb,YACJ,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;;;;;;;;;;;;;;;EA2BG,YAAY,eAAe,QAAQ;EAoBzC,oBAAoB,eAAe,QAAQ;;EAKrC,aAAa,iBAAiB,iBAAiB;EAIrD,eAAe,QAAQ"}
@@ -29,8 +29,8 @@ export declare class JourneyFactSource implements FactSource {
29
29
  */
30
30
  getProperty(path: string): Promise<ScalarIR | undefined>;
31
31
  getSegmentCondition(name: string): Promise<ConditionIR | undefined>;
32
- /** The user's registered devices; none, and push skips while `reachable('push')` is false. */
33
- isSuppressed(channel: string): Promise<boolean>;
32
+ /** The user's opt-outs: the channel's, or with a topic that topic's as well. */
33
+ isSuppressed(channel: string, topic?: string): Promise<boolean>;
34
34
  listDevices(): Promise<PushDevice[]>;
35
35
  }
36
36
  //# sourceMappingURL=facts.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"facts.d.mts","names":[],"sources":["../../src/store/facts.ts"],"mappings":";;;;qBAMa;;qBAGA,6BAA6B;mBAErB;mBACA;EAFnB,YACmB,OAAO,cACP;EAGb,YACJ,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;;;;;;;;;;;;;;;EA2BG,YAAY,eAAe,QAAQ;EAoBzC,oBAAoB,eAAe,QAAQ;;EAKrC,aAAa,kBAAkB;EAIrC,eAAe,QAAQ"}
1
+ {"version":3,"file":"facts.d.mts","names":[],"sources":["../../src/store/facts.ts"],"mappings":";;;;qBAMa;;qBAGA,6BAA6B;mBAErB;mBACA;EAFnB,YACmB,OAAO,cACP;EAGb,YACJ,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;;;;;;;;;;;;;;;EA2BG,YAAY,eAAe,QAAQ;EAoBzC,oBAAoB,eAAe,QAAQ;;EAKrC,aAAa,iBAAiB,iBAAiB;EAIrD,eAAe,QAAQ"}
@@ -44,9 +44,9 @@ var JourneyFactSource = class {
44
44
  getSegmentCondition(name) {
45
45
  return this.store.getSegmentCondition(name);
46
46
  }
47
- /** The user's registered devices; none, and push skips while `reachable('push')` is false. */
48
- async isSuppressed(channel) {
49
- return this.store.isSuppressed(this.userId, channel);
47
+ /** The user's opt-outs: the channel's, or with a topic that topic's as well. */
48
+ async isSuppressed(channel, topic) {
49
+ return this.store.isSuppressed(this.userId, channel, topic);
50
50
  }
51
51
  listDevices() {
52
52
  return this.store.listDevices(this.userId);
@@ -1 +1 @@
1
- {"version":3,"file":"facts.mjs","names":[],"sources":["../../src/store/facts.ts"],"sourcesContent":["import { matchesWhere } from '../engine/condition';\nimport type { FactSource, PushDevice } from '../engine/ports';\nimport type { ConditionIR, ScalarIR } from '../ir';\nimport type { JourneyStore } from './index';\n\n/** The profile path that reads the user's own id (`u.userId`). */\nexport const USER_ID_PROPERTY = 'userId';\n\n/** Single-user fact source over a JourneyStore: evaluation lives in engine/condition.ts, this only reads. */\nexport class JourneyFactSource implements FactSource {\n constructor(\n private readonly store: JourneyStore,\n private readonly userId: string\n ) {}\n\n async countEvents(\n event: string,\n opts?: { sinceMs?: number; untilMs?: number; where?: ConditionIR }\n ): Promise<number> {\n const window = {\n ...(opts?.sinceMs === undefined ? {} : { sinceMs: opts.sinceMs }),\n ...(opts?.untilMs === undefined ? {} : { untilMs: opts.untilMs }),\n };\n if (opts?.where === undefined) return this.store.countEvents(this.userId, event, window);\n // Payload filtering happens in JS via the shared evaluator — one set of\n // where semantics with the in-memory FactSource; per-user row counts are small.\n const where = opts.where;\n const payloads = await this.store.listEventPayloads(this.userId, event, window);\n return payloads.filter((payload) => matchesWhere(payload, where)).length;\n }\n\n /**\n * The scalar boundary. Everything downstream — comparisons, subject\n * placeholders, resolved message props — is typed ScalarIR, but nothing so\n * far has checked that the stored JSON is one: /identify validates only that\n * `props` is a plain object, and the merge stores whatever nesting it is\n * handed. Left unchecked, `gt(score, 3)` on `['5']` coerces its way to true\n * while `eq(score, 5)` is false, and `String(recipient)` on an object\n * addresses an email to `[object Object]`.\n *\n * So a non-scalar value reads as absent, matching how matchesWhere treats a\n * non-scalar payload leaf. The port cannot say \"present but not a scalar\",\n * and guessing at a coercion is what produced the wrong branch in the first\n * place.\n */\n async getProperty(path: string): Promise<ScalarIR | undefined> {\n // The system field: the request's identity, never stored in the profile (contract.user.userId)\n if (path === USER_ID_PROPERTY) return this.userId;\n const props: Record<string, unknown> | undefined = await this.store.getProfile(this.userId);\n if (props === undefined) return undefined;\n // Own keys only: `props[path]` would otherwise find Object.prototype, so\n // `exists(u.toString)` held for every user with a profile row and a subject\n // placeholder rendered native-code text into the email.\n if (!Object.hasOwn(props, path)) return undefined;\n const value = props[path];\n // An explicit null means \"unset\" and behaves as not_exists. /identify\n // itself clears a property by removing the key (the merge drops nulls), so\n // this covers profiles written by other means — a direct SQL fixup, a\n // migration, an import.\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n return value;\n }\n return undefined;\n }\n\n getSegmentCondition(name: string): Promise<ConditionIR | undefined> {\n return this.store.getSegmentCondition(name);\n }\n\n /** The user's registered devices; none, and push skips while `reachable('push')` is false. */\n async isSuppressed(channel: string): Promise<boolean> {\n return this.store.isSuppressed(this.userId, channel);\n }\n\n listDevices(): Promise<PushDevice[]> {\n return this.store.listDevices(this.userId);\n }\n}\n"],"mappings":";;;AAMA,MAAa,mBAAmB;;AAGhC,IAAa,oBAAb,MAAqD;CAEhC;CACA;CAFnB,YACE,OACA,QACA;EAFiB,KAAA,QAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,YACJ,OACA,MACiB;EACjB,MAAM,SAAS;GACb,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;GAC/D,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;EACjE;EACA,IAAI,MAAM,UAAU,KAAA,GAAW,OAAO,KAAK,MAAM,YAAY,KAAK,QAAQ,OAAO,MAAM;EAGvF,MAAM,QAAQ,KAAK;EAEnB,QAAO,MADgB,KAAK,MAAM,kBAAkB,KAAK,QAAQ,OAAO,MAAM,EAAA,CAC9D,QAAQ,YAAY,aAAa,SAAS,KAAK,CAAC,CAAC,CAAC;CACpE;;;;;;;;;;;;;;;CAgBA,MAAM,YAAY,MAA6C;EAE7D,IAAI,SAAA,UAA2B,OAAO,KAAK;EAC3C,MAAM,QAA6C,MAAM,KAAK,MAAM,WAAW,KAAK,MAAM;EAC1F,IAAI,UAAU,KAAA,GAAW,OAAO,KAAA;EAIhC,IAAI,CAAC,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO,KAAA;EACxC,MAAM,QAAQ,MAAM;EAKpB,IAAI,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,WAC7E,OAAO;CAGX;CAEA,oBAAoB,MAAgD;EAClE,OAAO,KAAK,MAAM,oBAAoB,IAAI;CAC5C;;CAGA,MAAM,aAAa,SAAmC;EACpD,OAAO,KAAK,MAAM,aAAa,KAAK,QAAQ,OAAO;CACrD;CAEA,cAAqC;EACnC,OAAO,KAAK,MAAM,YAAY,KAAK,MAAM;CAC3C;AACF"}
1
+ {"version":3,"file":"facts.mjs","names":[],"sources":["../../src/store/facts.ts"],"sourcesContent":["import { matchesWhere } from '../engine/condition';\nimport type { FactSource, PushDevice } from '../engine/ports';\nimport type { ConditionIR, ScalarIR } from '../ir';\nimport type { JourneyStore } from './index';\n\n/** The profile path that reads the user's own id (`u.userId`). */\nexport const USER_ID_PROPERTY = 'userId';\n\n/** Single-user fact source over a JourneyStore: evaluation lives in engine/condition.ts, this only reads. */\nexport class JourneyFactSource implements FactSource {\n constructor(\n private readonly store: JourneyStore,\n private readonly userId: string\n ) {}\n\n async countEvents(\n event: string,\n opts?: { sinceMs?: number; untilMs?: number; where?: ConditionIR }\n ): Promise<number> {\n const window = {\n ...(opts?.sinceMs === undefined ? {} : { sinceMs: opts.sinceMs }),\n ...(opts?.untilMs === undefined ? {} : { untilMs: opts.untilMs }),\n };\n if (opts?.where === undefined) return this.store.countEvents(this.userId, event, window);\n // Payload filtering happens in JS via the shared evaluator — one set of\n // where semantics with the in-memory FactSource; per-user row counts are small.\n const where = opts.where;\n const payloads = await this.store.listEventPayloads(this.userId, event, window);\n return payloads.filter((payload) => matchesWhere(payload, where)).length;\n }\n\n /**\n * The scalar boundary. Everything downstream — comparisons, subject\n * placeholders, resolved message props — is typed ScalarIR, but nothing so\n * far has checked that the stored JSON is one: /identify validates only that\n * `props` is a plain object, and the merge stores whatever nesting it is\n * handed. Left unchecked, `gt(score, 3)` on `['5']` coerces its way to true\n * while `eq(score, 5)` is false, and `String(recipient)` on an object\n * addresses an email to `[object Object]`.\n *\n * So a non-scalar value reads as absent, matching how matchesWhere treats a\n * non-scalar payload leaf. The port cannot say \"present but not a scalar\",\n * and guessing at a coercion is what produced the wrong branch in the first\n * place.\n */\n async getProperty(path: string): Promise<ScalarIR | undefined> {\n // The system field: the request's identity, never stored in the profile (contract.user.userId)\n if (path === USER_ID_PROPERTY) return this.userId;\n const props: Record<string, unknown> | undefined = await this.store.getProfile(this.userId);\n if (props === undefined) return undefined;\n // Own keys only: `props[path]` would otherwise find Object.prototype, so\n // `exists(u.toString)` held for every user with a profile row and a subject\n // placeholder rendered native-code text into the email.\n if (!Object.hasOwn(props, path)) return undefined;\n const value = props[path];\n // An explicit null means \"unset\" and behaves as not_exists. /identify\n // itself clears a property by removing the key (the merge drops nulls), so\n // this covers profiles written by other means — a direct SQL fixup, a\n // migration, an import.\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n return value;\n }\n return undefined;\n }\n\n getSegmentCondition(name: string): Promise<ConditionIR | undefined> {\n return this.store.getSegmentCondition(name);\n }\n\n /** The user's opt-outs: the channel's, or with a topic that topic's as well. */\n async isSuppressed(channel: string, topic?: string): Promise<boolean> {\n return this.store.isSuppressed(this.userId, channel, topic);\n }\n\n listDevices(): Promise<PushDevice[]> {\n return this.store.listDevices(this.userId);\n }\n}\n"],"mappings":";;;AAMA,MAAa,mBAAmB;;AAGhC,IAAa,oBAAb,MAAqD;CAEhC;CACA;CAFnB,YACE,OACA,QACA;EAFiB,KAAA,QAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,YACJ,OACA,MACiB;EACjB,MAAM,SAAS;GACb,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;GAC/D,GAAI,MAAM,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ;EACjE;EACA,IAAI,MAAM,UAAU,KAAA,GAAW,OAAO,KAAK,MAAM,YAAY,KAAK,QAAQ,OAAO,MAAM;EAGvF,MAAM,QAAQ,KAAK;EAEnB,QAAO,MADgB,KAAK,MAAM,kBAAkB,KAAK,QAAQ,OAAO,MAAM,EAAA,CAC9D,QAAQ,YAAY,aAAa,SAAS,KAAK,CAAC,CAAC,CAAC;CACpE;;;;;;;;;;;;;;;CAgBA,MAAM,YAAY,MAA6C;EAE7D,IAAI,SAAA,UAA2B,OAAO,KAAK;EAC3C,MAAM,QAA6C,MAAM,KAAK,MAAM,WAAW,KAAK,MAAM;EAC1F,IAAI,UAAU,KAAA,GAAW,OAAO,KAAA;EAIhC,IAAI,CAAC,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO,KAAA;EACxC,MAAM,QAAQ,MAAM;EAKpB,IAAI,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,WAC7E,OAAO;CAGX;CAEA,oBAAoB,MAAgD;EAClE,OAAO,KAAK,MAAM,oBAAoB,IAAI;CAC5C;;CAGA,MAAM,aAAa,SAAiB,OAAkC;EACpE,OAAO,KAAK,MAAM,aAAa,KAAK,QAAQ,SAAS,KAAK;CAC5D;CAEA,cAAqC;EACnC,OAAO,KAAK,MAAM,YAAY,KAAK,MAAM;CAC3C;AACF"}
@@ -1,9 +1,9 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_store_facts = require("./facts.cjs");
2
3
  const require_store_erasure = require("./erasure.cjs");
3
4
  const require_store_postgres_reporting = require("./postgres-reporting.cjs");
4
5
  const require_store_tables = require("./tables.cjs");
5
6
  const require_store_postgres = require("./postgres.cjs");
6
- const require_store_facts = require("./facts.cjs");
7
7
  // src/store/index.ts
8
8
  /**
9
9
  * The live database lacks tables or columns the engine reads (an SDK upgrade added them, or the