@shware/workflow 5.0.1 → 6.1.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 (190) hide show
  1. package/README.md +224 -53
  2. package/dist/aws/index.cjs +1 -1
  3. package/dist/aws/index.mjs +1 -1
  4. package/dist/client.cjs +68 -4
  5. package/dist/client.cjs.map +1 -1
  6. package/dist/client.d.cts +39 -3
  7. package/dist/client.d.cts.map +1 -1
  8. package/dist/client.d.mts +39 -3
  9. package/dist/client.d.mts.map +1 -1
  10. package/dist/client.mjs +68 -4
  11. package/dist/client.mjs.map +1 -1
  12. package/dist/cloudflare/index.cjs +4 -4
  13. package/dist/cloudflare/index.mjs +4 -4
  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 +3 -2
  53. package/dist/email-sender.cjs.map +1 -1
  54. package/dist/email-sender.d.cts +5 -4
  55. package/dist/email-sender.d.cts.map +1 -1
  56. package/dist/email-sender.d.mts +5 -4
  57. package/dist/email-sender.d.mts.map +1 -1
  58. package/dist/email-sender.mjs +3 -2
  59. package/dist/email-sender.mjs.map +1 -1
  60. package/dist/email.cjs +66 -11
  61. package/dist/email.cjs.map +1 -1
  62. package/dist/email.d.cts +72 -12
  63. package/dist/email.d.cts.map +1 -1
  64. package/dist/email.d.mts +72 -12
  65. package/dist/email.d.mts.map +1 -1
  66. package/dist/email.mjs +63 -12
  67. package/dist/email.mjs.map +1 -1
  68. package/dist/engine/condition.cjs.map +1 -1
  69. package/dist/engine/condition.d.cts.map +1 -1
  70. package/dist/engine/condition.d.mts.map +1 -1
  71. package/dist/engine/condition.mjs.map +1 -1
  72. package/dist/engine/index.cjs +1 -1
  73. package/dist/engine/index.mjs +1 -1
  74. package/dist/engine/interpreter.cjs +21 -4
  75. package/dist/engine/interpreter.cjs.map +1 -1
  76. package/dist/engine/interpreter.mjs +21 -4
  77. package/dist/engine/interpreter.mjs.map +1 -1
  78. package/dist/engine/ports.d.cts +14 -2
  79. package/dist/engine/ports.d.cts.map +1 -1
  80. package/dist/engine/ports.d.mts +14 -2
  81. package/dist/engine/ports.d.mts.map +1 -1
  82. package/dist/host/channels.cjs +1 -1
  83. package/dist/host/channels.mjs +1 -1
  84. package/dist/host/execute.cjs +2 -2
  85. package/dist/host/execute.d.cts +1 -1
  86. package/dist/host/execute.d.mts +1 -1
  87. package/dist/host/execute.mjs +2 -2
  88. package/dist/host/http.cjs +75 -5
  89. package/dist/host/http.cjs.map +1 -1
  90. package/dist/host/http.d.cts +2 -2
  91. package/dist/host/http.d.cts.map +1 -1
  92. package/dist/host/http.d.mts +2 -2
  93. package/dist/host/http.d.mts.map +1 -1
  94. package/dist/host/http.mjs +76 -6
  95. package/dist/host/http.mjs.map +1 -1
  96. package/dist/host/ingest.cjs +173 -4
  97. package/dist/host/ingest.cjs.map +1 -1
  98. package/dist/host/ingest.d.cts +99 -0
  99. package/dist/host/ingest.d.cts.map +1 -1
  100. package/dist/host/ingest.d.mts +99 -0
  101. package/dist/host/ingest.d.mts.map +1 -1
  102. package/dist/host/ingest.mjs +167 -5
  103. package/dist/host/ingest.mjs.map +1 -1
  104. package/dist/host/journey-host.cjs +10 -8
  105. package/dist/host/journey-host.cjs.map +1 -1
  106. package/dist/host/journey-host.mjs +10 -8
  107. package/dist/host/journey-host.mjs.map +1 -1
  108. package/dist/index.cjs +8 -2
  109. package/dist/index.d.cts +5 -5
  110. package/dist/index.d.mts +5 -5
  111. package/dist/index.mjs +5 -5
  112. package/dist/ir.cjs +11 -2
  113. package/dist/ir.cjs.map +1 -1
  114. package/dist/ir.d.cts +17 -0
  115. package/dist/ir.d.cts.map +1 -1
  116. package/dist/ir.d.mts +17 -0
  117. package/dist/ir.d.mts.map +1 -1
  118. package/dist/ir.mjs +11 -3
  119. package/dist/ir.mjs.map +1 -1
  120. package/dist/react-email.cjs +6 -5
  121. package/dist/react-email.cjs.map +1 -1
  122. package/dist/react-email.d.cts.map +1 -1
  123. package/dist/react-email.d.mts.map +1 -1
  124. package/dist/react-email.mjs +6 -5
  125. package/dist/react-email.mjs.map +1 -1
  126. package/dist/reporting/observer.cjs +4 -1
  127. package/dist/reporting/observer.cjs.map +1 -1
  128. package/dist/reporting/observer.d.cts +2 -1
  129. package/dist/reporting/observer.d.cts.map +1 -1
  130. package/dist/reporting/observer.d.mts +2 -1
  131. package/dist/reporting/observer.d.mts.map +1 -1
  132. package/dist/reporting/observer.mjs +4 -1
  133. package/dist/reporting/observer.mjs.map +1 -1
  134. package/dist/reporting/tracking.cjs +31 -10
  135. package/dist/reporting/tracking.cjs.map +1 -1
  136. package/dist/reporting/tracking.d.cts +15 -6
  137. package/dist/reporting/tracking.d.cts.map +1 -1
  138. package/dist/reporting/tracking.d.mts +15 -6
  139. package/dist/reporting/tracking.d.mts.map +1 -1
  140. package/dist/reporting/tracking.mjs +31 -11
  141. package/dist/reporting/tracking.mjs.map +1 -1
  142. package/dist/store/facts.cjs +3 -3
  143. package/dist/store/facts.cjs.map +1 -1
  144. package/dist/store/facts.d.cts +2 -2
  145. package/dist/store/facts.d.cts.map +1 -1
  146. package/dist/store/facts.d.mts +2 -2
  147. package/dist/store/facts.d.mts.map +1 -1
  148. package/dist/store/facts.mjs +3 -3
  149. package/dist/store/facts.mjs.map +1 -1
  150. package/dist/store/index.cjs +1 -1
  151. package/dist/store/index.cjs.map +1 -1
  152. package/dist/store/index.d.cts +44 -13
  153. package/dist/store/index.d.cts.map +1 -1
  154. package/dist/store/index.d.mts +44 -13
  155. package/dist/store/index.d.mts.map +1 -1
  156. package/dist/store/index.mjs +1 -1
  157. package/dist/store/index.mjs.map +1 -1
  158. package/dist/store/postgres-reporting.cjs +30 -10
  159. package/dist/store/postgres-reporting.cjs.map +1 -1
  160. package/dist/store/postgres-reporting.d.cts +4 -2
  161. package/dist/store/postgres-reporting.d.cts.map +1 -1
  162. package/dist/store/postgres-reporting.d.mts +4 -2
  163. package/dist/store/postgres-reporting.d.mts.map +1 -1
  164. package/dist/store/postgres-reporting.mjs +30 -10
  165. package/dist/store/postgres-reporting.mjs.map +1 -1
  166. package/dist/store/postgres.cjs +39 -3
  167. package/dist/store/postgres.cjs.map +1 -1
  168. package/dist/store/postgres.d.cts +4 -2
  169. package/dist/store/postgres.d.cts.map +1 -1
  170. package/dist/store/postgres.d.mts +4 -2
  171. package/dist/store/postgres.d.mts.map +1 -1
  172. package/dist/store/postgres.mjs +40 -4
  173. package/dist/store/postgres.mjs.map +1 -1
  174. package/dist/store/tables.cjs +75 -11
  175. package/dist/store/tables.cjs.map +1 -1
  176. package/dist/store/tables.d.cts +17 -1
  177. package/dist/store/tables.d.cts.map +1 -1
  178. package/dist/store/tables.d.mts +17 -1
  179. package/dist/store/tables.d.mts.map +1 -1
  180. package/dist/store/tables.mjs +74 -12
  181. package/dist/store/tables.mjs.map +1 -1
  182. package/dist/templates.cjs +60 -7
  183. package/dist/templates.cjs.map +1 -1
  184. package/dist/templates.d.cts +101 -13
  185. package/dist/templates.d.cts.map +1 -1
  186. package/dist/templates.d.mts +101 -13
  187. package/dist/templates.d.mts.map +1 -1
  188. package/dist/templates.mjs +61 -9
  189. package/dist/templates.mjs.map +1 -1
  190. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { BundleIR } from "../ir.cjs";
