@openchambery/web 1.19.0-beta.4 → 1.19.0-beta.41

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 (128) hide show
  1. package/dist/assets/AgentAvatar-Bt6ACcoy.js +1 -0
  2. package/dist/assets/AssistantView-DSVnoF6D.js +1 -0
  3. package/dist/assets/{ChatContainer-BTOShmJo.js → ChatContainer-BR8bX7eo.js} +39 -41
  4. package/dist/assets/ContextPanel-08KY9_yh.js +288 -0
  5. package/dist/assets/{DiagramView-CkAyvW7S.js → DiagramView-CcwntnET.js} +1 -1
  6. package/dist/assets/{FadeInOnReveal-By2F9vcI.js → FadeInOnReveal-BpIMrqet.js} +1 -1
  7. package/dist/assets/{Icon-DpjYnXxn.js → Icon-Bs6GJIoO.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-B0eBCtyD.js → MarkdownRendererImpl-DvCzwWEz.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-DL4SVma_.js → MobileDetailNavigation-Bevs3Mx_.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-FZ2V3gRM.js → MobileShareBridge-DK5v0Rir.js} +2 -2
  11. package/dist/assets/MobileSurface-D2ms2C0d.js +2 -0
  12. package/dist/assets/{MultiRunWindow-D-kO7Hwi.js → MultiRunWindow-B73TNXR9.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-BKmuT7AL.js → OnboardingScreen-DuB2zBXh.js} +1 -1
  14. package/dist/assets/OverlayScrollbar-VZKKu-cu.js +2 -0
  15. package/dist/assets/SettingsView-BK7_Oz8z.js +25 -0
  16. package/dist/assets/{SettingsWindow-L92_gMCL.js → SettingsWindow-LNjg25i3.js} +1 -1
  17. package/dist/assets/{TerminalView-D4W3gUrF.js → TerminalView-B8mRchYI.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-CpMU9Jmc.js → ToolOutputDialog-CjIqC0IE.js} +4 -4
  19. package/dist/assets/appThemeRegistry-BZw1SpcA.js +2 -0
  20. package/dist/assets/{button-C7OXr4ae.js → button-BkTMa7Qj.js} +1 -1
  21. package/dist/assets/{clipboard-DWuULPzo.js → clipboard-mffeUZRt.js} +1 -1
  22. package/dist/assets/{collapsible-DaEya6b6.js → collapsible-CVL-5iEB.js} +1 -1
  23. package/dist/assets/{desktopHosts-Txp4xOVQ.js → desktopHosts-Dzx3McaD.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-C_FSEHKM.js → desktopRelayRestore-B9S0Rp5s.js} +1 -1
  25. package/dist/assets/dialog-D8720PGH.js +1 -0
  26. package/dist/assets/{es-BX0Vm3Ac.js → es-DlnJ_LoQ.js} +2 -2
  27. package/dist/assets/{fileQueries-B_fTkH6s.js → fileQueries-_VUJM8AE.js} +1 -1
  28. package/dist/assets/fr-qNdP8tDC.js +13 -0
  29. package/dist/assets/{gitApi-BIqXEOV4.js → gitApi-0_4_-WcX.js} +3 -3
  30. package/dist/assets/{input-BzYA603C.js → input-Ca2tUhgN.js} +1 -1
  31. package/dist/assets/insertionBoundaries-Bl_qiyTR.js +4 -0
  32. package/dist/assets/{ja-DGtzjrFO.js → ja-DPKgF5Ex.js} +3 -3
  33. package/dist/assets/{ko-DZ9Di0je.js → ko-BFmhVYY-.js} +3 -3
  34. package/dist/assets/main-B27rFGjh.js +29 -0
  35. package/dist/assets/{main-BtWdJ1o-.js → main-DljC1FuL.js} +2 -2
  36. package/dist/assets/{markdown-worker-W06-MhF5.js → markdown-worker-9Sn2jsDp.js} +1 -1
  37. package/dist/assets/{markdownTheme-B89dbj3N.js → markdownTheme-DidS-jC5.js} +1 -1
  38. package/dist/assets/{miniChat-CJzPIIkQ.js → miniChat-DWhKSy9J.js} +2 -2
  39. package/dist/assets/mobile-B5z9BrGL.js +2 -0
  40. package/dist/assets/multirun-B2_-qAoC.js +4 -0
  41. package/dist/assets/{persistence-6sclr7B6.js → persistence-aCOlo6ew.js} +2 -2
  42. package/dist/assets/pl-DtrJUxrk.js +15 -0
  43. package/dist/assets/platform-ulFAVx1H.js +2 -0
  44. package/dist/assets/projectMeta-BueyKfXu.js +3 -0
  45. package/dist/assets/{pt-BR-Brf4RMsw.js → pt-BR-BQVvvMJt.js} +2 -2
  46. package/dist/assets/radio-_bcJkk2w.js +1 -0
  47. package/dist/assets/renderElectronMiniChatApp-Bb6K99m4.js +2 -0
  48. package/dist/assets/renderMobileApp-ByiWWEfa.js +23 -0
  49. package/dist/assets/{runtimeConfig-DMpRvT8y.js → runtimeConfig-CPxbwfqk.js} +1 -1
  50. package/dist/assets/runtimeEndpointReset-CQIhCLle.js +4 -0
  51. package/dist/assets/{shikiHighlight-CGuRQJIr.js → shikiHighlight-DaveQmV5.js} +1 -1
  52. package/dist/assets/{sortable-tabs-strip-nLsPC06f.js → sortable-tabs-strip-D2D5nqti.js} +1 -1
  53. package/dist/assets/src-BwToVDxi.css +2 -0
  54. package/dist/assets/{store-DQY8Fkb0.js → store-DcNAsu-r.js} +4 -4
  55. package/dist/assets/{terminalApi-BpPeXWIu.js → terminalApi-HsIEks5U.js} +1 -1
  56. package/dist/assets/{tooltip-D_orJMYd.js → tooltip-GC3dg3gO.js} +1 -1
  57. package/dist/assets/{uk-C-wmaPTi.js → uk-BhJpEyza.js} +2 -2
  58. package/dist/assets/{url-BsaBG5-1.js → url-BQHM4bC4.js} +1 -1
  59. package/dist/assets/{useAppFontEffects-Dlfg51WY.js → useAppFontEffects-Bo8Gg2wk.js} +5 -5
  60. package/dist/assets/{useAssistantUIStore-BHsHXCKE.js → useAssistantUIStore-DxZsjSPQ.js} +1 -1
  61. package/dist/assets/{useDesktopWindowControlsLayout-B6l3XRlk.js → useDesktopWindowControlsLayout-CcJfst4E.js} +1 -1
  62. package/dist/assets/useEffectiveDirectory-CrmbaDn6.js +1 -0
  63. package/dist/assets/{useFontPreferences-ckzpXQaJ.js → useFontPreferences-DaFspdDd.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-Ce5rebqo.js +475 -0
  65. package/dist/assets/useSessionAutoCleanup-ggCjN2VE.js +1 -0
  66. package/dist/assets/{useUIStore-DzwCfRqr.js → useUIStore-Wyu09VUF.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DXDqlnez.js → useWorkerHighlightedLines-CbqnYoLv.js} +1 -1
  68. package/dist/assets/{utils-B1EK5zKa.js → utils-CJdWZupK.js} +1 -1
  69. package/dist/assets/{zh-CN-DueRrIql.js → zh-CN-C5ctnTME.js} +2 -2
  70. package/dist/assets/zh-TW-B97f1ndw.js +15 -0
  71. package/dist/index.html +9 -9
  72. package/dist/mini-chat.html +9 -9
  73. package/dist/mobile.html +9 -9
  74. package/package.json +1 -1
  75. package/server/index.js +12 -15
  76. package/server/lib/config-sync/DOCUMENTATION.md +6 -7
  77. package/server/lib/config-sync/engine.js +0 -8
  78. package/server/lib/config-sync/index.js +0 -7
  79. package/server/lib/config-sync/receiver.js +0 -35
  80. package/server/lib/config-sync/routes.js +8 -41
  81. package/server/lib/config-sync/routes.test.js +1 -38
  82. package/server/lib/notifications/APNS.md +53 -38
  83. package/server/lib/notifications/DOCUMENTATION.md +5 -4
  84. package/server/lib/notifications/apns-runtime.js +235 -71
  85. package/server/lib/notifications/apns-runtime.test.js +447 -0
  86. package/server/lib/notifications/apns-titles.js +212 -0
  87. package/server/lib/notifications/apns-titles.test.js +55 -0
  88. package/server/lib/notifications/routes.js +2 -1
  89. package/server/lib/notifications/runtime.js +5 -13
  90. package/server/lib/opencode/openchamber-routes.js +1 -7
  91. package/server/lib/opencode/settings-helpers.js +0 -3
  92. package/server/lib/opencode/shutdown-runtime.js +0 -2
  93. package/server/lib/relay/DOCUMENTATION.md +2 -2
  94. package/server/lib/relay/service.js +45 -3
  95. package/server/lib/relay/service.test.js +117 -0
  96. package/server/lib/relay/signing-key.js +1 -1
  97. package/server/lib/session-goal/DOCUMENTATION.md +3 -3
  98. package/server/lib/session-goal/runtime.js +3 -3
  99. package/server/lib/session-title/DOCUMENTATION.md +1 -2
  100. package/dist/assets/AgentAvatar-DDcjamcJ.js +0 -1
  101. package/dist/assets/AssistantView-BmrllM8T.js +0 -1
  102. package/dist/assets/ContextPanel-DrOpxE_Z.js +0 -288
  103. package/dist/assets/MobileSurface-DeRexDW2.js +0 -2
  104. package/dist/assets/OverlayScrollbar-Ch3J6ASR.js +0 -2
  105. package/dist/assets/SettingsView-yNqH1aC2.js +0 -25
  106. package/dist/assets/appThemeRegistry-DohSaUlr.js +0 -2
  107. package/dist/assets/dialog-DPS0W9eS.js +0 -1
  108. package/dist/assets/fr-CuPzXTYx.js +0 -13
  109. package/dist/assets/insertionBoundaries-9AGImjk0.js +0 -1
  110. package/dist/assets/main-CnioFiw7.js +0 -29
  111. package/dist/assets/mobile-CvkyrtMY.js +0 -2
  112. package/dist/assets/multirun-BjeA-Baw.js +0 -4
  113. package/dist/assets/pl-BuCO3qb1.js +0 -15
  114. package/dist/assets/platform-CbeLpKPk.js +0 -2
  115. package/dist/assets/projectMeta-BP0IYkad.js +0 -3
  116. package/dist/assets/radio-dL5rRE7d.js +0 -1
  117. package/dist/assets/renderElectronMiniChatApp-Dzfy8-St.js +0 -2
  118. package/dist/assets/renderMobileApp-Bi1Sm_lT.js +0 -23
  119. package/dist/assets/runtimeEndpointReset-C6_MkS3Z.js +0 -4
  120. package/dist/assets/src-RGaBtbGz.css +0 -2
  121. package/dist/assets/useEffectiveDirectory-DrRShKin.js +0 -1
  122. package/dist/assets/useMobileNavigationStore-CncTmwzO.js +0 -475
  123. package/dist/assets/useSessionAutoCleanup-BkMesfd-.js +0 -1
  124. package/dist/assets/zh-TW-6875X80V.js +0 -15
  125. package/server/lib/config-sync/credential-auth.js +0 -39
  126. package/server/lib/session-assist/DOCUMENTATION.md +0 -62
  127. package/server/lib/session-assist/runtime.js +0 -376
  128. package/server/lib/session-assist/runtime.test.js +0 -114
@@ -13,17 +13,20 @@ registered them — so a leaked device token alone can't be used to push.
13
13
  `POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key
14
14
  (`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records
15
15
  `token → serverId` where `serverId = SHA-256(publicKey)`.
16
- 3. On a trigger (ready/error/question/permission), the server composes **generic, content-free**
17
- text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent
18
- needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/
19
- message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body,
20
- badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send`
21
- (`apns-runtime.js` `sendViaRelay`). It does **not** gate on UI visibility (see below).
22
- 4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature +
23
- `ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds
24
- the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each
25
- token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop`
26
- (410 / BadDeviceToken). The relay stores no secret only `token serverId` hashes.
16
+ 3. On a trigger (ready/error/question/permission/goal_*), the server composes **generic,
17
+ content-free** text — a **locale-specific** scenario title (from `apns-titles.js`, keyed off
18
+ the locale stored with each device token at `POST /api/push/apns-token`) + the **session name**
19
+ as the body, no model/project/message content — plus a **`badge`** count (see below). Tokens
20
+ are grouped by locale (relay signatures cover `title`, so mixed locales cannot share one
21
+ batch) and each group POSTs `{ tokens, title, body, badge, env, data:{sessionId},
22
+ publicKeyJwk, ts, sig }` to `POST /v1/push/send` (`apns-runtime.js` → `sendViaRelay`). It does
23
+ **not** gate on UI visibility (see below). Legacy tokens without a locale fall back to English.
24
+ 4. The **Push Relay** (`packages/relay-server/src/push/`, process `openchamber-push-relay`)
25
+ verifies the signature + `ts` freshness, derives `serverId`, and only delivers to tokens bound
26
+ to that server. It holds the project APNs `.p8` key, signs an ES256 JWT with Node crypto, and
27
+ sends each token to APNs over HTTP/2, returning per-token results; the server drops tokens
28
+ flagged `drop` (410 / BadDeviceToken). The Push database stores `token → serverId` bindings,
29
+ not application plaintext. Layer 1 (`openchamber-relay`) never sees these secrets.
27
30
  5. Tapping a push deep-links to its session via the forwarded `sessionId`.
28
31
 
29
32
  ## Foreground suppression
@@ -34,8 +37,8 @@ responses. Instead the server always sends, and **iOS** suppresses the foregroun
34
37
  (`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification
35
38
  while the app is active, with no race. APNs is the native app's **only** channel; local
36
39
  notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()`
37
- is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native
38
- app with notifications on has a registered token and a trigger fires.
40
+ is unreliable — so they leaked while the app was open). The Push Relay is contacted only when a
41
+ native app with notifications on has a registered token and a trigger fires.
39
42
 
40
43
  ## App-icon badge
41
44
 
@@ -61,36 +64,45 @@ mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegat
61
64
  server and device in sync.
62
65
 
63
66
  The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body /
64
- direct-mode `aps.badge`) → relay (`pushSendSchema.badge` → `aps.badge`). It is **not** signed (like
65
- `body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every
66
- device token of a server sees the same badge.
67
+ direct-mode `aps.badge`) → Push Relay (`packages/relay-server/src/push/schema.js` → `aps.badge`).
68
+ It is **not** signed (like `body`/`data`); the relay still only delivers to bound tokens. The set
69
+ is server-global, so every device token of a server sees the same badge.
67
70
 
