@openchambery/web 1.19.0-beta.35 → 1.19.0-beta.37

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 (44) hide show
  1. package/dist/assets/{AssistantView-IaH1kmtL.js → AssistantView-D5pDX9ES.js} +1 -1
  2. package/dist/assets/{ChatContainer-CN8nekZY.js → ChatContainer-DBuXfy4g.js} +31 -31
  3. package/dist/assets/{ContextPanel-CIv9CDdO.js → ContextPanel-RniXr46u.js} +3 -3
  4. package/dist/assets/{DiagramView-DBK_oQnr.js → DiagramView-BwNKhbsZ.js} +1 -1
  5. package/dist/assets/{MarkdownRendererImpl-mONEf7SR.js → MarkdownRendererImpl-wJgiOdis.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-CIn1W3V3.js → MobileDetailNavigation-Dl6KVNYY.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-CXp9Vfhp.js → MobileShareBridge-BxR0pE_m.js} +1 -1
  8. package/dist/assets/{MobileSurface-Bg2VihXc.js → MobileSurface-LhR2YNCl.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-lCNPscu-.js → MultiRunWindow-Cgg9u8VX.js} +1 -1
  10. package/dist/assets/{SettingsView-CKLn5zHk.js → SettingsView-CBtDTnbu.js} +1 -1
  11. package/dist/assets/{SettingsWindow-DdSU1aJ8.js → SettingsWindow-kBA9auJC.js} +1 -1
  12. package/dist/assets/{TerminalView-DBH_K8Ym.js → TerminalView-DhVu4pCB.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-DqTpXhaV.js → ToolOutputDialog-cDDD9uCL.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DjOsNU9m.js → appThemeRegistry-staJMB2J.js} +2 -2
  15. package/dist/assets/{gitApi-C-gMopTX.js → gitApi-CYDWoaIF.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-Dl86wE65.js → insertionBoundaries-DbiBrF9p.js} +1 -1
  17. package/dist/assets/{main-BQK5qC01.js → main-Dkf8xb0G.js} +3 -3
  18. package/dist/assets/{main-yqA5mVW0.js → main-cvGY60fq.js} +2 -2
  19. package/dist/assets/{miniChat-ryqA9hOe.js → miniChat-Cz1ftAm8.js} +2 -2
  20. package/dist/assets/{mobile-Cf1M12K7.js → mobile-C6Di4aP0.js} +2 -2
  21. package/dist/assets/{multirun-CgAKGa6a.js → multirun-Dp0YasTz.js} +1 -1
  22. package/dist/assets/{projectMeta-DDgFjb62.js → projectMeta-CVORtKSv.js} +1 -1
  23. package/dist/assets/{radio-BurHnKcg.js → radio-BwGc_8vf.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-DMh1IBDh.js → renderElectronMiniChatApp-DovEohIn.js} +2 -2
  25. package/dist/assets/{renderMobileApp-WqUAhzQG.js → renderMobileApp-DKt9BrQO.js} +5 -5
  26. package/dist/assets/{runtimeConfig-DE8K0bOi.js → runtimeConfig-ClXahK8y.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-D5gjdp03.js → runtimeEndpointReset-Wd0jJx7c.js} +1 -1
  28. package/dist/assets/{shikiHighlight-BOqky8UX.js → shikiHighlight-rNaL87ld.js} +1 -1
  29. package/dist/assets/{src-ILRsQd_W.css → src-Dc5_NNo2.css} +1 -1
  30. package/dist/assets/{useAppFontEffects-4WtryKPT.js → useAppFontEffects-BgJOiL9O.js} +2 -2
  31. package/dist/assets/{useDesktopWindowControlsLayout-DMHRm-1S.js → useDesktopWindowControlsLayout-BUMOckKt.js} +1 -1
  32. package/dist/assets/{useEffectiveDirectory-kTQjG3bM.js → useEffectiveDirectory-DmcTREeQ.js} +1 -1
  33. package/dist/assets/{useMobileNavigationStore-DC0j169I.js → useMobileNavigationStore-B5TOw3Mw.js} +1 -1
  34. package/dist/assets/{useSessionAutoCleanup-Ca5AKlM6.js → useSessionAutoCleanup-CZdJsOtU.js} +1 -1
  35. package/dist/assets/{useWorkerHighlightedLines-BPoLqoHK.js → useWorkerHighlightedLines-DaZyFcgv.js} +1 -1
  36. package/dist/index.html +3 -3
  37. package/dist/mini-chat.html +3 -3
  38. package/dist/mobile.html +3 -3
  39. package/package.json +1 -1
  40. package/server/index.js +8 -1
  41. package/server/lib/notifications/apns-runtime.js +122 -28
  42. package/server/lib/notifications/apns-runtime.test.js +159 -0
  43. package/server/lib/relay/service.js +45 -3
  44. package/server/lib/relay/service.test.js +117 -0