2
- import { JourneyStore } from "../store/index.cjs";
3
2
  import { Contract } from "../contract.cjs";
3
+ import { JourneyStore } from "../store/index.cjs";
4
4
  import { EmailRenderer } from "../email-sender.cjs";
5
5
  import { Authorize } from "./auth.cjs";
6
6
  import { JourneyEnv } from "./platform.cjs";
@@ -51,6 +51,6 @@ export interface ServeContext {
51
51
  }
52
52
  /** One request through the table: the production gate, the bearer, the route, the error mapping. */
53
53
  export declare function serve(request: Request, ctx: ServeContext): Promise<Response>;
54
- /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */
54
+ /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/subscriptions/:userId`, `/subscriptions/by-message/:messageId`, `/users/forget`, `/deploy`. */
55
55
  export declare function handleRequest(request: Request, env: JourneyEnv, store: JourneyStore, validation?: IngestValidation): Promise<Response>;
56
56
  //# sourceMappingURL=http.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.cts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBAwCiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA8WF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
1
+ {"version":3,"file":"http.d.cts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA8CiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA2bF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -1,6 +1,6 @@
1
1
  import { BundleIR } from "../ir.mjs";
2
- import { JourneyStore } from "../store/index.mjs";
3
2
  import { Contract } from "../contract.mjs";
3
+ import { JourneyStore } from "../store/index.mjs";
4
4
  import { EmailRenderer } from "../email-sender.mjs";
5
5
  import { Authorize } from "./auth.mjs";
6
6
  import { JourneyEnv } from "./platform.mjs";
@@ -51,6 +51,6 @@ export interface ServeContext {
51
51
  }
52
52
  /** One request through the table: the production gate, the bearer, the route, the error mapping. */
53
53
  export declare function serve(request: Request, ctx: ServeContext): Promise<Response>;
54
- /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */
54
+ /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/subscriptions/:userId`, `/subscriptions/by-message/:messageId`, `/users/forget`, `/deploy`. */
55
55
  export declare function handleRequest(request: Request, env: JourneyEnv, store: JourneyStore, validation?: IngestValidation): Promise<Response>;
56
56
  //# sourceMappingURL=http.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.mts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBAwCiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA8WF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
1
+ {"version":3,"file":"http.d.mts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA8CiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA2bF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -1,10 +1,11 @@
1
- import { TemplateValidationError } from "../templates.mjs";
2
- import { checkEvent } from "../contract.mjs";
1
+ import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
3
2
  import { UserErasedError } from "../store/erasure.mjs";
4
3
  import { SchemaMismatchError } from "../store/index.mjs";
5
- import { BundleIntegrityError, deployBundle, forgetUser, identifyUser, ingestEvent, registerDevice, removeDevice, unbindDevice } from "./ingest.mjs";
4
+ import { BundleIntegrityError, deployBundle, forgetUser, getSubscriptions, getSubscriptionsByMessage, identifyUser, ingestEvent, parseSubscriptionsPatch, registerDevice, removeDevice, setSubscriptions, setSubscriptionsByMessage, unbindDevice } from "./ingest.mjs";
5
+ import { PREVIEW_MESSAGE_ID } from "../email.mjs";
6
+ import { TemplateValidationError } from "../templates.mjs";
7
+ import { checkEvent } from "../contract.mjs";
6
8
  import { journeyMode } from "./channels.mjs";
7
- import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
8
9
  import { authorized } from "./auth.mjs";
9
10
  import * as z from "zod/mini";
10
11
  // src/host/http.ts
@@ -67,6 +68,23 @@ function checked(validation, result, original, what) {
67
68
  issues: result.issues
68
69
  }, 400) };
69
70
  }
71
+ /**
72
+ * The topics the engine knows: the deployed bundle's list (compiled from the contract), or the
73
+ * contract's when the bare API runs without a host — never the caller's.
74
+ */
75
+ function declaredTopics(ctx) {
76
+ return ctx.host.bundle?.().subscriptions?.topics ?? ctx.validation.contract?.topics ?? [];
77
+ }
78
+ const SUBSCRIPTIONS = /^\/subscriptions\/[^/]+$/;
79
+ const subscriptionsUser = (url) => decodeURIComponent(url.pathname.slice(15));
80
+ /**
81
+ * The same record, reached by one of the user's copies instead of their id: for the app's
82
+ * preference page opened from an email's `preferencesUrl?m=<message>` without a sign-in. The
83
+ * message id is the capability (an unguessable UUID, as `/t/u` trusts it); the bearer still
84
+ * guards the route, so the page's own API proxies it, and rate-limits it as it sees fit.
85
+ */
86
+ const SUBSCRIPTIONS_BY_MESSAGE = /^\/subscriptions\/by-message\/[^/]+$/;
87
+ const subscriptionsMessage = (url) => decodeURIComponent(url.pathname.slice(26));
70
88
  const routes = [
71
89
  {
72
90
  method: "GET",
@@ -181,6 +199,58 @@ const routes = [
181
199
  });
182
200
  }
183
201
  },