68
71
  ## Modes
69
72
 
70
- - **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to
71
- `https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`).
73
+ - **Relay (default):** Host has no Apple key. Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the
74
+ Host maps the effective Relay `wss://`/`ws://` URL to the same host as `https://`/`http://`
75
+ `/v1/push/send` (register URL is `/v1/push/register-token`). A Relay switch re-registers
76
+ persisted tokens and binds them again before the first send.
72
77
  - **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/
73
78
  TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed.
74
79
 
75
80
  ## Config
76
81
 
77
- Server (`apns-runtime.js`):
78
- - `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT`
79
- (`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set.
82
+ Host (`apns-runtime.js`):
83
+ - `OPENCHAMBER_PUSH_RELAY_URL` (explicit send-URL override), `OPENCHAMBER_APNS_ENVIRONMENT`
84
+ (`sandbox` default / `production` for TestFlight and App Store). The signing keypair is
85
+ auto-generated — nothing to set.
80
86
  - Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
81
87
  (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
82
88
 
83
- Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`,
84
- `APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens`
85
- binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy).
89
+ Push process (`packages/relay-server/src/push/`, env prefix `OPENCHAMBER_PUSH_RELAY_`):
90
+ `HOST` (default `127.0.0.1`), `PORT` (default `8788`), `TRUST_PROXY`, `DATABASE_PATH`,
91
+ timestamp/replay/rate-limit/token/`MAX_IN_FLIGHT` limits, plus APNs `APNS_KEY_ID`,
92
+ `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and `APNS_P8` or `APNS_P8_PATH`. See
93
+ `packages/relay-server/README.md`. SQLite is single-instance. Do not log `.p8` material,
94
+ device tokens, or signatures.
86
95
 
87
96
  ## Apple setup (one-time)
88
97
 
89
98
  1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID;
90
- enable **Push Notifications** on App ID `com.openchamber.app`.
91
- 2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`,
92
- `APNS_TEAM_ID`. Push to `main` relay deploys, secrets sync, D1 migrations apply.
99
+ enable **Push Notifications** on the App ID. TestFlight / App Store builds of this product
100
+ use bundle ID `com.yee94.openchamber`.
101
+ 2. Give those values only to the Push process (environment or Docker secret). Do not put the
102
+ `.p8` on Layer 1 or on each OpenChamber Host when using Relay mode.
93
103
  3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device.
