@shware/workflow 3.3.0 → 4.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 (350) hide show
  1. package/README.md +230 -40
  2. package/dist/apns.cjs +113 -26
  3. package/dist/apns.cjs.map +1 -1
  4. package/dist/apns.d.cts +52 -6
  5. package/dist/apns.d.cts.map +1 -1
  6. package/dist/apns.d.mts +52 -6
  7. package/dist/apns.d.mts.map +1 -1
  8. package/dist/apns.mjs +111 -27
  9. package/dist/apns.mjs.map +1 -1
  10. package/dist/aws/engine-step.cjs +5 -5
  11. package/dist/aws/engine-step.cjs.map +1 -1
  12. package/dist/aws/engine-step.d.cts +8 -2
  13. package/dist/aws/engine-step.d.cts.map +1 -1
  14. package/dist/aws/engine-step.d.mts +8 -2
  15. package/dist/aws/engine-step.d.mts.map +1 -1
  16. package/dist/aws/engine-step.mjs +5 -5
  17. package/dist/aws/engine-step.mjs.map +1 -1
  18. package/dist/aws/index.cjs +50 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +4 -3
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +4 -3
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +49 -7
  25. package/dist/aws/index.mjs.map +1 -1
  26. package/dist/aws/instances.cjs +29 -6
  27. package/dist/aws/instances.cjs.map +1 -1
  28. package/dist/aws/instances.d.cts +11 -1
  29. package/dist/aws/instances.d.cts.map +1 -1
  30. package/dist/aws/instances.d.mts +11 -1
  31. package/dist/aws/instances.d.mts.map +1 -1
  32. package/dist/aws/instances.mjs +27 -4
  33. package/dist/aws/instances.mjs.map +1 -1
  34. package/dist/client.cjs +4 -1
  35. package/dist/client.cjs.map +1 -1
  36. package/dist/client.d.cts +2 -2
  37. package/dist/client.d.cts.map +1 -1
  38. package/dist/client.d.mts +2 -2
  39. package/dist/client.d.mts.map +1 -1
  40. package/dist/client.mjs +4 -1
  41. package/dist/client.mjs.map +1 -1
  42. package/dist/cloudflare/bindings.cjs +28 -25
  43. package/dist/cloudflare/bindings.cjs.map +1 -1
  44. package/dist/cloudflare/bindings.d.cts +15 -129
  45. package/dist/cloudflare/bindings.d.cts.map +1 -1
  46. package/dist/cloudflare/bindings.d.mts +15 -129
  47. package/dist/cloudflare/bindings.d.mts.map +1 -1
  48. package/dist/cloudflare/bindings.mjs +28 -23
  49. package/dist/cloudflare/bindings.mjs.map +1 -1
  50. package/dist/cloudflare/d1-env.cjs +6 -6
  51. package/dist/cloudflare/d1-env.cjs.map +1 -1
  52. package/dist/cloudflare/d1-env.d.cts +5 -5
  53. package/dist/cloudflare/d1-env.d.mts +5 -5
  54. package/dist/cloudflare/d1-env.mjs +6 -6
  55. package/dist/cloudflare/d1-env.mjs.map +1 -1
  56. package/dist/cloudflare/index.cjs +29 -18
  57. package/dist/cloudflare/index.d.cts +8 -4
  58. package/dist/cloudflare/index.d.mts +8 -4
  59. package/dist/cloudflare/index.mjs +9 -5
  60. package/dist/cloudflare/runner.cjs +29 -14
  61. package/dist/cloudflare/runner.cjs.map +1 -1
  62. package/dist/cloudflare/runner.d.cts +9 -2
  63. package/dist/cloudflare/runner.d.cts.map +1 -1
  64. package/dist/cloudflare/runner.d.mts +9 -2
  65. package/dist/cloudflare/runner.d.mts.map +1 -1
  66. package/dist/cloudflare/runner.mjs +28 -13
  67. package/dist/cloudflare/runner.mjs.map +1 -1
  68. package/dist/cloudflare/schema.sql +27 -6
  69. package/dist/cloudflare/worker.cjs +19 -9
  70. package/dist/cloudflare/worker.cjs.map +1 -1
  71. package/dist/cloudflare/worker.d.cts +2 -2
  72. package/dist/cloudflare/worker.d.cts.map +1 -1
  73. package/dist/cloudflare/worker.d.mts +2 -2
  74. package/dist/cloudflare/worker.d.mts.map +1 -1
  75. package/dist/cloudflare/worker.mjs +19 -9
  76. package/dist/cloudflare/worker.mjs.map +1 -1
  77. package/dist/config.cjs +21 -5
  78. package/dist/config.cjs.map +1 -1
  79. package/dist/config.mjs +21 -5
  80. package/dist/config.mjs.map +1 -1
  81. package/dist/dsl/action.cjs +3 -6
  82. package/dist/dsl/action.cjs.map +1 -1
  83. package/dist/dsl/action.d.cts +12 -11
  84. package/dist/dsl/action.d.cts.map +1 -1
  85. package/dist/dsl/action.d.mts +12 -11
  86. package/dist/dsl/action.d.mts.map +1 -1
  87. package/dist/dsl/action.mjs +3 -6
  88. package/dist/dsl/action.mjs.map +1 -1
  89. package/dist/dsl/bundle.cjs +35 -31
  90. package/dist/dsl/bundle.cjs.map +1 -1
  91. package/dist/dsl/bundle.d.cts.map +1 -1
  92. package/dist/dsl/bundle.d.mts.map +1 -1
  93. package/dist/dsl/bundle.mjs +35 -31
  94. package/dist/dsl/bundle.mjs.map +1 -1
  95. package/dist/dsl/condition.cjs +13 -1
  96. package/dist/dsl/condition.cjs.map +1 -1
  97. package/dist/dsl/condition.d.cts +9 -1
  98. package/dist/dsl/condition.d.cts.map +1 -1
  99. package/dist/dsl/condition.d.mts +9 -1
  100. package/dist/dsl/condition.d.mts.map +1 -1
  101. package/dist/dsl/condition.mjs +13 -2
  102. package/dist/dsl/condition.mjs.map +1 -1
  103. package/dist/dsl/flow.cjs +33 -11
  104. package/dist/dsl/flow.cjs.map +1 -1
  105. package/dist/dsl/flow.d.cts +9 -1
  106. package/dist/dsl/flow.d.cts.map +1 -1
  107. package/dist/dsl/flow.d.mts +9 -1
  108. package/dist/dsl/flow.d.mts.map +1 -1
  109. package/dist/dsl/flow.mjs +34 -13
  110. package/dist/dsl/flow.mjs.map +1 -1
  111. package/dist/dsl/workflow.cjs +2 -3
  112. package/dist/dsl/workflow.cjs.map +1 -1
  113. package/dist/dsl/workflow.d.cts.map +1 -1
  114. package/dist/dsl/workflow.d.mts.map +1 -1
  115. package/dist/dsl/workflow.mjs +3 -4
  116. package/dist/dsl/workflow.mjs.map +1 -1
  117. package/dist/engine/actions.cjs +1 -8
  118. package/dist/engine/actions.cjs.map +1 -1
  119. package/dist/engine/actions.d.cts +1 -12
  120. package/dist/engine/actions.d.cts.map +1 -1
  121. package/dist/engine/actions.d.mts +1 -12
  122. package/dist/engine/actions.d.mts.map +1 -1
  123. package/dist/engine/actions.mjs +1 -8
  124. package/dist/engine/actions.mjs.map +1 -1
  125. package/dist/engine/condition.cjs +25 -5
  126. package/dist/engine/condition.cjs.map +1 -1
  127. package/dist/engine/condition.d.cts +12 -3
  128. package/dist/engine/condition.d.cts.map +1 -1
  129. package/dist/engine/condition.d.mts +12 -3
  130. package/dist/engine/condition.d.mts.map +1 -1
  131. package/dist/engine/condition.mjs +25 -6
  132. package/dist/engine/condition.mjs.map +1 -1
  133. package/dist/engine/index.cjs +3 -0
  134. package/dist/engine/index.d.cts +5 -5
  135. package/dist/engine/index.d.mts +5 -5
  136. package/dist/engine/index.mjs +4 -4
  137. package/dist/engine/interpreter.cjs +27 -20
  138. package/dist/engine/interpreter.cjs.map +1 -1
  139. package/dist/engine/interpreter.d.cts.map +1 -1
  140. package/dist/engine/interpreter.d.mts.map +1 -1
  141. package/dist/engine/interpreter.mjs +27 -20
  142. package/dist/engine/interpreter.mjs.map +1 -1
  143. package/dist/engine/ports.d.cts +15 -6
  144. package/dist/engine/ports.d.cts.map +1 -1
  145. package/dist/engine/ports.d.mts +15 -6
  146. package/dist/engine/ports.d.mts.map +1 -1
  147. package/dist/engine/retry.cjs +13 -0
  148. package/dist/engine/retry.cjs.map +1 -1
  149. package/dist/engine/retry.d.cts +12 -0
  150. package/dist/engine/retry.d.cts.map +1 -1
  151. package/dist/engine/retry.d.mts +12 -0
  152. package/dist/engine/retry.d.mts.map +1 -1
  153. package/dist/engine/retry.mjs +13 -1
  154. package/dist/engine/retry.mjs.map +1 -1
  155. package/dist/engine/time-scale.cjs +90 -15
  156. package/dist/engine/time-scale.cjs.map +1 -1
  157. package/dist/engine/time-scale.d.cts +9 -6
  158. package/dist/engine/time-scale.d.cts.map +1 -1
  159. package/dist/engine/time-scale.d.mts +9 -6
  160. package/dist/engine/time-scale.d.mts.map +1 -1
  161. package/dist/engine/time-scale.mjs +90 -16
  162. package/dist/engine/time-scale.mjs.map +1 -1
  163. package/dist/fcm.cjs +111 -38
  164. package/dist/fcm.cjs.map +1 -1
  165. package/dist/fcm.d.cts +45 -5
  166. package/dist/fcm.d.cts.map +1 -1
  167. package/dist/fcm.d.mts +45 -5
  168. package/dist/fcm.d.mts.map +1 -1
  169. package/dist/fcm.mjs +110 -39
  170. package/dist/fcm.mjs.map +1 -1
  171. package/dist/hash.cjs +6 -6
  172. package/dist/hash.cjs.map +1 -1
  173. package/dist/hash.mjs +6 -6
  174. package/dist/hash.mjs.map +1 -1
  175. package/dist/host/auth.cjs +49 -0
  176. package/dist/host/auth.cjs.map +1 -0
  177. package/dist/host/auth.d.cts +35 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +35 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +46 -0
  182. package/dist/host/auth.mjs.map +1 -0
  183. package/dist/host/channels.cjs +118 -40
  184. package/dist/host/channels.cjs.map +1 -1
  185. package/dist/host/channels.d.cts +27 -1
  186. package/dist/host/channels.d.cts.map +1 -1
  187. package/dist/host/channels.d.mts +27 -1
  188. package/dist/host/channels.d.mts.map +1 -1
  189. package/dist/host/channels.mjs +121 -44
  190. package/dist/host/channels.mjs.map +1 -1
  191. package/dist/host/execute.cjs +14 -2
  192. package/dist/host/execute.cjs.map +1 -1
  193. package/dist/host/execute.d.cts +4 -4
  194. package/dist/host/execute.d.cts.map +1 -1
  195. package/dist/host/execute.d.mts +4 -4
  196. package/dist/host/execute.d.mts.map +1 -1
  197. package/dist/host/execute.mjs +13 -1
  198. package/dist/host/execute.mjs.map +1 -1
  199. package/dist/host/fresh-read.cjs +30 -0
  200. package/dist/host/fresh-read.cjs.map +1 -0
  201. package/dist/host/fresh-read.d.cts +18 -0
  202. package/dist/host/fresh-read.d.cts.map +1 -0
  203. package/dist/host/fresh-read.d.mts +18 -0
  204. package/dist/host/fresh-read.d.mts.map +1 -0
  205. package/dist/host/fresh-read.mjs +29 -0
  206. package/dist/host/fresh-read.mjs.map +1 -0
  207. package/dist/host/http.cjs +340 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +58 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +58 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +336 -0
  214. package/dist/host/http.mjs.map +1 -0
  215. package/dist/host/{router.cjs → ingest.cjs} +147 -175
  216. package/dist/host/ingest.cjs.map +1 -0
  217. package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
  218. package/dist/host/ingest.d.cts.map +1 -0
  219. package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
  220. package/dist/host/ingest.d.mts.map +1 -0
  221. package/dist/host/{router.mjs → ingest.mjs} +144 -173
  222. package/dist/host/ingest.mjs.map +1 -0
  223. package/dist/host/journey-host.cjs +66 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +31 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +31 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +66 -93
  230. package/dist/host/journey-host.mjs.map +1 -1
  231. package/dist/host/platform.cjs +21 -0
  232. package/dist/host/platform.cjs.map +1 -0
  233. package/dist/host/platform.d.cts +126 -0
  234. package/dist/host/platform.d.cts.map +1 -0
  235. package/dist/host/platform.d.mts +126 -0
  236. package/dist/host/platform.d.mts.map +1 -0
  237. package/dist/host/platform.mjs +19 -0
  238. package/dist/host/platform.mjs.map +1 -0
  239. package/dist/host/senders.cjs +18 -9
  240. package/dist/host/senders.cjs.map +1 -1
  241. package/dist/host/senders.d.cts +9 -4
  242. package/dist/host/senders.d.cts.map +1 -1
  243. package/dist/host/senders.d.mts +9 -4
  244. package/dist/host/senders.d.mts.map +1 -1
  245. package/dist/host/senders.mjs +18 -9
  246. package/dist/host/senders.mjs.map +1 -1
  247. package/dist/index.cjs +1 -1
  248. package/dist/index.mjs +1 -1
  249. package/dist/ir.cjs +5 -7
  250. package/dist/ir.cjs.map +1 -1
  251. package/dist/ir.d.cts +3 -12
  252. package/dist/ir.d.cts.map +1 -1
  253. package/dist/ir.d.mts +3 -12
  254. package/dist/ir.d.mts.map +1 -1
  255. package/dist/ir.mjs +5 -7
  256. package/dist/ir.mjs.map +1 -1
  257. package/dist/plan.cjs +27 -33
  258. package/dist/plan.cjs.map +1 -1
  259. package/dist/plan.d.cts +1 -1
  260. package/dist/plan.d.cts.map +1 -1
  261. package/dist/plan.d.mts +1 -1
  262. package/dist/plan.d.mts.map +1 -1
  263. package/dist/plan.mjs +27 -33
  264. package/dist/plan.mjs.map +1 -1
  265. package/dist/reporting/tracking.cjs +14 -4
  266. package/dist/reporting/tracking.cjs.map +1 -1
  267. package/dist/reporting/tracking.d.cts +10 -10
  268. package/dist/reporting/tracking.d.cts.map +1 -1
  269. package/dist/reporting/tracking.d.mts +10 -10
  270. package/dist/reporting/tracking.d.mts.map +1 -1
  271. package/dist/reporting/tracking.mjs +14 -4
  272. package/dist/reporting/tracking.mjs.map +1 -1
  273. package/dist/store/d1.cjs +88 -29
  274. package/dist/store/d1.cjs.map +1 -1
  275. package/dist/store/d1.d.cts +29 -11
  276. package/dist/store/d1.d.cts.map +1 -1
  277. package/dist/store/d1.d.mts +29 -11
  278. package/dist/store/d1.d.mts.map +1 -1
  279. package/dist/store/d1.mjs +88 -29
  280. package/dist/store/d1.mjs.map +1 -1
  281. package/dist/store/erasure.cjs +22 -0
  282. package/dist/store/erasure.cjs.map +1 -0
  283. package/dist/store/erasure.d.cts +18 -0
  284. package/dist/store/erasure.d.cts.map +1 -0
  285. package/dist/store/erasure.d.mts +18 -0
  286. package/dist/store/erasure.d.mts.map +1 -0
  287. package/dist/store/erasure.mjs +21 -0
  288. package/dist/store/erasure.mjs.map +1 -0
  289. package/dist/store/index.cjs +2 -0
  290. package/dist/store/index.cjs.map +1 -1
  291. package/dist/store/index.d.cts +67 -13
  292. package/dist/store/index.d.cts.map +1 -1
  293. package/dist/store/index.d.mts +67 -13
  294. package/dist/store/index.d.mts.map +1 -1
  295. package/dist/store/index.mjs +2 -1
  296. package/dist/store/index.mjs.map +1 -1
  297. package/dist/store/postgres-reporting.cjs +125 -62
  298. package/dist/store/postgres-reporting.cjs.map +1 -1
  299. package/dist/store/postgres-reporting.d.cts +30 -2
  300. package/dist/store/postgres-reporting.d.cts.map +1 -1
  301. package/dist/store/postgres-reporting.d.mts +30 -2
  302. package/dist/store/postgres-reporting.d.mts.map +1 -1
  303. package/dist/store/postgres-reporting.mjs +125 -62
  304. package/dist/store/postgres-reporting.mjs.map +1 -1
  305. package/dist/store/postgres.cjs +179 -60
  306. package/dist/store/postgres.cjs.map +1 -1
  307. package/dist/store/postgres.d.cts +44 -7
  308. package/dist/store/postgres.d.cts.map +1 -1
  309. package/dist/store/postgres.d.mts +44 -7
  310. package/dist/store/postgres.d.mts.map +1 -1
  311. package/dist/store/postgres.mjs +179 -60
  312. package/dist/store/postgres.mjs.map +1 -1
  313. package/dist/store/tables.cjs +7 -5
  314. package/dist/store/tables.cjs.map +1 -1
  315. package/dist/store/tables.d.cts.map +1 -1
  316. package/dist/store/tables.d.mts.map +1 -1
  317. package/dist/store/tables.mjs +7 -5
  318. package/dist/store/tables.mjs.map +1 -1
  319. package/dist/store/user-lock.cjs +43 -0
  320. package/dist/store/user-lock.cjs.map +1 -0
  321. package/dist/store/user-lock.d.cts +32 -0
  322. package/dist/store/user-lock.d.cts.map +1 -0
  323. package/dist/store/user-lock.d.mts +32 -0
  324. package/dist/store/user-lock.d.mts.map +1 -0
  325. package/dist/store/user-lock.mjs +40 -0
  326. package/dist/store/user-lock.mjs.map +1 -0
  327. package/dist/transports/cloudflare-email.cjs +1 -1
  328. package/dist/transports/cloudflare-email.mjs +1 -1
  329. package/dist/transports/ses.cjs +47 -6
  330. package/dist/transports/ses.cjs.map +1 -1
  331. package/dist/transports/ses.d.cts +9 -1
  332. package/dist/transports/ses.d.cts.map +1 -1
  333. package/dist/transports/ses.d.mts +9 -1
  334. package/dist/transports/ses.d.mts.map +1 -1
  335. package/dist/transports/ses.mjs +47 -7
  336. package/dist/transports/ses.mjs.map +1 -1
  337. package/dist/webpush.cjs +133 -47
  338. package/dist/webpush.cjs.map +1 -1
  339. package/dist/webpush.d.cts +49 -8
  340. package/dist/webpush.d.cts.map +1 -1
  341. package/dist/webpush.d.mts +49 -8
  342. package/dist/webpush.d.mts.map +1 -1
  343. package/dist/webpush.mjs +130 -47
  344. package/dist/webpush.mjs.map +1 -1
  345. package/package.json +1 -1
  346. package/src/cloudflare/schema.sql +27 -6
  347. package/dist/host/router.cjs.map +0 -1
  348. package/dist/host/router.d.cts.map +0 -1
  349. package/dist/host/router.d.mts.map +0 -1
  350. package/dist/host/router.mjs.map +0 -1
