@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/README.md CHANGED
@@ -564,9 +564,23 @@ with its secret in the environment:
564
564
  | `android` | `fcm: { androidChannelId? }` | `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `@shware/workflow/fcm` |
565
565
  | `web` | `webPush: { subject, openedEvent? }` | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | `@shware/workflow/webpush` |
566
566
 
567
- A secret is only read when something is sent on that platform. Tokens APNs,
568
- FCM or a push service report as gone are removed, and waits on
569
- `reachable('push')` wake on every device change.
567
+ A secret is only read when something is sent on that platform. Only a token
568
+ the provider reports as gone is removed (and waits on `reachable('push')` wake
569
+ on every device change); a configuration error keeps the device and fails the
570
+ step without retrying, and a transient answer is retried:
571
+
572
+ | Provider | Token removed, copy settled | Settled, device kept | Failed, no retry (`PermanentError`) | Retried |
573
+ | -------- | ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
574
+ | APNs | 410; `Unregistered`, `ExpiredToken` | `BadDeviceToken` (a token or the environment) | `DeviceTokenNotForTopic`, `TopicDisallowed`, `BadTopic`, `MissingTopic`, bad certificate or provider token, other 4xx | 403 `ExpiredProviderToken` (re-signed), 429, 5xx |
575
+ | FCM | `UNREGISTERED`; 404 `NOT_FOUND` | `INVALID_ARGUMENT` | `SENDER_ID_MISMATCH`, `THIRD_PARTY_AUTH_ERROR`, other 4xx | 401 (access token re-minted), `QUOTA_EXCEEDED`, `UNAVAILABLE`, 429, 5xx |
576
+ | Web Push | 404, 410 | — | payload over 3993 bytes (refused before sending), 400, 401, 403, 413, other 4xx | 429, 5xx |
577
+
578
+ Every APNs request carries `apns-id` and `apns-collapse-id`, and every web
579
+ push a `Topic`, derived from the copy's idempotency key, so a copy sent again
580
+ after a lost checkpoint collapses on the device. Provider credentials are kept
581
+ per host (`ApnsProviderToken`, `FcmAccessToken`, `VapidSigner`): the APNs
582
+ provider token, the FCM access token and the VAPID key are reused across runs,
583
+ while the senders themselves are built per run.
570
584
 
571
585
  `apns.environment` maps a mode to `sandbox` or `production`; unlisted modes use
572
586
  `production` in production and `sandbox` otherwise. Generate a VAPID pair once
@@ -652,8 +666,10 @@ Secrets never go in the config; the Worker reads them from its environment:
652
666
  | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | The Web Push key pair |
653
667
  | `APNS_RELAY_ORIGIN` | Development only: the local APNs relay |
654
668
 
655
- `journeyWorker` also takes `observer`, `messages` and `runtime` overrides, and
656
- `contractMode: 'report'` to log contract refusals instead of answering 400.
669
+ `journeyWorker` (and `journeyLambda`) also take `observer`, `messages` and `runtime` overrides,
670
+ `contractMode: 'report'` to log contract refusals instead of answering 400, and `authorize` to
671
+ replace the `API_TOKEN` bearer with a check of your own (see
672
+ [AWS deployment notes](#aws-deployment-notes)).
657
673
 
658
674
  ## `JourneyClient`
659
675
 
@@ -726,20 +742,47 @@ backward-compatible changes, so a new required prop means a new id.
726
742
 
727
743
  ## Cloudflare setup
728
744
 
729
- The default store needs three bindings, named as in `JourneyEnv`:
745
+ The default store needs two bindings, named as in `JourneyEnv`:
730
746
 
731
- | Binding | Type | Holds |
732
- | ------------- | ------------ | ----------------------------------------- |
733
- | `WORKFLOW_KV` | KV namespace | Compiled IR, content-addressed by hash |
734
- | `DB` | D1 database | Events, profiles, routing, entries, wakes |
735
- | `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
747
+ | Binding | Type | Holds |
748
+ | --------- | ----------- | ------------------------------------------------------ |
749
+ | `DB` | D1 database | Events, profiles, routing, compiled IR, entries, wakes |
750
+ | `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
736
751
 
737
- A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
752
+ The compiled IR lives in D1 with the routes, never in a KV namespace: KV is eventually consistent
753
+ while the routes in D1 are not, so an instance started in another colo right after a deploy could
754
+ miss its IR.
755
+
756
+ The Worker needs the `nodejs_compat` compatibility flag (the bearer check uses
757
+ `node:crypto`'s `timingSafeEqual`). A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
738
758
  when it sends through Cloudflare Email (`cloudflareEmailTransport({ binding: env.EMAIL })`), and a `triggers.crons` entry with `scheduled`
739
759
  exported to run the segment sweep.
740
760
 
741
- Set `API_TOKEN` in production. Without it every mutating endpoint is open, and
742
- `/deploy` rewrites the whole routing table.
761
+ Set `API_TOKEN` in production: a host whose `ENVIRONMENT` resolves to `production` refuses every
762
+ request but `GET /health` and `/t/...` with a 503 until it is set. Any other mode (`development`,
763
+ say) runs open, for local work only — `/deploy` rewrites the whole routing table.
764
+
765
+ Erasing a user (`POST /users/forget`) commits an erasure tombstone first,
766
+ which every write about the user is conditional on from then on. A runner
767
+ admits itself against that tombstone before its first effect — before the
768
+ observer hears of it, before any action or send — on every invocation and on
769
+ every replay after an eviction, never from a checkpoint: an instance whose
770
+ `create` lands after the tombstone, or that wakes after it, exits without
771
+ running. The erasure stops the admitted instances (`terminate`), and only once
772
+ each is known stopped deletes the data; `erased: true` means every admitted
773
+ runner was confirmed stopped, the entries never admitted can never run, and
774
+ the data is gone. An admitted instance that will not stop answers
775
+ `erased: false` and the scheduled sweep retries it. Every write about the user
776
+ runs in the user's locked transaction, the lock the tombstone itself takes: a
777
+ write either committed before the tombstone and is deleted by the erase, or
778
+ waited for the lock and is refused by the tombstone it then sees. There is no
779
+ window to come back for and no second pass; the tombstone stays, so a late
780
+ write cannot bring the user back.
781
+
782
+ One database per host. The routing tables are global (`triggers`, `segments`, the live
783
+ `deployment` row), so two Workers on one D1 database or one Postgres schema would swap each
784
+ other's routes on every deploy: give staging and production their own database or schema
785
+ (`database: { schema }`).
743
786
 
744
787
  The D1 schema ships with the package. Apply it before the first deploy:
745
788
 
@@ -754,7 +797,31 @@ the single source for both.
754
797
  There are no migrations, and re-applying the file is not one: every statement is
755
798
  `CREATE TABLE IF NOT EXISTS`, so an existing database keeps its old tables and
756
799
  the first insert needing a new column fails. Recreate it instead. The demo has
757
- `pnpm db:reset`.
800
+ `pnpm db:reset`. The `events` key is `(user_id, id)`, and `workflow_ir` holds the compiled IR.
801
+
802
+ ### Cloudflare deployment notes
803
+
804
+ - **Email transport.** Pass `email` explicitly: `cloudflareEmailTransport({ binding: env.EMAIL })`
805
+ for Cloudflare Email, or `sesEmailTransport({ region, credentials })` with credentials from the
806
+ Worker's env, since a Worker has no execution role. A Worker whose workflows email without one
807
+ fails at startup.
808
+ - **List-Unsubscribe.** Cloudflare Email rejects a `List-Unsubscribe` header with a non-TLS URI, so
809
+ the one-click header is only sent with an https `WORKFLOW_ORIGIN`. `wrangler dev` on
810
+ `http://localhost` sends none.
811
+ - **Workflows limits** (paid plan):
812
+ - An instance's wall time is unlimited.
813
+ - A single `step.sleep` lasts at most 365 days.
814
+ - 10,000 steps per instance by default, configurable up to 25,000. Sleeps do not count, but every
815
+ wait attempt costs a few steps.
816
+ - A step's result is at most 1 MiB.
817
+ - A completed instance's state is kept 30 days.
818
+ - **Authorization.** Set `API_TOKEN` with `wrangler secret put`, or pass `authorize` (below).
819
+ The tracking routes (`/t/...`) stay public by design.
820
+ - **Deploying.** With the `CF_VERSION_METADATA` binding and a Postgres store, each `wrangler deploy`
821
+ makes its own bundle live (see [Postgres](#postgres)). On D1, call `POST /bundle/deploy`.
822
+ - **APNs in development.** `wrangler dev` (workerd) cannot make HTTP/2 subrequests: run
823
+ `@shware/workflow/apns-relay` and point `APNS_RELAY_ORIGIN` at it. A deployed Worker needs no
824
+ relay.
758
825
 
759
826
  ## AWS Lambda
760
827
 
@@ -804,11 +871,18 @@ re-entered as a second execution. Retries stop after 12 hours (`startRetryMs`,
804
871
  keep it under the execution retention) and the start is left unresolved. A
805
872
  failed entry that is entered again gets a new execution, never the closed one.
806
873
 
807
- Erasing a user (`POST /users/forget`) stops their executions, then waits
808
- `stopDrainMs` (15 minutes by default; at least the function's timeout) before
809
- erasing: a stop does not interrupt an invocation already running, which goes on
810
- to its next checkpoint. Until then the erasure answers `erased: false` and the
811
- sweep finishes it.
874
+ Erasing a user (`POST /users/forget`) commits the tombstone, stops their
875
+ admitted executions, then waits `stopDrainMs` (15 minutes by default; at least
876
+ the function's timeout) before erasing: a stop does not interrupt an invocation
877
+ already running, which goes on to its next checkpoint. Until then the erasure
878
+ answers `erased: false` and the sweep finishes it; the drain is one judgment
879
+ for every path — the sweep's reconcile keeps a stopped execution's entry
880
+ `running` until the drain has passed, and an execution the platform reports
881
+ stopped before the stop was recorded is dated at its first sighting, never
882
+ later. An execution whose runner never admitted itself needs no stop: its run
883
+ entry meets the tombstone and exits, and so does a wake of a stopped one. The
884
+ tombstone takes the user's lock, which every write about the user also takes,
885
+ so nothing of theirs lands after it and no second pass is needed.
812
886
 
813
887
  Deploy one artifact per version: publish a numbered version and point the
814
888
  `live` alias, which the Function URL is bound to, at it. Each version carries its
@@ -832,19 +906,122 @@ Workflow:
832
906
  FunctionUrlConfig: { AuthType: NONE, InvokeMode: BUFFERED }
833
907
  ```
834
908
 
835
- The execution role needs the durable checkpoint permissions,
836
- `lambda:InvokeFunction` on its own versions, `lambda:SendDurableExecutionCallbackSuccess`,
837
- `lambda:GetDurableExecution`, `lambda:ListDurableExecutionsByFunction` and
838
- `lambda:StopDurableExecution`, plus `ses:SendEmail` when it sends through SES.
839
- With `AuthType: NONE`, scope the public `lambda:InvokeFunction` grant to
840
- `lambda:InvokedViaFunctionUrl = true` so the URL cannot be used to invoke the
841
- function directly. APNs goes over Node's HTTP/2 client; a custom domain needs
842
- CloudFront (or another HTTPS proxy) in front of the Function URL.
909
+ ### AWS deployment notes
910
+
911
+ Checked against a real durable Lambda (Node 24, us-east-1).
912
+
913
+ **Function URL: `AuthType: NONE`, with the API protected in the app.** Not every caller can
914
+ sign a request:
915
+
916
+ | Caller | Routes |
917
+ | ------------------------------------------ | -------------------------------------------------------- |
918
+ | Your backend | `/events`, `/identify`, `/devices`, `/users/forget`, ... |
919
+ | Recipients' mail clients and browsers | `/t/o`, `/t/l`, `/t/u` |
920
+ | Gmail / Yahoo one-click unsubscribe | `POST /t/u/<message>` (RFC 8058) |
921
+ | iOS notification extension, service worker | `POST /t/d`, `POST /t/p` |
922
+
923
+ An alias has one URL and one auth type, and the tracking and one-click routes must stay public:
924
+
925
+ - `AWS_IAM` would break those routes. SigV4 also takes the `Authorization` header, so the bearer
926
+ could not travel with it.
927
+ - CloudFront's origin access control (OAC) does not help either: with OAC, every POST must carry
928
+ the body's SHA-256 in `x-amz-content-sha256`, and a mail provider's one-click POST does not.
929
+
930
+ Keep the routes public and protect the API in the app: the `API_TOKEN` bearer by default, or
931
+ your own `authorize`. Production refuses to serve without one of the two.
932
+
933
+ **Your own authorization.** `authorize(request, env)` replaces the bearer check on every API
934
+ route, in any format you choose: an HMAC over the body, a JWT, a header your proxy adds. It
935
+ receives a copy of the request, so it can read the body, and the env the host validated. True
936
+ lets the request through, false answers 401, and a throw answers 500 rather than letting the
937
+ request in. The tracking routes and `/health` never ask it. `bearerMatches` and
938
+ `timingSafeEqual` are exported for building one:
939
+
940
+ ```ts
941
+ import { journeyLambda, timingSafeEqual } from '@shware/workflow/aws';
942
+
943
+ journeyLambda({
944
+ // ...
945
+ authorize: async (request, env) =>
946
+ timingSafeEqual(
947
+ request.headers.get('x-signature') ?? '',
948
+ await hmac(env.SECRET, await request.text())
949
+ ),
950
+ });
951
+ ```
952
+
953
+ **Resource policy.** Grant `lambda:InvokeFunctionUrl` to everyone (`FunctionUrlAuthType: NONE`)
954
+ and `lambda:InvokeFunction` only with `lambda:InvokedViaFunctionUrl = true`, so the URL cannot
955
+ be used to invoke the function directly.
956
+
957
+ **Put CloudFront and WAF in front, without OAC.** A custom domain needs CloudFront (or another
958
+ HTTPS proxy) anyway; Route 53 alone cannot terminate TLS for a Function URL. Add a rate-based WAF
959
+ rule there: every request that reaches the function starts a durable execution and is billed,
960
+ even one the app answers 401. Reserved concurrency caps what a flood can cost.
961
+
962
+ **What a request costs.** Every Function URL request is a durable execution of its own:
963
+
964
+ | Execution | Durable ops | Per million |
965
+ | ------------------------------------------- | ----------- | ----------- |
966
+ | `GET` (pixel, redirect, `/health`), no step | 1 | about $9 |
967
+ | `POST` (an event, an identify), one step | 2 | about $17 |
968
+ | A plain, non-durable Lambda for comparison | - | about $1 |
969
+ | A journey: email, delay, one wake-up, email | 24 | about $190 |
970
+
971
+ Durable operations cost $8 per million, on top of Lambda's request and duration charges. That is
972
+ negligible at thousands of requests a month and worth a second, non-durable function for HTTP at
973
+ tens of millions. Server-side latency stays small: `/health` takes about 6 ms, a write 40–300 ms.
974
+
975
+ **Quotas.**
976
+
977
+ - Starting durable executions: 3,000 per second (6,000 in us-east-1, us-west-2 and eu-west-1).
978
+ HTTP requests and journey starts share it.
979
+ - 30,000 operations per execution, which cannot be raised. A wait attempt costs about five: the
980
+ callback plus four steps.
981
+ - 1 MB per operation's payload. Reads (`GET`, `HEAD`) are answered outside a step so their bodies
982
+ are not checkpointed.
983
+
984
+ **Timeouts and retention.**
985
+
986
+ - Set `DurableConfig.ExecutionTimeout` to the longest journey (waits, delays and the goal
987
+ window), up to 366 days, and pass the same value as `journeyLambda({ executionTimeoutMs })`: a
988
+ workflow that could outlive it fails at startup instead of being stopped midway.
989
+ - `RetentionPeriodInDays` (1–90) only keeps history after an execution ends. Keep `startRetryMs`
990
+ (12 hours) well under it.
991
+ - Keep `stopDrainMs` (15 minutes) at least the function's `Timeout`: a stop does not interrupt a
992
+ running invocation.
993
+
994
+ **Execution role.**
995
+
996
+ - `AWSLambdaBasicDurableExecutionRolePolicy` (checkpoints, state, logs).
997
+ - On `arn:...:function:<name>` and `arn:...:function:<name>:*`: `lambda:InvokeFunction`,
998
+ `lambda:SendDurableExecutionCallbackSuccess`, `lambda:GetDurableExecution`,
999
+ `lambda:StopDurableExecution` and `lambda:ListDurableExecutionsByFunction`. Durable execution
1000
+ ARNs carry the version, so an unqualified ARN alone matches nothing.
1001
+ - `ses:SendEmail` when sending through SES.
1002
+
1003
+ **Versions.**
1004
+
1005
+ - Publish numbered versions behind the `live` alias. `$LATEST` lets a paused execution resume on
1006
+ changed code.
1007
+ - Do not delete a version while executions are pinned to it.
1008
+ - Rolling the alias back does not roll the routes back: the newer bundle stays live until
1009
+ `POST /bundle/deploy` from the restored version.
1010
+
1011
+ **Emails.** The one-click unsubscribe header needs an https `WORKFLOW_ORIGIN`; over http no
1012
+ `List-Unsubscribe` is sent. Mailbox providers honour the header only when DKIM signs it, so check
1013
+ one real message for `dkim=pass` with `list-unsubscribe` in `h=`.
1014
+
1015
+ **Memory.** Size it for the app; the store is remote. APNs goes over the package's Node HTTP/2
1016
+ client, because Node 24's `fetch` speaks HTTP/1.1 only (see `src/http2-client.ts`).
843
1017
 
844
1018
  ## Postgres
845
1019
 
846
1020
  A host on Postgres (through Hyperdrive, say) keeps Cloudflare Workflows for
847
- execution and the data in its own database:
1021
+ execution and the data in its own database. Turn Hyperdrive's query caching off for this
1022
+ database (`caching: { disabled: true }` on the Hyperdrive config): the engine reads wake
1023
+ subscriptions, profiles and segment membership right after writing them, and a cached answer
1024
+ up to 60 seconds old misses wakes and decides on stale data.
848
1025
 
849
1026
  ```ts
850
1027
  const journeys = journeyWorker<Env>({
@@ -859,7 +1036,10 @@ says `database: { schema }`, so their short names never meet the app's. The
859
1036
  host owns the migrations. `npx workflow-ui generate --out <file>` (from
860
1037
  `@shware/workflow-ui`) writes the tables as one drizzle file on that schema;
861
1038
  re-export it from your drizzle schema and let drizzle-kit write the migration.
862
- Run `--check` in CI to catch an SDK upgrade that changed them. Without drizzle,
1039
+ Run `--check` in CI to catch an SDK upgrade that changed them. The unique index
1040
+ on `event` is on `(user_id, event_id)`: regenerate and migrate before deploying —
1041
+ the deploy check verifies the unique indexes as well as the columns, and refuses
1042
+ to run until they match. Without drizzle,
863
1043
  `renderPostgresDdl()` returns the same tables as plain DDL. Nothing creates or
864
1044
  alters them at runtime: `POST /bundle/deploy` checks the live database first
865
1045
  and answers 503 with every missing table or column.
@@ -910,13 +1090,22 @@ On Postgres the engine also keeps:
910
1090
 
911
1091
  `handleRequest` serves the ingest API:
912
1092
 
913
- - `GET /health` is the only unauthenticated one.
1093
+ - `GET /health` is the only unauthenticated one. Every other route asks the
1094
+ host's `authorize`, or without one the `API_TOKEN` bearer: its scheme is
1095
+ matched case-insensitively and the token compared in constant time.
914
1096
  - `POST /events` takes `{ userId, event, payload?, ts?, id? }` and answers with
915
1097
  the id the occurrence was recorded under. Event names beginning with `$` are
916
- reserved for internal use.
1098
+ reserved for internal use. `ts` is epoch milliseconds: a non-negative integer
1099
+ no later than five minutes ahead of the host's clock (400 otherwise).
917
1100
  - `POST /identify` takes `{ userId, props }` and merges the properties into the
918
- profile. A property set to `null` is removed, which is the only way to unset
919
- one.
1101
+ profile. Values are scalars (string, number, boolean); a property set to
1102
+ `null` is removed, which is the only way to unset one. Nested objects are
1103
+ refused with a 400.
1104
+ - A user with an erasure tombstone (`POST /users/forget`, finished or not) is
1105
+ refused on `/events`, `/identify` and `/devices` with a 410: nothing is
1106
+ written about them between the stop of their journeys and the erase, and the
1107
+ id is never reactivated after it.
1108
+ - A body that is not JSON answers 400, never 500.
920
1109
  - `POST /devices` takes `{ userId, token, platform, ts? }`;
921
1110
  `POST /devices/unbind` and `POST /devices/remove` take `{ token }`.
922
1111
  - `POST /messages/opened` takes `{ userId, messageId }`.
@@ -931,8 +1120,8 @@ anything is stored, woken or started.
931
1120
 
932
1121
  ### Give each event an `id`
933
1122
 
934
- `id` is the occurrence's identity, and sending the same one twice records it
935
- once. This matters because every count- and window-based condition reads the
1123
+ `id` is the occurrence's identity **for that user**, and sending the same one
1124
+ twice records it once. This matters because every count- and window-based condition reads the
936
1125
  event log, so a second copy of one event silently changes what a journey
937
1126
  decides.
938
1127
 
@@ -947,7 +1136,8 @@ you can reproduce and the retry costs nothing.
947
1136
 
948
1137
  Use something that identifies the occurrence, not the event name: a row id from
949
1138
  your own system, an upstream webhook's delivery id, or a message id from your
950
- queue. Workflows emitting events through `.sendEvent()` already do this, passing
1139
+ queue. The id is scoped to the user (the unique key is `(user_id, event_id)`),
1140
+ so two users may reuse one id without one of them losing their event. Workflows emitting events through `.sendEvent()` already do this, passing
951
1141
  the node's identity within the running instance.
952
1142
 
953
1143
  ## Entry policy
@@ -970,10 +1160,10 @@ is a design question this package does not yet answer.
970
1160
  | `@shware/workflow/postgres-js` | `postgresJsStore`, the Postgres store over postgres.js (peer `postgres`) |
971
1161
  | `@shware/workflow/react-email` | `registryRenderer`, the email renderer over the registry (peers `react`, `react-dom`) |
972
1162
  | `@shware/workflow/push` | `registryPushRenderer`, push content from the registry |
973
- | `@shware/workflow/apns` | `ApnsPushSender`, token-based APNs over HTTP/2 |
1163
+ | `@shware/workflow/apns` | `ApnsPushSender` over an `ApnsProviderToken`, token-based APNs over HTTP/2 |
974
1164
  | `@shware/workflow/apns-relay` | `startApnsRelay`, the Node HTTP/1.1 to HTTP/2 relay for `wrangler dev` |
975
- | `@shware/workflow/fcm` | `FcmPushSender`, FCM HTTP v1 for Android |
976
- | `@shware/workflow/webpush` | `WebPushSender` and `generateVapidKeys`, standard Web Push with VAPID |
1165
+ | `@shware/workflow/fcm` | `FcmPushSender` over an `FcmAccessToken`, FCM HTTP v1 for Android |
1166
+ | `@shware/workflow/webpush` | `WebPushSender` over a `VapidSigner`, `generateVapidKeys`; standard Web Push with VAPID |
977
1167
  | `@shware/workflow/webhooks` | `SlackWebhookSender` and `DiscordWebhookSender` |
978
1168
  | `@shware/workflow/schema.sql` | The D1 schema file |
979
1169
 
package/dist/apns.cjs CHANGED
@@ -1,22 +1,94 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_engine_retry = require("./engine/retry.cjs");
2
3
  const require_jwt = require("./jwt.cjs");
3
4
  //#region src/apns.ts
4
5
  const APNS_HOSTS = {
5
6
  sandbox: "https://api.sandbox.push.apple.com",
6
7
  production: "https://api.push.apple.com"
7
8
  };
8
- /** Reasons APNs gives for a token that will never deliver again — not worth a retry. */
9
- const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
10
- "Unregistered",
11
- "BadDeviceToken",
9
+ /**
10
+ * How an APNs answer is classified, after Apple's response table
11
+ * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):
12
+ *
13
+ * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the
14
+ * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles
15
+ * undelivered and the host forgets the token.
16
+ * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but
17
+ * just as well for a token of the other environment (a sandbox build pushed through the
18
+ * production host), which would answer for every device at once; the copy settles with the
19
+ * reason, so the report shows it, and nothing is deleted on a guess.
20
+ * - configuration: the topic, certificate or provider key is wrong. Every device on this app
21
+ * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,
22
+ * the deployment is not.
23
+ * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,
24
+ * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.
25
+ * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,
26
+ * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.
27
+ */
28
+ const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set(["Unregistered", "ExpiredToken"]);
29
+ /** The app's topic, certificate or provider key is wrong: the request is refused for every device. */
30
+ const CONFIGURATION_REASONS = /* @__PURE__ */ new Set([
12
31
  "DeviceTokenNotForTopic",
13
- "ExpiredToken"
32
+ "TopicDisallowed",
33
+ "BadTopic",
34
+ "MissingTopic",
35
+ "BadCertificate",
36
+ "BadCertificateEnvironment",
37
+ "Forbidden",
38
+ "InvalidProviderToken",
39
+ "MissingProviderToken"
14
40
  ]);
41
+ /**
42
+ * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the
43
+ * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the
44
+ * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —
45
+ * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose
46
+ * the node id at its end, and two nodes of one instance would collapse into one notification.
47
+ */
48
+ async function apnsIdFromKey(idempotencyKey) {
49
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
50
+ bytes[6] = bytes[6] & 15 | 128;
51
+ bytes[8] = bytes[8] & 63 | 128;
52
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
53
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
54
+ }
15
55
  /** Apple: reuse a provider token for at least 20 minutes, never past 60. */
16
56
  const TOKEN_TTL_MS = 3e6;
57
+ /**
58
+ * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple
59
+ * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent
60
+ * callers on a fresh or aged token share the one signing in flight, and a signing that failed is
61
+ * not kept, so the next call tries again. One instance serves every sender built on the key — a
62
+ * host keeps it across runs, and builds the senders per run.
63
+ */
64
+ var ApnsProviderToken = class {
65
+ options;
66
+ signed;
67
+ signing;
68
+ constructor(options) {
69
+ this.options = options;
70
+ }
71
+ /** The current token, signed now if there is none or it has aged out. */
72
+ async token(now = Date.now()) {
73
+ if (this.signed !== void 0 && now - this.signed.issuedAt < TOKEN_TTL_MS) return this.signed.value;
74
+ this.signing ??= signApnsJwt(this.options, now).then((value) => {
75
+ this.signed = {
76
+ value,
77
+ issuedAt: now
78
+ };
79
+ return value;
80
+ }).finally(() => {
81
+ this.signing = void 0;
82
+ });
83
+ return this.signing;
84
+ }
85
+ /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */
86
+ invalidate() {
87
+ this.signed = void 0;
88
+ }
89
+ };
17
90
  var ApnsPushSender = class {
18
91
  options;
19
- token;
20
92
  constructor(options) {
21
93
  this.options = options;
22
94
  }
@@ -27,13 +99,17 @@ var ApnsPushSender = class {
27
99
  const mutable = image !== void 0 || message.tracking?.data?.delivered_url !== void 0;
28
100
  const upstream = APNS_HOSTS[this.options.environment];
29
101
  const origin = this.options.origin ?? upstream;
30
- const response = await (this.options.fetch ?? fetch)(`${origin}/3/device/${message.recipient}`, {
102
+ const doFetch = this.options.fetch ?? fetch;
103
+ const copyId = await apnsIdFromKey(message.idempotencyKey);
104
+ const response = await doFetch(`${origin}/3/device/${message.recipient}`, {
31
105
  method: "POST",
32
106
  headers: {
33
- authorization: `bearer ${await this.providerToken()}`,
107
+ authorization: `bearer ${await this.options.credentials.token()}`,
34
108
  "apns-topic": this.options.topic,
35
109
  "apns-push-type": "alert",
36
110
  "apns-priority": "10",
111
+ "apns-id": copyId,
112
+ "apns-collapse-id": copyId,
37
113
  "content-type": "application/json",
38
114
  ...this.options.origin === void 0 ? {} : { "apns-relay-upstream": upstream }
39
115
  },
@@ -55,26 +131,29 @@ var ApnsPushSender = class {
55
131
  });
56
132
  if (response.ok) return;
57
133
  const text = await response.text();
58
- const reason = apnsReason(text);
59
- if (response.status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
60
- console.warn(`[apns] token gone for user ${message.userId}: ${reason || response.status}`);
61
- await this.options.onUnregistered?.(message);
134
+ const { status } = response;
135
+ const reason = apnsReason(text) || (status === 410 ? "Unregistered" : "");
136
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
137
+ console.warn(`[apns] token gone for user ${message.userId}: ${reason}`);
62
138
  return {
63
139
  delivered: false,
64
- reason: reason || `HTTP ${response.status}`
140
+ reason
65
141
  };
66
142
  }
67
- throw new Error(`APNs send failed: ${response.status} ${text}`);
68
- }
69
- async providerToken() {
70
- const now = Date.now();
71
- if (this.token !== void 0 && now - this.token.issuedAt < TOKEN_TTL_MS) return this.token.value;
72
- const value = await signApnsJwt(this.options, now);
73
- this.token = {
74
- value,
75
- issuedAt: now
76
- };
77
- return value;
143
+ if (reason === "BadDeviceToken") {
144
+ console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);
145
+ return {
146
+ delivered: false,
147
+ reason
148
+ };
149
+ }
150
+ if (reason === "ExpiredProviderToken") {
151
+ this.options.credentials.invalidate();
152
+ throw new Error(`APNs send failed: ${status} ${reason} (provider token renewed for the retry)`);
153
+ }
154
+ if (CONFIGURATION_REASONS.has(reason)) throw new require_engine_retry.PermanentError(`APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`);
155
+ if (require_engine_retry.isRetryableStatus(status)) throw new Error(`APNs send failed: ${status} ${reason || text}`);
156
+ throw new require_engine_retry.PermanentError(`APNs refused the request: ${status} ${reason || text}`);
78
157
  }
79
158
  };
80
159
  /** APNs error bodies are `{ "reason": "…" }`. */
@@ -107,13 +186,21 @@ async function signApnsJwt(key, nowMs = Date.now()) {
107
186
  }
108
187
  /** The `.p8` as it is usually shipped in a secret: base64 of the file. */
109
188
  function apnsKeyFromBase64(value) {
110
- const pem = atob(value);
111
- if (!pem.includes("-----BEGIN PRIVATE KEY-----")) throw new Error("APNs key is not a PKCS #8 PEM (.p8) file");
189
+ let pem;
190
+ try {
191
+ pem = atob(value);
192
+ } catch (error) {
193
+ throw new require_engine_retry.PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
194
+ }
195
+ if (!pem.includes("-----BEGIN PRIVATE KEY-----")) throw new require_engine_retry.PermanentError("APNs key is not a PKCS #8 PEM (.p8) file");
112
196
  return pem;
113
197
  }
114
198
  //#endregion
115
199
  exports.APNS_HOSTS = APNS_HOSTS;
200
+ exports.ApnsProviderToken = ApnsProviderToken;
116
201
  exports.ApnsPushSender = ApnsPushSender;
202
+ exports.DEAD_TOKEN_REASONS = DEAD_TOKEN_REASONS;
203
+ exports.apnsIdFromKey = apnsIdFromKey;
117
204
  exports.apnsKeyFromBase64 = apnsKeyFromBase64;
118
205
  exports.apnsReason = apnsReason;
119
206
  exports.signApnsJwt = signApnsJwt;
package/dist/apns.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"apns.cjs","names":["base64url","pemToDer"],"sources":["../src/apns.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\nexport interface ApnsPushOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n /** Called for a token APNs reports as gone (`Unregistered`, `BadDeviceToken`); `channelsFromConfig` passes it on to the DeviceDirectory's `disable`. */\n onUnregistered?: (message: OutboundMessage) => Promise<void> | void;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/** Reasons APNs gives for a token that will never deliver again — not worth a retry. */\nconst DEAD_TOKEN_REASONS = new Set([\n 'Unregistered',\n 'BadDeviceToken',\n 'DeviceTokenNotForTopic',\n 'ExpiredToken',\n]);\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\nexport class ApnsPushSender implements MessageSender {\n private token: { value: string; issuedAt: number } | undefined;\n\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.providerToken()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const reason = apnsReason(text);\n if (response.status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[apns] token gone for user ${message.userId}: ${reason || response.status}`);\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: reason || `HTTP ${response.status}` };\n }\n throw new Error(`APNs send failed: ${response.status} ${text}`);\n }\n\n private async providerToken(): Promise<string> {\n const now = Date.now();\n if (this.token !== undefined && now - this.token.issuedAt < TOKEN_TTL_MS) {\n return this.token.value;\n }\n const value = await signApnsJwt(this.options, now);\n this.token = { value, issuedAt: now };\n return value;\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: Pick<ApnsPushOptions, 'keyId' | 'teamId' | 'privateKey'>,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n const pem = atob(value);\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new Error('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":";;;AA8CA,MAAa,aAAa;CACxB,SAAS;CACT,YAAY;AACd;;AAGA,MAAM,qCAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACA;AACF,CAAC;;AAGD,MAAM,eAAe;AAErB,IAAa,iBAAb,MAAqD;CAGtB;CAF7B;CAEA,YAAY,SAA2C;EAA1B,KAAA,UAAA;CAA2B;CAExD,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,QACtB,MAAM,IAAI,MAAM,wCAAwC,QAAQ,QAAQ,EAAE;EAE5E,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,2CAA2C,QAAQ,OAAO,EAAE;EAE9E,MAAM,EAAE,OAAO,MAAM,OAAO,QAAQ,MAAM,KAAK,QAAQ,OAAO,QAAQ,UAAU,QAAQ,KAAK;EAG7F,MAAM,UAAU,UAAU,KAAA,KAAa,QAAQ,UAAU,MAAM,kBAAkB,KAAA;EACjF,MAAM,WAAW,WAAW,KAAK,QAAQ;EACzC,MAAM,SAAS,KAAK,QAAQ,UAAU;EAGtC,MAAM,WAAW,OAFD,KAAK,QAAQ,SAAS,MAAA,CAEP,GAAG,OAAO,YAAY,QAAQ,aAAa;GACxE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU,MAAM,KAAK,cAAc;IAClD,cAAc,KAAK,QAAQ;IAC3B,kBAAkB;IAClB,iBAAiB;IACjB,gBAAgB;IAChB,GAAI,KAAK,QAAQ,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,uBAAuB,SAAS;GACjF;GACA,MAAM,KAAK,UAAU;IACnB,KAAK;KACH,OAAO;MAAE;MAAO;KAAK;KACrB,OAAO;KACP,GAAI,UAAU,EAAE,mBAAmB,EAAE,IAAI,CAAC;IAC5C;IAGA,UAAU,QAAQ;IAClB,gBAAgB,QAAQ;IACxB,GAAI,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM;IACvC,GAAI,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI;IACnC,GAAG,QAAQ,UAAU;GACvB,CAAC;EACH,CAAC;EACD,IAAI,SAAS,IAAI;EAEjB,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,MAAM,SAAS,WAAW,IAAI;EAC9B,IAAI,SAAS,WAAW,OAAO,mBAAmB,IAAI,MAAM,GAAG;GAG7D,QAAQ,KAAK,8BAA8B,QAAQ,OAAO,IAAI,UAAU,SAAS,QAAQ;GACzF,MAAM,KAAK,QAAQ,iBAAiB,OAAO;GAC3C,OAAO;IAAE,WAAW;IAAO,QAAQ,UAAU,QAAQ,SAAS;GAAS;EACzE;EACA,MAAM,IAAI,MAAM,qBAAqB,SAAS,OAAO,GAAG,MAAM;CAChE;CAEA,MAAc,gBAAiC;EAC7C,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,KAAK,UAAU,KAAA,KAAa,MAAM,KAAK,MAAM,WAAW,cAC1D,OAAO,KAAK,MAAM;EAEpB,MAAM,QAAQ,MAAM,YAAY,KAAK,SAAS,GAAG;EACjD,KAAK,QAAQ;GAAE;GAAO,UAAU;EAAI;EACpC,OAAO;CACT;AACF;;AAGA,SAAgB,WAAW,MAAsB;CAC/C,IAAI;EAEF,OADe,KAAK,MAAM,IACd,CAAC,CAAC,UAAU;CAC1B,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,eAAsB,YACpB,KACA,QAAQ,KAAK,IAAI,GACA;CACjB,MAAM,SAASA,YAAAA,UAAU,KAAK,UAAU;EAAE,KAAK;EAAS,KAAK,IAAI;CAAM,CAAC,CAAC;CACzE,MAAM,SAASA,YAAAA,UAAU,KAAK,UAAU;EAAE,KAAK,IAAI;EAAQ,KAAK,KAAK,MAAM,QAAQ,GAAI;CAAE,CAAC,CAAC;CAC3F,MAAM,YAAY,MAAM,OAAO,OAAO,UACpC,SACAC,YAAAA,SAAS,IAAI,UAAU,GACvB;EAAE,MAAM;EAAS,YAAY;CAAQ,GACrC,OACA,CAAC,MAAM,CACT;CAEA,MAAM,YAAY,MAAM,OAAO,OAAO,KACpC;EAAE,MAAM;EAAS,MAAM;CAAU,GACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,GAAG,OAAO,GAAG,QAAQ,CAChD;CACA,OAAO,GAAG,OAAO,GAAG,OAAO,GAAGD,YAAAA,UAAU,IAAI,WAAW,SAAS,CAAC;AACnE;;AAGA,SAAgB,kBAAkB,OAAuB;CACvD,MAAM,MAAM,KAAK,KAAK;CACtB,IAAI,CAAC,IAAI,SAAS,6BAA6B,GAC7C,MAAM,IAAI,MAAM,0CAA0C;CAE5D,OAAO;AACT"}
1
+ {"version":3,"file":"apns.cjs","names":["PermanentError","isRetryableStatus","base64url","pemToDer"],"sources":["../src/apns.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = atob(value);\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}