202
+ {
203
+ method: "GET",
204
+ path: SUBSCRIPTIONS,
205
+ auth: "api",
206
+ async handle(ctx) {
207
+ const userId = subscriptionsUser(ctx.url);
208
+ if (userId === "") return json({ error: "userId required" }, 400);
209
+ return json(await getSubscriptions(ctx.store, userId, declaredTopics(ctx)));
210
+ }
211
+ },
212
+ {
213
+ method: "PUT",
214
+ path: SUBSCRIPTIONS,
215
+ auth: "api",
216
+ async handle(ctx) {
217
+ const userId = subscriptionsUser(ctx.url);
218
+ if (userId === "") return json({ error: "userId required" }, 400);
219
+ const topics = declaredTopics(ctx);
220
+ const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);
221
+ if (!parsed.ok) return json({
222
+ error: "subscriptions refused",
223
+ issues: parsed.issues
224
+ }, 400);
225
+ return json(await setSubscriptions(ctx.env, ctx.store, userId, parsed.patch, topics));
226
+ }
227
+ },
228
+ {
229
+ method: "GET",
230
+ path: SUBSCRIPTIONS_BY_MESSAGE,
231
+ auth: "api",
232
+ async handle(ctx) {
233
+ const messageId = subscriptionsMessage(ctx.url);
234
+ const found = await getSubscriptionsByMessage(ctx.store, messageId, declaredTopics(ctx));
235
+ return found === void 0 ? json({ error: "unknown message" }, 404) : json(found);
236
+ }
237
+ },
238
+ {
239
+ method: "PUT",
240
+ path: SUBSCRIPTIONS_BY_MESSAGE,
241
+ auth: "api",
242
+ async handle(ctx) {
243
+ const messageId = subscriptionsMessage(ctx.url);
244
+ const topics = declaredTopics(ctx);
245
+ const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);
246
+ if (!parsed.ok) return json({
247
+ error: "subscriptions refused",
248
+ issues: parsed.issues
249
+ }, 400);
250
+ const found = await setSubscriptionsByMessage(ctx.env, ctx.store, messageId, parsed.patch, topics);
251
+ return found === void 0 ? json({ error: "unknown message" }, 404) : json(found);
252
+ }
253
+ },
184
254
  {
185
255
  method: "POST",
186
256
  path: "/users/forget",
@@ -240,7 +310,7 @@ const routes = [
240
310
  return json({ error: "props must be a JSON object" }, 400);
241
311
  }
242
312
  if (!isPlainObject(props)) return json({ error: "props must be a JSON object" }, 400);
243
- const { subject, html } = await preview.render(key, props);
313
+ const { subject, html } = await preview.render(key, props, { messageId: PREVIEW_MESSAGE_ID });
244
314
  return new Response(html, { headers: {
245
315
  "content-type": "text/html; charset=utf-8",
246
316
  "x-subject": encodeURIComponent(subject)
@@ -321,7 +391,7 @@ async function serve(request, ctx) {
321
391
  return failure(error);
322
392
  }
323
393
  }
324
- /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */
394
+ /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/subscriptions/:userId`, `/subscriptions/by-message/:messageId`, `/users/forget`, `/deploy`. */
325
395
  function handleRequest(request, env, store, validation = {}) {
326
396
  return serve(request, {
327
397
  env,
@@ -1 +1 @@
1
- {"version":3,"file":"http.mjs","names":[],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\nimport type { EmailRenderer } from '../email-sender';\nimport type { BundleIR, ScalarIR } from '../ir';\nimport { type TrackingOptions, handleTracking, recordPushOpen } from '../reporting/tracking';\nimport {\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n UserErasedError,\n} from '../store/index';\nimport { TemplateValidationError } from '../templates';\nimport { type Authorize, authorized } from './auth';\nimport { journeyMode } from './channels';\nimport {\n BundleIntegrityError,\n type SweepResult,\n deployBundle,\n forgetUser,\n identifyUser,\n ingestEvent,\n registerDevice,\n removeDevice,\n unbindDevice,\n} from './ingest';\nimport type { JourneyEnv } from './platform';\n\n/**\n * The HTTP surface, as one route table: the bare ingest API (`handleRequest`) and the routes\n * the full host adds (`serve` with its parts — the bundle, previews, tracking, the sweep). A\n * route whose part is absent answers 404, so the two share the table, the bearer check, the\n * body validation and the error mapping.\n */\n\n/**\n * How the ingest API applies a contract: 'enforce' refuses an invalid event or\n * patch with a 400 before anything is stored, woken or started; 'report' logs\n * the issues (event id, name, field paths — never the payload) and accepts it,\n * for checking real producers before turning enforcement on.\n */\nexport interface IngestValidation {\n contract?: Contract | undefined;\n mode?: 'enforce' | 'report' | undefined;\n}\n\n/** What the full host serves beyond the ingest API. A route whose part is absent answers 404. */\nexport interface HostParts {\n /** The bundle compiled into the code: `GET /bundle` shows it, `POST /bundle/deploy` makes it live. */\n bundle?: (() => BundleIR) | undefined;\n /** `GET /preview/:id?props=<JSON>`: the email templates, rendered. */\n preview?: { has(id: string): boolean; render: EmailRenderer } | undefined;\n /** `/t/*`, what recipients open (no bearer): served on a store that keeps reporting. */\n tracking?: TrackingOptions | undefined;\n /** `POST /segments/sweep`. */\n sweep?: (() => Promise<SweepResult>) | undefined;\n}\n\nexport interface ServeContext {\n env: JourneyEnv;\n store: JourneyStore;\n validation?: IngestValidation | undefined;\n /**\n * The host's parts. Their presence is what makes this a deployed host rather than the bare\n * API: production then refuses to serve without `API_TOKEN` — unless the host authorizes\n * callers itself (`authorize`).\n */\n host?: HostParts | undefined;\n /** The host's own authorization for the API routes; the env's `API_TOKEN` bearer without one. */\n authorize?: Authorize<JourneyEnv> | undefined;\n}\n\ninterface RouteContext {\n request: Request;\n url: URL;\n env: JourneyEnv;\n store: JourneyStore;\n validation: IngestValidation;\n host: HostParts;\n}\n\ninterface Route {\n /** '*' takes every method (the tracking routes answer GET, HEAD and POST themselves). */\n method: 'GET' | 'POST' | '*';\n path: string | RegExp | ((pathname: string) => boolean);\n /** `api`: the caller must be authorized (the host's `authorize`, or the bearer); `none`: public. */\n auth: 'none' | 'api';\n handle(ctx: RouteContext): Promise<Response>;\n}\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\nconst notFound = () => json({ error: 'not found' }, 404);\n\n/* --------------------------------- bodies ---------------------------------- */\n\n/** The request body is not JSON: the caller's to fix (a 400), not a server fault. */\nclass InvalidJsonError extends Error {\n override readonly name = 'InvalidJsonError';\n}\n\nasync function readJson(request: Request): Promise<unknown> {\n try {\n return await request.json();\n } catch (error) {\n throw new InvalidJsonError('invalid JSON body', { cause: error });\n }\n}\n\n/**\n * Request bodies are untrusted JSON. They are read as unknown fields and\n * checked, never cast into the shape they are supposed to have: a cast only\n * silences the compiler, and what arrives here reaches SQL parameters.\n */\nasync function readBody(request: Request): Promise<Record<string, unknown>> {\n const body = await readJson(request);\n return isPlainObject(body) ? body : {};\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0;\n}\n\n/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */\nfunction isScalarOrNull(value: unknown): value is string | number | boolean | null {\n return (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n );\n}\n\n/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */\nconst TS_FUTURE_TOLERANCE_MS = 5 * 60_000;\nconst TS_ERROR = 'ts must be a non-negative integer no later than 5 minutes in the future';\n\n/**\n * A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.\n * A far-future ts would put the event ahead of everything a window condition reads, and a\n * fractional or unsafe one does not survive the store.\n */\nfunction isValidTs(ts: unknown): ts is number {\n return (\n typeof ts === 'number' &&\n Number.isSafeInteger(ts) &&\n ts >= 0 &&\n ts <= Date.now() + TS_FUTURE_TOLERANCE_MS\n );\n}\n\n/** The contract's verdict: the value to use, or the response refusing the request. */\nfunction checked<T>(\n validation: IngestValidation,\n result: ContractResult<T>,\n original: T,\n what: string\n): { value: T } | { refused: Response } {\n if (result.ok) return { value: result.data };\n const issues = result.issues.map((i) => `${i.path || '(root)'}: ${i.message}`);\n if (validation.mode === 'report') {\n console.warn(`[workflow http] contract would refuse ${what}: ${issues.join('; ')}`);\n return { value: original };\n }\n return {\n refused: json({ error: `${what} refused by the contract`, issues: result.issues }, 400),\n };\n}\n\n/* --------------------------------- routes ---------------------------------- */\n\nconst routes: readonly Route[] = [\n { method: 'GET', path: '/health', auth: 'none', handle: async () => json({ ok: true }) },\n {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n method: '*',\n path: /^\\/t\\//,\n auth: 'none',\n async handle({ request, store, host }) {\n if (host.tracking === undefined) return notFound();\n return (await handleTracking(request, store.reporting, host.tracking)) ?? notFound();\n },\n },\n {\n method: 'POST',\n path: '/events',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { id, userId, event, payload, ts } = await readBody(request);\n if (!isNonEmptyString(userId) || !isNonEmptyString(event)) {\n return json({ error: 'userId and event required' }, 400);\n }\n if (event.startsWith('$')) {\n return json({ error: 'event names starting with $ are reserved' }, 400);\n }\n // An empty id is rejected rather than treated as absent: a caller that\n // sent one meant to identify the occurrence, and silently minting a\n // different one would turn their retry into a duplicate.\n if (id !== undefined && !isNonEmptyString(id)) {\n return json({ error: 'id must be a non-empty string' }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n if (payload !== undefined && !isPlainObject(payload)) {\n return json({ error: 'payload must be an object' }, 400);\n }\n let accepted = payload;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n checkEvent(validation.contract, event, payload ?? {}),\n accepted ?? {},\n `event '${event}'${typeof id === 'string' ? ` (${id})` : ''}`\n );\n if ('refused' in verdict) return verdict.refused;\n accepted = verdict.value;\n }\n return json(await ingestEvent(env, { id, userId, event, payload: accepted, ts }, store));\n },\n },\n {\n method: 'POST',\n path: '/identify',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { userId, props } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isPlainObject(props)) return json({ error: 'props must be an object' }, 400);\n // Profiles hold scalars only (both stores merge at the top level; a nested object would\n // be merged differently by each): refused here, contract or not\n if (!Object.values(props).every(isScalarOrNull)) {\n return json(\n { error: 'props values must be scalars (string, number, boolean) or null' },\n 400\n );\n }\n let patch = props as ProfileProps;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n validation.contract.parseProfilePatch(props),\n patch,\n 'identify'\n );\n if ('refused' in verdict) return verdict.refused;\n patch = verdict.value as ProfileProps;\n }\n return json(await identifyUser(env, userId, patch, store));\n },\n },\n {\n method: 'POST',\n path: '/devices',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId, token, platform, ts } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n if (platform !== 'ios' && platform !== 'android' && platform !== 'web') {\n return json({ error: \"platform must be 'ios', 'android' or 'web'\" }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n const device = { userId, token, platform, ...(ts === undefined ? {} : { ts }) };\n return json(await registerDevice(env, device, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/unbind',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await unbindDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/remove',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await removeDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/messages/opened',\n auth: 'api',\n async handle({ request, store }) {\n const { userId, messageId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(messageId)) return json({ error: 'messageId required' }, 400);\n const recorded = await recordPushOpen(store.reporting, userId, messageId);\n return json({ ok: true, recorded });\n },\n },\n {\n method: 'POST',\n path: '/users/forget',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n return json(await forgetUser(env, userId, store));\n },\n },\n {\n method: 'POST',\n path: '/deploy',\n auth: 'api',\n async handle({ request, env, store }) {\n return json(await deployBundle(env, await readJson(request), store));\n },\n },\n {\n method: 'GET',\n path: '/bundle',\n auth: 'api',\n async handle({ host }) {\n return host.bundle === undefined ? notFound() : json(host.bundle());\n },\n },\n {\n method: 'POST',\n path: '/bundle/deploy',\n auth: 'api',\n async handle({ env, store, host }) {\n if (host.bundle === undefined) return notFound();\n return json(await deployBundle(env, host.bundle(), store));\n },\n },\n {\n method: 'POST',\n path: '/segments/sweep',\n auth: 'api',\n async handle({ host }) {\n return host.sweep === undefined ? notFound() : json(await host.sweep());\n },\n },\n {\n method: 'GET',\n path: /^\\/preview\\//,\n auth: 'api',\n async handle({ url, host }) {\n const { preview } = host;\n if (preview === undefined) return notFound();\n const key = url.pathname.slice('/preview/'.length);\n if (!preview.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown;\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (!isPlainObject(props)) return json({ error: 'props must be a JSON object' }, 400);\n const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>);\n // A header holds bytes, not text: a subject with a non-Latin-1 character would throw\n // here, so it travels percent-encoded (the reader `decodeURIComponent`s it)\n return new Response(html, {\n headers: {\n 'content-type': 'text/html; charset=utf-8',\n 'x-subject': encodeURIComponent(subject),\n },\n });\n },\n },\n];\n\nfunction matches(path: Route['path'], pathname: string): boolean {\n if (typeof path === 'string') return path === pathname;\n if (path instanceof RegExp) return path.test(pathname);\n return path(pathname);\n}\n\n/**\n * Production without a bearer token (and no authorizer of the host's own) is not an open\n * instance, it is a mistake: anyone could ingest, identify and deploy. Only what recipients open\n * (`/t/*`) and the health check stay up.\n */\nfunction refusesOpen(request: Request, ctx: ServeContext, pathname: string): boolean {\n return (\n ctx.authorize === undefined &&\n ctx.env.API_TOKEN === undefined &&\n journeyMode(ctx.env) === 'production' &&\n !pathname.startsWith('/t/') &&\n !(request.method === 'GET' && pathname === '/health')\n );\n}\n\n/**\n * Whether the caller may use an API route: the host's authorizer on a copy of the request (so a\n * body it verifies is still there for the route), or the env's `API_TOKEN` as a bearer. An\n * authorizer that throws is a failure, never an admission.\n */\nasync function allowed(request: Request, ctx: ServeContext): Promise<boolean> {\n if (ctx.authorize === undefined) return authorized(request, ctx.env);\n return ctx.authorize(request.clone(), ctx.env);\n}\n\n/**\n * What a failed request answers. Validation problems are the caller's to fix (a 400, saying\n * what); anything else stays out of the response body (internals are logged, not leaked).\n */\nfunction failure(error: unknown): Response {\n if (error instanceof InvalidJsonError) return json({ error: 'invalid JSON body' }, 400);\n if (error instanceof z.core.$ZodError) {\n return json({ error: `invalid payload: ${error.message}` }, 400);\n }\n // The caller sent a self-inconsistent bundle: their problem to fix, so say what is wrong\n if (error instanceof BundleIntegrityError) return json({ error: error.message }, 400);\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n // The user has an erasure tombstone: gone, and nothing about them is taken, now or later\n if (error instanceof UserErasedError) return json({ error: 'user is erased' }, 410);\n // The operator's to fix (run the migrations), so the response says what is missing\n if (error instanceof SchemaMismatchError) {\n return json({ error: error.message, problems: error.problems }, 503);\n }\n console.error('[workflow http]', error);\n return json({ error: 'internal error' }, 500);\n}\n\n/** One request through the table: the production gate, the bearer, the route, the error mapping. */\nexport async function serve(request: Request, ctx: ServeContext): Promise<Response> {\n const url = new URL(request.url);\n if (ctx.host !== undefined && refusesOpen(request, ctx, url.pathname)) {\n return json(\n {\n error:\n 'API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)',\n },\n 503\n );\n }\n const candidates = routes.filter((route) => matches(route.path, url.pathname));\n if (candidates.length === 0) return notFound();\n // Every API route asks the authorizer; the health check and the recipients' routes never do.\n // An unset token and no authorizer means an open dev instance (see JourneyEnv.API_TOKEN);\n // production refuses to run that way\n if (candidates.some((route) => route.auth === 'api')) {\n let ok: boolean;\n try {\n ok = await allowed(request, ctx);\n } catch (error) {\n console.error('[workflow http] authorize failed', error);\n return json({ error: 'internal error' }, 500);\n }\n if (!ok) return json({ error: 'unauthorized' }, 401);\n }\n const route = candidates.find((r) => r.method === '*' || r.method === request.method);\n if (route === undefined) return json({ error: 'method not allowed' }, 405);\n try {\n return await route.handle({\n request,\n url,\n env: ctx.env,\n store: ctx.store,\n validation: ctx.validation ?? {},\n host: ctx.host ?? {},\n });\n } catch (error) {\n return failure(error);\n }\n}\n\n/** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */\nexport function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore,\n validation: IngestValidation = {}\n): Promise<Response> {\n return serve(request, { env, store, validation });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"http.mjs","names":[],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\nimport { PREVIEW_MESSAGE_ID } from '../email';\nimport type { EmailRenderer } from '../email-sender';\nimport type { BundleIR, ScalarIR } from '../ir';\nimport { type TrackingOptions, handleTracking, recordPushOpen } from '../reporting/tracking';\nimport {\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n UserErasedError,\n} from '../store/index';\nimport { TemplateValidationError } from '../templates';\nimport { type Authorize, authorized } from './auth';\nimport { journeyMode } from './channels';\nimport {\n BundleIntegrityError,\n type SweepResult,\n deployBundle,\n forgetUser,\n getSubscriptions,\n getSubscriptionsByMessage,\n identifyUser,\n ingestEvent,\n parseSubscriptionsPatch,\n registerDevice,\n removeDevice,\n setSubscriptions,\n setSubscriptionsByMessage,\n unbindDevice,\n} from './ingest';\nimport type { JourneyEnv } from './platform';\n\n/**\n * The HTTP surface, as one route table: the bare ingest API (`handleRequest`) and the routes\n * the full host adds (`serve` with its parts — the bundle, previews, tracking, the sweep). A\n * route whose part is absent answers 404, so the two share the table, the bearer check, the\n * body validation and the error mapping.\n */\n\n/**\n * How the ingest API applies a contract: 'enforce' refuses an invalid event or\n * patch with a 400 before anything is stored, woken or started; 'report' logs\n * the issues (event id, name, field paths — never the payload) and accepts it,\n * for checking real producers before turning enforcement on.\n */\nexport interface IngestValidation {\n contract?: Contract | undefined;\n mode?: 'enforce' | 'report' | undefined;\n}\n\n/** What the full host serves beyond the ingest API. A route whose part is absent answers 404. */\nexport interface HostParts {\n /** The bundle compiled into the code: `GET /bundle` shows it, `POST /bundle/deploy` makes it live. */\n bundle?: (() => BundleIR) | undefined;\n /** `GET /preview/:id?props=<JSON>`: the email templates, rendered. */\n preview?: { has(id: string): boolean; render: EmailRenderer } | undefined;\n /** `/t/*`, what recipients open (no bearer): served on a store that keeps reporting. */\n tracking?: TrackingOptions | undefined;\n /** `POST /segments/sweep`. */\n sweep?: (() => Promise<SweepResult>) | undefined;\n}\n\nexport interface ServeContext {\n env: JourneyEnv;\n store: JourneyStore;\n validation?: IngestValidation | undefined;\n /**\n * The host's parts. Their presence is what makes this a deployed host rather than the bare\n * API: production then refuses to serve without `API_TOKEN` — unless the host authorizes\n * callers itself (`authorize`).\n */\n host?: HostParts | undefined;\n /** The host's own authorization for the API routes; the env's `API_TOKEN` bearer without one. */\n authorize?: Authorize<JourneyEnv> | undefined;\n}\n\ninterface RouteContext {\n request: Request;\n url: URL;\n env: JourneyEnv;\n store: JourneyStore;\n validation: IngestValidation;\n host: HostParts;\n}\n\ninterface Route {\n /** '*' takes every method (the tracking routes answer GET, HEAD and POST themselves). */\n method: 'GET' | 'POST' | 'PUT' | '*';\n path: string | RegExp | ((pathname: string) => boolean);\n /** `api`: the caller must be authorized (the host's `authorize`, or the bearer); `none`: public. */\n auth: 'none' | 'api';\n handle(ctx: RouteContext): Promise<Response>;\n}\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\nconst notFound = () => json({ error: 'not found' }, 404);\n\n/* --------------------------------- bodies ---------------------------------- */\n\n/** The request body is not JSON: the caller's to fix (a 400), not a server fault. */\nclass InvalidJsonError extends Error {\n override readonly name = 'InvalidJsonError';\n}\n\nasync function readJson(request: Request): Promise<unknown> {\n try {\n return await request.json();\n } catch (error) {\n throw new InvalidJsonError('invalid JSON body', { cause: error });\n }\n}\n\n/**\n * Request bodies are untrusted JSON. They are read as unknown fields and\n * checked, never cast into the shape they are supposed to have: a cast only\n * silences the compiler, and what arrives here reaches SQL parameters.\n */\nasync function readBody(request: Request): Promise<Record<string, unknown>> {\n const body = await readJson(request);\n return isPlainObject(body) ? body : {};\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0;\n}\n\n/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */\nfunction isScalarOrNull(value: unknown): value is string | number | boolean | null {\n return (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n );\n}\n\n/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */\nconst TS_FUTURE_TOLERANCE_MS = 5 * 60_000;\nconst TS_ERROR = 'ts must be a non-negative integer no later than 5 minutes in the future';\n\n/**\n * A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.\n * A far-future ts would put the event ahead of everything a window condition reads, and a\n * fractional or unsafe one does not survive the store.\n */\nfunction isValidTs(ts: unknown): ts is number {\n return (\n typeof ts === 'number' &&\n Number.isSafeInteger(ts) &&\n ts >= 0 &&\n ts <= Date.now() + TS_FUTURE_TOLERANCE_MS\n );\n}\n\n/** The contract's verdict: the value to use, or the response refusing the request. */\nfunction checked<T>(\n validation: IngestValidation,\n result: ContractResult<T>,\n original: T,\n what: string\n): { value: T } | { refused: Response } {\n if (result.ok) return { value: result.data };\n const issues = result.issues.map((i) => `${i.path || '(root)'}: ${i.message}`);\n if (validation.mode === 'report') {\n console.warn(`[workflow http] contract would refuse ${what}: ${issues.join('; ')}`);\n return { value: original };\n }\n return {\n refused: json({ error: `${what} refused by the contract`, issues: result.issues }, 400),\n };\n}\n\n/**\n * The topics the engine knows: the deployed bundle's list (compiled from the contract), or the\n * contract's when the bare API runs without a host — never the caller's.\n */\nfunction declaredTopics(ctx: Pick<RouteContext, 'host' | 'validation'>): readonly string[] {\n return ctx.host.bundle?.().subscriptions?.topics ?? ctx.validation.contract?.topics ?? [];\n}\n\nconst SUBSCRIPTIONS = /^\\/subscriptions\\/[^/]+$/;\nconst subscriptionsUser = (url: URL) =>\n decodeURIComponent(url.pathname.slice('/subscriptions/'.length));\n/**\n * The same record, reached by one of the user's copies instead of their id: for the app's\n * preference page opened from an email's `preferencesUrl?m=<message>` without a sign-in. The\n * message id is the capability (an unguessable UUID, as `/t/u` trusts it); the bearer still\n * guards the route, so the page's own API proxies it, and rate-limits it as it sees fit.\n */\nconst SUBSCRIPTIONS_BY_MESSAGE = /^\\/subscriptions\\/by-message\\/[^/]+$/;\nconst subscriptionsMessage = (url: URL) =>\n decodeURIComponent(url.pathname.slice('/subscriptions/by-message/'.length));\n\n/* --------------------------------- routes ---------------------------------- */\n\nconst routes: readonly Route[] = [\n { method: 'GET', path: '/health', auth: 'none', handle: async () => json({ ok: true }) },\n {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n method: '*',\n path: /^\\/t\\//,\n auth: 'none',\n async handle({ request, store, host }) {\n if (host.tracking === undefined) return notFound();\n return (await handleTracking(request, store.reporting, host.tracking)) ?? notFound();\n },\n },\n {\n method: 'POST',\n path: '/events',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { id, userId, event, payload, ts } = await readBody(request);\n if (!isNonEmptyString(userId) || !isNonEmptyString(event)) {\n return json({ error: 'userId and event required' }, 400);\n }\n if (event.startsWith('$')) {\n return json({ error: 'event names starting with $ are reserved' }, 400);\n }\n // An empty id is rejected rather than treated as absent: a caller that\n // sent one meant to identify the occurrence, and silently minting a\n // different one would turn their retry into a duplicate.\n if (id !== undefined && !isNonEmptyString(id)) {\n return json({ error: 'id must be a non-empty string' }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n if (payload !== undefined && !isPlainObject(payload)) {\n return json({ error: 'payload must be an object' }, 400);\n }\n let accepted = payload;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n checkEvent(validation.contract, event, payload ?? {}),\n accepted ?? {},\n `event '${event}'${typeof id === 'string' ? ` (${id})` : ''}`\n );\n if ('refused' in verdict) return verdict.refused;\n accepted = verdict.value;\n }\n return json(await ingestEvent(env, { id, userId, event, payload: accepted, ts }, store));\n },\n },\n {\n method: 'POST',\n path: '/identify',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { userId, props } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isPlainObject(props)) return json({ error: 'props must be an object' }, 400);\n // Profiles hold scalars only (both stores merge at the top level; a nested object would\n // be merged differently by each): refused here, contract or not\n if (!Object.values(props).every(isScalarOrNull)) {\n return json(\n { error: 'props values must be scalars (string, number, boolean) or null' },\n 400\n );\n }\n let patch = props as ProfileProps;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n validation.contract.parseProfilePatch(props),\n patch,\n 'identify'\n );\n if ('refused' in verdict) return verdict.refused;\n patch = verdict.value as ProfileProps;\n }\n return json(await identifyUser(env, userId, patch, store));\n },\n },\n {\n method: 'POST',\n path: '/devices',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId, token, platform, ts } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n if (platform !== 'ios' && platform !== 'android' && platform !== 'web') {\n return json({ error: \"platform must be 'ios', 'android' or 'web'\" }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n const device = { userId, token, platform, ...(ts === undefined ? {} : { ts }) };\n return json(await registerDevice(env, device, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/unbind',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await unbindDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/remove',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await removeDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/messages/opened',\n auth: 'api',\n async handle({ request, store }) {\n const { userId, messageId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(messageId)) return json({ error: 'messageId required' }, 400);\n const recorded = await recordPushOpen(store.reporting, userId, messageId);\n return json({ ok: true, recorded });\n },\n },\n {\n method: 'GET',\n path: SUBSCRIPTIONS,\n auth: 'api',\n async handle(ctx) {\n const userId = subscriptionsUser(ctx.url);\n if (userId === '') return json({ error: 'userId required' }, 400);\n return json(await getSubscriptions(ctx.store, userId, declaredTopics(ctx)));\n },\n },\n {\n method: 'PUT',\n path: SUBSCRIPTIONS,\n auth: 'api',\n async handle(ctx) {\n const userId = subscriptionsUser(ctx.url);\n if (userId === '') return json({ error: 'userId required' }, 400);\n const topics = declaredTopics(ctx);\n const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);\n if (!parsed.ok) return json({ error: 'subscriptions refused', issues: parsed.issues }, 400);\n return json(await setSubscriptions(ctx.env, ctx.store, userId, parsed.patch, topics));\n },\n },\n {\n method: 'GET',\n path: SUBSCRIPTIONS_BY_MESSAGE,\n auth: 'api',\n async handle(ctx) {\n const messageId = subscriptionsMessage(ctx.url);\n const found = await getSubscriptionsByMessage(ctx.store, messageId, declaredTopics(ctx));\n return found === undefined ? json({ error: 'unknown message' }, 404) : json(found);\n },\n },\n {\n method: 'PUT',\n path: SUBSCRIPTIONS_BY_MESSAGE,\n auth: 'api',\n async handle(ctx) {\n const messageId = subscriptionsMessage(ctx.url);\n const topics = declaredTopics(ctx);\n const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);\n if (!parsed.ok) return json({ error: 'subscriptions refused', issues: parsed.issues }, 400);\n const found = await setSubscriptionsByMessage(\n ctx.env,\n ctx.store,\n messageId,\n parsed.patch,\n topics\n );\n return found === undefined ? json({ error: 'unknown message' }, 404) : json(found);\n },\n },\n {\n method: 'POST',\n path: '/users/forget',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n return json(await forgetUser(env, userId, store));\n },\n },\n {\n method: 'POST',\n path: '/deploy',\n auth: 'api',\n async handle({ request, env, store }) {\n return json(await deployBundle(env, await readJson(request), store));\n },\n },\n {\n method: 'GET',\n path: '/bundle',\n auth: 'api',\n async handle({ host }) {\n return host.bundle === undefined ? notFound() : json(host.bundle());\n },\n },\n {\n method: 'POST',\n path: '/bundle/deploy',\n auth: 'api',\n async handle({ env, store, host }) {\n if (host.bundle === undefined) return notFound();\n return json(await deployBundle(env, host.bundle(), store));\n },\n },\n {\n method: 'POST',\n path: '/segments/sweep',\n auth: 'api',\n async handle({ host }) {\n return host.sweep === undefined ? notFound() : json(await host.sweep());\n },\n },\n {\n method: 'GET',\n path: /^\\/preview\\//,\n auth: 'api',\n async handle({ url, host }) {\n const { preview } = host;\n if (preview === undefined) return notFound();\n const key = url.pathname.slice('/preview/'.length);\n if (!preview.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown;\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (!isPlainObject(props)) return json({ error: 'props must be a JSON object' }, 400);\n // A preview is no copy, but it shows the footer a sent one has: the preferences link\n // renders with a placeholder message id\n const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>, {\n messageId: PREVIEW_MESSAGE_ID,\n });\n // A header holds bytes, not text: a subject with a non-Latin-1 character would throw\n // here, so it travels percent-encoded (the reader `decodeURIComponent`s it)\n return new Response(html, {\n headers: {\n 'content-type': 'text/html; charset=utf-8',\n 'x-subject': encodeURIComponent(subject),\n },\n });\n },\n },\n];\n\nfunction matches(path: Route['path'], pathname: string): boolean {\n if (typeof path === 'string') return path === pathname;\n if (path instanceof RegExp) return path.test(pathname);\n return path(pathname);\n}\n\n/**\n * Production without a bearer token (and no authorizer of the host's own) is not an open\n * instance, it is a mistake: anyone could ingest, identify and deploy. Only what recipients open\n * (`/t/*`) and the health check stay up.\n */\nfunction refusesOpen(request: Request, ctx: ServeContext, pathname: string): boolean {\n return (\n ctx.authorize === undefined &&\n ctx.env.API_TOKEN === undefined &&\n journeyMode(ctx.env) === 'production' &&\n !pathname.startsWith('/t/') &&\n !(request.method === 'GET' && pathname === '/health')\n );\n}\n\n/**\n * Whether the caller may use an API route: the host's authorizer on a copy of the request (so a\n * body it verifies is still there for the route), or the env's `API_TOKEN` as a bearer. An\n * authorizer that throws is a failure, never an admission.\n */\nasync function allowed(request: Request, ctx: ServeContext): Promise<boolean> {\n if (ctx.authorize === undefined) return authorized(request, ctx.env);\n return ctx.authorize(request.clone(), ctx.env);\n}\n\n/**\n * What a failed request answers. Validation problems are the caller's to fix (a 400, saying\n * what); anything else stays out of the response body (internals are logged, not leaked).\n */\nfunction failure(error: unknown): Response {\n if (error instanceof InvalidJsonError) return json({ error: 'invalid JSON body' }, 400);\n if (error instanceof z.core.$ZodError) {\n return json({ error: `invalid payload: ${error.message}` }, 400);\n }\n // The caller sent a self-inconsistent bundle: their problem to fix, so say what is wrong\n if (error instanceof BundleIntegrityError) return json({ error: error.message }, 400);\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n // The user has an erasure tombstone: gone, and nothing about them is taken, now or later\n if (error instanceof UserErasedError) return json({ error: 'user is erased' }, 410);\n // The operator's to fix (run the migrations), so the response says what is missing\n if (error instanceof SchemaMismatchError) {\n return json({ error: error.message, problems: error.problems }, 503);\n }\n console.error('[workflow http]', error);\n return json({ error: 'internal error' }, 500);\n}\n\n/** One request through the table: the production gate, the bearer, the route, the error mapping. */\nexport async function serve(request: Request, ctx: ServeContext): Promise<Response> {\n const url = new URL(request.url);\n if (ctx.host !== undefined && refusesOpen(request, ctx, url.pathname)) {\n return json(\n {\n error:\n 'API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)',\n },\n 503\n );\n }\n const candidates = routes.filter((route) => matches(route.path, url.pathname));\n if (candidates.length === 0) return notFound();\n // Every API route asks the authorizer; the health check and the recipients' routes never do.\n // An unset token and no authorizer means an open dev instance (see JourneyEnv.API_TOKEN);\n // production refuses to run that way\n if (candidates.some((route) => route.auth === 'api')) {\n let ok: boolean;\n try {\n ok = await allowed(request, ctx);\n } catch (error) {\n console.error('[workflow http] authorize failed', error);\n return json({ error: 'internal error' }, 500);\n }\n if (!ok) return json({ error: 'unauthorized' }, 401);\n }\n const route = candidates.find((r) => r.method === '*' || r.method === request.method);\n if (route === undefined) return json({ error: 'method not allowed' }, 405);\n try {\n return await route.handle({\n request,\n url,\n env: ctx.env,\n store: ctx.store,\n validation: ctx.validation ?? {},\n host: ctx.host ?? {},\n });\n } catch (error) {\n return failure(error);\n }\n}\n\n/** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/subscriptions/:userId`, `/subscriptions/by-message/:messageId`, `/users/forget`, `/deploy`. */\nexport function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore,\n validation: IngestValidation = {}\n): Promise<Response> {\n return serve(request, { env, store, validation });\n}\n"],"mappings":"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"}
@@ -1,10 +1,12 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_hash = require("../hash.cjs");
3
- const require_ir = require("../ir.cjs");
4
- const require_store_erasure = require("../store/erasure.cjs");
5
2
  const require_engine_bucket = require("../engine/bucket.cjs");
3
+ const require_engine_recipient = require("../engine/recipient.cjs");
6
4
  const require_engine_condition = require("../engine/condition.cjs");
5
+ const require_hash = require("../hash.cjs");
6
+ const require_ir = require("../ir.cjs");
7
+ const require_reporting_tracking = require("../reporting/tracking.cjs");
7
8
  const require_store_facts = require("../store/facts.cjs");
9
+ const require_store_erasure = require("../store/erasure.cjs");
8
10
  const require_store_index = require("../store/index.cjs");
9
11
  const require_host_platform = require("./platform.cjs");
10
12
  // src/host/ingest.ts
@@ -13,7 +15,7 @@ const require_host_platform = require("./platform.cjs");
13
15
  * event does four things: it is stored, it wakes waiting instances, it is matched against event
14
16
  * triggers, and it re-evaluates segment-trigger membership. An identify merges a profile, which
15
17
  * wakes the waits on the properties it patched and re-evaluates segment triggers too. Plus
16
- * devices, erasure, the segment sweep and bundle deploys.
18
+ * devices, subscriptions, erasure, the segment sweep and bundle deploys.
17
19
  *
18
20
  * Storage goes through a JourneyStore; every entry point takes the one the host opened.
19
21
  */
@@ -496,15 +498,182 @@ async function deployBundle(env, bundle, store, version) {
496
498
  applied
497
499
  };
498
500
  }
501
+ /** The channels a user opts out of per topic: `suppression` rows are keyed by (user, channel, topic). */
502
+ const SUBSCRIPTION_CHANNELS = ["email", "push"];
503
+ /**
504
+ * A subscriptions patch as it arrives over HTTP: untrusted JSON, read field by field and never
505
+ * cast. Every topic must be one the bundle declares — the engine's list, not the caller's word.
506
+ */
507
+ function parseSubscriptionsPatch(body, topics) {
508
+ const issues = [];
509
+ const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
510
+ if (!object(body)) return {
511
+ ok: false,
512
+ issues: [{
513
+ path: "",
514
+ message: "must be an object"
515
+ }]
516
+ };
517
+ const known = new Set(topics);
518
+ const patch = {};
519
+ for (const [channel, value] of Object.entries(body)) {
520
+ if (!SUBSCRIPTION_CHANNELS.includes(channel)) {
521
+ issues.push({
522
+ path: channel,
523
+ message: "not a subscription channel"
524
+ });
525
+ continue;
526
+ }
527
+ if (!object(value)) {
528
+ issues.push({
529
+ path: channel,
530
+ message: "must be an object"
531
+ });
532
+ continue;
533
+ }
534
+ const part = {};
535
+ for (const [field, setting] of Object.entries(value)) if (field === "all") {
536
+ if (typeof setting === "boolean") part.all = setting;
537
+ else issues.push({
538
+ path: `${channel}.all`,
539
+ message: "must be a boolean"
540
+ });
541
+ } else if (field === "topics") {
542
+ if (!object(setting)) {
543
+ issues.push({
544
+ path: `${channel}.topics`,
545
+ message: "must be an object"
546
+ });
547
+ continue;
548
+ }
549
+ const chosen = {};
550
+ for (const [topic, subscribed] of Object.entries(setting)) if (!known.has(topic)) issues.push({
551
+ path: `${channel}.topics.${topic}`,
552
+ message: "not a declared topic"
553
+ });
554
+ else if (typeof subscribed !== "boolean") issues.push({
555
+ path: `${channel}.topics.${topic}`,
556
+ message: "must be a boolean"
557
+ });
558
+ else chosen[topic] = subscribed;
559
+ part.topics = chosen;
560
+ } else issues.push({
561
+ path: `${channel}.${field}`,
562
+ message: "unknown field"
563
+ });
564
+ patch[channel] = part;
565
+ }
566
+ return issues.length > 0 ? {
567
+ ok: false,
568
+ issues
569
+ } : {
570
+ ok: true,
571
+ patch
572
+ };
573
+ }
574
+ async function readSubscriptions(store, userId, topics) {
575
+ const read = async (channel) => {
576
+ const suppressed = new Set(await store.listSuppressions(userId, channel));
577
+ return {
578
+ all: !suppressed.has(null),
579
+ topics: Object.fromEntries(topics.map((topic) => [topic, !suppressed.has(topic)]))
580
+ };
581
+ };
582
+ return {
583
+ email: await read("email"),
584
+ push: await read("push")
585
+ };
586
+ }
587
+ /** The user's subscriptions over the declared `topics`; a user with an erasure tombstone is refused. */
588
+ async function getSubscriptions(store, userId, topics) {
589
+ await refuseIfErased(store, userId);
590
+ return readSubscriptions(store, userId, topics);
591
+ }
592
+ /**
593
+ * Apply a patch — each field given sets or clears the matching opt-out, one transaction per
594
+ * channel — and answer the subscriptions as they now stand. A change of a channel's `all` is a
595
+ * change of its reachability, so the waits reading it wake, both ways: an
596
+ * `exitWhen: not(reachable('email'))` fires on the opt-out, and a wait for the channel to become
597
+ * reachable resumes on the opt-in. Email waits key on the address property, push waits on the
598
+ * device channel (`reachable('push')` subscribes to device changes).
599
+ */
600
+ async function setSubscriptions(env, store, userId, patch, topics, now = Date.now()) {
601
+ await refuseIfErased(store, userId);
602
+ for (const channel of SUBSCRIPTION_CHANNELS) {
603
+ const part = patch[channel];
604
+ if (part === void 0) continue;
605
+ const changes = [];
606
+ if (part.all !== void 0) changes.push({
607
+ topic: null,
608
+ suppressed: !part.all
609
+ });
610
+ for (const [topic, subscribed] of Object.entries(part.topics ?? {})) if (subscribed !== void 0) changes.push({
611
+ topic,
612
+ suppressed: !subscribed
613
+ });
614
+ if (changes.length === 0) continue;
615
+ await store.updateSuppressions(userId, channel, changes, now);
616
+ if (part.all !== void 0) await wakeSubscribers(env, store, userId, [channel === "push" ? require_engine_condition.DEVICES_UPDATED_EVENT : require_engine_condition.profileUpdatedEvent(require_engine_recipient.RECIPIENT_PROPERTY.email ?? "email")]);
617
+ }
618
+ return readSubscriptions(store, userId, topics);
619
+ }
620
+ /**
621
+ * The recipient of a topic copy, by the copy's id. The id is the capability: a UUID nobody can
622
+ * guess, the same trust `/t/u/<message>` runs on. Undefined — a 404 — for an id that names no
623
+ * copy (never sent, or erased with its user by `forgetUser`), and for a transactional one, which
624
+ * has nothing to opt out of; the same rule as `/t/u`. A push copy's `push_<platform>` channel
625
+ * reads as `push`, the channel its subscriptions are keyed on.
626
+ */
627
+ async function resolveMessageOwner(store, messageId) {
628
+ if (!require_reporting_tracking.isMessageId(messageId)) return void 0;
629
+ const found = await store.reporting.lookupMessage(messageId);
630
+ if (found === void 0) return void 0;
631
+ const { userId, topic } = found;
632
+ if (topic === null) return void 0;
633
+ const channel = found.channel === "email" ? "email" : found.channel.startsWith("push") ? "push" : void 0;
634
+ if (channel === void 0) return void 0;
635
+ return {
636
+ userId,
637
+ message: {
638
+ channel,
639
+ topic
640
+ }
641
+ };
642
+ }
643
+ /** `getSubscriptions` for the copy's recipient, with the copy's channel and topic; undefined when `resolveMessageOwner` is. */
644
+ async function getSubscriptionsByMessage(store, messageId, topics) {
645
+ const owner = await resolveMessageOwner(store, messageId);
646
+ if (owner === void 0) return void 0;
647
+ return {
648
+ ...await getSubscriptions(store, owner.userId, topics),
649
+ message: owner.message
650
+ };
651
+ }
652
+ /** `setSubscriptions` for the copy's recipient, answering as `getSubscriptionsByMessage` does; undefined when `resolveMessageOwner` is. */
653
+ async function setSubscriptionsByMessage(env, store, messageId, patch, topics, now = Date.now()) {
654
+ const owner = await resolveMessageOwner(store, messageId);
655
+ if (owner === void 0) return void 0;
656
+ return {
657
+ ...await setSubscriptions(env, store, owner.userId, patch, topics, now),
658
+ message: owner.message
659
+ };
660
+ }
499
661
  exports.BundleIntegrityError = BundleIntegrityError;
662
+ exports.SUBSCRIPTION_CHANNELS = SUBSCRIPTION_CHANNELS;
500
663
  exports.SWEEP_PAGE_SIZE = SWEEP_PAGE_SIZE;
501
664
  exports.deployBundle = deployBundle;
502
665
  exports.forgetUser = forgetUser;
666
+ exports.getSubscriptions = getSubscriptions;
667
+ exports.getSubscriptionsByMessage = getSubscriptionsByMessage;
503
668
  exports.identifyUser = identifyUser;
504
669
  exports.ingestEvent = ingestEvent;
670
+ exports.parseSubscriptionsPatch = parseSubscriptionsPatch;
505
671
  exports.refreshSegmentTriggers = refreshSegmentTriggers;
506
672
  exports.registerDevice = registerDevice;
507
673
  exports.removeDevice = removeDevice;
674
+ exports.resolveMessageOwner = resolveMessageOwner;
675
+ exports.setSubscriptions = setSubscriptions;
676
+ exports.setSubscriptionsByMessage = setSubscriptionsByMessage;
508
677
  exports.sweepMaintenance = sweepMaintenance;
509
678
  exports.sweepSegments = sweepSegments;
510
679
  exports.sweepSegmentsPaged = sweepSegmentsPaged;