104
+ Development builds use APNs `sandbox`. TestFlight and App Store Hosts set
105
+ `OPENCHAMBER_APNS_ENVIRONMENT=production`.
94
106
 
95
107
  ## Security posture
96
108
 
@@ -98,10 +110,12 @@ binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy
98
110
  is signed by the server's private key, and the relay only delivers to a token from its bound
99
111
  `serverId`. A leaked token alone is useless — an attacker has neither the private key nor a
100
112
  matching binding.
101
- - `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak
102
- exposes only `token serverId` hashes. The signed `ts` (±5 min window) blocks replay.
113
+ - `serverId` self-certifies (`SHA-256(publicKey)`). A SQLite leak exposes `token serverId`
114
+ bindings, not Host private keys or application plaintext. The signed `ts` (±5 min window)
115
+ blocks replay. Signature verification lives in `packages/relay-server/src/push/crypto.js`
116
+ and must stay aligned with `packages/web/server/lib/relay/signing-key.js`.
103
117
  - Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since
104
- registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth.
118
+ registering already requires possessing the token. Process rate limits are defence-in-depth.
105
119
 
106
120
  ## Data confidentiality (what the relay / Apple can see)
107
121
 
@@ -113,17 +127,18 @@ to APNs). The request **signature is authentication, not encryption** — the re
113
127
  Who can read the alert text:
114
128
 
115
129
  - **Network hops:** nothing (TLS).
116
- - **The relay (Cloudflare):** the generic title + body (session name), the device token, and
117
- `sessionId`. It stores only `token → serverId` hashes (no text, no payload).
130
+ - **The Push Relay process:** the generic title + body (session name), the device token, and
131
+ `sessionId`. It stores `token → serverId` bindings (no alert text, no payload).
118
132
  - **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push.
119
133
  - **The device:** displays it.
120
134
 
121
- This is acceptable **because the text is deliberately content-free**: a fixed scenario title +
122
- the session name only — no model, project, or message content (`runtime.js` →
123
- `toApnsGenericPayload`). The session name is the single semi-personal field that crosses the
124
- relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload**
125
- (`mutable-content` + a Notification Service Extension that decrypts on-device with a key never
126
- sent to the relay) — not implemented, and unnecessary for generic text.
135
+ This is acceptable **because the text is deliberately content-free**: a fixed scenario title
136
+ (localized per device) + the session name only — no model, project, or message content
137
+ (`runtime.js` → `toApnsGenericPayload` → `localizeApnsPayload`). The session name is the single
138
+ semi-personal field that crosses the relay/Apple. To hide even that from Apple would require an
139
+ end-to-end **encrypted payload** (`mutable-content` + a Notification Service Extension that
140
+ decrypts on-device with a key never sent to the relay) — not implemented, and unnecessary for
141
+ generic text.
127
142
 
128
143
  ## Android (FCM) note
129
144
 
@@ -7,7 +7,7 @@ This module provides notification message preparation utilities for the web serv
7
7
  - `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
