@openchambery/web 1.19.0-beta.9 → 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.
- package/dist/assets/AgentAvatar-DgGHpQoD.js +1 -0
- package/dist/assets/AssistantView-WZ8qXOm6.js +1 -0
- package/dist/assets/{ChatContainer-Dvicpi3T.js → ChatContainer-CPOlhHJn.js} +41 -43
- package/dist/assets/ContextPanel-ClzJl6RQ.js +288 -0
- package/dist/assets/{DiagramView-Ct9M_fMh.js → DiagramView-BFE0XxUH.js} +1 -1
- package/dist/assets/{FadeInOnReveal-By2F9vcI.js → FadeInOnReveal-BpIMrqet.js} +1 -1
- package/dist/assets/{Icon-DpjYnXxn.js → Icon-Bs6GJIoO.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-CTTZH1DE.js → MarkdownRendererImpl-BisXjTdJ.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-BMQiOv_u.js → MobileDetailNavigation-DRiFdQXq.js} +1 -1
- package/dist/assets/{MobileShareBridge-D5J6Qnvn.js → MobileShareBridge-D-kwfbug.js} +2 -2
- package/dist/assets/MobileSurface-NsB1sKld.js +2 -0
- package/dist/assets/{MultiRunWindow-Bw_vmN_V.js → MultiRunWindow-C_Tbyfco.js} +1 -1
- package/dist/assets/{OnboardingScreen-BKmuT7AL.js → OnboardingScreen-n85WAcBc.js} +1 -1
- package/dist/assets/OverlayScrollbar-VZKKu-cu.js +2 -0
- package/dist/assets/{SettingsView-BcFNPdSl.js → SettingsView-BOrARuHr.js} +10 -10
- package/dist/assets/{SettingsWindow-5QE3UEs3.js → SettingsWindow-BmegFGj-.js} +1 -1
- package/dist/assets/{TerminalView-DyW2LZuM.js → TerminalView-BcFMX_mg.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CZPvZV_w.js → ToolOutputDialog-DN2IYmqU.js} +4 -4
- package/dist/assets/appThemeRegistry-7v5TIiNK.js +2 -0
- package/dist/assets/{button-C7OXr4ae.js → button-BkTMa7Qj.js} +1 -1
- package/dist/assets/{clipboard-DWuULPzo.js → clipboard-BL4PvBYZ.js} +1 -1
- package/dist/assets/{collapsible-DaEya6b6.js → collapsible-CVL-5iEB.js} +1 -1
- package/dist/assets/{desktopHosts-Txp4xOVQ.js → desktopHosts-Dzx3McaD.js} +1 -1
- package/dist/assets/{desktopRelayRestore-C_FSEHKM.js → desktopRelayRestore-B9S0Rp5s.js} +1 -1
- package/dist/assets/dialog-CD0j-atu.js +1 -0
- package/dist/assets/{es-BX0Vm3Ac.js → es-C-RVtNF4.js} +2 -2
- package/dist/assets/{fileQueries-B_fTkH6s.js → fileQueries-_VUJM8AE.js} +1 -1
- package/dist/assets/{fr-CuPzXTYx.js → fr-C6VZ9yt-.js} +4 -4
- package/dist/assets/{gitApi-DAUlmpqX.js → gitApi-CxPLKmZ2.js} +3 -3
- package/dist/assets/{input-BzYA603C.js → input-Ca2tUhgN.js} +1 -1
- package/dist/assets/insertionBoundaries-2Mzsx_VV.js +4 -0
- package/dist/assets/{ja-DGtzjrFO.js → ja-BdksT20v.js} +3 -3
- package/dist/assets/{ko-DZ9Di0je.js → ko-CvsczTmf.js} +3 -3
- package/dist/assets/main-C1IdVCQf.js +29 -0
- package/dist/assets/{main-IaZbd8eq.js → main-dGakBRsd.js} +2 -2
- package/dist/assets/{markdown-worker-W06-MhF5.js → markdown-worker-9Sn2jsDp.js} +1 -1
- package/dist/assets/{markdownTheme-B89dbj3N.js → markdownTheme-DidS-jC5.js} +1 -1
- package/dist/assets/{miniChat-Byg-MsGQ.js → miniChat-C8rpOcNP.js} +2 -2
- package/dist/assets/mobile-Dm22ebC9.js +2 -0
- package/dist/assets/multirun-ColPhFw1.js +4 -0
- package/dist/assets/{persistence-6sclr7B6.js → persistence-aCOlo6ew.js} +2 -2
- package/dist/assets/pl-C84UGmMr.js +15 -0
- package/dist/assets/platform-ulFAVx1H.js +2 -0
- package/dist/assets/projectMeta-CKI_msIR.js +3 -0
- package/dist/assets/{pt-BR-Brf4RMsw.js → pt-BR-DISmT3zF.js} +2 -2
- package/dist/assets/radio-DKNOd1MW.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-B8ly-dZn.js +2 -0
- package/dist/assets/renderMobileApp-OAlYU54b.js +23 -0
- package/dist/assets/{runtimeConfig-CMLxQH75.js → runtimeConfig-QFdp6Itq.js} +1 -1
- package/dist/assets/runtimeEndpointReset-MzR_-tYR.js +4 -0
- package/dist/assets/{shikiHighlight-g-Djz_ic.js → shikiHighlight-C7KkxQ9i.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-nLsPC06f.js → sortable-tabs-strip-Bl26ebY_.js} +1 -1
- package/dist/assets/src-BwToVDxi.css +2 -0
- package/dist/assets/{store-DQY8Fkb0.js → store-Dge7C5E4.js} +4 -4
- package/dist/assets/{terminalApi-BpPeXWIu.js → terminalApi-HsIEks5U.js} +1 -1
- package/dist/assets/{tooltip-D_orJMYd.js → tooltip-GC3dg3gO.js} +1 -1
- package/dist/assets/{uk-C-wmaPTi.js → uk-Ba5HBTQU.js} +2 -2
- package/dist/assets/{url-BsaBG5-1.js → url-BQHM4bC4.js} +1 -1
- package/dist/assets/{useAppFontEffects-DcZfHbUS.js → useAppFontEffects-D0FmY4C_.js} +5 -5
- package/dist/assets/{useAssistantUIStore-BHsHXCKE.js → useAssistantUIStore-DxZsjSPQ.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-FEUxMDnk.js → useDesktopWindowControlsLayout-Ce9FSwxa.js} +1 -1
- package/dist/assets/useEffectiveDirectory-C5CrdVlr.js +1 -0
- package/dist/assets/{useFontPreferences-ckzpXQaJ.js → useFontPreferences-DaFspdDd.js} +1 -1
- package/dist/assets/useMobileNavigationStore-jKG6OU5F.js +475 -0
- package/dist/assets/useSessionAutoCleanup-C6GD9W2z.js +1 -0
- package/dist/assets/{useUIStore-DzwCfRqr.js → useUIStore-Wyu09VUF.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-D6-XaqdP.js → useWorkerHighlightedLines-mc9J5erB.js} +1 -1
- package/dist/assets/{utils-B1EK5zKa.js → utils-CJdWZupK.js} +1 -1
- package/dist/assets/{zh-CN-DueRrIql.js → zh-CN-BZYwBvs-.js} +2 -2
- package/dist/assets/zh-TW-BsJuPsIi.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +3 -2
- package/server/index.js +12 -15
- package/server/lib/config-sync/DOCUMENTATION.md +6 -7
- package/server/lib/config-sync/engine.js +0 -8
- package/server/lib/config-sync/index.js +0 -7
- package/server/lib/config-sync/receiver.js +0 -35
- package/server/lib/config-sync/routes.js +8 -41
- package/server/lib/config-sync/routes.test.js +1 -38
- package/server/lib/notifications/APNS.md +53 -38
- package/server/lib/notifications/DOCUMENTATION.md +5 -4
- package/server/lib/notifications/apns-runtime.js +235 -71
- package/server/lib/notifications/apns-runtime.test.js +447 -0
- package/server/lib/notifications/apns-titles.js +212 -0
- package/server/lib/notifications/apns-titles.test.js +55 -0
- package/server/lib/notifications/routes.js +2 -1
- package/server/lib/notifications/runtime.js +5 -13
- package/server/lib/opencode/openchamber-routes.js +1 -7
- package/server/lib/opencode/settings-helpers.js +0 -3
- package/server/lib/opencode/shutdown-runtime.js +0 -2
- package/server/lib/relay/DOCUMENTATION.md +2 -2
- package/server/lib/relay/service.js +45 -3
- package/server/lib/relay/service.test.js +117 -0
- package/server/lib/relay/signing-key.js +1 -1
- package/server/lib/session-goal/DOCUMENTATION.md +3 -3
- package/server/lib/session-goal/runtime.js +3 -3
- package/server/lib/session-title/DOCUMENTATION.md +1 -2
- package/dist/assets/AgentAvatar-DDcjamcJ.js +0 -1
- package/dist/assets/AssistantView-BipKB0Xu.js +0 -1
- package/dist/assets/ContextPanel-DxaTJbBJ.js +0 -288
- package/dist/assets/MobileSurface-Bob51m6v.js +0 -2
- package/dist/assets/OverlayScrollbar-Ch3J6ASR.js +0 -2
- package/dist/assets/appThemeRegistry-Ds7o-iDC.js +0 -2
- package/dist/assets/dialog-DPS0W9eS.js +0 -1
- package/dist/assets/insertionBoundaries-DN03Ffgf.js +0 -1
- package/dist/assets/main-rv0Iw_-o.js +0 -29
- package/dist/assets/mobile-eAdQk_4x.js +0 -2
- package/dist/assets/multirun-lZV5lnAU.js +0 -4
- package/dist/assets/pl-BuCO3qb1.js +0 -15
- package/dist/assets/platform-CbeLpKPk.js +0 -2
- package/dist/assets/projectMeta-2o-eC3Cz.js +0 -3
- package/dist/assets/radio-CHY-FZIg.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-DuK-UF8v.js +0 -2
- package/dist/assets/renderMobileApp-B-UiIevb.js +0 -23
- package/dist/assets/runtimeEndpointReset-5XhUYqlE.js +0 -4
- package/dist/assets/src-RGaBtbGz.css +0 -2
- package/dist/assets/useEffectiveDirectory-BmTAJUVK.js +0 -1
- package/dist/assets/useMobileNavigationStore-DkUlpkW4.js +0 -475
- package/dist/assets/useSessionAutoCleanup-CQmXCiFr.js +0 -1
- package/dist/assets/zh-TW-6875X80V.js +0 -15
- package/server/lib/config-sync/credential-auth.js +0 -39
- package/server/lib/session-assist/DOCUMENTATION.md +0 -62
- package/server/lib/session-assist/runtime.js +0 -376
- 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
|
});
|