@@ -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,7 @@ 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;
72
75
  });
73
76
 
74
77
  describe('apns runtime relay mode (default)', () => {
@@ -193,4 +196,160 @@ describe('apns runtime direct fallback (relay disabled)', () => {
193
196
  expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' });
194
197
  expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123');
195
198
  });
199
+
200
+ it('does not register tokens with the push relay in direct mode', async () => {
201
+ process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
202
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
203
+ vi.stubGlobal('fetch', fetchMock);
204
+ const http2 = {
205
+ connect: () => ({
206
+ on: () => {},
207
+ close: () => {},
208
+ request: (headers) => {
209
+ const listeners = {};
210
+ const req = {
211
+ on: (event, cb) => { listeners[event] = cb; return req; },
212
+ setEncoding: () => req,
213
+ end: () => {
214
+ queueMicrotask(() => {
215
+ listeners.response?.({ ':status': '200' });
216
+ listeners.end?.();
217
+ });
218
+ },
219
+ };
220
+ return req;
221
+ },
222
+ }),
223
+ };
224
+ const runtime = createApnsRuntime(
225
+ makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
226
+ );
227
+ await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
228
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
229
+ await expect(runtime.reRegisterAllTokens()).resolves.toEqual({ attempted: 0, succeeded: 0, failed: 0 });
230
+ expect(fetchMock).not.toHaveBeenCalled();
231
+ });
232
+ });
233
+
234
+ describe('apns runtime push relay derivation and re-register', () => {
235
+ const registerUrls = (fetchMock) =>
236
+ fetchMock.mock.calls.filter(isRegister).map(([url]) => url);
237
+ const sendUrls = (fetchMock) =>
238
+ fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send')).map(([url]) => url);
239
+
240
+ it('prefers OPENCHAMBER_PUSH_RELAY_URL over derived relay URLs', async () => {
241
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws';
242
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://explicit.test/v1/push/send';
243
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
244
+ vi.stubGlobal('fetch', fetchMock);
245
+
246
+ let settings = { privateRelay: { enabled: true, relayUrl: 'wss://settings.example/ws' } };
247
+ const runtime = createApnsRuntime(makeDeps({
248
+ readSettingsFromDiskMigrated: async () => settings,
249
+ writeSettingsToDisk: async (next) => { settings = next; },
250
+ resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
251
+ }));
252
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
253
+ expect(registerUrls(fetchMock)).toEqual(['https://explicit.test/v1/push/register-token']);
254
+ });
255
+
256
+ it('derives the push send URL from settings then env relay URLs', async () => {
257
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
258
+ vi.stubGlobal('fetch', fetchMock);
259
+
260
+ let settings = { privateRelay: { enabled: true, relayUrl: 'wss://settings.example/custom' } };
261
+ const runtime = createApnsRuntime(makeDeps({
262
+ readSettingsFromDiskMigrated: async () => settings,
263
+ writeSettingsToDisk: async (next) => { settings = next; },
264
+ resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
265
+ }));
266
+
267
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
268
+ expect(registerUrls(fetchMock)).toEqual(['https://settings.example/v1/push/register-token']);
269
+
270
+ fetchMock.mockClear();
271
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws';
272
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
273
+ expect(registerUrls(fetchMock)).toEqual(['https://env.example/v1/push/register-token']);
274
+ });
275
+
276
+ it('defaults to the hosted relay push endpoint', async () => {
277
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
278
+ vi.stubGlobal('fetch', fetchMock);
279
+ const runtime = createApnsRuntime(makeDeps());
280
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
281
+ expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
282
+ });
283
+
284
+ it('re-registers persisted tokens against the current relay', async () => {
285
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
286
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
287
+ vi.stubGlobal('fetch', fetchMock);
288
+ const runtime = createApnsRuntime(makeDeps());
289
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
290
+ await runtime.addOrUpdateApnsToken('s2', 'tokenB');
291
+ await runtime.addOrUpdateApnsToken('s3', 'tokenA');
292
+
293
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
294
+ fetchMock.mockClear();
295
+ await expect(runtime.reRegisterAllTokens()).resolves.toEqual({
296
+ attempted: 2,
297
+ succeeded: 2,
298
+ failed: 0,
299
+ });
300
+ expect(registerUrls(fetchMock).every((url) => url === 'https://relay-b.test/v1/push/register-token')).toBe(true);
301
+ expect(registerUrls(fetchMock)).toHaveLength(2);
302
+ expect(sendUrls(fetchMock)).toEqual([]);
303
+ });
304
+
305
+ it('registers with the new relay before the first send after a switch', async () => {
306
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
307
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
308
+ vi.stubGlobal('fetch', fetchMock);
309
+ const runtime = createApnsRuntime(makeDeps());
310
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
311
+
312
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
313
+ fetchMock.mockClear();
314
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
315
+
316
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
317
+ 'https://relay-b.test/v1/push/register-token',
318
+ 'https://relay-b.test/v1/push/send',
319
+ ]);
320
+ });
321
+
322
+ it('sends only tokens that registered successfully after a partial failure', async () => {
323
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
324
+ const fetchMock = vi.fn(async (url, init) => {
325
+ if (String(url).endsWith('/register-token')) {
326
+ const body = JSON.parse(init.body);
327
+ if (body.token === 'tokenB') return jsonResponse({ error: true }, 500);
328
+ return jsonResponse({ ok: true });
329
+ }
330
+ return jsonResponse({ results: [] });
331
+ });
332
+ vi.stubGlobal('fetch', fetchMock);
333
+ const runtime = createApnsRuntime(makeDeps());
334
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
335
+ await runtime.addOrUpdateApnsToken('s2', 'tokenB');
336
+
337
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
338
+ fetchMock.mockClear();
339
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
340
+
341
+ const registerBodies = fetchMock.mock.calls
342
+ .filter(isRegister)
343
+ .map(([, init]) => JSON.parse(init.body).token)
344
+ .sort();
345
+ expect(registerBodies).toEqual(['tokenA', 'tokenB']);
346
+ expect(sendUrls(fetchMock)).toEqual(['https://relay-b.test/v1/push/send']);
347
+ expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
348
+
349
+ fetchMock.mockClear();
350
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
351
+ const retryRegister = fetchMock.mock.calls.filter(isRegister).map(([, init]) => JSON.parse(init.body).token);
352
+ expect(retryRegister).toEqual(['tokenB']);
353
+ expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
354
+ });
196
355
  });