package/dist/webpush.mjs CHANGED
@@ -1,5 +1,23 @@
1
+ import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
1
2
  import { base64url } from "./jwt.mjs";
2
3
  //#region src/webpush.ts
4
+ /**
5
+ * How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services
6
+ * answering as documented there):
7
+ *
8
+ * - dead subscription: 404 and 410 — the browser unsubscribed or the subscription expired
9
+ * (RFC 8030 §7.3: 410 Gone for an expired subscription) — and a subscription the sender
10
+ * cannot parse (`InvalidSubscription`). The copy settles undelivered and the host forgets it.
11
+ * - transient: 429 Too Many Requests (§8.4) and 5xx.
12
+ * - everything else is this request or this sender: 400 (a bad header or body), 401 / 403 (the
13
+ * VAPID key pair or subject, §8.2 of RFC 8292), 413 (the payload, RFC 8030 §7.2) and any other
14
+ * 4xx are a `PermanentError`.
15
+ */
16
+ const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
17
+ "Gone",
18
+ "NotFound",
19
+ "InvalidSubscription"
20
+ ]);
3
21
  /** A fresh VAPID key pair (once per project; the private key is a secret). */
4
22
  async function generateVapidKeys() {
5
23
  const pair = await crypto.subtle.generateKey({
@@ -13,8 +31,15 @@ async function generateVapidKeys() {
13
31
  privateKey: jwk.d ?? ""
14
32
  };
15
33
  }
16
- /** Records are one per message here: the payload stays far below the 4096-byte record size. */
34
+ /** The record size the header announces: RFC 8291 §4 fixes it at 4096 and one record per push. */
17
35
  const RECORD_SIZE = 4096;
36
+ /**
37
+ * The largest plaintext one record carries. A push message body is at most 4096 octets (RFC 8030
38
+ * §7.2); the aes128gcm header takes 86 of them (16 salt + 4 rs + 1 idlen + 65 key), the record its
39
+ * 16-octet tag and the 1-octet delimiter — 3993 octets of plaintext (RFC 8291 §4). Anything past
40
+ * that is either an invalid record or a 413 from the push service.
41
+ */
42
+ const MAX_PLAINTEXT_BYTES = 3993;
18
43
  const encoder = new TextEncoder();
19
44
  const utf8 = (text) => encoder.encode(text);
20
45
  function fromBase64url(text) {
@@ -73,35 +98,83 @@ async function encryptPayload(payload, p256dh, authSecret, salt = crypto.getRand
73
98
  header[20] = senderPublic.length;
74
99
  return concat(header, senderPublic, sealed);
75
100
  }
76
- /** The VAPID `Authorization` header for one push service (RFC 8292 §3). */
77
- async function vapidAuthorization(endpoint, vapid, now = Date.now()) {
78
- const publicKey = fromBase64url(vapid.publicKey);
79
- const key = await crypto.subtle.importKey("jwk", {
80
- kty: "EC",
81
- crv: "P-256",
82
- d: vapid.privateKey,
83
- x: base64url(publicKey.slice(1, 33)),
84
- y: base64url(publicKey.slice(33, 65)),
85
- ext: true
86
- }, {
87
- name: "ECDSA",
88
- namedCurve: "P-256"
89
- }, false, ["sign"]);
90
- const header = base64url(JSON.stringify({
91
- typ: "JWT",
92
- alg: "ES256"
93
- }));
94
- const claims = base64url(JSON.stringify({
95
- aud: new URL(endpoint).origin,
96
- exp: Math.floor(now / 1e3) + 43200,
97
- sub: vapid.subject
98
- }));
99
- const signature = new Uint8Array(await crypto.subtle.sign({
100
- name: "ECDSA",
101
- hash: "SHA-256"
102
- }, key, utf8(`${header}.${claims}`)));
103
- return `vapid t=${header}.${claims}.${base64url(signature)}, k=${vapid.publicKey}`;
104
- }
101
+ /** How long a VAPID token is good for: RFC 8292 §2 allows at most 24 hours. */
102
+ const VAPID_EXP_S = 86400;
103
+ /** How long a signed token is reused: half its life, so it is never sent near expiry. */
104
+ const VAPID_TTL_MS = 432e5;
105
+ /**
106
+ * The VAPID `Authorization` header (RFC 8292 §3) for one key pair. The private key is imported
107
+ * once; the JWT is signed per push service (its `aud` is the service's origin) and reused for
108
+ * `VAPID_TTL_MS`. Concurrent callers for one origin share the one signing in flight, and a signing
109
+ * that failed is not kept. One instance serves every sender built on the pair — a host keeps it
110
+ * across runs, and builds the senders per run.
111
+ */
112
+ var VapidSigner = class {
113
+ options;
114
+ key;
115
+ signed = /* @__PURE__ */ new Map();
116
+ signing = /* @__PURE__ */ new Map();
117
+ constructor(options) {
118
+ this.options = options;
119
+ }
120
+ /** The header value for the push service at `audience` — its origin, or any URL on it. */
121
+ async authorization(audience, now = Date.now()) {
122
+ const origin = new URL(audience).origin;
123
+ const current = this.signed.get(origin);
124
+ if (current !== void 0 && now - current.issuedAt < VAPID_TTL_MS) return current.value;
125
+ let pending = this.signing.get(origin);
126
+ if (pending === void 0) {
127
+ pending = this.sign(origin, now).then((value) => {
128
+ this.signed.set(origin, {
129
+ value,
130
+ issuedAt: now
131
+ });
132
+ return value;
133
+ }).finally(() => {
134
+ this.signing.delete(origin);
135
+ });
136
+ this.signing.set(origin, pending);
137
+ }
138
+ return pending;
139
+ }
140
+ privateKey() {
141
+ if (this.key === void 0) {
142
+ const publicKey = fromBase64url(this.options.publicKey);
143
+ this.key = crypto.subtle.importKey("jwk", {
144
+ kty: "EC",
145
+ crv: "P-256",
146
+ d: this.options.privateKey,
147
+ x: base64url(publicKey.slice(1, 33)),
148
+ y: base64url(publicKey.slice(33, 65)),
149
+ ext: true
150
+ }, {
151
+ name: "ECDSA",
152
+ namedCurve: "P-256"
153
+ }, false, ["sign"]).catch((error) => {
154
+ this.key = void 0;
155
+ throw error;
156
+ });
157
+ }
158
+ return this.key;
159
+ }
160
+ async sign(origin, now) {
161
+ const key = await this.privateKey();
162
+ const header = base64url(JSON.stringify({
163
+ typ: "JWT",
164
+ alg: "ES256"
165
+ }));
166
+ const claims = base64url(JSON.stringify({
167
+ aud: origin,
168
+ exp: Math.floor(now / 1e3) + VAPID_EXP_S,
169
+ sub: this.options.subject
170
+ }));
171
+ const signature = new Uint8Array(await crypto.subtle.sign({
172
+ name: "ECDSA",
173
+ hash: "SHA-256"
174
+ }, key, utf8(`${header}.${claims}`)));
175
+ return `vapid t=${header}.${claims}.${base64url(signature)}, k=${this.options.publicKey}`;
176
+ }
177
+ };
105
178
  /**
106
179
  * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256
107
180
  * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,
@@ -132,44 +205,54 @@ var WebPushSender = class {
132
205
  if (message.channel !== "push") throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);
133
206
  if (message.recipient === void 0) throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);
134
207
  const subscription = parseSubscription(message.recipient);
135
- if (subscription === void 0) {
136
- await this.options.onUnregistered?.(message);
137
- return {
138
- delivered: false,
139
- reason: "InvalidSubscription"
140
- };
141
- }
208
+ if (subscription === void 0) return {
209
+ delivered: false,
210
+ reason: "InvalidSubscription"
211
+ };
142
212
  const content = await this.options.render(message.template, message.props);
143
- const body = await encryptPayload(utf8(JSON.stringify({
213
+ const payload = utf8(JSON.stringify({
144
214
  ...content,
145
215
  template: message.template,
146
216
  idempotencyKey: message.idempotencyKey,
147
217
  ...message.tracking?.data
148
- })), subscription.p256dh, subscription.auth);
218
+ }));
219
+ if (payload.length > 3993) throw new PermanentError(`[webpush] payload for template '${message.template}' is ${payload.length} bytes; a push message carries at most ${MAX_PLAINTEXT_BYTES}`);
220
+ const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);
149
221
  const response = await (this.options.fetch ?? fetch)(subscription.endpoint, {
150
222
  method: "POST",
151
223
  headers: {
152
- authorization: await vapidAuthorization(subscription.endpoint, this.options.vapid),
224
+ authorization: await this.options.credentials.authorization(subscription.endpoint),
153
225
  "content-encoding": "aes128gcm",
154
226
  "content-type": "application/octet-stream",
155
227
  ttl: String(this.options.ttl ?? 86400),
156
- urgency: "normal"
228
+ urgency: "normal",
229
+ topic: await webPushTopic(message.idempotencyKey)
157
230
  },
158
231
  body
159
232
  });
160
233
  if (response.ok) return;
161
- if (response.status === 404 || response.status === 410) {
162
- console.warn(`[webpush] subscription gone for user ${message.userId}: ${response.status}`);
163
- await this.options.onUnregistered?.(message);
234
+ const { status } = response;
235
+ if (status === 404 || status === 410) {
236
+ console.warn(`[webpush] subscription gone for user ${message.userId}: ${status}`);
164
237
  return {
165
238
  delivered: false,
166
- reason: response.status === 410 ? "Gone" : "NotFound"
239
+ reason: status === 410 ? "Gone" : "NotFound"
167
240
  };
168
241
  }
169
- throw new Error(`[webpush] ${response.status}: ${await response.text()}`);
242
+ const text = await response.text();
243
+ if (isRetryableStatus(status)) throw new Error(`[webpush] ${status}: ${text}`);
244
+ throw new PermanentError(`[webpush] push service refused the request: ${status} ${text}`);
170
245
  }
171
246
  };
247
+ /**
248
+ * The `Topic` header for one copy: RFC 8030 §5.4 allows at most 32 characters of the URL-safe
249
+ * base64 alphabet, so the idempotency key goes through SHA-256 and is cut to size.
250
+ */
251
+ async function webPushTopic(idempotencyKey) {
252
+ const digest = await crypto.subtle.digest("SHA-256", utf8(idempotencyKey));
253
+ return base64url(new Uint8Array(digest)).slice(0, 32);
254
+ }
172
255
  //#endregion
173
- export { WebPushSender, encryptPayload, fromBase64url, generateVapidKeys, vapidAuthorization };
256
+ export { DEAD_TOKEN_REASONS, MAX_PLAINTEXT_BYTES, VapidSigner, WebPushSender, encryptPayload, fromBase64url, generateVapidKeys, webPushTopic };
174
257
 
175
258
  //# sourceMappingURL=webpush.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"webpush.mjs","names":[],"sources":["../src/webpush.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { base64url } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/webpush` — browser push through the standard Web Push protocol: VAPID\n * (RFC 8292) identifies the sender, the payload is encrypted for the subscription (RFC 8291,\n * `aes128gcm`, RFC 8188). One sender reaches Chrome, Edge, Firefox and Safari 16+ — each\n * browser's push service accepts the same request, over HTTP/1.1 — with nothing but WebCrypto.\n *\n * The recipient is a browser's `PushSubscription` as JSON (`JSON.stringify(subscription)`),\n * registered like an app token (`registerDevice(userId, { token, platform: 'web' })`). The\n * service worker receives `{ title, body, image?, url?, template, …tracking data }` as the push\n * event's JSON.\n */\n\n/** A VAPID key pair: the uncompressed P-256 public key and the private scalar, base64url. */\nexport interface VapidKeys {\n publicKey: string;\n privateKey: string;\n}\n\nexport interface WebPushOptions {\n vapid: VapidKeys & {\n /** Who to contact about this sender: `mailto:` or an `https:` URL (RFC 8292). */\n subject: string;\n };\n render: PushRenderer;\n /** Seconds the push service keeps an undelivered message; a day by default. */\n ttl?: number;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n /** Called for a subscription the push service reports as gone (404 / 410). */\n onUnregistered?: (message: OutboundMessage) => Promise<void> | void;\n}\n\ninterface Subscription {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** A fresh VAPID key pair (once per project; the private key is a secret). */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [\n 'sign',\n 'verify',\n ]);\n const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));\n const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);\n return { publicKey: base64url(raw), privateKey: jwk.d ?? '' };\n}\n\n/** Records are one per message here: the payload stays far below the 4096-byte record size. */\nconst RECORD_SIZE = 4096;\n/** Bytes WebCrypto accepts (backed by a plain ArrayBuffer). */\ntype Bytes = Uint8Array<ArrayBuffer>;\nconst encoder = new TextEncoder();\nconst utf8 = (text: string): Bytes => encoder.encode(text);\n\nexport function fromBase64url(text: string): Bytes {\n const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n}\n\nfunction concat(...parts: Uint8Array[]): Bytes {\n const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nasync function hmac(key: Bytes, data: Bytes): Promise<Bytes> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return new Uint8Array(await crypto.subtle.sign('HMAC', imported, data));\n}\n\n/** HKDF-SHA256 with a single output block (every length here is ≤ 32 bytes). */\nasync function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, length: number): Promise<Bytes> {\n const prk = await hmac(salt, ikm);\n return (await hmac(prk, concat(info, new Uint8Array([1])))).slice(0, length);\n}\n\n/**\n * The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's\n * ephemeral public key, then the payload sealed under a key both sides derive from ECDH and the\n * subscription's auth secret.\n */\nexport async function encryptPayload(\n payload: Bytes,\n p256dh: Bytes,\n authSecret: Bytes,\n salt: Bytes = crypto.getRandomValues(new Uint8Array(16))\n): Promise<Bytes> {\n const ephemeral = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [\n 'deriveBits',\n ]);\n const senderPublic = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));\n const receiver = await crypto.subtle.importKey(\n 'raw',\n p256dh,\n { name: 'ECDH', namedCurve: 'P-256' },\n false,\n []\n );\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'ECDH', public: receiver }, ephemeral.privateKey, 256)\n );\n const ikm = await hkdf(\n authSecret,\n shared,\n concat(utf8('WebPush: info\\0'), p256dh, senderPublic),\n 32\n );\n const cek = await hkdf(salt, ikm, utf8('Content-Encoding: aes128gcm\\0'), 16);\n const nonce = await hkdf(salt, ikm, utf8('Content-Encoding: nonce\\0'), 12);\n const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt']);\n // The single, last record: the payload and the 0x02 delimiter, no padding\n const sealed = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: nonce },\n key,\n concat(payload, new Uint8Array([2]))\n )\n );\n const header = new Uint8Array(16 + 4 + 1);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE);\n header[20] = senderPublic.length;\n return concat(header, senderPublic, sealed);\n}\n\n/** The VAPID `Authorization` header for one push service (RFC 8292 §3). */\nexport async function vapidAuthorization(\n endpoint: string,\n vapid: WebPushOptions['vapid'],\n now = Date.now()\n): Promise<string> {\n const publicKey = fromBase64url(vapid.publicKey);\n const key = await crypto.subtle.importKey(\n 'jwk',\n {\n kty: 'EC',\n crv: 'P-256',\n d: vapid.privateKey,\n x: base64url(publicKey.slice(1, 33)),\n y: base64url(publicKey.slice(33, 65)),\n ext: true,\n },\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = base64url(\n JSON.stringify({\n aud: new URL(endpoint).origin,\n exp: Math.floor(now / 1000) + 12 * 3600,\n sub: vapid.subject,\n })\n );\n const signature = new Uint8Array(\n await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${header}.${claims}`))\n );\n return `vapid t=${header}.${claims}.${base64url(signature)}, k=${vapid.publicKey}`;\n}\n\n/**\n * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256\n * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,\n * an endpoint that is not https — is undefined.\n */\nfunction parseSubscription(\n token: string\n): { endpoint: string; p256dh: Bytes; auth: Bytes } | undefined {\n try {\n const value = JSON.parse(token) as Partial<Subscription>;\n if (typeof value.endpoint !== 'string' || new URL(value.endpoint).protocol !== 'https:') {\n return undefined;\n }\n const p256dh = fromBase64url(value.keys?.p256dh ?? '');\n const auth = fromBase64url(value.keys?.auth ?? '');\n if (p256dh.length !== 65 || p256dh[0] !== 4 || auth.length !== 16) return undefined;\n return { endpoint: value.endpoint, p256dh, auth };\n } catch {\n return undefined;\n }\n}\n\nexport class WebPushSender implements MessageSender {\n constructor(private readonly options: WebPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);\n }\n const subscription = parseSubscription(message.recipient);\n if (subscription === undefined) {\n // Not a usable subscription: nothing will ever deliver to it, so do not retry into it\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: 'InvalidSubscription' };\n }\n const content = await this.options.render(message.template, message.props);\n const payload = utf8(\n JSON.stringify({\n ...content,\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...message.tracking?.data,\n })\n );\n const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(subscription.endpoint, {\n method: 'POST',\n headers: {\n authorization: await vapidAuthorization(subscription.endpoint, this.options.vapid),\n 'content-encoding': 'aes128gcm',\n 'content-type': 'application/octet-stream',\n ttl: String(this.options.ttl ?? 86_400),\n urgency: 'normal',\n },\n body,\n });\n if (response.ok) return;\n if (response.status === 404 || response.status === 410) {\n // The browser unsubscribed or the subscription expired: it will never deliver again\n console.warn(`[webpush] subscription gone for user ${message.userId}: ${response.status}`);\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: response.status === 410 ? 'Gone' : 'NotFound' };\n }\n throw new Error(`[webpush] ${response.status}: ${await response.text()}`);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"webpush.mjs","names":[],"sources":["../src/webpush.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/webpush` — browser push through the standard Web Push protocol: VAPID\n * (RFC 8292) identifies the sender, the payload is encrypted for the subscription (RFC 8291,\n * `aes128gcm`, RFC 8188). One sender reaches Chrome, Edge, Firefox and Safari 16+ — each\n * browser's push service accepts the same request, over HTTP/1.1 — with nothing but WebCrypto.\n *\n * The recipient is a browser's `PushSubscription` as JSON (`JSON.stringify(subscription)`),\n * registered like an app token (`registerDevice(userId, { token, platform: 'web' })`). The\n * service worker receives `{ title, body, image?, url?, template, …tracking data }` as the push\n * event's JSON.\n */\n\n/** A VAPID key pair: the uncompressed P-256 public key and the private scalar, base64url. */\nexport interface VapidKeys {\n publicKey: string;\n privateKey: string;\n}\n\nexport interface VapidSignerOptions extends VapidKeys {\n /** Who to contact about this sender: `mailto:` or an `https:` URL (RFC 8292). */\n subject: string;\n}\n\nexport interface WebPushOptions {\n /** The VAPID signer the requests are authorized with — shared by every sender on the same key pair. */\n credentials: VapidSigner;\n render: PushRenderer;\n /** Seconds the push service keeps an undelivered message; a day by default. */\n ttl?: number;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services\n * answering as documented there):\n *\n * - dead subscription: 404 and 410 — the browser unsubscribed or the subscription expired\n * (RFC 8030 §7.3: 410 Gone for an expired subscription) — and a subscription the sender\n * cannot parse (`InvalidSubscription`). The copy settles undelivered and the host forgets it.\n * - transient: 429 Too Many Requests (§8.4) and 5xx.\n * - everything else is this request or this sender: 400 (a bad header or body), 401 / 403 (the\n * VAPID key pair or subject, §8.2 of RFC 8292), 413 (the payload, RFC 8030 §7.2) and any other\n * 4xx are a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set([\n 'Gone',\n 'NotFound',\n 'InvalidSubscription',\n]);\n\ninterface Subscription {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** A fresh VAPID key pair (once per project; the private key is a secret). */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [\n 'sign',\n 'verify',\n ]);\n const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));\n const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);\n return { publicKey: base64url(raw), privateKey: jwk.d ?? '' };\n}\n\n/** The record size the header announces: RFC 8291 §4 fixes it at 4096 and one record per push. */\nconst RECORD_SIZE = 4096;\n/**\n * The largest plaintext one record carries. A push message body is at most 4096 octets (RFC 8030\n * §7.2); the aes128gcm header takes 86 of them (16 salt + 4 rs + 1 idlen + 65 key), the record its\n * 16-octet tag and the 1-octet delimiter — 3993 octets of plaintext (RFC 8291 §4). Anything past\n * that is either an invalid record or a 413 from the push service.\n */\nexport const MAX_PLAINTEXT_BYTES = 3993;\n/** Bytes WebCrypto accepts (backed by a plain ArrayBuffer). */\ntype Bytes = Uint8Array<ArrayBuffer>;\nconst encoder = new TextEncoder();\nconst utf8 = (text: string): Bytes => encoder.encode(text);\n\nexport function fromBase64url(text: string): Bytes {\n const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n}\n\nfunction concat(...parts: Uint8Array[]): Bytes {\n const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nasync function hmac(key: Bytes, data: Bytes): Promise<Bytes> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return new Uint8Array(await crypto.subtle.sign('HMAC', imported, data));\n}\n\n/** HKDF-SHA256 with a single output block (every length here is ≤ 32 bytes). */\nasync function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, length: number): Promise<Bytes> {\n const prk = await hmac(salt, ikm);\n return (await hmac(prk, concat(info, new Uint8Array([1])))).slice(0, length);\n}\n\n/**\n * The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's\n * ephemeral public key, then the payload sealed under a key both sides derive from ECDH and the\n * subscription's auth secret.\n */\nexport async function encryptPayload(\n payload: Bytes,\n p256dh: Bytes,\n authSecret: Bytes,\n salt: Bytes = crypto.getRandomValues(new Uint8Array(16))\n): Promise<Bytes> {\n const ephemeral = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [\n 'deriveBits',\n ]);\n const senderPublic = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));\n const receiver = await crypto.subtle.importKey(\n 'raw',\n p256dh,\n { name: 'ECDH', namedCurve: 'P-256' },\n false,\n []\n );\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'ECDH', public: receiver }, ephemeral.privateKey, 256)\n );\n const ikm = await hkdf(\n authSecret,\n shared,\n concat(utf8('WebPush: info\\0'), p256dh, senderPublic),\n 32\n );\n const cek = await hkdf(salt, ikm, utf8('Content-Encoding: aes128gcm\\0'), 16);\n const nonce = await hkdf(salt, ikm, utf8('Content-Encoding: nonce\\0'), 12);\n const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt']);\n // The single, last record: the payload and the 0x02 delimiter, no padding\n const sealed = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: nonce },\n key,\n concat(payload, new Uint8Array([2]))\n )\n );\n const header = new Uint8Array(16 + 4 + 1);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE);\n header[20] = senderPublic.length;\n return concat(header, senderPublic, sealed);\n}\n\n/** How long a VAPID token is good for: RFC 8292 §2 allows at most 24 hours. */\nconst VAPID_EXP_S = 24 * 3600;\n/** How long a signed token is reused: half its life, so it is never sent near expiry. */\nconst VAPID_TTL_MS = 12 * 3600_000;\n\n/**\n * The VAPID `Authorization` header (RFC 8292 §3) for one key pair. The private key is imported\n * once; the JWT is signed per push service (its `aud` is the service's origin) and reused for\n * `VAPID_TTL_MS`. Concurrent callers for one origin share the one signing in flight, and a signing\n * that failed is not kept. One instance serves every sender built on the pair — a host keeps it\n * across runs, and builds the senders per run.\n */\nexport class VapidSigner {\n private key: Promise<CryptoKey> | undefined;\n private readonly signed = new Map<string, { value: string; issuedAt: number }>();\n private readonly signing = new Map<string, Promise<string>>();\n\n constructor(private readonly options: VapidSignerOptions) {}\n\n /** The header value for the push service at `audience` — its origin, or any URL on it. */\n async authorization(audience: string, now = Date.now()): Promise<string> {\n const origin = new URL(audience).origin;\n const current = this.signed.get(origin);\n if (current !== undefined && now - current.issuedAt < VAPID_TTL_MS) return current.value;\n let pending = this.signing.get(origin);\n if (pending === undefined) {\n pending = this.sign(origin, now)\n .then((value) => {\n this.signed.set(origin, { value, issuedAt: now });\n return value;\n })\n .finally(() => {\n this.signing.delete(origin);\n });\n this.signing.set(origin, pending);\n }\n return pending;\n }\n\n private privateKey(): Promise<CryptoKey> {\n if (this.key === undefined) {\n const publicKey = fromBase64url(this.options.publicKey);\n this.key = crypto.subtle\n .importKey(\n 'jwk',\n {\n kty: 'EC',\n crv: 'P-256',\n d: this.options.privateKey,\n x: base64url(publicKey.slice(1, 33)),\n y: base64url(publicKey.slice(33, 65)),\n ext: true,\n },\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n )\n .catch((error: unknown) => {\n this.key = undefined;\n throw error;\n });\n }\n return this.key;\n }\n\n private async sign(origin: string, now: number): Promise<string> {\n const key = await this.privateKey();\n const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = base64url(\n JSON.stringify({\n aud: origin,\n exp: Math.floor(now / 1000) + VAPID_EXP_S,\n sub: this.options.subject,\n })\n );\n const signature = new Uint8Array(\n await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${header}.${claims}`))\n );\n return `vapid t=${header}.${claims}.${base64url(signature)}, k=${this.options.publicKey}`;\n }\n}\n\n/**\n * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256\n * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,\n * an endpoint that is not https — is undefined.\n */\nfunction parseSubscription(\n token: string\n): { endpoint: string; p256dh: Bytes; auth: Bytes } | undefined {\n try {\n const value = JSON.parse(token) as Partial<Subscription>;\n if (typeof value.endpoint !== 'string' || new URL(value.endpoint).protocol !== 'https:') {\n return undefined;\n }\n const p256dh = fromBase64url(value.keys?.p256dh ?? '');\n const auth = fromBase64url(value.keys?.auth ?? '');\n if (p256dh.length !== 65 || p256dh[0] !== 4 || auth.length !== 16) return undefined;\n return { endpoint: value.endpoint, p256dh, auth };\n } catch {\n return undefined;\n }\n}\n\nexport class WebPushSender implements MessageSender {\n constructor(private readonly options: WebPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);\n }\n const subscription = parseSubscription(message.recipient);\n if (subscription === undefined) {\n // Not a usable subscription: nothing will ever deliver to it, so do not retry into it\n return { delivered: false, reason: 'InvalidSubscription' };\n }\n const content = await this.options.render(message.template, message.props);\n const payload = utf8(\n JSON.stringify({\n ...content,\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...message.tracking?.data,\n })\n );\n if (payload.length > MAX_PLAINTEXT_BYTES) {\n // The same template renders the same size on every attempt: settle it before encrypting\n throw new PermanentError(\n `[webpush] payload for template '${message.template}' is ${payload.length} bytes; a push message carries at most ${MAX_PLAINTEXT_BYTES}`\n );\n }\n const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(subscription.endpoint, {\n method: 'POST',\n headers: {\n authorization: await this.options.credentials.authorization(subscription.endpoint),\n 'content-encoding': 'aes128gcm',\n 'content-type': 'application/octet-stream',\n ttl: String(this.options.ttl ?? 86_400),\n urgency: 'normal',\n // A resend of the same copy replaces the first one while it is still queued (RFC 8030\n // §5.4), so an at-least-once retry does not show the notification twice\n topic: await webPushTopic(message.idempotencyKey),\n },\n body,\n });\n if (response.ok) return;\n const { status } = response;\n if (status === 404 || status === 410) {\n // The browser unsubscribed or the subscription expired: it will never deliver again\n console.warn(`[webpush] subscription gone for user ${message.userId}: ${status}`);\n return { delivered: false, reason: status === 410 ? 'Gone' : 'NotFound' };\n }\n const text = await response.text();\n if (isRetryableStatus(status)) throw new Error(`[webpush] ${status}: ${text}`);\n throw new PermanentError(`[webpush] push service refused the request: ${status} ${text}`);\n }\n}\n\n/**\n * The `Topic` header for one copy: RFC 8030 §5.4 allows at most 32 characters of the URL-safe\n * base64 alphabet, so the idempotency key goes through SHA-256 and is cut to size.\n */\nexport async function webPushTopic(idempotencyKey: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', utf8(idempotencyKey));\n return base64url(new Uint8Array(digest)).slice(0, 32);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkDA,MAAa,qCAA0C,IAAI,IAAI;CAC7D;CACA;CACA;AACF,CAAC;;AAQD,eAAsB,oBAAwC;CAC5D,MAAM,OAAO,MAAM,OAAO,OAAO,YAAY;EAAE,MAAM;EAAS,YAAY;CAAQ,GAAG,MAAM,CACzF,QACA,QACF,CAAC;CACD,MAAM,MAAM,IAAI,WAAW,MAAM,OAAO,OAAO,UAAU,OAAO,KAAK,SAAS,CAAC;CAC/E,MAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,KAAK,UAAU;CAChE,OAAO;EAAE,WAAW,UAAU,GAAG;EAAG,YAAY,IAAI,KAAK;CAAG;AAC9D;;AAGA,MAAM,cAAc;;;;;;;AAOpB,MAAa,sBAAsB;AAGnC,MAAM,UAAU,IAAI,YAAY;AAChC,MAAM,QAAQ,SAAwB,QAAQ,OAAO,IAAI;AAEzD,SAAgB,cAAc,MAAqB;CACjD,MAAM,SAAS,KAAK,KAAK,QAAQ,MAAM,GAAG,CAAC,CAAC,QAAQ,MAAM,GAAG,CAAC;CAC9D,OAAO,WAAW,KAAK,SAAS,SAAS,KAAK,WAAW,CAAC,CAAC;AAC7D;AAEA,SAAS,OAAO,GAAG,OAA4B;CAC7C,MAAM,MAAM,IAAI,WAAW,MAAM,QAAQ,GAAG,SAAS,IAAI,KAAK,QAAQ,CAAC,CAAC;CACxE,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,IAAI,MAAM,MAAM;EACpB,UAAU,KAAK;CACjB;CACA,OAAO;AACT;AAEA,eAAe,KAAK,KAAY,MAA6B;CAC3D,MAAM,WAAW,MAAM,OAAO,OAAO,UACnC,OACA,KACA;EAAE,MAAM;EAAQ,MAAM;CAAU,GAChC,OACA,CAAC,MAAM,CACT;CACA,OAAO,IAAI,WAAW,MAAM,OAAO,OAAO,KAAK,QAAQ,UAAU,IAAI,CAAC;AACxE;;AAGA,eAAe,KAAK,MAAa,KAAY,MAAa,QAAgC;CAExF,QAAQ,MAAM,KAAK,MADD,KAAK,MAAM,GAAG,GACR,OAAO,MAAM,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,EAAA,CAAG,MAAM,GAAG,MAAM;AAC7E;;;;;;AAOA,eAAsB,eACpB,SACA,QACA,YACA,OAAc,OAAO,gCAAgB,IAAI,WAAW,EAAE,CAAC,GACvC;CAChB,MAAM,YAAY,MAAM,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ,YAAY;CAAQ,GAAG,MAAM,CAC7F,YACF,CAAC;CACD,MAAM,eAAe,IAAI,WAAW,MAAM,OAAO,OAAO,UAAU,OAAO,UAAU,SAAS,CAAC;CAC7F,MAAM,WAAW,MAAM,OAAO,OAAO,UACnC,OACA,QACA;EAAE,MAAM;EAAQ,YAAY;CAAQ,GACpC,OACA,CAAC,CACH;CAIA,MAAM,MAAM,MAAM,KAChB,YACA,IALiB,WACjB,MAAM,OAAO,OAAO,WAAW;EAAE,MAAM;EAAQ,QAAQ;CAAS,GAAG,UAAU,YAAY,GAAG,CAIvF,GACL,OAAO,KAAK,iBAAiB,GAAG,QAAQ,YAAY,GACpD,EACF;CACA,MAAM,MAAM,MAAM,KAAK,MAAM,KAAK,KAAK,+BAA+B,GAAG,EAAE;CAC3E,MAAM,QAAQ,MAAM,KAAK,MAAM,KAAK,KAAK,2BAA2B,GAAG,EAAE;CACzE,MAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,KAAK,WAAW,OAAO,CAAC,SAAS,CAAC;CAEnF,MAAM,SAAS,IAAI,WACjB,MAAM,OAAO,OAAO,QAClB;EAAE,MAAM;EAAW,IAAI;CAAM,GAC7B,KACA,OAAO,SAAS,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,CACrC,CACF;CACA,MAAM,yBAAS,IAAI,WAAW,EAAU;CACxC,OAAO,IAAI,MAAM,CAAC;CAClB,IAAI,SAAS,OAAO,MAAM,CAAC,CAAC,UAAU,IAAI,WAAW;CACrD,OAAO,MAAM,aAAa;CAC1B,OAAO,OAAO,QAAQ,cAAc,MAAM;AAC5C;;AAGA,MAAM,cAAc;;AAEpB,MAAM,eAAe;;;;;;;;AASrB,IAAa,cAAb,MAAyB;CAKM;CAJ7B;CACA,yBAA0B,IAAI,IAAiD;CAC/E,0BAA2B,IAAI,IAA6B;CAE5D,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;;CAG3D,MAAM,cAAc,UAAkB,MAAM,KAAK,IAAI,GAAoB;EACvE,MAAM,SAAS,IAAI,IAAI,QAAQ,CAAC,CAAC;EACjC,MAAM,UAAU,KAAK,OAAO,IAAI,MAAM;EACtC,IAAI,YAAY,KAAA,KAAa,MAAM,QAAQ,WAAW,cAAc,OAAO,QAAQ;EACnF,IAAI,UAAU,KAAK,QAAQ,IAAI,MAAM;EACrC,IAAI,YAAY,KAAA,GAAW;GACzB,UAAU,KAAK,KAAK,QAAQ,GAAG,CAAC,CAC7B,MAAM,UAAU;IACf,KAAK,OAAO,IAAI,QAAQ;KAAE;KAAO,UAAU;IAAI,CAAC;IAChD,OAAO;GACT,CAAC,CAAC,CACD,cAAc;IACb,KAAK,QAAQ,OAAO,MAAM;GAC5B,CAAC;GACH,KAAK,QAAQ,IAAI,QAAQ,OAAO;EAClC;EACA,OAAO;CACT;CAEA,aAAyC;EACvC,IAAI,KAAK,QAAQ,KAAA,GAAW;GAC1B,MAAM,YAAY,cAAc,KAAK,QAAQ,SAAS;GACtD,KAAK,MAAM,OAAO,OACf,UACC,OACA;IACE,KAAK;IACL,KAAK;IACL,GAAG,KAAK,QAAQ;IAChB,GAAG,UAAU,UAAU,MAAM,GAAG,EAAE,CAAC;IACnC,GAAG,UAAU,UAAU,MAAM,IAAI,EAAE,CAAC;IACpC,KAAK;GACP,GACA;IAAE,MAAM;IAAS,YAAY;GAAQ,GACrC,OACA,CAAC,MAAM,CACT,CAAC,CACA,OAAO,UAAmB;IACzB,KAAK,MAAM,KAAA;IACX,MAAM;GACR,CAAC;EACL;EACA,OAAO,KAAK;CACd;CAEA,MAAc,KAAK,QAAgB,KAA8B;EAC/D,MAAM,MAAM,MAAM,KAAK,WAAW;EAClC,MAAM,SAAS,UAAU,KAAK,UAAU;GAAE,KAAK;GAAO,KAAK;EAAQ,CAAC,CAAC;EACrE,MAAM,SAAS,UACb,KAAK,UAAU;GACb,KAAK;GACL,KAAK,KAAK,MAAM,MAAM,GAAI,IAAI;GAC9B,KAAK,KAAK,QAAQ;EACpB,CAAC,CACH;EACA,MAAM,YAAY,IAAI,WACpB,MAAM,OAAO,OAAO,KAAK;GAAE,MAAM;GAAS,MAAM;EAAU,GAAG,KAAK,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC,CAC/F;EACA,OAAO,WAAW,OAAO,GAAG,OAAO,GAAG,UAAU,SAAS,EAAE,MAAM,KAAK,QAAQ;CAChF;AACF;;;;;;AAOA,SAAS,kBACP,OAC8D;CAC9D,IAAI;EACF,MAAM,QAAQ,KAAK,MAAM,KAAK;EAC9B,IAAI,OAAO,MAAM,aAAa,YAAY,IAAI,IAAI,MAAM,QAAQ,CAAC,CAAC,aAAa,UAC7E;EAEF,MAAM,SAAS,cAAc,MAAM,MAAM,UAAU,EAAE;EACrD,MAAM,OAAO,cAAc,MAAM,MAAM,QAAQ,EAAE;EACjD,IAAI,OAAO,WAAW,MAAM,OAAO,OAAO,KAAK,KAAK,WAAW,IAAI,OAAO,KAAA;EAC1E,OAAO;GAAE,UAAU,MAAM;GAAU;GAAQ;EAAK;CAClD,QAAQ;EACN;CACF;AACF;AAEA,IAAa,gBAAb,MAAoD;CACrB;CAA7B,YAAY,SAA0C;EAAzB,KAAA,UAAA;CAA0B;CAEvD,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,QACtB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,QAAQ,EAAE;EAE3E,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,4CAA4C,QAAQ,OAAO,EAAE;EAE/E,MAAM,eAAe,kBAAkB,QAAQ,SAAS;EACxD,IAAI,iBAAiB,KAAA,GAEnB,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAsB;EAE3D,MAAM,UAAU,MAAM,KAAK,QAAQ,OAAO,QAAQ,UAAU,QAAQ,KAAK;EACzE,MAAM,UAAU,KACd,KAAK,UAAU;GACb,GAAG;GACH,UAAU,QAAQ;GAClB,gBAAgB,QAAQ;GACxB,GAAG,QAAQ,UAAU;EACvB,CAAC,CACH;EACA,IAAI,QAAQ,SAAA,MAEV,MAAM,IAAI,eACR,mCAAmC,QAAQ,SAAS,OAAO,QAAQ,OAAO,yCAAyC,qBACrH;EAEF,MAAM,OAAO,MAAM,eAAe,SAAS,aAAa,QAAQ,aAAa,IAAI;EAEjF,MAAM,WAAW,OADD,KAAK,QAAQ,SAAS,MAAA,CACP,aAAa,UAAU;GACpD,QAAQ;GACR,SAAS;IACP,eAAe,MAAM,KAAK,QAAQ,YAAY,cAAc,aAAa,QAAQ;IACjF,oBAAoB;IACpB,gBAAgB;IAChB,KAAK,OAAO,KAAK,QAAQ,OAAO,KAAM;IACtC,SAAS;IAGT,OAAO,MAAM,aAAa,QAAQ,cAAc;GAClD;GACA;EACF,CAAC;EACD,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,IAAI,WAAW,OAAO,WAAW,KAAK;GAEpC,QAAQ,KAAK,wCAAwC,QAAQ,OAAO,IAAI,QAAQ;GAChF,OAAO;IAAE,WAAW;IAAO,QAAQ,WAAW,MAAM,SAAS;GAAW;EAC1E;EACA,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,kBAAkB,MAAM,GAAG,MAAM,IAAI,MAAM,aAAa,OAAO,IAAI,MAAM;EAC7E,MAAM,IAAI,eAAe,+CAA+C,OAAO,GAAG,MAAM;CAC1F;AACF;;;;;AAMA,eAAsB,aAAa,gBAAyC;CAC1E,MAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,KAAK,cAAc,CAAC;CACzE,OAAO,UAAU,IAAI,WAAW,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE;AACtD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/workflow",
3
- "version": "3.3.0",
3
+ "version": "4.1.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ShwareHQ/shware-sdk.git"
@@ -9,16 +9,21 @@
9
9
  -- interpreter's send_event step being retried after it already committed its
10
10
  -- row), so both pass an id they can reproduce.
11
11
  --
12
+ -- The id is per user: the key is (user_id, id), so one sender's id can never
13
+ -- block another user's event, and a producer only has to keep its ids unique
14
+ -- within a user.
15
+ --
12
16
  -- Only the sender knows whether two deliveries describe one occurrence or two,
13
17
  -- so the engine never invents that answer: an ingest that arrives without an id
14
18
  -- is given a fresh one, which is a truthful "this is its own occurrence" rather
15
19
  -- than a guess.
16
20
  CREATE TABLE IF NOT EXISTS events (
17
- id TEXT PRIMARY KEY NOT NULL,
21
+ id TEXT NOT NULL,
18
22
  user_id TEXT NOT NULL,
19
23
  name TEXT NOT NULL,
20
24
  ts INTEGER NOT NULL,
21
- payload TEXT NOT NULL DEFAULT '{}'
25
+ payload TEXT NOT NULL DEFAULT '{}',
26
+ PRIMARY KEY (user_id, id)
22
27
  );
23
28
  CREATE INDEX IF NOT EXISTS idx_events_user_name_ts ON events (user_id, name, ts);
24
29
 
@@ -27,6 +32,15 @@ CREATE TABLE IF NOT EXISTS profiles (
27
32
  props TEXT NOT NULL DEFAULT '{}'
28
33
  );
29
34
 
35
+ -- Workflow bodies (WorkflowIR JSON), content-addressed. They live beside the
36
+ -- routes that point at them so a deploy is read-your-writes: the trigger row
37
+ -- and the body it names are in one strongly consistent database, and a route
38
+ -- never names a body another store has yet to catch up on.
39
+ CREATE TABLE IF NOT EXISTS workflow_ir (
40
+ content_hash TEXT PRIMARY KEY,
41
+ ir TEXT NOT NULL
42
+ );
43
+
30
44
  -- Deploy artefacts: segment definitions (resolved by id during evaluation)
31
45
  -- and the trigger routing table.
32
46
  -- Push devices: one row per provider token, owned by the user who registered it last.
@@ -87,11 +101,14 @@ CREATE TABLE IF NOT EXISTS segment_starts (
87
101
  PRIMARY KEY (segment, workflow, user_id)
88
102
  );
89
103
 
90
- -- Erasures in progress (forgetUser): the user's data stays until every running
91
- -- instance is stopped, and the scheduled sweep retries what is left.
104
+ -- Erasure tombstones (forgetUser): every write about a user is conditional on
105
+ -- no row here. While erased_at is null the user's data stays until every
106
+ -- running instance is stopped, and the scheduled sweep retries what is left;
107
+ -- once set, the data is gone and the row stays so the id is never written again.
92
108
  CREATE TABLE IF NOT EXISTS erasures (
93
- user_id TEXT PRIMARY KEY,
94
- ts INTEGER NOT NULL
109
+ user_id TEXT PRIMARY KEY,
110
+ ts INTEGER NOT NULL,
111
+ erased_at INTEGER
95
112
  );
96
113
 
97
114
  -- Entry ledger: the once-per-user policy, instance addressing, and the audit
@@ -110,6 +127,10 @@ CREATE TABLE IF NOT EXISTS entries (
110
127
  hash TEXT NOT NULL,
111
128
  status TEXT NOT NULL,
112
129
  ts INTEGER NOT NULL,
130
+ -- When the runner admitted itself (admitInstance), before its first
131
+ -- observable effect; null until then, so an erasure knows nothing of the
132
+ -- instance has run yet.
133
+ admitted_at INTEGER,
113
134
  PRIMARY KEY (workflow, user_id)
114
135
  );
115
136
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"router.cjs","names":["d1Store","isInstanceNotFound","WAKE_EVENT_TYPE","matchesWhere","JourneyFactSource","evaluateCondition","murmur3","PROFILE_UPDATED_EVENT","DEVICES_UPDATED_EVENT","semanticHash","BundleIR","SchemaMismatchError","checkEvent","recordPushOpen","z"],"sources":["../../src/host/router.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport {\n type JourneyEnv,\n type JourneyParams,\n WAKE_EVENT_TYPE,\n type WorkflowInstanceLike,\n isInstanceNotFound,\n} from '../cloudflare/bindings';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\nimport { murmur3 } from '../engine/bucket';\nimport {\n DEVICES_UPDATED_EVENT,\n PROFILE_UPDATED_EVENT,\n evaluateCondition,\n matchesWhere,\n} from '../engine/condition';\nimport { semanticHash } from '../hash';\nimport { BundleIR, type ConditionIR, type TriggerIR } from '../ir';\nimport { recordPushOpen } from '../reporting/tracking';\nimport { JourneyFactSource } from '../store/facts';\nimport {\n type BundleVersion,\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n type SegmentTriggerRoute,\n} from '../store/index';\n\n/**\n * Ingest router. An incoming event does four things: it is stored, it wakes\n * waiting instances, it is matched against event triggers, and it re-evaluates\n * segment-trigger membership. Plus /deploy (persist a bundle) and /identify\n * (merge a profile — which wakes property-condition waits via\n * PROFILE_UPDATED_EVENT and re-evaluates segment triggers too).\n *\n * Storage goes through a JourneyStore; the `store` argument defaults to the\n * D1 + KV store built from the env's bindings, so a Worker that only has those\n * calls these functions exactly as before.\n */\n\nexport interface IngestInput {\n userId: string;\n event: string;\n payload?: Record<string, unknown>;\n ts?: number;\n /**\n * This occurrence's identity, and with it the idempotency of sending it.\n *\n * Only the sender can know whether two deliveries describe one occurrence or\n * two, so the engine does not decide: an id it has already stored makes the\n * second delivery a no-op. That matters because ingest is reachable from two\n * retrying callers — an HTTP client retrying a 500, and the interpreter's\n * send_event step being retried after it already committed its row — and a\n * second copy of an event silently changes every count- and window-based\n * condition that reads the log.\n *\n * Callers that retry should send an id they can reproduce. Omitting it gets\n * a fresh one, which records the event as its own occurrence.\n */\n id?: string;\n}\n\nexport interface IngestResult {\n /** The occurrence's identity: the caller's `id`, or the one minted for it. */\n id: string;\n /** False when that id was already in the log: nothing was written this time. */\n stored: boolean;\n woke: number;\n started: string[];\n}\n\nexport async function ingestEvent(\n env: JourneyEnv,\n input: IngestInput,\n store: JourneyStore = d1Store(env)\n): Promise<IngestResult> {\n if (input.event.startsWith('$')) {\n throw new Error(`event name '${input.event}' is reserved ('$' prefix is internal)`);\n }\n const ts = input.ts ?? Date.now();\n const payload = input.payload ?? {};\n // No id means the sender is not claiming this is a repeat of anything, which\n // a fresh one states truthfully — unlike a key derived from the contents,\n // which would silently collapse two identical events a second apart.\n const id = input.id ?? crypto.randomUUID();\n\n const stored = await store.insertEvent({\n id,\n userId: input.userId,\n event: input.event,\n ts,\n payload,\n });\n\n /*\n * The rest runs even for a duplicate. A retry arrives precisely because the\n * first attempt did not finish — it committed the row and then failed — so\n * returning early would strand the journeys the retry exists to start. Both\n * halves are independently idempotent: waking an instance that already moved\n * on is a no-op, and the entry ledger refuses a second entry.\n */\n const woke = await wakeSubscribers(env, store, input.userId, input.event);\n const started = await startTriggeredJourneys(env, store, { ...input, payload }, ts);\n started.push(...(await refreshSegmentTriggers(env, store, input.userId, ts)));\n return { id, stored, woke, started };\n}\n\nasync function wakeSubscribers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n event: string\n): Promise<number> {\n let woke = 0;\n for (const handle of await store.findWakeHandles(userId, event)) {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(handle);\n } catch (error) {\n // Unknown instance (errored out without cleanup): drop the dangling subscription. Any\n // other failure (a database blip) keeps it: deleting would silence the wait for good\n if (isInstanceNotFound(error)) await store.unsubscribe(handle);\n else console.warn(`[workflow] looking up ${handle} to wake it failed`, error);\n continue;\n }\n try {\n await instance.sendEvent({ type: WAKE_EVENT_TYPE, payload: { event } });\n woke++;\n } catch {\n // Transient send failure (or an instance finishing right now): keep the\n // subscription — a later event retries it, and the wait's own timeout\n // bounds the damage. Deleting here would silence all future wake-ups.\n }\n }\n return woke;\n}\n\nasync function startTriggeredJourneys(\n env: JourneyEnv,\n store: JourneyStore,\n input: IngestInput & { payload: Record<string, unknown> },\n ts: number\n): Promise<string[]> {\n const started: string[] = [];\n for (const route of await store.findTriggers(input.event)) {\n // where: the payload gate — evaluated against the incoming event first\n if (route.where !== null && !matchesWhere(input.payload, route.where)) continue;\n\n // filter: the profile gate\n if (route.filter !== null) {\n const facts = new JourneyFactSource(store, input.userId);\n if (!(await evaluateCondition(route.filter, facts, ts))) continue;\n }\n\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n input.userId,\n ts,\n {\n event: input.event,\n payload: input.payload,\n },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n }\n return started;\n}\n\n/**\n * Segment-entry triggers, maintained lazily: on each ingest/identify for a\n * user, re-evaluate every trigger-routed segment for that user and act on the\n * membership transition (enter → start journeys, leave → drop membership so a\n * later re-entry is observable). Purely time-driven drift (an inactivity\n * segment turning true by clock alone) has no event to ride on: the scheduled\n * sweep catches it (`sweepSegments`).\n */\nexport async function refreshSegmentTriggers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number\n): Promise<string[]> {\n return refreshSegments(env, store, userId, ts, await store.listSegmentTriggers());\n}\n\n/**\n * Re-evaluate segments for one user — the routed ones only, or with `segments` every one named —\n * and act on each membership transition: entering records the membership and starts the\n * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for\n * users who enter after it is deployed; a user already a member is not entered again.\n */\nasync function refreshSegments(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number,\n routes: readonly SegmentTriggerRoute[],\n segments: readonly string[] = [],\n // A sweep loads each definition once for all its users, not once per user\n definitions = new Map<string, ConditionIR | undefined>()\n): Promise<string[]> {\n const bySegment = new Map<string, { workflow: string; hash: string; version?: string }[]>(\n segments.map((name) => [name, []])\n );\n for (const route of routes) {\n const list = bySegment.get(route.segment) ?? [];\n list.push({\n workflow: route.workflow,\n hash: route.hash,\n ...(route.version === undefined ? {} : { version: route.version }),\n });\n bySegment.set(route.segment, list);\n }\n if (bySegment.size === 0) return [];\n\n const facts = new JourneyFactSource(store, userId);\n const started: string[] = [];\n const failures: unknown[] = [];\n for (const [segmentId, segmentRoutes] of bySegment) {\n if (!definitions.has(segmentId)) {\n definitions.set(segmentId, await store.getSegmentCondition(segmentId));\n }\n const definition = definitions.get(segmentId);\n if (definition === undefined) continue; // route without a definition: broken deploy, skip\n\n const matches = await evaluateCondition(definition, facts, ts);\n const member = await store.isSegmentMember(segmentId, userId);\n\n if (matches && !member) {\n // Entry transition: the membership and the starts it owes, recorded together\n await store.addSegmentMember(\n segmentId,\n userId,\n ts,\n segmentRoutes.map((route) => route.workflow)\n );\n } else if (!matches && member) {\n await store.removeSegmentMember(segmentId, userId);\n continue;\n }\n if (!matches) continue;\n\n // Every start the entry still owes — a fresh entry's, or one an earlier evaluation failed on.\n // Each is crossed off once its instance exists, so one workflow failing neither blocks the\n // others nor gets lost: the next evaluation (the sweep, the user's next event) retries it.\n const pending = new Set(await store.pendingSegmentStarts(segmentId, userId));\n for (const route of segmentRoutes) {\n if (!pending.has(route.workflow)) continue;\n try {\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n userId,\n ts,\n { event: '$segment_entry', payload: { segment: segmentId } },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n await store.completeSegmentStart(segmentId, userId, route.workflow);\n } catch (error) {\n failures.push(error);\n }\n }\n }\n // Surface a failure to the caller (an ingest answers 500) once every start had its chance\n if (failures.length > 0) throw failures[0];\n return started;\n}\n\nexport interface SweepResult {\n users: number;\n /** Journeys the segments' entries started. */\n started: string[];\n /** Each deployed segment's size after the sweep (empty when the store keeps no reporting). */\n sizes: { segment: string; size: number }[];\n}\n\n/**\n * The scheduled sweep: every deployed segment evaluated for every known user — the evaluator a\n * journey's conditions run on, so a member is exactly who a journey would see in it. Membership\n * follows (joiners keep the time they joined, leavers are removed), segment triggers start the\n * journeys of whoever entered by the clock alone, and the store records each segment's size.\n * It walks users one by one: fine for thousands, a large audience wants SQL-side evaluation.\n */\nexport async function sweepSegments(\n env: JourneyEnv,\n store: JourneyStore,\n now = Date.now()\n): Promise<SweepResult> {\n // The runtime's own upkeep first (AWS: unconfirmed starts, executions that ended unseen)\n try {\n await env.JOURNEY.maintain?.();\n } catch (error) {\n console.error('[workflow] runtime maintenance failed', error);\n }\n // Erasures left unfinished (an instance would not stop) first: their users are not evaluated\n const erasing = new Set<string>();\n for (const userId of await store.pendingErasures()) {\n try {\n const { erased } = await eraseUser(env, userId, store);\n if (!erased) erasing.add(userId);\n } catch (error) {\n // Still pending: the next sweep tries again, and the rest of this one goes on\n erasing.add(userId);\n console.error(`[workflow] erasure of ${userId} failed`, error);\n }\n }\n const segments = (await store.listSegments()).map((segment) => segment.id);\n const routes = await store.listSegmentTriggers();\n const users = (await store.listUserIds()).filter((userId) => !erasing.has(userId));\n const started: string[] = [];\n const definitions = new Map<string, ConditionIR | undefined>();\n for (const userId of users) {\n try {\n started.push(\n ...(await refreshSegments(env, store, userId, now, routes, segments, definitions))\n );\n } catch (error) {\n // One user's failed start is kept for the next sweep; it must not stop everyone else's\n console.error(`[workflow] segment refresh for ${userId} failed`, error);\n }\n }\n const sizes = (await store.reporting?.snapshotSegments(now)) ?? [];\n return { users: users.length, started, sizes };\n}\n\n/**\n * Create one journey instance behind the entry ledger. Returns null when the\n * once-policy blocks the entry (the store's attempt is atomic, so concurrent\n * ingests cannot double-enter). A pair whose previous instance is recorded as\n * 'failed' is reclaimed rather than barred forever over one outage.\n */\nasync function startJourney(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n hash: string,\n userId: string,\n ts: number,\n trigger: { event: string; payload: Record<string, unknown> },\n /** The code version whose bundle routed this start, read with the hash (see TriggerRoute). */\n version?: string\n): Promise<string | null> {\n const instanceId = buildInstanceId(workflow, hash, userId, ts);\n const params: JourneyParams = { workflowId: workflow, contentHash: hash, userId, trigger };\n const execution = await env.JOURNEY.plan?.({ id: instanceId, params, ts, version });\n\n const outcome = await store.enterJourney({ workflow, userId, instanceId, hash, ts, execution });\n if (outcome === null) {\n // Already entered and still alive — unless its start never got through: this retry is the\n // first chance to finish it\n await env.JOURNEY.resume?.({ workflow, userId });\n return null;\n }\n\n try {\n await env.JOURNEY.create({ id: instanceId, params });\n } catch (error) {\n // A recorded start stays: the runtime may have taken it, and a retry with the same name\n // and payload finds that execution instead of starting a second one\n if (execution !== undefined) throw error;\n if (outcome === 'reclaimed') {\n // Put the corpse back rather than deleting it: the entry is still a\n // failure, and dropping the row would erase that from the ledger.\n await store.setEntryStatus(instanceId, 'failed');\n } else {\n // Roll the ledger back so a retried ingest can enter — otherwise the\n // once-policy would permanently record an entry that never ran.\n await store.removeEntry(instanceId);\n }\n throw error;\n }\n return instanceId;\n}\n\nconst utf8 = new TextEncoder();\n\n/**\n * Instance id: addressable (the router gets it directly to wake it), ≤100\n * chars, legal charset. The murmur3 fingerprint keeps ids distinct when the\n * sanitized/truncated userId alone would collide.\n */\nfunction buildInstanceId(workflow: string, hash: string, userId: string, ts: number): string {\n const uid = userId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 24);\n const fingerprint = murmur3(utf8.encode(userId)).toString(36);\n return `${workflow.slice(0, 32)}-${hash.slice(0, 8)}-${uid}-${fingerprint}-${ts.toString(36)}`;\n}\n\n/* --------------------------------- identify --------------------------------- */\n\nexport interface IdentifyResult {\n ok: true;\n props: ProfileProps;\n woke: number;\n started: string[];\n}\n\n/**\n * Merge-patch profile properties (a null removes one), then wake property waits\n * and re-evaluate segment triggers. The merge is one statement in the store, so\n * two identifies landing together cannot lose each other's fields.\n */\nexport async function identifyUser(\n env: JourneyEnv,\n userId: string,\n props: ProfileProps,\n store: JourneyStore = d1Store(env)\n): Promise<IdentifyResult> {\n const ts = Date.now();\n const merged = await store.mergeProfile(userId, props);\n // Property-condition waits subscribe to this reserved event (see relevantEvents)\n const woke = await wakeSubscribers(env, store, userId, PROFILE_UPDATED_EVENT);\n // A profile change can move the user into (or out of) a trigger-routed segment\n const started = await refreshSegmentTriggers(env, store, userId, ts);\n return { ok: true, props: merged, woke, started };\n}\n\nexport interface DeviceRegistration {\n userId: string;\n token: string;\n platform: string;\n ts?: number;\n}\n\n/**\n * A push token registered for the user (or moved to them from another account: a token belongs\n * to one user at a time). Wakes the waits that read devices (`reachable('push')` inside\n * `waitUntil`), for the new owner and the previous one.\n */\nexport async function registerDevice(\n env: JourneyEnv,\n device: DeviceRegistration,\n store: JourneyStore = d1Store(env)\n): Promise<{ ok: true; woke: number }> {\n const previous = await store.removeDevice(device.token);\n await store.upsertDevice({ ...device, ts: device.ts ?? Date.now() });\n let woke = await wakeSubscribers(env, store, device.userId, DEVICES_UPDATED_EVENT);\n if (previous !== undefined && previous !== device.userId) {\n woke += await wakeSubscribers(env, store, previous, DEVICES_UPDATED_EVENT);\n }\n return { ok: true, woke };\n}\n\n/**\n * A deleted account: everything the engine keeps about the user is erased and the journeys still\n * running for them are terminated, so nothing more is sent. Idempotent.\n */\n/** Workflow instance states that will never run another step. */\nconst STOPPED_STATUSES = new Set(['complete', 'errored', 'terminated']);\n\n/**\n * Stop one instance for good; true once it is known not to run again. An instance the platform\n * no longer knows, or one already finished (terminate refuses those), counts as stopped. Any other\n * failure is not taken for \"already gone\": it answers false, and the erasure waits for a retry.\n */\nasync function stopInstance(env: JourneyEnv, instanceId: string): Promise<boolean> {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(instanceId);\n } catch (error) {\n if (isInstanceNotFound(error)) return true;\n console.warn(`[workflow] looking up ${instanceId} to stop it failed`, error);\n return false;\n }\n if (instance.terminate === undefined) return false;\n try {\n await instance.terminate();\n return true;\n } catch (error) {\n const status = await instance.status?.().catch(() => undefined);\n if (status !== undefined && STOPPED_STATUSES.has(status.status)) return true;\n console.warn(`[workflow] terminating ${instanceId} failed`, error);\n return false;\n }\n}\n\n/**\n * Carry out a recorded erasure: stop every running instance of the user, and only once all have\n * stopped erase their data (the request with it). Stopping first means no step can write rows\n * back after the erase; erasing nothing until then keeps the entries that name the instances\n * still to stop, so a retry can finish the job.\n */\nasync function eraseUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore\n): Promise<{ erased: boolean; pending: string[] }> {\n const pending: string[] = [];\n for (const instanceId of await store.runningInstances(userId)) {\n if (!(await stopInstance(env, instanceId))) pending.push(instanceId);\n }\n if (pending.length > 0) return { erased: false, pending };\n await store.forgetUser(userId);\n return { erased: true, pending };\n}\n\n/**\n * Forget a user (a deleted account): the request is recorded first, then carried out. An\n * instance that will not stop leaves it pending — nothing erased yet — and the scheduled sweep\n * retries it; `erased: false` says so.\n */\nexport async function forgetUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore = d1Store(env),\n now = Date.now()\n): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n await store.requestErasure(userId, now);\n return { ok: true, ...(await eraseUser(env, userId, store)) };\n}\n\n/**\n * The user signed out on the device: the token stays registered, bound to nobody, so the next\n * sign-in only has to bind it again. Wakes the former owner's device waits (`reachable('push')`\n * may no longer hold).\n */\nexport async function unbindDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore = d1Store(env)\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.unbindDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, DEVICES_UPDATED_EVENT);\n return { ok: true, woke };\n}\n\n/** A push token forgotten for good (the push service reports it gone); wakes its owner's device waits. */\nexport async function removeDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore = d1Store(env)\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.removeDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, DEVICES_UPDATED_EVENT);\n return { ok: true, woke };\n}\n\n/* ---------------------------------- deploy ---------------------------------- */\n\nexport interface DeployResult {\n workflows: string[];\n /**\n * Workflows whose trigger type has no runtime routing yet — they deploy (the\n * IR is stored and versioned) but will never start until the routing lands.\n * Currently: date (TODO — needs cron + Queues, instance creation is\n * rate-limited and a date trigger fans out to the whole audience at once)\n * and webhook (TODO — needs endpoint allocation).\n */\n unrouted: { workflow: string; trigger: TriggerIR['type'] }[];\n /**\n * Whether the routes now point at this bundle: always for a forced deploy, false for a\n * versioned one when a version as new or newer is already live (nothing was swapped).\n */\n applied: boolean;\n}\n\n/** A versioned deploy on a store that cannot tell which version is live (D1). */\nexport class UnversionedStoreError extends Error {\n override readonly name = 'UnversionedStoreError';\n constructor() {\n super('this store does not record which version is live: deploy with POST /bundle/deploy');\n }\n}\n\n/** A bundle whose declared hashes do not describe its contents — rejected before anything is written. */\nexport class BundleIntegrityError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'BundleIntegrityError';\n }\n}\n\n/**\n * Recompute every declared contentHash and refuse the bundle if one disagrees.\n *\n * The contentHash is the address an in-flight journey reads its IR from, so\n * a hash that does not describe its own content is a way to change what a\n * pinned instance executes: submit an edited flow under the old hash and the\n * next replay picks it up. The compiler always computes the hash it ships, so\n * a mismatch means a hand-rolled payload or a stale client — neither should be\n * able to overwrite a version that journeys are running.\n *\n * Each hash is recomputed exactly the way its compiler produced it: a workflow\n * hashes its whole IR (contentHash strips itself), a segment hashes only its\n * condition. Mirroring them is load-bearing — recomputing a segment over the\n * whole record would reject every honest deploy.\n */\nfunction verifyBundleHashes(parsed: BundleIR): void {\n const mismatched: string[] = [];\n for (const workflow of parsed.workflows) {\n if (semanticHash(workflow) !== workflow.contentHash) {\n mismatched.push(`workflow '${workflow.id}'`);\n }\n }\n for (const segment of parsed.segments) {\n if (semanticHash(segment.condition) !== segment.contentHash) {\n mismatched.push(`segment '${segment.id}'`);\n }\n }\n if (mismatched.length > 0) {\n throw new BundleIntegrityError(\n `contentHash does not match content for ${mismatched.join(', ')} — recompile the bundle instead of editing it by hand`\n );\n }\n}\n\n/**\n * Bundle deploy, terraform-apply style: workflow bodies first (content-addressed, so a failed\n * deploy leaves only harmless orphans), then the routing swap. With a `version` the swap happens\n * only over an older live version (`replaceRoutesIfNewer`) — how journeyWorker applies the bundle\n * compiled into it; without one it is forced, whatever is live.\n */\nexport async function deployBundle(\n env: JourneyEnv,\n bundle: unknown,\n store: JourneyStore = d1Store(env),\n version?: BundleVersion\n): Promise<DeployResult> {\n if (version !== undefined && store.replaceRoutesIfNewer === undefined) {\n throw new UnversionedStoreError();\n }\n const parsed = BundleIR.parse(bundle);\n // Before any write: a bad hash must not reach the store, where it would address content it does not describe\n verifyBundleHashes(parsed);\n // Nor a bundle the database cannot hold: the host migrates, the engine only checks\n const problems = (await store.checkSchema?.()) ?? [];\n if (problems.length > 0) throw new SchemaMismatchError(problems);\n\n for (const workflow of parsed.workflows) await store.putWorkflowIR(workflow);\n let applied = true;\n if (version === undefined) await store.replaceRoutes(parsed);\n else applied = (await store.replaceRoutesIfNewer?.(parsed, version)) ?? false;\n\n const unrouted = parsed.workflows\n .filter((workflow) => workflow.trigger.type === 'date' || workflow.trigger.type === 'webhook')\n .map((workflow) => ({ workflow: workflow.id, trigger: workflow.trigger.type }));\n\n return {\n workflows: parsed.workflows.map((w) => `${w.id}@${w.contentHash.slice(0, 8)}`),\n unrouted,\n applied,\n };\n}\n\n/* ----------------------------------- http ----------------------------------- */\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: unknown = await request.json();\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/**\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\nexport async function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore = d1Store(env),\n validation: IngestValidation = {}\n): Promise<Response> {\n const url = new URL(request.url);\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n const { contract } = validation;\n const enforce = validation.mode !== 'report';\n /** The contract's verdict: the value to use, or the response refusing the request. */\n const checked = <T>(\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 (!enforce) {\n console.warn(`[workflow router] 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 if (request.method === 'GET' && url.pathname === '/health') {\n return json({ ok: true });\n }\n\n // Bearer auth on everything mutating. An unset token means an open dev\n // instance (see JourneyEnv.API_TOKEN) — production must configure it.\n if (env.API_TOKEN !== undefined) {\n if (request.headers.get('authorization') !== `Bearer ${env.API_TOKEN}`) {\n return json({ error: 'unauthorized' }, 401);\n }\n }\n\n try {\n if (request.method === 'POST' && url.pathname === '/events') {\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 && typeof ts !== 'number') {\n return json({ error: 'ts must be a number' }, 400);\n }\n if (payload !== undefined && !isPlainObject(payload)) {\n return json({ error: 'payload must be an object' }, 400);\n }\n let accepted = payload;\n if (contract !== undefined) {\n const verdict = checked(\n checkEvent(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 if (request.method === 'POST' && url.pathname === '/identify') {\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 let patch = props as ProfileProps;\n if (contract !== undefined) {\n const verdict = checked(contract.parseProfilePatch(props), patch, 'identify');\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 if (request.method === 'POST' && url.pathname === '/devices') {\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 && typeof ts !== 'number')\n return json({ error: 'ts must be a number' }, 400);\n return json(\n await registerDevice(\n env,\n { userId, token, platform, ...(ts === undefined ? {} : { ts }) },\n store\n )\n );\n }\n\n if (request.method === 'POST' && url.pathname === '/messages/opened') {\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 =\n store.reporting !== undefined && (await recordPushOpen(store.reporting, userId, messageId));\n return json({ ok: true, recorded });\n }\n\n if (request.method === 'POST' && url.pathname === '/users/forget') {\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 if (request.method === 'POST' && url.pathname === '/devices/unbind') {\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 if (request.method === 'POST' && url.pathname === '/devices/remove') {\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 if (request.method === 'POST' && url.pathname === '/deploy') {\n return json(await deployBundle(env, await request.json(), store));\n }\n\n return json({ error: 'not found' }, 404);\n } catch (error) {\n // Validation problems are the caller's to fix; anything else stays out of\n // the response body (internals are logged, not leaked).\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) {\n return json({ error: error.message }, 400);\n }\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 router]', error);\n return json({ error: 'internal error' }, 500);\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"router.d.cts","names":[],"sources":["../../src/host/router.ts"],"mappings":";;;;;;;;;;;;;;;;iBAyCiB;EACf;EACA;EACA,UAAU;EACV;;;;;;;;;;;;;;;EAeA;;iBAGe;;EAEf;;EAEA;EACA;EACA;;wBAGoB,YACpB,KAAK,YACL,OAAO,aACP,QAAO,eACN,QAAQ;;;;;;;;;wBAyGW,uBACpB,KAAK,YACL,OAAO,cACP,gBACA,aACC;iBA0Fc;EACf;;EAEA;;EAEA;IAAS;IAAiB;;;;;;;;;;wBAUN,cACpB,KAAK,YACL,OAAO,cACP,eACC,QAAQ;iBAsGM;EACf;EACA,OAAO;EACP;EACA;;;;;;;wBAQoB,aACpB,KAAK,YACL,gBACA,OAAO,cACP,QAAO,eACN,QAAQ;iBAUM;EACf;EACA;EACA;EACA;;;;;;;wBAQoB,eACpB,KAAK,YACL,QAAQ,oBACR,QAAO,eACN;EAAU;EAAU;;;;;;;wBAoED,WACpB,KAAK,YACL,gBACA,QAAO,cACP,eACC;EAAU;EAAU;EAAiB;;;;;;;wBAUlB,aACpB,KAAK,YACL,eACA,QAAO,eACN;EAAU;EAAU;;;wBAQD,aACpB,KAAK,YACL,eACA,QAAO,eACN;EAAU;EAAU;;iBASN;EACf;;;;;;;;EAQA;IAAY;IAAkB,SAAS;;;;;;EAKvC;;;qBAIW,8BAA8B;WACvB;EAClB;;;qBAMW,6BAA6B;EACxC,YAAY;;;;;;;;wBA8CQ,aACpB,KAAK,YACL,iBACA,QAAO,cACP,UAAU,gBACT,QAAQ;;;;;;;iBAqDM;EACf,WAAW;EACX;;wBAGoB,cACpB,SAAS,SACT,KAAK,YACL,QAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"router.d.mts","names":[],"sources":["../../src/host/router.ts"],"mappings":";;;;;;;;;;;;;;;;iBAyCiB;EACf;EACA;EACA,UAAU;EACV;;;;;;;;;;;;;;;EAeA;;iBAGe;;EAEf;;EAEA;EACA;EACA;;wBAGoB,YACpB,KAAK,YACL,OAAO,aACP,QAAO,eACN,QAAQ;;;;;;;;;wBAyGW,uBACpB,KAAK,YACL,OAAO,cACP,gBACA,aACC;iBA0Fc;EACf;;EAEA;;EAEA;IAAS;IAAiB;;;;;;;;;;wBAUN,cACpB,KAAK,YACL,OAAO,cACP,eACC,QAAQ;iBAsGM;EACf;EACA,OAAO;EACP;EACA;;;;;;;wBAQoB,aACpB,KAAK,YACL,gBACA,OAAO,cACP,QAAO,eACN,QAAQ;iBAUM;EACf;EACA;EACA;EACA;;;;;;;wBAQoB,eACpB,KAAK,YACL,QAAQ,oBACR,QAAO,eACN;EAAU;EAAU;;;;;;;wBAoED,WACpB,KAAK,YACL,gBACA,QAAO,cACP,eACC;EAAU;EAAU;EAAiB;;;;;;;wBAUlB,aACpB,KAAK,YACL,eACA,QAAO,eACN;EAAU;EAAU;;;wBAQD,aACpB,KAAK,YACL,eACA,QAAO,eACN;EAAU;EAAU;;iBASN;EACf;;;;;;;;EAQA;IAAY;IAAkB,SAAS;;;;;;EAKvC;;;qBAIW,8BAA8B;WACvB;EAClB;;;qBAMW,6BAA6B;EACxC,YAAY;;;;;;;;wBA8CQ,aACpB,KAAK,YACL,iBACA,QAAO,cACP,UAAU,gBACT,QAAQ;;;;;;;iBAqDM;EACf,WAAW;EACX;;wBAGoB,cACpB,SAAS,SACT,KAAK,YACL,QAAO,cACP,aAAY,mBACX,QAAQ"}