8
8
  - `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints.
9
9
  - `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
10
- - `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
10
+ - `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the isolated Push Relay in `packages/relay-server/src/push/`, which holds the project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the Host maps the effective Relay `wss`/`ws` URL to the same host as `https`/`http` `/v1/push/send`. Before each relay send, the runtime checks an in-memory registration success cache (keyed by current register URL + token + platform) and only POSTs `/v1/push/register-token` for cache misses; only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so persisted tokens re-bind to the new origin on the next send or `reRegisterAllTokens()`. Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
11
11
  - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
12
12
  - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
13
13
  - `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
@@ -67,12 +67,13 @@ This module provides notification message preparation utilities for the web serv
67
67
  ### APNs runtime API (apns-runtime.js)
68
68
  - `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair).
69
69
  - Returned API:
70
- - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`).
70
+ - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent, platform?, locale?)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). Persists app UI `locale` for localized push titles.
71
71
  - `removeApnsToken(uiSessionToken, deviceToken)`
72
72
  - `removeApnsTokenFromAllSessions(deviceToken)`
73
- - `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
73
+ - `sendApnsToAllUiSessions(payload)` — localizes scenario titles per stored token locale (`apns-titles.js`), groups tokens by locale, then signs + sends (no UI-visibility gate; iOS suppresses the foreground banner). Before a relay send, each token is checked against the in-memory registration success cache for the current register URL; cache hits skip `/v1/push/register-token`, misses are registered with bounded concurrency, and only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so the next send re-binds persisted tokens to the new origin. No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
74
+ - `reRegisterAllTokens()` — binds every persisted device token to the current Push origin (same success cache and URL-switch invalidation as send).
74
75
  - `resolveApnsConfig()`