@@ -54,6 +54,25 @@ export const canonicalizeRelayUrl = (value) => {
54
54
  }
55
55
  };
56
56
 
57
+ // Canonical ws(s) relay URL → Push send endpoint on the same host/port.
58
+ // Always `/v1/push/send`; never derived from window/location.
59
+ export const derivePushSendUrlFromRelayUrl = (value) => {
60
+ const canonical = canonicalizeRelayUrl(value);
61
+ if (!canonical) return null;
62
+ try {
63
+ const url = new URL(canonical);
64
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
65
+ url.pathname = '/v1/push/send';
66
+ url.username = '';
67
+ url.password = '';
68
+ url.hash = '';
69
+ url.search = '';
70
+ return url.toString();
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+
57
76
  const normalizeRelayUrl = (value) => {
58
77
  if (typeof value !== 'string') return DEFAULT_RELAY_URL;
59
78
  return canonicalizeRelayUrl(value) ?? DEFAULT_RELAY_URL;
@@ -63,12 +82,16 @@ const normalizeRelayUrl = (value) => {
63
82
  // your own Cloudflare account/domain). When set and valid it overrides the
64
83
  // stored setting entirely, so the host connection, the pairing offer, and the
65
84
  // status all point at it — clients then inherit it from the offer automatically.
66
- const envRelayUrlOverride = () => {
67
- const raw = process.env.OPENCHAMBER_RELAY_URL;
85
+ const envRelayUrlOverride = (env = process.env) => {
86
+ const raw = env?.OPENCHAMBER_RELAY_URL;
68
87
  if (typeof raw !== 'string' || !raw.trim()) return null;
69
88
  return canonicalizeRelayUrl(raw);
70
89
  };
71
90
 
91
+ // Env (`OPENCHAMBER_RELAY_URL`) → settings.privateRelay.relayUrl → DEFAULT_RELAY_URL.
92
+ export const resolveEffectiveRelayUrl = ({ settings = null, env = process.env } = {}) =>
93
+ envRelayUrlOverride(env) ?? normalizeRelayUrl(settings?.privateRelay?.relayUrl);
94
+
72
95
  /**
73
96
  * @param {{
74
97
  * crypto: typeof import('node:crypto'),
@@ -77,6 +100,7 @@ const envRelayUrlOverride = () => {
77
100
  * getLocalPort: () => number,
78
101
  * logger?: Pick<Console, 'warn'>,
79
102
  * canHostRelay?: () => boolean,
103
+ * onRelayUrlChanged?: () => Promise<void> | void,
80
104
  * }} deps
81
105
  */
82
106
  export const createRelayService = ({
@@ -98,6 +122,7 @@ export const createRelayService = ({
98
122
  // Host gate: false for non-host runtimes (dev server, ordinary CLI, VS Code, …).
99
123
  canHostRelay = () => isRelayHostRuntime(),
100
124
  logger = console,
125
+ onRelayUrlChanged = null,
101
126
  }) => {
102
127
  const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
103
128
 
@@ -131,13 +156,22 @@ export const createRelayService = ({
131
156
  const override = envRelayUrlOverride();
132
157
  return {
133
158
  enabled: stored?.enabled === true,
134
- relayUrl: override ?? normalizeRelayUrl(stored?.relayUrl),
159
+ relayUrl: resolveEffectiveRelayUrl({ settings }),
135
160
  // True when the endpoint is pinned by OPENCHAMBER_RELAY_URL (a self-hosted
136
161
  // relay); the stored setting is ignored while it is set.
137
162
  relayUrlLocked: override !== null,
138
163
  };
139
164
  };
140
165
 
166
+ const notifyRelayUrlChanged = async () => {
167
+ if (typeof onRelayUrlChanged !== 'function') return;
168
+ try {
169
+ await onRelayUrlChanged();
170
+ } catch (error) {
171
+ logger.warn(`[Relay] push token re-register failed: ${error?.message ?? error}`);
172
+ }
173
+ };
174
+
141
175
  const writeConfig = async (config) => {
142
176
  const settings = await readSettingsFromDiskMigrated();
143
177
  await writeSettingsToDisk({
@@ -365,6 +399,10 @@ export const createRelayService = ({
365
399
  if (!config.enabled || endpointChanged) {
366
400
  await writeConfig({ enabled: true, relayUrl });
367
401
  }
402
+ const next = await readConfig();
403
+ if (next.relayUrl !== config.relayUrl) {
404
+ await notifyRelayUrlChanged();
405
+ }
368
406
  if (endpointChanged) {
369
407
  // One Host identity can have only one live Relay control connection. A
370
408
  // custom endpoint therefore becomes the authoritative transport before
@@ -409,6 +447,10 @@ export const createRelayService = ({
409
447
  relayUrl = canonical;
410
448
  }
411
449
  await writeConfig({ enabled: true, relayUrl });
450
+ const next = await readConfig();
451
+ if (next.relayUrl !== current.relayUrl) {
452
+ await notifyRelayUrlChanged();
453
+ }
412
454
  if (hostClient) stop();
413
455
  // Explicit user action: take the machine's host claim like pairing does.
414
456
  await start(relayUrl, { claim: 'force' });
@@ -34,8 +34,10 @@ vi.mock('./host-client.js', () => ({
34
34
  import {
35
35
  createRelayService,
36
36
  DEFAULT_RELAY_URL,
37
+ derivePushSendUrlFromRelayUrl,
37
38
  isRelayHostRuntime,
38
39
  RELAY_HOST_DESKTOP_ONLY_MESSAGE,
40
+ resolveEffectiveRelayUrl,
39
41
  } from './service.js';
40
42
 
41
43
  const createSettingsHarness = () => {
@@ -60,6 +62,59 @@ const createTestRelayService = ({ canHostRelay = true, ...overrides } = {}) => {
60
62
  return { service, settings };
61
63
  };
62
64
 
65
+ describe('derivePushSendUrlFromRelayUrl', () => {
66
+ it('converts wss and ws canonical relay URLs to the fixed push send path', () => {
67
+ expect(derivePushSendUrlFromRelayUrl('wss://relay.openchamber.dev/ws')).toBe(
68
+ 'https://relay.openchamber.dev/v1/push/send',
69
+ );
70
+ expect(derivePushSendUrlFromRelayUrl('ws://127.0.0.1:8787/custom')).toBe(
71
+ 'http://127.0.0.1:8787/v1/push/send',
72
+ );
73
+ expect(derivePushSendUrlFromRelayUrl('wss://relay.example:8443/any/path?token=secret#frag')).toBe(
74
+ 'https://relay.example:8443/v1/push/send',
75
+ );
76
+ });
77
+
78
+ it('rejects userinfo and non-ws(s) URLs', () => {
79
+ expect(derivePushSendUrlFromRelayUrl('wss://user:pass@relay.example/ws')).toBeNull();
80
+ expect(derivePushSendUrlFromRelayUrl('https://relay.openchamber.dev/ws')).toBeNull();
81
+ expect(derivePushSendUrlFromRelayUrl('http://relay.example/v1/push/send')).toBeNull();
82
+ expect(derivePushSendUrlFromRelayUrl('')).toBeNull();
83
+ expect(derivePushSendUrlFromRelayUrl(null)).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe('resolveEffectiveRelayUrl', () => {
88
+ afterEach(() => {
89
+ delete process.env.OPENCHAMBER_RELAY_URL;
90
+ });
91
+
92
+ it('prefers a valid env URL, then settings, then the default', () => {
93
+ expect(resolveEffectiveRelayUrl()).toBe(DEFAULT_RELAY_URL);
94
+ expect(
95
+ resolveEffectiveRelayUrl({
96
+ settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
97
+ }),
98
+ ).toBe('wss://settings.example/ws');
99
+
100
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws?x=1#frag';
101
+ expect(
102
+ resolveEffectiveRelayUrl({
103
+ settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
104
+ }),
105
+ ).toBe('wss://env.example/ws');
106
+ });
107
+
108
+ it('ignores an invalid env URL and falls through to settings', () => {
109
+ process.env.OPENCHAMBER_RELAY_URL = 'https://not-a-relay.example/ws';
110
+ expect(
111
+ resolveEffectiveRelayUrl({
112
+ settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
113
+ }),
114
+ ).toBe('wss://settings.example/ws');
115
+ });
116
+ });
117
+
63
118
  describe('relay service pairing endpoint', () => {
64
119
  afterEach(() => {
65
120
  relayMocks.starts.length = 0;
@@ -142,6 +197,68 @@ describe('relay service pairing endpoint', () => {
142
197
  });
143
198
  expect(relayMocks.starts).toEqual(['wss://relay.example/custom']);
144
199
  });
200
+
201
+ it('invokes onRelayUrlChanged only when the canonical URL actually changes', async () => {
202
+ const onRelayUrlChanged = vi.fn(async () => {});
203
+ const { service } = createTestRelayService({ onRelayUrlChanged });
204
+
205
+ await service.ensureEnabledForPairing();
206
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
207
+
208
+ await service.ensureEnabledForPairing('wss://relay.example/custom');
209
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
210
+
211
+ await service.ensureEnabledForPairing('wss://relay.example/custom?x=1#frag');
212
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
213
+
214
+ const app = express();
215
+ service.registerRoutes(app);
216
+ await request(app)
217
+ .post('/api/openchamber/relay/enable')
218
+ .send({ relayUrl: 'wss://relay.example/other' })
219
+ .expect(200);
220
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(2);
221
+
222
+ await request(app)
223
+ .post('/api/openchamber/relay/enable')
224
+ .send({ relayUrl: 'wss://relay.example/other' })
225
+ .expect(200);
226
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(2);
227
+ });
228
+
229
+ it('does not invoke onRelayUrlChanged for an env-pinned relay and still uses the pin', async () => {
230
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://pinned.example/ws';
231
+ const onRelayUrlChanged = vi.fn(async () => {});
232
+ const { service } = createTestRelayService({ onRelayUrlChanged });
233
+ const app = express();
234
+ service.registerRoutes(app);
235
+
236
+ const candidate = await service.ensureEnabledForPairing('wss://ignored.example/ws');
237
+ expect(candidate.relayUrl).toBe('wss://pinned.example/ws');
238
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
239
+
240
+ await request(app)
241
+ .post('/api/openchamber/relay/enable')
242
+ .send({ relayUrl: 'wss://ignored.example/ws' })
243
+ .expect(200);
244
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
245
+ expect((await service.getStatus()).relayUrl).toBe('wss://pinned.example/ws');
246
+ });
247
+
248
+ it('continues the relay switch when onRelayUrlChanged fails', async () => {
249
+ const onRelayUrlChanged = vi.fn(async () => {
250
+ throw new Error('push relay unavailable');
251
+ });
252
+ const logger = { warn: vi.fn() };
253
+ const { service } = createTestRelayService({ onRelayUrlChanged, logger });
254
+
255
+ await expect(service.ensureEnabledForPairing('wss://relay.example/custom')).resolves.toMatchObject({
256
+ relayUrl: 'wss://relay.example/custom',
257
+ });
258
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
259
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('push token re-register failed'));
260
+ expect(relayMocks.starts).toEqual(['wss://relay.example/custom']);
261
+ });
145
262
  });
146
263
 
147
264
  describe('relay host runtime gate', () => {