@openchambery/web 1.19.0-beta.36 → 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.
- package/dist/assets/{AssistantView-DKkGbIaN.js → AssistantView-D5pDX9ES.js} +1 -1
- package/dist/assets/{ChatContainer-BV1_DKRK.js → ChatContainer-DBuXfy4g.js} +31 -31
- package/dist/assets/{ContextPanel-y2-b_Z2K.js → ContextPanel-RniXr46u.js} +3 -3
- package/dist/assets/{DiagramView-BQynaQK8.js → DiagramView-BwNKhbsZ.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BLuEiJfe.js → MarkdownRendererImpl-wJgiOdis.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DvQ7TyBM.js → MobileDetailNavigation-Dl6KVNYY.js} +1 -1
- package/dist/assets/{MobileShareBridge-CuVKbp4w.js → MobileShareBridge-BxR0pE_m.js} +1 -1
- package/dist/assets/{MobileSurface-Ckqf46Ht.js → MobileSurface-LhR2YNCl.js} +1 -1
- package/dist/assets/{MultiRunWindow-CryWGQm3.js → MultiRunWindow-Cgg9u8VX.js} +1 -1
- package/dist/assets/{SettingsView-4Qx32CM5.js → SettingsView-CBtDTnbu.js} +1 -1
- package/dist/assets/{SettingsWindow-5o4fZ7Mm.js → SettingsWindow-kBA9auJC.js} +1 -1
- package/dist/assets/{TerminalView-BcACDBI2.js → TerminalView-DhVu4pCB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DPz8gn8_.js → ToolOutputDialog-cDDD9uCL.js} +1 -1
- package/dist/assets/{appThemeRegistry-5J_p1u_I.js → appThemeRegistry-staJMB2J.js} +2 -2
- package/dist/assets/{gitApi-iEiu2XFG.js → gitApi-CYDWoaIF.js} +1 -1
- package/dist/assets/{insertionBoundaries-0eRxJVW6.js → insertionBoundaries-DbiBrF9p.js} +1 -1
- package/dist/assets/{main-eSewAXup.js → main-Dkf8xb0G.js} +3 -3
- package/dist/assets/{main-BLhg2XWt.js → main-cvGY60fq.js} +2 -2
- package/dist/assets/{miniChat-D1-a2j3X.js → miniChat-Cz1ftAm8.js} +2 -2
- package/dist/assets/{mobile-Ci7TpzO5.js → mobile-C6Di4aP0.js} +2 -2
- package/dist/assets/{multirun-DSpk4Zg7.js → multirun-Dp0YasTz.js} +1 -1
- package/dist/assets/{projectMeta-BiPtYEsc.js → projectMeta-CVORtKSv.js} +1 -1
- package/dist/assets/{radio-DHDmOqeF.js → radio-BwGc_8vf.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-C_TDVAS5.js → renderElectronMiniChatApp-DovEohIn.js} +2 -2
- package/dist/assets/{renderMobileApp-CZkBh4ph.js → renderMobileApp-DKt9BrQO.js} +4 -4
- package/dist/assets/{runtimeConfig-D40WiKD4.js → runtimeConfig-ClXahK8y.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-N20BnvKg.js → runtimeEndpointReset-Wd0jJx7c.js} +1 -1
- package/dist/assets/{shikiHighlight-DSolaZz5.js → shikiHighlight-rNaL87ld.js} +1 -1
- package/dist/assets/{src-ILRsQd_W.css → src-Dc5_NNo2.css} +1 -1
- package/dist/assets/{useAppFontEffects-DovmEWUn.js → useAppFontEffects-BgJOiL9O.js} +2 -2
- package/dist/assets/{useDesktopWindowControlsLayout-uYmYg3ee.js → useDesktopWindowControlsLayout-BUMOckKt.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-lReAbPFb.js → useEffectiveDirectory-DmcTREeQ.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-BKVdzZVI.js → useMobileNavigationStore-B5TOw3Mw.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-BkX_0XVu.js → useSessionAutoCleanup-CZdJsOtU.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DFOYREuh.js → useWorkerHighlightedLines-DaZyFcgv.js} +1 -1
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/index.js +8 -1
- package/server/lib/notifications/apns-runtime.js +122 -28
- package/server/lib/notifications/apns-runtime.test.js +159 -0
- package/server/lib/relay/service.js +45 -3
- 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 =
|
|
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:
|
|
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', () => {
|