75
- - Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`).
76
+ - Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_PUSH_RELAY_URL` (optional send-URL override), `OPENCHAMBER_PUSH_RELAY_DISABLED`, `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.yee94.openchamber`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production` for TestFlight and App Store).
76
77
 
77
78
  ### Emitter runtime API (emitter-runtime.js)
78
79
  - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two
4
4
  // modes, chosen at send time:
5
- // - Relay (default): POST tokens + generic text to the central Cloudflare relay, which
6
- // holds the single project APNs key and signs+sends so users configure nothing.
5
+ // - Relay (default): POST tokens + generic text to the same-origin Push service
6
+ // (`/v1/push/*` on the effective Relay host). The isolated Push process holds the
7
+ // project APNs key and signs+sends — so users configure nothing.
7
8
  // - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves,
8
9
  // for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true.
9
10
  // Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
@@ -13,18 +14,46 @@ import {
13
14
  getOrCreateRelaySigningKeypair,
14
15
  signRelayMessage as signRelayMessageShared,
15
16
  } from '../relay/signing-key.js';
17
+ import { DEFAULT_RELAY_URL, derivePushSendUrlFromRelayUrl } from '../relay/service.js';
18
+ import {
19
+ APNS_DEFAULT_LOCALE,
20
+ localizeApnsPayload,
21
+ normalizeApnsLocale,
22
+ } from './apns-titles.js';
16
23
 
17
24
  const APNS_TOKENS_VERSION = 1;
18
25
  const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
19
26
  const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
20
27
  // APNs rejects auth tokens older than 1h; refresh well inside that window.
21
28
  const JWT_TTL_MS = 50 * 60 * 1000;
22
- const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
23
- const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send';
29
+ const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
24
30
  const MAX_TOKENS_PER_SESSION = 10;
31
+ const MAX_RELAY_SEND_TOKENS = 100;
32
+ const RELAY_REGISTER_CONCURRENCY = 16;
33
+ const APNS_COLLAPSE_ID_MAX_BYTES = 64;
25
34
  // APNs reasons that mean the token is permanently invalid → drop it.
26
35
  const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
27
36
 
37
+ const mapWithBoundedConcurrency = async (items, concurrency, mapper) => {
38
+ const results = new Array(items.length);
39
+ const limit = Math.max(1, concurrency);
40
+ for (let start = 0; start < items.length; start += limit) {
41
+ const end = Math.min(start + limit, items.length);
42
+ await Promise.all(
43
+ items.slice(start, end).map(async (item, offset) => {
44
+ results[start + offset] = await mapper(item, start + offset);
45
+ }),
46
+ );
47
+ }
48
+ return results;
49
+ };
50
+
51
+ const apnsCollapseId = (tag) => {
52
+ if (typeof tag !== 'string' || tag.length === 0) return undefined;
53
+ if (Buffer.byteLength(tag, 'utf8') > APNS_COLLAPSE_ID_MAX_BYTES) return undefined;
54
+ return tag;
55
+ };
56
+
28
57
  const trimmedEnv = (name) => {
29
58
  const value = process.env[name];
30
59
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
@@ -44,12 +73,15 @@ export const createApnsRuntime = (deps) => {
44
73
  writeSettingsToDisk,
45
74
  // Strict settings reader gating identity regeneration (see signing-key.js).
46
75
  readSettingsStrict,
76
+ resolveEffectiveRelayUrl,
47
77
  } = deps;
48
78
 
49
79
  let persistLock = Promise.resolve();
50
80
  let cachedJwt = null; // { token, issuedAtMs, keyId }
51
81
  let cachedRelayKey = null; // { privateKey, publicJwk }
52
82
  let warnedUnconfigured = false;
83
+ let registrationSuccessCache = new Map();
84
+ let registrationCacheRegisterUrl = null;
53
85
 
54
86
  // ---------------------------------------------------------------------------
55
87
  // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
@@ -76,9 +108,31 @@ export const createApnsRuntime = (deps) => {
76
108
  y: publicJwk.y,
77
109
  });
78
110
 
79
- const registerTokenWithRelay = async (token, platform = 'ios') => {
80
- const relay = resolveRelayConfig();
81
- if (!relay) return; // direct mode — no relay binding needed
111
+ const registrationCacheKey = (registerUrl, token, platform) =>
112
+ `${registerUrl}\0${token}\0${platform}`;
113
+
114
+ const rememberRelayRegisterUrl = (registerUrl) => {
115
+ if (registrationCacheRegisterUrl === registerUrl) return;
116
+ registrationSuccessCache = new Map();
117
+ registrationCacheRegisterUrl = registerUrl;
118
+ };
119
+
120
+ const boundRegistrationCache = (registerUrl, entries) => {
121
+ rememberRelayRegisterUrl(registerUrl);
122
+ const allowed = new Set(
123
+ entries.map((entry) => registrationCacheKey(registerUrl, entry.deviceToken, entry.platform)),
124
+ );
125
+ for (const key of registrationSuccessCache.keys()) {
126
+ if (!allowed.has(key)) registrationSuccessCache.delete(key);
127
+ }
128
+ };
129
+
130
+ const registerTokenWithRelay = async (token, platform = 'ios', relayOverride) => {
131
+ const relay = relayOverride ?? (await resolveRelayConfig());
132
+ if (!relay) return true; // direct mode — no relay binding needed
133
+ rememberRelayRegisterUrl(relay.registerUrl);
134
+ const cacheKey = registrationCacheKey(relay.registerUrl, token, platform);
135
+ if (registrationSuccessCache.has(cacheKey)) return true;
82
136
  try {
83
137
  const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
84
138
  const ts = Date.now();
@@ -89,9 +143,15 @@ export const createApnsRuntime = (deps) => {
89
143
  headers: { 'content-type': 'application/json' },
90
144
  body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
91
145
  });
92
- if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
146
+ if (!res.ok) {
147
+ console.warn(`[Push relay] register-token failed status=${res.status}`);
148
+ return false;
149
+ }
150
+ registrationSuccessCache.set(cacheKey, true);
151
+ return true;
93
152
  } catch (error) {
94
153
  console.warn('[Push relay] register-token request failed:', error?.message ?? error);
154
+ return false;
95
155
  }
96
156
  };
97
157
 
@@ -149,6 +209,8 @@ export const createApnsRuntime = (deps) => {
149
209
  userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
150
210
  // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
151
211
  platform: entry.platform === 'android' ? 'android' : 'ios',
212
+ // App UI locale at last registration; missing → English titles (legacy tokens).
213
+ locale: normalizeApnsLocale(entry.locale),
152
214
  };
153
215
  })
154
216
  .filter(Boolean);
@@ -158,22 +220,47 @@ export const createApnsRuntime = (deps) => {
158
220
  // was the only registrant before Android/FCM existed.
159
221
  const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
160
222
 
161
- const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
223
+ const listUniqueTokenEntries = (store) => {
224
+ const unique = [];
225
+ const seen = new Map();
226
+ for (const record of Object.values(store.tokensBySession || {})) {
227
+ for (const entry of normalizeTokens(record)) {
228
+ const key = `${entry.deviceToken}\0${entry.platform}`;
229
+ const priorIndex = seen.get(key);
230
+ if (priorIndex == null) {
231
+ seen.set(key, unique.length);
232
+ unique.push(entry);
233
+ continue;
234
+ }
235
+ // Same device may register under multiple UI sessions; keep the freshest locale.
236
+ const prior = unique[priorIndex];
237
+ const priorSeen = typeof prior.lastSeenAt === 'number' ? prior.lastSeenAt : 0;
238
+ const nextSeen = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : 0;
239
+ if (nextSeen >= priorSeen) unique[priorIndex] = entry;
240
+ }
241
+ }
242
+ return unique;
243
+ };
244
+
245
+ const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, locale) => {
162
246
  if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
163
247
  const token = deviceToken.trim();
164
248
  const tokenPlatform = normalizePlatform(platform);
249
+ const tokenLocale = normalizeApnsLocale(locale);
165
250
  const now = Date.now();
166
251
 
167
252
  await persistTokenUpdate((current) => {
168
253
  const tokensBySession = { ...(current.tokensBySession || {}) };
169
254
  const existing = normalizeTokens(tokensBySession[uiSessionToken]);
255
+ const prior = existing.find((entry) => entry.deviceToken === token);
170
256
  const filtered = existing.filter((entry) => entry.deviceToken !== token);
171
257
  filtered.unshift({
172
258
  deviceToken: token,
173
- createdAt: now,
259
+ createdAt: typeof prior?.createdAt === 'number' ? prior.createdAt : now,
174
260
  lastSeenAt: now,
175
261
  userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
176
262
  platform: tokenPlatform,
263
+ locale: tokenLocale,
177
264
  });
178
265
  tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
179
266
  return { version: APNS_TOKENS_VERSION, tokensBySession };
@@ -316,8 +403,9 @@ export const createApnsRuntime = (deps) => {
316
403
  'apns-push-type': 'alert',
317
404
  'apns-priority': '10',
318
405
  };
319
- // collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
320
- const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined;
406
+ // collapse-id dedups like web-push tags; APNs caps it at 64 UTF-8 bytes.
407
+ // Keep legal values; omit anything over the limit (do not truncate mid-codepoint).
408
+ const collapseId = apnsCollapseId(config.tag);
321
409
  if (collapseId) headers['apns-collapse-id'] = collapseId;
322
410
 
323
411
  let req;
@@ -363,61 +451,83 @@ export const createApnsRuntime = (deps) => {
363
451
  req.end(body);
364
452
  });
365
453
 
366
- // Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
367
- // each user's server — so users configure nothing. The server just POSTs device tokens +
368
- // generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
369
- // is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
370
- const resolveRelayConfig = () => {
454
+ // Relay mode (default): the single APNs key lives in the isolated Push process on the same
455
+ // Relay origin (`/v1/push/*`), not on each user's server — so users configure nothing. The
456
+ // server just POSTs device tokens + generic text; Push signs + sends and reports which tokens
457
+ // to drop. Direct mode (below) is the fallback for self-hosters who set OPENCHAMBER_APNS_*
458
+ // and disable the relay.
459
+ const buildRelayConfig = (url) => ({
460
+ url,
461
+ registerUrl: url.replace(/\/send$/, '/register-token'),
462
+ environment:
463
+ (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
464
+ ? 'production'
465
+ : 'sandbox',
466
+ });
467
+
468
+ const resolveRelayConfig = async () => {
371
469
  if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
372
- const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
373
- return {
374
- url,
375
- registerUrl: url.replace(/\/send$/, '/register-token'),
376
- environment:
377
- (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
378
- ? 'production'
379
- : 'sandbox',
380
- };
470
+ const explicit = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL');
471
+ if (explicit) return buildRelayConfig(explicit);
472
+
473
+ let relayWsUrl = DEFAULT_RELAY_URL;
474
+ if (typeof resolveEffectiveRelayUrl === 'function') {
475
+ try {
476
+ const resolved = await resolveEffectiveRelayUrl();
477
+ if (typeof resolved === 'string' && resolved.trim()) {
478
+ if (derivePushSendUrlFromRelayUrl(resolved)) relayWsUrl = resolved.trim();
479
+ else console.warn('[Push relay] resolver returned a non-WebSocket URL; falling back to default');
480
+ }
481
+ } catch {
482
+ console.warn('[Push relay] resolver failed; falling back to default');
483
+ }
484
+ }
485
+ const url = derivePushSendUrlFromRelayUrl(relayWsUrl) || derivePushSendUrlFromRelayUrl(DEFAULT_RELAY_URL);
486
+ if (!url) return null;
487
+ return buildRelayConfig(url);
381
488
  };
382
489
 
383
490
  const sendViaRelay = async (deviceTokens, payload, relay) => {
384
- const tokens = deviceTokens.slice(0, 100);
491
+ if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return;
385
492
  const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
386
493
  const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
387
- const ts = Date.now();
388
- // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
389
- const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
390
- const requestBody = JSON.stringify({
391
- tokens,
392
- title,
393
- body: typeof payload?.body === 'string' ? payload.body : '',
394
- badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
395
- collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
396
- env: relay.environment,
397
- data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
398
- publicKeyJwk: relayPublicJwk(publicJwk),
399
- ts,
400
- sig,
401
- });
402
- try {
403
- const res = await fetch(relay.url, {
404
- method: 'POST',
405
- headers: { 'content-type': 'application/json' },
406
- body: requestBody,
494
+ for (let start = 0; start < deviceTokens.length; start += MAX_RELAY_SEND_TOKENS) {
495
+ const tokens = deviceTokens.slice(start, start + MAX_RELAY_SEND_TOKENS);
496
+ const ts = Date.now();
497
+ // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
498
+ const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
499
+ const requestBody = JSON.stringify({
500
+ tokens,
501
+ title,
502
+ body: typeof payload?.body === 'string' ? payload.body : '',
503
+ badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
504
+ collapseId: apnsCollapseId(payload?.tag),
505
+ env: relay.environment,
506
+ data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
507
+ publicKeyJwk: relayPublicJwk(publicJwk),
508
+ ts,
509
+ sig,
407
510
  });
408
- if (!res.ok) {
409
- console.warn(`[APNs relay] send failed status=${res.status}`);
410
- return;
411
- }
412
- const data = await res.json().catch(() => null);
413
- const results = Array.isArray(data?.results) ? data.results : [];
414
- for (const result of results) {
415
- if (result && result.drop === true && typeof result.token === 'string') {
416
- await removeApnsTokenFromAllSessions(result.token);
511
+ try {
512
+ const res = await fetch(relay.url, {
513
+ method: 'POST',
514
+ headers: { 'content-type': 'application/json' },
515
+ body: requestBody,
516
+ });
517
+ if (!res.ok) {
518
+ console.warn(`[APNs relay] send failed status=${res.status}`);
519
+ continue;
417
520
  }
521
+ const data = await res.json().catch(() => null);
522
+ const results = Array.isArray(data?.results) ? data.results : [];
523
+ for (const result of results) {
524
+ if (result && result.drop === true && typeof result.token === 'string') {
525
+ await removeApnsTokenFromAllSessions(result.token);
526
+ }
527
+ }
528
+ } catch (error) {
529
+ console.warn('[APNs relay] request failed:', error?.message ?? error);
418
530
  }
419
- } catch (error) {
420
- console.warn('[APNs relay] request failed:', error?.message ?? error);
421
531
  }
422
532
  };
423
533
 
@@ -473,26 +583,79 @@ export const createApnsRuntime = (deps) => {
473
583
  // background push for short responses. Instead we always send, and rely on iOS to NOT
474
584
  // display the alert while the app is foreground (presentationOptions: [] in
475
585
  // capacitor.config) — so there is no notification when the app is active, with no race.
476
- const sendApnsToAllUiSessions = async (payload, _options = {}) => {
586
+ const reRegisterAllTokens = async () => {
587
+ const relay = await resolveRelayConfig();
588
+ if (!relay) {
589
+ return { attempted: 0, succeeded: 0, failed: 0 };
590
+ }
477
591
  const store = await readTokensFromDisk();
478
- const deviceTokens = [];
479
- const seen = new Set();
480
- for (const record of Object.values(store.tokensBySession || {})) {
481
- for (const entry of normalizeTokens(record)) {
482
- if (!seen.has(entry.deviceToken)) {
483
- seen.add(entry.deviceToken);
484
- deviceTokens.push(entry.deviceToken);
485
- }
592
+ const entries = listUniqueTokenEntries(store);
593
+ boundRegistrationCache(relay.registerUrl, entries);
594
+ const outcomes = await mapWithBoundedConcurrency(
595
+ entries,
596
+ RELAY_REGISTER_CONCURRENCY,
597
+ (entry) => registerTokenWithRelay(entry.deviceToken, entry.platform, relay),
598
+ );
599
+ let succeeded = 0;
600
+ let failed = 0;
601
+ for (const ok of outcomes) {
602
+ if (ok) succeeded += 1;
603
+ else failed += 1;
604
+ }
605
+ return { attempted: entries.length, succeeded, failed };
606
+ };
607
+
608
+ // Group ready device tokens by app locale so each batch can carry a localized title.
609
+ // Relay signatures cover title, so mixed-locale tokens must not share one send body.
610
+ const groupTokensByLocale = (entries) => {
611
+ const groups = new Map();
612
+ for (const entry of entries) {
613
+ const locale = entry?.locale ? normalizeApnsLocale(entry.locale) : APNS_DEFAULT_LOCALE;
614
+ const token = entry?.deviceToken;
615
+ if (typeof token !== 'string' || token.length === 0) continue;
616
+ let list = groups.get(locale);
617
+ if (!list) {
618
+ list = [];
619
+ groups.set(locale, list);
486
620
  }
621
+ list.push(token);
487
622
  }
488
- if (deviceTokens.length === 0) return;
623
+ return groups;
624
+ };
489
625
 
490
- const relay = resolveRelayConfig();
626
+ const sendApnsToAllUiSessions = async (payload, _options = {}) => {
627
+ const store = await readTokensFromDisk();
628
+ const uniqueEntries = listUniqueTokenEntries(store);
629
+ if (uniqueEntries.length === 0) return;
630
+
631
+ const relay = await resolveRelayConfig();
491
632
  if (relay) {
492
- await sendViaRelay(deviceTokens, payload, relay);
633
+ boundRegistrationCache(relay.registerUrl, uniqueEntries);
634
+ const outcomes = await mapWithBoundedConcurrency(
635
+ uniqueEntries,
636
+ RELAY_REGISTER_CONCURRENCY,
637
+ async (entry) => {
638
+ const registered = await registerTokenWithRelay(entry.deviceToken, entry.platform, relay);
639
+ return registered ? entry : null;
640
+ },
641
+ );
642
+ const readyEntries = [];
643
+ const readySeen = new Set();
644
+ for (const entry of outcomes) {
645
+ if (!entry?.deviceToken || readySeen.has(entry.deviceToken)) continue;
646
+ readySeen.add(entry.deviceToken);
647
+ readyEntries.push(entry);
648
+ }
649
+ if (readyEntries.length === 0) return;
650
+ for (const [locale, tokens] of groupTokensByLocale(readyEntries)) {
651
+ await sendViaRelay(tokens, localizeApnsPayload(payload, locale), relay);
652
+ }
493
653
  return;
494
654
  }
495
- await sendViaDirectApns(deviceTokens, payload);
655
+ const byLocale = groupTokensByLocale(uniqueEntries);
656
+ for (const [locale, deviceTokens] of byLocale) {
657
+ await sendViaDirectApns(deviceTokens, localizeApnsPayload(payload, locale));
658
+ }
496
659
  };
497
660
 
498
661
  return {
@@ -500,6 +663,7 @@ export const createApnsRuntime = (deps) => {
500
663
  removeApnsToken,
501
664
  removeApnsTokenFromAllSessions,
502
665
  sendApnsToAllUiSessions,
666
+ reRegisterAllTokens,
503
667
  resolveApnsConfig,
504
668
  // exposed for tests
505
669
  signApnsJwt,