@openchambery/web 1.19.0-beta.8 → 1.19.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 (126) hide show
  1. package/dist/assets/AgentAvatar-DgGHpQoD.js +1 -0
  2. package/dist/assets/AssistantView-WZ8qXOm6.js +1 -0
  3. package/dist/assets/{ChatContainer-DluzgF-E.js → ChatContainer-CPOlhHJn.js} +39 -41
  4. package/dist/assets/ContextPanel-ClzJl6RQ.js +288 -0
  5. package/dist/assets/{DiagramView-D2LAd7Eb.js → DiagramView-BFE0XxUH.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-kZyWB8rW.js → MarkdownRendererImpl-BisXjTdJ.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-DS-wnuMK.js → MobileDetailNavigation-DRiFdQXq.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BqApUBr9.js → MobileShareBridge-D-kwfbug.js} +2 -2
  11. package/dist/assets/MobileSurface-NsB1sKld.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BWnWHGg7.js → MultiRunWindow-C_Tbyfco.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-BKmuT7AL.js → OnboardingScreen-n85WAcBc.js} +1 -1
  14. package/dist/assets/OverlayScrollbar-VZKKu-cu.js +2 -0
  15. package/dist/assets/{SettingsView-BSaU4EcO.js → SettingsView-BOrARuHr.js} +10 -10
  16. package/dist/assets/{SettingsWindow-D0WK0Ffo.js → SettingsWindow-BmegFGj-.js} +1 -1
  17. package/dist/assets/{TerminalView-CiRsjQ9Z.js → TerminalView-BcFMX_mg.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-7k2sfWCv.js → ToolOutputDialog-DN2IYmqU.js} +4 -4
  19. package/dist/assets/appThemeRegistry-7v5TIiNK.js +2 -0
  20. package/dist/assets/{button-C7OXr4ae.js → button-BkTMa7Qj.js} +1 -1
  21. package/dist/assets/{clipboard-DWuULPzo.js → clipboard-BL4PvBYZ.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-CD0j-atu.js +1 -0
  26. package/dist/assets/{es-BX0Vm3Ac.js → es-C-RVtNF4.js} +2 -2
  27. package/dist/assets/{fileQueries-B_fTkH6s.js → fileQueries-_VUJM8AE.js} +1 -1
  28. package/dist/assets/{fr-CuPzXTYx.js → fr-C6VZ9yt-.js} +4 -4
  29. package/dist/assets/{gitApi-0AwQOHWx.js → gitApi-CxPLKmZ2.js} +3 -3
  30. package/dist/assets/{input-BzYA603C.js → input-Ca2tUhgN.js} +1 -1
  31. package/dist/assets/insertionBoundaries-2Mzsx_VV.js +4 -0
  32. package/dist/assets/{ja-DGtzjrFO.js → ja-BdksT20v.js} +3 -3
  33. package/dist/assets/{ko-DZ9Di0je.js → ko-CvsczTmf.js} +3 -3
  34. package/dist/assets/main-C1IdVCQf.js +29 -0
  35. package/dist/assets/{main-DKVp0M_j.js → main-dGakBRsd.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-B3T83jpk.js → miniChat-C8rpOcNP.js} +2 -2
  39. package/dist/assets/mobile-Dm22ebC9.js +2 -0
  40. package/dist/assets/multirun-ColPhFw1.js +4 -0
  41. package/dist/assets/{persistence-6sclr7B6.js → persistence-aCOlo6ew.js} +2 -2
  42. package/dist/assets/pl-C84UGmMr.js +15 -0
  43. package/dist/assets/platform-ulFAVx1H.js +2 -0
  44. package/dist/assets/projectMeta-CKI_msIR.js +3 -0
  45. package/dist/assets/{pt-BR-Brf4RMsw.js → pt-BR-DISmT3zF.js} +2 -2
  46. package/dist/assets/radio-DKNOd1MW.js +1 -0
  47. package/dist/assets/renderElectronMiniChatApp-B8ly-dZn.js +2 -0
  48. package/dist/assets/renderMobileApp-OAlYU54b.js +23 -0
  49. package/dist/assets/{runtimeConfig-OpHdyZTE.js → runtimeConfig-QFdp6Itq.js} +1 -1
  50. package/dist/assets/runtimeEndpointReset-MzR_-tYR.js +4 -0
  51. package/dist/assets/{shikiHighlight-Bdg4P8Bj.js → shikiHighlight-C7KkxQ9i.js} +1 -1
  52. package/dist/assets/{sortable-tabs-strip-nLsPC06f.js → sortable-tabs-strip-Bl26ebY_.js} +1 -1
  53. package/dist/assets/src-BwToVDxi.css +2 -0
  54. package/dist/assets/{store-DQY8Fkb0.js → store-Dge7C5E4.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-Ba5HBTQU.js} +2 -2
  58. package/dist/assets/{url-BsaBG5-1.js → url-BQHM4bC4.js} +1 -1
  59. package/dist/assets/{useAppFontEffects-BGAIE8QJ.js → useAppFontEffects-D0FmY4C_.js} +5 -5
  60. package/dist/assets/{useAssistantUIStore-BHsHXCKE.js → useAssistantUIStore-DxZsjSPQ.js} +1 -1
  61. package/dist/assets/{useDesktopWindowControlsLayout-4to8Rxxt.js → useDesktopWindowControlsLayout-Ce9FSwxa.js} +1 -1
  62. package/dist/assets/useEffectiveDirectory-C5CrdVlr.js +1 -0
  63. package/dist/assets/{useFontPreferences-ckzpXQaJ.js → useFontPreferences-DaFspdDd.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-jKG6OU5F.js +475 -0
  65. package/dist/assets/useSessionAutoCleanup-C6GD9W2z.js +1 -0
  66. package/dist/assets/{useUIStore-DzwCfRqr.js → useUIStore-Wyu09VUF.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bai09fsR.js → useWorkerHighlightedLines-mc9J5erB.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-BZYwBvs-.js} +2 -2
  70. package/dist/assets/zh-TW-BsJuPsIi.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 +3 -2
  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-8RpsL3L1.js +0 -1
  102. package/dist/assets/ContextPanel-HI7zsCB-.js +0 -288
  103. package/dist/assets/MobileSurface-DUpc2Iot.js +0 -2
  104. package/dist/assets/OverlayScrollbar-Ch3J6ASR.js +0 -2
  105. package/dist/assets/appThemeRegistry-Z2k3u99d.js +0 -2
  106. package/dist/assets/dialog-DPS0W9eS.js +0 -1
  107. package/dist/assets/insertionBoundaries-B8h_Ths5.js +0 -1
  108. package/dist/assets/main-Bn86s9By.js +0 -29
  109. package/dist/assets/mobile-DAIrr5zs.js +0 -2
  110. package/dist/assets/multirun-CVHyiIS9.js +0 -4
  111. package/dist/assets/pl-BuCO3qb1.js +0 -15
  112. package/dist/assets/platform-CbeLpKPk.js +0 -2
  113. package/dist/assets/projectMeta-vXFAASGJ.js +0 -3
  114. package/dist/assets/radio-DW3ArwIj.js +0 -1
  115. package/dist/assets/renderElectronMiniChatApp-CbnE5CjL.js +0 -2
  116. package/dist/assets/renderMobileApp-CAnAmcjM.js +0 -23
  117. package/dist/assets/runtimeEndpointReset-CGrAX24S.js +0 -4
  118. package/dist/assets/src-RGaBtbGz.css +0 -2
  119. package/dist/assets/useEffectiveDirectory-D2qKwAfL.js +0 -1
  120. package/dist/assets/useMobileNavigationStore-LD-sghLP.js +0 -475
  121. package/dist/assets/useSessionAutoCleanup-BLQeRvag.js +0 -1
  122. package/dist/assets/zh-TW-6875X80V.js +0 -15
  123. package/server/lib/config-sync/credential-auth.js +0 -39
  124. package/server/lib/session-assist/DOCUMENTATION.md +0 -62
  125. package/server/lib/session-assist/runtime.js +0 -376
  126. package/server/lib/session-assist/runtime.test.js +0 -114
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  import { createApnsRuntime } from './apns-runtime.js';
5
+ import { resolveEffectiveRelayUrl } from '../relay/service.js';
5
6
 
6
7
  // A real P-256 key so the ES256 signing path (direct mode) runs for real.
7
8
  const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
@@ -38,6 +39,7 @@ const makeDeps = (overrides = {}) => {
38
39
  APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json',
39
40
  readSettingsFromDiskMigrated: vi.fn(async () => settings),
40
41
  writeSettingsToDisk: vi.fn(async (next) => { settings = next; }),
42
+ resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
41
43
  ...overrides,
42
44
  };
43
45
  };
@@ -69,6 +71,44 @@ afterEach(() => {
69
71
  vi.unstubAllGlobals();
70
72
  delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
71
73
  delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
74
+ delete process.env.OPENCHAMBER_RELAY_URL;
75
+ delete process.env.OPENCHAMBER_APNS_KEY_ID;
76
+ delete process.env.OPENCHAMBER_APNS_TEAM_ID;
77
+ delete process.env.OPENCHAMBER_APNS_P8;
78
+ delete process.env.OPENCHAMBER_APNS_BUNDLE_ID;
79
+ });
80
+
81
+ describe('apns runtime bundle ID', () => {
82
+ it('defaults to the current product bundle ID when env and settings omit it', async () => {
83
+ process.env.OPENCHAMBER_APNS_KEY_ID = 'KEY123';
84
+ process.env.OPENCHAMBER_APNS_TEAM_ID = 'TEAM123';
85
+ process.env.OPENCHAMBER_APNS_P8 = P8;
86
+ const runtime = createApnsRuntime(makeDeps());
87
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.yee94.openchamber' });
88
+ });
89
+
90
+ it('prefers OPENCHAMBER_APNS_BUNDLE_ID over the default and settings', async () => {
91
+ process.env.OPENCHAMBER_APNS_BUNDLE_ID = 'com.example.app';
92
+ const runtime = createApnsRuntime(
93
+ makeDeps({
94
+ readSettingsFromDiskMigrated: vi.fn(async () => ({
95
+ apnsConfig: { ...APNS_CONFIG, bundleId: 'com.example.settings' },
96
+ })),
97
+ }),
98
+ );
99
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.example.app' });
100
+ });
101
+
102
+ it('prefers settings.apnsConfig.bundleId over the default', async () => {
103
+ const runtime = createApnsRuntime(
104
+ makeDeps({
105
+ readSettingsFromDiskMigrated: vi.fn(async () => ({
106
+ apnsConfig: { ...APNS_CONFIG, bundleId: 'com.example.settings' },
107
+ })),
108
+ }),
109
+ );
110
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.example.settings' });
111
+ });
72
112
  });
73
113
 
74
114
  describe('apns runtime relay mode (default)', () => {
@@ -151,6 +191,36 @@ describe('apns runtime relay mode (default)', () => {
151
191
  await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
152
192
  expect(fetchMock).not.toHaveBeenCalled();
153
193
  });
194
+
195
+ it('omits relay collapseId when CJK/emoji tags exceed 64 UTF-8 bytes', async () => {
196
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
197
+ vi.stubGlobal('fetch', fetchMock);
198
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
199
+ const runtime = createApnsRuntime(makeDeps());
200
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
201
+
202
+ const sendTag = async (tag) => {
203
+ fetchMock.mockClear();
204
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag });
205
+ const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
206
+ return Object.prototype.hasOwnProperty.call(sent, 'collapseId') ? sent.collapseId : undefined;
207
+ };
208
+
209
+ const cjkKeep = '会话'.repeat(10); // 60 bytes
210
+ const cjkOmit = '会话'.repeat(11); // 66 bytes
211
+ const emojiKeep = '🎉'.repeat(16); // 64 bytes
212
+ const emojiOmit = '🎉'.repeat(17); // 68 bytes
213
+ expect(Buffer.byteLength(cjkKeep, 'utf8')).toBeLessThanOrEqual(64);
214
+ expect(Buffer.byteLength(cjkOmit, 'utf8')).toBeGreaterThan(64);
215
+ expect(Buffer.byteLength(emojiKeep, 'utf8')).toBe(64);
216
+ expect(Buffer.byteLength(emojiOmit, 'utf8')).toBeGreaterThan(64);
217
+
218
+ expect(await sendTag('ready-x')).toBe('ready-x');
219
+ expect(await sendTag(cjkKeep)).toBe(cjkKeep);
220
+ expect(await sendTag(cjkOmit)).toBeUndefined();
221
+ expect(await sendTag(emojiKeep)).toBe(emojiKeep);
222
+ expect(await sendTag(emojiOmit)).toBeUndefined();
223
+ });
154
224
  });
155
225
 
156
226
  describe('apns runtime direct fallback (relay disabled)', () => {
@@ -186,6 +256,57 @@ describe('apns runtime direct fallback (relay disabled)', () => {
186
256
  expect(targeted).toEqual(['tokenDirect']);
187
257
  });
188
258
 
259
+ it('keeps direct APNs collapse-id within 64 UTF-8 bytes and omits CJK/emoji over the limit', async () => {
260
+ process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
261
+ const collapseIds = [];
262
+ const http2 = {
263
+ connect: () => ({
264
+ on: () => {},
265
+ close: () => {},
266
+ request: (headers) => {
267
+ collapseIds.push(headers['apns-collapse-id']);
268
+ const listeners = {};
269
+ const req = {
270
+ on: (event, cb) => { listeners[event] = cb; return req; },
271
+ setEncoding: () => req,
272
+ end: () => {
273
+ queueMicrotask(() => {
274
+ listeners.response?.({ ':status': '200' });
275
+ listeners.end?.();
276
+ });
277
+ },
278
+ };
279
+ return req;
280
+ },
281
+ }),
282
+ };
283
+ const runtime = createApnsRuntime(
284
+ makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
285
+ );
286
+ await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
287
+
288
+ const sendTag = async (tag) => {
289
+ collapseIds.length = 0;
290
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag });
291
+ return collapseIds[0];
292
+ };
293
+
294
+ const cjkKeep = '会话'.repeat(10); // 60 bytes
295
+ const cjkOmit = '会话'.repeat(11); // 66 bytes
296
+ const emojiKeep = '🎉'.repeat(16); // 64 bytes
297
+ const emojiOmit = '🎉'.repeat(17); // 68 bytes
298
+ expect(Buffer.byteLength(cjkKeep, 'utf8')).toBeLessThanOrEqual(64);
299
+ expect(Buffer.byteLength(cjkOmit, 'utf8')).toBeGreaterThan(64);
300
+ expect(Buffer.byteLength(emojiKeep, 'utf8')).toBe(64);
301
+ expect(Buffer.byteLength(emojiOmit, 'utf8')).toBeGreaterThan(64);
302
+
303
+ expect(await sendTag('ready-x')).toBe('ready-x');
304
+ expect(await sendTag(cjkKeep)).toBe(cjkKeep);
305
+ expect(await sendTag(cjkOmit)).toBeUndefined();
306
+ expect(await sendTag(emojiKeep)).toBe(emojiKeep);
307
+ expect(await sendTag(emojiOmit)).toBeUndefined();
308
+ });
309
+
189
310
  it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => {
190
311
  const runtime = createApnsRuntime(makeDeps());
191
312
  const parts = runtime.signApnsJwt(APNS_CONFIG).split('.');
@@ -193,4 +314,330 @@ describe('apns runtime direct fallback (relay disabled)', () => {
193
314
  expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' });
194
315
  expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123');
195
316
  });
317
+
318
+ it('does not register tokens with the push relay in direct mode', async () => {
319
+ process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
320
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
321
+ vi.stubGlobal('fetch', fetchMock);
322
+ const http2 = {
323
+ connect: () => ({
324
+ on: () => {},
325
+ close: () => {},
326
+ request: (headers) => {
327
+ const listeners = {};
328
+ const req = {
329
+ on: (event, cb) => { listeners[event] = cb; return req; },
330
+ setEncoding: () => req,
331
+ end: () => {
332
+ queueMicrotask(() => {
333
+ listeners.response?.({ ':status': '200' });
334
+ listeners.end?.();
335
+ });
336
+ },
337
+ };
338
+ return req;
339
+ },
340
+ }),
341
+ };
342
+ const runtime = createApnsRuntime(
343
+ makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
344
+ );
345
+ await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
346
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
347
+ await expect(runtime.reRegisterAllTokens()).resolves.toEqual({ attempted: 0, succeeded: 0, failed: 0 });
348
+ expect(fetchMock).not.toHaveBeenCalled();
349
+ });
350
+ });
351
+
352
+ describe('apns runtime push relay derivation and re-register', () => {
353
+ const registerUrls = (fetchMock) =>
354
+ fetchMock.mock.calls.filter(isRegister).map(([url]) => url);
355
+ const sendUrls = (fetchMock) =>
356
+ fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send')).map(([url]) => url);
357
+
358
+ it('prefers OPENCHAMBER_PUSH_RELAY_URL over derived relay URLs', async () => {
359
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws';
360
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://explicit.test/v1/push/send';
361
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
362
+ vi.stubGlobal('fetch', fetchMock);
363
+
364
+ let settings = { privateRelay: { enabled: true, relayUrl: 'wss://settings.example/ws' } };
365
+ const runtime = createApnsRuntime(makeDeps({
366
+ readSettingsFromDiskMigrated: async () => settings,
367
+ writeSettingsToDisk: async (next) => { settings = next; },
368
+ resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
369
+ }));
370
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
371
+ expect(registerUrls(fetchMock)).toEqual(['https://explicit.test/v1/push/register-token']);
372
+ });
373
+
374
+ it('derives the push send URL from settings then env relay URLs', async () => {
375
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
376
+ vi.stubGlobal('fetch', fetchMock);
377
+
378
+ let settings = { privateRelay: { enabled: true, relayUrl: 'wss://settings.example/custom' } };
379
+ const runtime = createApnsRuntime(makeDeps({
380
+ readSettingsFromDiskMigrated: async () => settings,
381
+ writeSettingsToDisk: async (next) => { settings = next; },
382
+ resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
383
+ }));
384
+
385
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
386
+ expect(registerUrls(fetchMock)).toEqual(['https://settings.example/v1/push/register-token']);
387
+
388
+ fetchMock.mockClear();
389
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws';
390
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
391
+ expect(registerUrls(fetchMock)).toEqual(['https://env.example/v1/push/register-token']);
392
+ });
393
+
394
+ it('defaults to the hosted relay push endpoint', async () => {
395
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
396
+ vi.stubGlobal('fetch', fetchMock);
397
+ const runtime = createApnsRuntime(makeDeps());
398
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
399
+ expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
400
+ });
401
+
402
+ it('re-registers persisted tokens against the current relay', async () => {
403
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
404
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
405
+ vi.stubGlobal('fetch', fetchMock);
406
+ const runtime = createApnsRuntime(makeDeps());
407
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
408
+ await runtime.addOrUpdateApnsToken('s2', 'tokenB');
409
+ await runtime.addOrUpdateApnsToken('s3', 'tokenA');
410
+
411
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
412
+ fetchMock.mockClear();
413
+ await expect(runtime.reRegisterAllTokens()).resolves.toEqual({
414
+ attempted: 2,
415
+ succeeded: 2,
416
+ failed: 0,
417
+ });
418
+ expect(registerUrls(fetchMock).every((url) => url === 'https://relay-b.test/v1/push/register-token')).toBe(true);
419
+ expect(registerUrls(fetchMock)).toHaveLength(2);
420
+ expect(sendUrls(fetchMock)).toEqual([]);
421
+ });
422
+
423
+ it('registers with the new relay before the first send after a switch', async () => {
424
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
425
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
426
+ vi.stubGlobal('fetch', fetchMock);
427
+ const runtime = createApnsRuntime(makeDeps());
428
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
429
+
430
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
431
+ fetchMock.mockClear();
432
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
433
+
434
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
435
+ 'https://relay-b.test/v1/push/register-token',
436
+ 'https://relay-b.test/v1/push/send',
437
+ ]);
438
+ });
439
+
440
+ it('sends only tokens that registered successfully after a partial failure', async () => {
441
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
442
+ const fetchMock = vi.fn(async (url, init) => {
443
+ if (String(url).endsWith('/register-token')) {
444
+ const body = JSON.parse(init.body);
445
+ if (body.token === 'tokenB') return jsonResponse({ error: true }, 500);
446
+ return jsonResponse({ ok: true });
447
+ }
448
+ return jsonResponse({ results: [] });
449
+ });
450
+ vi.stubGlobal('fetch', fetchMock);
451
+ const runtime = createApnsRuntime(makeDeps());
452
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
453
+ await runtime.addOrUpdateApnsToken('s2', 'tokenB');
454
+
455
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
456
+ fetchMock.mockClear();
457
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
458
+
459
+ const registerBodies = fetchMock.mock.calls
460
+ .filter(isRegister)
461
+ .map(([, init]) => JSON.parse(init.body).token)
462
+ .sort();
463
+ expect(registerBodies).toEqual(['tokenA', 'tokenB']);
464
+ expect(sendUrls(fetchMock)).toEqual(['https://relay-b.test/v1/push/send']);
465
+ expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
466
+
467
+ fetchMock.mockClear();
468
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
469
+ const retryRegister = fetchMock.mock.calls.filter(isRegister).map(([, init]) => JSON.parse(init.body).token);
470
+ expect(retryRegister).toEqual(['tokenB']);
471
+ expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
472
+ });
473
+
474
+ it('warns without leaking the URL when the resolver returns a non-WebSocket URL, then falls back to default', async () => {
475
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
476
+ vi.stubGlobal('fetch', fetchMock);
477
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
478
+ const leaked = 'https://evil.example/v1/push/send?token=secret';
479
+ const runtime = createApnsRuntime(makeDeps({
480
+ resolveEffectiveRelayUrl: async () => leaked,
481
+ }));
482
+
483
+ try {
484
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
485
+ expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
486
+ expect(warn).toHaveBeenCalled();
487
+ const messages = warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
488
+ expect(messages).toContain('falling back to default');
489
+ expect(messages).not.toContain(leaked);
490
+ expect(messages).not.toContain('evil.example');
491
+ expect(messages).not.toContain('secret');
492
+ expect(messages).not.toContain('token=');
493
+ } finally {
494
+ warn.mockRestore();
495
+ }
496
+ });
497
+
498
+ it('warns without leaking the error when the resolver throws, then falls back to default', async () => {
499
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
500
+ vi.stubGlobal('fetch', fetchMock);
501
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
502
+ const leaked = 'wss://evil.example/ws?token=secret';
503
+ const runtime = createApnsRuntime(makeDeps({
504
+ resolveEffectiveRelayUrl: async () => {
505
+ throw new Error(leaked);
506
+ },
507
+ }));
508
+
509
+ try {
510
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
511
+ expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
512
+ expect(warn).toHaveBeenCalled();
513
+ const messages = warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
514
+ expect(messages).toContain('falling back to default');
515
+ expect(messages).not.toContain(leaked);
516
+ expect(messages).not.toContain('evil.example');
517
+ expect(messages).not.toContain('secret');
518
+ expect(messages).not.toContain('token=');
519
+ } finally {
520
+ warn.mockRestore();
521
+ }
522
+ });
523
+
524
+ it('sends more than 100 tokens in batches of at most 100, each token exactly once', async () => {
525
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
526
+ const fetchMock = vi.fn(async (url) =>
527
+ String(url).endsWith('/register-token')
528
+ ? jsonResponse({ ok: true })
529
+ : jsonResponse({ results: [] }),
530
+ );
531
+ vi.stubGlobal('fetch', fetchMock);
532
+ const runtime = createApnsRuntime(makeDeps());
533
+ const tokens = Array.from({ length: 101 }, (_, i) => `token${String(i).padStart(3, '0')}`);
534
+ for (const [i, token] of tokens.entries()) {
535
+ await runtime.addOrUpdateApnsToken(`s${i}`, token);
536
+ }
537
+
538
+ fetchMock.mockClear();
539
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
540
+
541
+ const sendCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send'));
542
+ expect(sendCalls).toHaveLength(2);
543
+ const batches = sendCalls.map(([, init]) => JSON.parse(init.body).tokens);
544
+ expect(batches.every((batch) => Array.isArray(batch) && batch.length > 0 && batch.length <= 100)).toBe(true);
545
+ const sent = batches.flat();
546
+ expect(sent).toHaveLength(101);
547
+ expect(new Set(sent).size).toBe(101);
548
+ expect(new Set(sent)).toEqual(new Set(tokens));
549
+ for (const [, init] of sendCalls) {
550
+ const sentBody = JSON.parse(init.body);
551
+ const sendMessage = `${sentBody.ts}.${[...sentBody.tokens].sort().join(',')}.${sentBody.title}`;
552
+ expect(await verifyRelaySignature(sentBody.publicKeyJwk, sendMessage, sentBody.sig)).toBe(true);
553
+ }
554
+ });
555
+
556
+ it('continues remaining send batches when one batch fails', async () => {
557
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
558
+ let sendCount = 0;
559
+ const fetchMock = vi.fn(async (url) => {
560
+ if (String(url).endsWith('/register-token')) return jsonResponse({ ok: true });
561
+ sendCount += 1;
562
+ if (sendCount === 1) return jsonResponse({ error: true }, 500);
563
+ return jsonResponse({ results: [{ token: 'token100', ok: false, drop: true }] });
564
+ });
565
+ vi.stubGlobal('fetch', fetchMock);
566
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
567
+ const runtime = createApnsRuntime(makeDeps());
568
+ const tokens = Array.from({ length: 101 }, (_, i) => `token${String(i).padStart(3, '0')}`);
569
+ for (const [i, token] of tokens.entries()) {
570
+ await runtime.addOrUpdateApnsToken(`s${i}`, token);
571
+ }
572
+
573
+ try {
574
+ fetchMock.mockClear();
575
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
576
+ const sendCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send'));
577
+ expect(sendCalls).toHaveLength(2);
578
+ expect(sendCalls.map(([, init]) => JSON.parse(init.body).tokens).flat()).toHaveLength(101);
579
+
580
+ fetchMock.mockClear();
581
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
582
+ const retryTokens = fetchMock.mock.calls
583
+ .filter(([url]) => String(url).endsWith('/v1/push/send'))
584
+ .flatMap(([, init]) => JSON.parse(init.body).tokens);
585
+ expect(retryTokens).not.toContain('token100');
586
+ expect(retryTokens).toHaveLength(100);
587
+ } finally {
588
+ warn.mockRestore();
589
+ }
590
+ });
591
+
592
+ it('sends localized scenario titles grouped by stored token locale', async () => {
593
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
594
+ vi.stubGlobal('fetch', fetchMock);
595
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
596
+
597
+ const runtime = createApnsRuntime(makeDeps());
598
+ await runtime.addOrUpdateApnsToken('s1', 'tokenEn', undefined, 'ios', 'en');
599
+ await runtime.addOrUpdateApnsToken('s2', 'tokenZh', undefined, 'ios', 'zh-CN');
600
+ await runtime.addOrUpdateApnsToken('s3', 'tokenJa', undefined, 'android', 'ja');
601
+
602
+ fetchMock.mockClear();
603
+ await runtime.sendApnsToAllUiSessions({
604
+ type: 'ready',
605
+ sessionName: 'My session',
606
+ badge: 2,
607
+ tag: 'ready-x',
608
+ data: { sessionId: 'sess1' },
609
+ });
610
+
611
+ const sendBodies = fetchMock.mock.calls
612
+ .filter(isSend)
613
+ .map(([, init]) => JSON.parse(init.body));
614
+ expect(sendBodies).toHaveLength(3);
615
+
616
+ const byTitle = Object.fromEntries(sendBodies.map((body) => [body.title, body]));
617
+ expect(byTitle['Agent response is ready']?.tokens).toEqual(['tokenEn']);
618
+ expect(byTitle['智能体回复已就绪']?.tokens).toEqual(['tokenZh']);
619
+ expect(byTitle['エージェントの応答が準備できました']?.tokens).toEqual(['tokenJa']);
620
+ for (const body of sendBodies) {
621
+ expect(body.body).toBe('My session');
622
+ expect(body.badge).toBe(2);
623
+ const sendMessage = `${body.ts}.${[...body.tokens].sort().join(',')}.${body.title}`;
624
+ expect(await verifyRelaySignature(body.publicKeyJwk, sendMessage, body.sig)).toBe(true);
625
+ }
626
+ });
627
+
628
+ it('defaults missing token locale to English titles', async () => {
629
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
630
+ vi.stubGlobal('fetch', fetchMock);
631
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
632
+
633
+ const runtime = createApnsRuntime(makeDeps());
634
+ await runtime.addOrUpdateApnsToken('s1', 'tokenLegacy');
635
+
636
+ fetchMock.mockClear();
637
+ await runtime.sendApnsToAllUiSessions({ type: 'permission', sessionName: 'S' });
638
+
639
+ const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
640
+ expect(sent.title).toBe('Agent needs permission');
641
+ expect(sent.body).toBe('S');
642
+ });
196
643
  });