@openchambery/web 1.19.4 → 1.19.5-beta.10
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-CVTgW_rx.js → AgentAvatar-BceI_qBC.js} +1 -1
- package/dist/assets/{AssistantView-D73E__f8.js → AssistantView-lSnZ7082.js} +1 -1
- package/dist/assets/{ChatContainer-pWolXKsP.js → ChatContainer-DJLqUdGE.js} +39 -39
- package/dist/assets/ContextPanel-CfO8NpOL.js +285 -0
- package/dist/assets/{DiagramView-BJBf7KGE.js → DiagramView-_LIlKVbO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Cy3sf2M5.js → FadeInOnReveal-Cj9ZC3Ab.js} +1 -1
- package/dist/assets/{Icon-BLWVqBee.js → Icon-Du1LX11Q.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-DpapNgFo.js +7 -0
- package/dist/assets/{MobileDetailNavigation-CUq86QUr.js → MobileDetailNavigation-Ct_3mr5K.js} +1 -1
- package/dist/assets/{MobileShareBridge-BMolTtSL.js → MobileShareBridge-7LjBYxHH.js} +2 -2
- package/dist/assets/MobileSurface-CuEJc-ya.js +2 -0
- package/dist/assets/{MultiRunWindow-Bv3moZF8.js → MultiRunWindow-BEM2Ucj4.js} +1 -1
- package/dist/assets/{OnboardingScreen-CTNi2zuA.js → OnboardingScreen-Czusf7rR.js} +1 -1
- package/dist/assets/{OverlayScrollbar-GQgJusKL.js → OverlayScrollbar-oOLMqq2s.js} +1 -1
- package/dist/assets/SettingsView-DUeyhTBn.js +25 -0
- package/dist/assets/{SettingsWindow-BMtwRXID.js → SettingsWindow-Qa8HXF_v.js} +1 -1
- package/dist/assets/{TerminalView-DwzG7zBO.js → TerminalView-CBSreprw.js} +1 -1
- package/dist/assets/{ToolOutputDialog-iYAB6WiK.js → ToolOutputDialog-CRIU0XUQ.js} +1 -1
- package/dist/assets/{angular-html-BXZQHcPZ.js → angular-html-CtgxlIZT.js} +1 -1
- package/dist/assets/{angular-ts-D8x7dmpg.js → angular-ts-DrvebXRV.js} +1 -1
- package/dist/assets/{apl-ugbjmfzm.js → apl-ct3-l_ov.js} +1 -1
- package/dist/assets/appThemeRegistry-CvvscooO.js +2 -0
- package/dist/assets/{astro-BrfzL78y.js → astro-9KCX_vNx.js} +1 -1
- package/dist/assets/{blade-D4cYRUdW.js → blade-DHzjLhcB.js} +1 -1
- package/dist/assets/{button-CeCkEpvn.js → button-p6vbd3r6.js} +1 -1
- package/dist/assets/{c-CMzMVpWc.js → c-CEfQ88z4.js} +1 -1
- package/dist/assets/{clipboard-BXC1vZwE.js → clipboard-Ca2PSbr_.js} +1 -1
- package/dist/assets/{cobol-DthQPUuS.js → cobol-ffQsCDPB.js} +1 -1
- package/dist/assets/{coffee-8Pqo9WtM.js → coffee-DtRj345i.js} +1 -1
- package/dist/assets/{collapsible-B-aagADh.js → collapsible-Cimoz0kx.js} +1 -1
- package/dist/assets/{cpp-D18L7odM.js → cpp-NWnna9VM.js} +1 -1
- package/dist/assets/{crystal-9GeLf5Ic.js → crystal-CnnqEH_e.js} +1 -1
- package/dist/assets/{css-CCmJvSl6.js → css-BRwHOW3t.js} +1 -1
- package/dist/assets/{desktopHosts-BrrYPqKq.js → desktopHosts-DceeVBky.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DR60DATT.js → desktopRelayRestore-B0MRdR5y.js} +1 -1
- package/dist/assets/{diagram-Cxma_IKI.js → diagram-DX_MPRs3.js} +1 -1
- package/dist/assets/{dialog-wraJHZQ2.js → dialog-B2ieeCSR.js} +1 -1
- package/dist/assets/{edge-BNU68miL.js → edge-BmL4YNvW.js} +1 -1
- package/dist/assets/{elixir-Clm-oycI.js → elixir-m9Mcv-AW.js} +1 -1
- package/dist/assets/{elm-DyuJMdF4.js → elm-Dazsuz2c.js} +1 -1
- package/dist/assets/{erb-BGQIoPeF.js → erb-CJ1X7LGG.js} +1 -1
- package/dist/assets/{es-DQqFAaWT.js → es-Bj9HwM-0.js} +3 -3
- package/dist/assets/{fileQueries-BJKLdYos.js → fileQueries-BDld6h3B.js} +1 -1
- package/dist/assets/{fr-CNfGRpc1.js → fr-BgEHZGug.js} +3 -3
- package/dist/assets/{git-rebase-ZHVbPCu8.js → git-rebase-DHqm8iFM.js} +1 -1
- package/dist/assets/{gitApi-C6XQwHcm.js → gitApi-BVB4hH6A.js} +3 -3
- package/dist/assets/{glimmer-js-Dx29eTyZ.js → glimmer-js-529w2Uvl.js} +1 -1
- package/dist/assets/{glimmer-ts-7AFohuRx.js → glimmer-ts-B38MdnnB.js} +1 -1
- package/dist/assets/{glsl-Nbih-1pG.js → glsl-IFfKD25X.js} +1 -1
- package/dist/assets/{graphql-IOG_4fSc.js → graphql-m-L9qWWB.js} +1 -1
- package/dist/assets/{hack-DyZU34CD.js → hack-S2S7pq0v.js} +1 -1
- package/dist/assets/{haml-mwEldSxy.js → haml-BE8HBwbH.js} +1 -1
- package/dist/assets/{handlebars-BRbCg-TR.js → handlebars-DPMWj4Da.js} +1 -1
- package/dist/assets/{html-derivative-DpusPW9p.js → html-derivative-D0yehinc.js} +1 -1
- package/dist/assets/{html-DH50wxss.js → html-gfIVmLgx.js} +1 -1
- package/dist/assets/{http-fM2Ft62a.js → http-Yj1uQSnx.js} +1 -1
- package/dist/assets/{hurl-C7ZRA11y.js → hurl-CiAC6gLf.js} +1 -1
- package/dist/assets/{input-CyG05w-V.js → input-gaFVNq1P.js} +1 -1
- package/dist/assets/insertionBoundaries-Cpj9r_o6.js +4 -0
- package/dist/assets/{ja-CQqehnVn.js → ja-clyxf6re.js} +3 -3
- package/dist/assets/{java-CuLv1JzP.js → java-lPyr66Qw.js} +1 -1
- package/dist/assets/{javascript-e9R9mij1.js → javascript-BlmKDQ-I.js} +1 -1
- package/dist/assets/{jinja-BqL4dpCe.js → jinja-DrvjNn4y.js} +1 -1
- package/dist/assets/{jison-BU1E246U.js → jison-BZ-k3PlS.js} +1 -1
- package/dist/assets/{json-CWUAz4dE.js → json-CaQi8cq5.js} +1 -1
- package/dist/assets/{jsx-Tb9QFw4P.js → jsx-kfzB3oTi.js} +1 -1
- package/dist/assets/{julia-C6q7nYBJ.js → julia-Bup0jpIM.js} +1 -1
- package/dist/assets/{just-CLHVCPQG.js → just-XMsX5iKL.js} +1 -1
- package/dist/assets/{ko-kmwi8UJD.js → ko-BZb3gnuq.js} +3 -3
- package/dist/assets/{latex-DzR96v9w.js → latex-CHhOHp9u.js} +1 -1
- package/dist/assets/{liquid-UjsTutWK.js → liquid-C90FVKxw.js} +1 -1
- package/dist/assets/{lua-laWCoC7H.js → lua-BXPwTBAD.js} +1 -1
- package/dist/assets/main-BsdsPsHT.js +29 -0
- package/dist/assets/{main-OM6EVqFo.js → main-CQLsl_QE.js} +2 -2
- package/dist/assets/markdown-shiki.worker-woF2hOAN.js +349 -0
- package/dist/assets/{markdown-worker-DqO-GnVj.js → markdown-worker-CoO82KYF.js} +4 -4
- package/dist/assets/{markdownTheme-H7_7lbuh.js → markdownTheme-St8zxDvJ.js} +1 -1
- package/dist/assets/{marko-Cw81uHQK.js → marko-oFkW-Ef7.js} +1 -1
- package/dist/assets/{mdc-5VRIrvpo.js → mdc-Dt17Fv3G.js} +1 -1
- package/dist/assets/{miniChat-CWJejsNH.js → miniChat-w-S3l9bR.js} +2 -2
- package/dist/assets/mobile-CHDToW-w.js +2 -0
- package/dist/assets/multirun-CMEZHI5g.js +4 -0
- package/dist/assets/{nginx-OjbRtDTh.js → nginx-LfNI-F2k.js} +1 -1
- package/dist/assets/{nim-BqzUQFfL.js → nim-so0j2_Ky.js} +1 -1
- package/dist/assets/{perl-Bba2cEgf.js → perl-DQcGQU9O.js} +1 -1
- package/dist/assets/{persistence-DE9RnVcA.js → persistence-CLUawIlm.js} +1 -1
- package/dist/assets/{php-CkVgF6HF.js → php-Dae1IcCM.js} +1 -1
- package/dist/assets/{pl-Blv5o2QA.js → pl-CwZzSvrm.js} +3 -3
- package/dist/assets/{platform-MjT8iqKB.js → platform-BrncW9ab.js} +1 -1
- package/dist/assets/{projectMeta-DCJQ6I3m.js → projectMeta-ZD-iXSOT.js} +2 -2
- package/dist/assets/{pt-BR-BK74t-Bd.js → pt-BR-6ali2BQX.js} +3 -3
- package/dist/assets/{pug-Dj44SzNY.js → pug-CaHd5xEa.js} +1 -1
- package/dist/assets/{qml-DETHcUPx.js → qml-lLqImktD.js} +1 -1
- package/dist/assets/{r-CrbPkwLJ.js → r-C2VuzPsf.js} +1 -1
- package/dist/assets/{radio-MAnQ7sDW.js → radio-B-nm8u03.js} +1 -1
- package/dist/assets/{razor-56IyEPA5.js → razor-DTpzsAil.js} +1 -1
- package/dist/assets/{regexp-BEMDgR1N.js → regexp-DJeMtopQ.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-65t0A6BD.js +2 -0
- package/dist/assets/renderMobileApp-a3Joz-yn.js +25 -0
- package/dist/assets/{rst-C8BEzC8U.js → rst-c6kvN9mK.js} +1 -1
- package/dist/assets/{ruby-DWyeL44t.js → ruby-B68kNTjT.js} +1 -1
- package/dist/assets/{runtimeConfig-C2Vygqku.js → runtimeConfig-BGuiq1aJ.js} +1 -1
- package/dist/assets/runtimeEndpointReset-HC9CW_hT.js +4 -0
- package/dist/assets/{sas-Dt-MhHv8.js → sas-Br3BRi6f.js} +1 -1
- package/dist/assets/{scss-jmTM-KWO.js → scss-ugvUHawi.js} +1 -1
- package/dist/assets/{shellscript-BgI_n_Bv.js → shellscript-Ci5rJl0t.js} +1 -1
- package/dist/assets/{shellsession-DhmUadx_.js → shellsession-Dxn1WKgk.js} +1 -1
- package/dist/assets/{shikiHighlight-Bpy8sf5r.js → shikiHighlight-DFiqYozo.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-BdbKDDAP.js → sortable-tabs-strip-CxLE1X9x.js} +1 -1
- package/dist/assets/{soy-qfQbh7dQ.js → soy-kgnQSRVZ.js} +1 -1
- package/dist/assets/{sql-CwyNz0Gh.js → sql-Cx3XaVwp.js} +1 -1
- package/dist/assets/src-D-LxNwlu.css +2 -0
- package/dist/assets/{stata-D9MlkA-a.js → stata-B5YhL3cp.js} +1 -1
- package/dist/assets/{store-UweBeN2y.js → store-BvrCRq6U.js} +3 -3
- package/dist/assets/{surrealql-xR93a923.js → surrealql-B8ysTfdK.js} +1 -1
- package/dist/assets/{svelte-BA7ahdky.js → svelte-D5-K3GME.js} +1 -1
- package/dist/assets/{templ-CxpzFYl8.js → templ-n2DDBrat.js} +1 -1
- package/dist/assets/{terminalApi-Bnx0KvaV.js → terminalApi-D2OzR0I3.js} +1 -1
- package/dist/assets/{tex-CePKgOWg.js → tex-CpNhXIXy.js} +1 -1
- package/dist/assets/{tooltip-B__x-HDJ.js → tooltip-D1a6rKLA.js} +1 -1
- package/dist/assets/{ts-tags-DP2fsj53.js → ts-tags-CrtENrUj.js} +1 -1
- package/dist/assets/{tsx-CI52QFY8.js → tsx-DEnzboO-.js} +1 -1
- package/dist/assets/{twig-BjoebDgo.js → twig-B7PeheP0.js} +1 -1
- package/dist/assets/{typescript-cpHrN9Ya.js → typescript-CUlPIybL.js} +1 -1
- package/dist/assets/{uk-6I-KXy3R.js → uk-qRqJs4GS.js} +3 -3
- package/dist/assets/{url-CTuw7Rjf.js → url-zwuNq3GS.js} +1 -1
- package/dist/assets/{useAppFontEffects-BVVxQm3q.js → useAppFontEffects-pF6rKdKM.js} +4 -4
- package/dist/assets/{useAssistantUIStore-D2K7ksGj.js → useAssistantUIStore-D699Sbdb.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BR2bI_26.js → useDesktopWindowControlsLayout-DH7Om-wY.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DdS6rQm9.js → useEffectiveDirectory-BvZFK9wU.js} +1 -1
- package/dist/assets/{useFontPreferences-CWZnNRjz.js → useFontPreferences-F2QPNE2V.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DFSb2gzX.js → useMobileNavigationStore-CGOeJb9D.js} +10 -10
- package/dist/assets/{useSessionAutoCleanup-BNC1lVkC.js → useSessionAutoCleanup-lBn8oZ8f.js} +1 -1
- package/dist/assets/{useUIStore-DOxLVeGb.js → useUIStore-BXjMQrel.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-B9mnTmvs.js → useWorkerHighlightedLines-Bc1NJlYO.js} +2 -2
- package/dist/assets/{utils-C2R0YoC5.js → utils-D94hxQYq.js} +1 -1
- package/dist/assets/{vue-DK5KPkZ5.js → vue-C6ZZy2AI.js} +1 -1
- package/dist/assets/{vue-html-BlUa8woS.js → vue-html-DsvGlIY2.js} +1 -1
- package/dist/assets/{vue-vine-D3hlJyLi.js → vue-vine-C8VLalyn.js} +1 -1
- package/dist/assets/{xml-CspvrcYx.js → xml-ChNx2zKg.js} +1 -1
- package/dist/assets/{xsl-D10hdnv5.js → xsl-yWKkpZli.js} +1 -1
- package/dist/assets/{yaml-C00-kDDU.js → yaml-CENNGtdf.js} +1 -1
- package/dist/assets/{zh-CN-DwlzS4yK.js → zh-CN-CwZ0adcY.js} +3 -3
- package/dist/assets/{zh-TW-p0VriyfT.js → zh-TW-DC0jiCzA.js} +3 -3
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +40 -3
- package/server/lib/notifications/DOCUMENTATION.md +2 -2
- package/server/lib/notifications/push-runtime.js +4 -14
- package/server/lib/notifications/runtime.js +11 -20
- package/server/lib/notifications/runtime.test.js +4 -6
- package/server/lib/opencode/core-routes.js +56 -23
- package/server/lib/opencode/core-routes.test.js +206 -6
- package/server/lib/relay/DOCUMENTATION.md +2 -1
- package/server/lib/relay/service.js +223 -71
- package/server/lib/relay/service.test.js +96 -13
- package/dist/assets/ContextPanel-GBq5lSES.js +0 -285
- package/dist/assets/MarkdownRendererImpl-BUXY9iuu.js +0 -7
- package/dist/assets/MobileSurface-DDRPrlAq.js +0 -2
- package/dist/assets/SettingsView-Ip_jVXoD.js +0 -25
- package/dist/assets/appThemeRegistry-rHYlrhb5.js +0 -2
- package/dist/assets/insertionBoundaries-DG_1_ENK.js +0 -4
- package/dist/assets/main-MkZ3xcbE.js +0 -29
- package/dist/assets/markdown-shiki.worker-CKKjvdhc.js +0 -12
- package/dist/assets/mobile-D-ubuubW.js +0 -2
- package/dist/assets/multirun-YB7NFEGX.js +0 -4
- package/dist/assets/renderElectronMiniChatApp-BPZ5LfbX.js +0 -2
- package/dist/assets/renderMobileApp-pqjbEzlN.js +0 -23
- package/dist/assets/runtimeEndpointReset-CuW2UDPM.js +0 -4
- package/dist/assets/src-eGuNWxHc.css +0 -2
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
// client, and the /api/openchamber/relay/* management routes.
|
|
3
3
|
//
|
|
4
4
|
// Config lives in the server settings file as `settings.privateRelay =
|
|
5
|
-
// { enabled, relayUrl }` (same storage precedent as
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// { enabled, relayUrl, extraRelayUrls }` (same storage precedent as
|
|
6
|
+
// tunnels/notifications). `relayUrl` is the PRIMARY endpoint (push
|
|
7
|
+
// registration, default pairing candidate); `extraRelayUrls` are additional
|
|
8
|
+
// endpoints ("multi-relay"): the host dials every configured endpoint at once
|
|
9
|
+
// with the SAME identity, so one server is reachable via several domains and
|
|
10
|
+
// clients treat serverId + relayUrl pairs as distinct instances. Routes are
|
|
11
|
+
// registered with the other OpenChamber feature routes, before the generic
|
|
12
|
+
// OpenCode proxy, and are covered by the same global UI auth gate.
|
|
8
13
|
//
|
|
9
14
|
// Host gate: only the Electron desktop runtime or an SSH-managed remote server
|
|
10
15
|
// may run a relay host (OPENCHAMBER_RUNTIME=desktop | ssh-remote).
|
|
@@ -78,6 +83,23 @@ const normalizeRelayUrl = (value) => {
|
|
|
78
83
|
return canonicalizeRelayUrl(value) ?? DEFAULT_RELAY_URL;
|
|
79
84
|
};
|
|
80
85
|
|
|
86
|
+
// Additional relay endpoints ("multi-relay"): one host identity may dial
|
|
87
|
+
// several relays at once so the same server is reachable (and pairable) via
|
|
88
|
+
// multiple domains. Canonicalized, de-duplicated; the primary endpoint is
|
|
89
|
+
// excluded by the caller (readConfig) because it lives in its own field.
|
|
90
|
+
const normalizeExtraRelayUrls = (value) => {
|
|
91
|
+
if (!Array.isArray(value)) return [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const result = [];
|
|
94
|
+
for (const entry of value) {
|
|
95
|
+
const canonical = canonicalizeRelayUrl(entry);
|
|
96
|
+
if (!canonical || seen.has(canonical)) continue;
|
|
97
|
+
seen.add(canonical);
|
|
98
|
+
result.push(canonical);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
|
|
81
103
|
// A deployment can pin the relay endpoint via env (e.g. a self-hosted relay on
|
|
82
104
|
// your own Cloudflare account/domain). When set and valid it overrides the
|
|
83
105
|
// stored setting entirely, so the host connection, the pairing offer, and the
|
|
@@ -123,11 +145,18 @@ export const createRelayService = ({
|
|
|
123
145
|
canHostRelay = () => isRelayHostRuntime(),
|
|
124
146
|
logger = console,
|
|
125
147
|
onRelayUrlChanged = null,
|
|
148
|
+
// Owner gate for endpoint-management routes (`/relay/endpoints`): only a UI
|
|
149
|
+
// session or the local desktop shell may add/remove relay endpoints. Optional
|
|
150
|
+
// dependency; when absent the route refuses everything.
|
|
151
|
+
isOwnerRequest = async (_req, _res) => false,
|
|
126
152
|
}) => {
|
|
127
153
|
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
|
|
128
154
|
|
|
129
|
-
|
|
155
|
+
// One host-control connection per relay endpoint; all share the machine's
|
|
156
|
+
// single relay identity (same serverId) and one host claim.
|
|
157
|
+
const hostClients = new Map();
|
|
130
158
|
let status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
|
159
|
+
const endpointStatuses = new Map();
|
|
131
160
|
// Re-checks the claim while enabled: a standby instance takes over when the
|
|
132
161
|
// claimant dies; a running host stands down when another process claims.
|
|
133
162
|
let claimWatchTimer = null;
|
|
@@ -154,9 +183,16 @@ export const createRelayService = ({
|
|
|
154
183
|
const settings = await readSettingsFromDiskMigrated();
|
|
155
184
|
const stored = settings?.privateRelay;
|
|
156
185
|
const override = envRelayUrlOverride();
|
|
186
|
+
const primary = resolveEffectiveRelayUrl({ settings });
|
|
187
|
+
const extras = normalizeExtraRelayUrls(stored?.extraRelayUrls).filter((url) => url !== primary);
|
|
157
188
|
return {
|
|
158
189
|
enabled: stored?.enabled === true,
|
|
159
|
-
relayUrl:
|
|
190
|
+
relayUrl: primary,
|
|
191
|
+
// Additional relay endpoints (multi-relay): the same host identity dials
|
|
192
|
+
// every endpoint simultaneously, so one server is reachable via several
|
|
193
|
+
// domains. The primary stays authoritative for push and default pairing.
|
|
194
|
+
extraRelayUrls: extras,
|
|
195
|
+
relayUrls: [primary, ...extras],
|
|
160
196
|
// True when the endpoint is pinned by OPENCHAMBER_RELAY_URL (a self-hosted
|
|
161
197
|
// relay); the stored setting is ignored while it is set.
|
|
162
198
|
relayUrlLocked: override !== null,
|
|
@@ -174,16 +210,40 @@ export const createRelayService = ({
|
|
|
174
210
|
|
|
175
211
|
const writeConfig = async (config) => {
|
|
176
212
|
const settings = await readSettingsFromDiskMigrated();
|
|
213
|
+
const primary = normalizeRelayUrl(config.relayUrl);
|
|
214
|
+
const extras = normalizeExtraRelayUrls(config.extraRelayUrls).filter((url) => url !== primary);
|
|
177
215
|
await writeSettingsToDisk({
|
|
178
216
|
...settings,
|
|
179
|
-
privateRelay: { enabled: config.enabled === true, relayUrl:
|
|
217
|
+
privateRelay: { enabled: config.enabled === true, relayUrl: primary, extraRelayUrls: extras },
|
|
180
218
|
});
|
|
181
219
|
};
|
|
182
220
|
|
|
183
|
-
const stopHostClient = () => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
221
|
+
const stopHostClient = (relayUrl) => {
|
|
222
|
+
const client = hostClients.get(relayUrl);
|
|
223
|
+
if (!client) return;
|
|
224
|
+
client.stop();
|
|
225
|
+
hostClients.delete(relayUrl);
|
|
226
|
+
endpointStatuses.delete(relayUrl);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const stopAllHostClients = () => {
|
|
230
|
+
for (const relayUrl of Array.from(hostClients.keys())) {
|
|
231
|
+
stopHostClient(relayUrl);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const aggregateStatus = () => {
|
|
236
|
+
if (hostClients.size === 0) return status;
|
|
237
|
+
const states = Array.from(endpointStatuses.values());
|
|
238
|
+
const state = states.some((entry) => entry.state === 'connected')
|
|
239
|
+
? 'connected'
|
|
240
|
+
: states.some((entry) => entry.state === 'connecting') ? 'connecting' : 'error';
|
|
241
|
+
const lastError = states.map((entry) => entry.lastError).find(Boolean) ?? null;
|
|
242
|
+
return { state, lastError, connectedClients: states.reduce((sum, entry) => sum + entry.connectedClients, 0) };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const refreshStatusFromEndpoints = () => {
|
|
246
|
+
status = aggregateStatus();
|
|
187
247
|
};
|
|
188
248
|
|
|
189
249
|
const standbyStatus = (holderPid) => ({
|
|
@@ -197,23 +257,24 @@ export const createRelayService = ({
|
|
|
197
257
|
// - running → another live process claimed → stand down (stop, standby).
|
|
198
258
|
// This back-off is what actually ends the mutual-eviction fight: the loser
|
|
199
259
|
// must STOP reconnecting, otherwise both keep replacing each other forever.
|
|
200
|
-
const ensureClaimWatch = (
|
|
260
|
+
const ensureClaimWatch = () => {
|
|
201
261
|
if (!hostLock || claimWatchTimer) return;
|
|
202
262
|
claimWatchTimer = setInterval(() => {
|
|
203
263
|
void (async () => {
|
|
204
264
|
try {
|
|
205
|
-
if (
|
|
265
|
+
if (hostClients.size > 0) {
|
|
206
266
|
if (!hostLock.holdsClaim() && hostLock.liveClaimantPid() !== null) {
|
|
207
267
|
logger.warn('[Relay] host claim taken by another local instance — standing down');
|
|
208
268
|
const holder = hostLock.liveClaimantPid();
|
|
209
|
-
|
|
269
|
+
stopAllHostClients();
|
|
210
270
|
status = standbyStatus(holder);
|
|
211
271
|
}
|
|
212
272
|
return;
|
|
213
273
|
}
|
|
214
274
|
if (status.state === 'standby' && hostLock.tryClaim()) {
|
|
215
275
|
logger.warn('[Relay] host claim is free — taking over the relay host');
|
|
216
|
-
await
|
|
276
|
+
const config = await readConfig();
|
|
277
|
+
await start(config.relayUrls);
|
|
217
278
|
}
|
|
218
279
|
} catch (error) {
|
|
219
280
|
logger.warn(`[Relay] claim watch failed: ${error?.message ?? error}`);
|
|
@@ -229,37 +290,49 @@ export const createRelayService = ({
|
|
|
229
290
|
claimWatchTimer = null;
|
|
230
291
|
};
|
|
231
292
|
|
|
232
|
-
|
|
293
|
+
// `relayUrls` may be a single canonical endpoint or the full list; each
|
|
294
|
+
// endpoint gets its own host-control connection (skipped when already up).
|
|
295
|
+
const start = async (relayUrls, { claim = 'try' } = {}) => {
|
|
233
296
|
if (!hostAllowed()) {
|
|
234
297
|
refuseHost();
|
|
235
298
|
return;
|
|
236
299
|
}
|
|
237
|
-
|
|
300
|
+
const endpoints = (Array.isArray(relayUrls) ? relayUrls : [relayUrls])
|
|
301
|
+
.map((url) => canonicalizeRelayUrl(url))
|
|
302
|
+
.filter((url, index, list) => url && list.indexOf(url) === index);
|
|
303
|
+
if (endpoints.length === 0) return;
|
|
304
|
+
const pending = endpoints.filter((url) => !hostClients.has(url));
|
|
305
|
+
if (pending.length === 0) return;
|
|
238
306
|
if (hostLock) {
|
|
239
307
|
const claimed = claim === 'force' ? hostLock.forceClaim() : hostLock.tryClaim();
|
|
240
308
|
if (!claimed) {
|
|
241
309
|
status = standbyStatus(hostLock.liveClaimantPid());
|
|
242
|
-
ensureClaimWatch(
|
|
310
|
+
ensureClaimWatch();
|
|
243
311
|
return;
|
|
244
312
|
}
|
|
245
313
|
}
|
|
246
314
|
const identity = await identityRuntime.getRelayIdentity();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
315
|
+
for (const relayUrl of pending) {
|
|
316
|
+
const hostClient = startRelayHost({
|
|
317
|
+
relayUrl,
|
|
318
|
+
identity,
|
|
319
|
+
getLocalPort,
|
|
320
|
+
logger,
|
|
321
|
+
onStatus: (next) => {
|
|
322
|
+
endpointStatuses.set(relayUrl, next);
|
|
323
|
+
refreshStatusFromEndpoints();
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
hostClients.set(relayUrl, hostClient);
|
|
327
|
+
endpointStatuses.set(relayUrl, hostClient.getStatus());
|
|
328
|
+
}
|
|
329
|
+
refreshStatusFromEndpoints();
|
|
330
|
+
ensureClaimWatch();
|
|
258
331
|
};
|
|
259
332
|
|
|
260
333
|
const stop = () => {
|
|
261
334
|
stopClaimWatch();
|
|
262
|
-
|
|
335
|
+
stopAllHostClients();
|
|
263
336
|
if (hostLock) hostLock.release();
|
|
264
337
|
status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
|
265
338
|
};
|
|
@@ -268,7 +341,7 @@ export const createRelayService = ({
|
|
|
268
341
|
try {
|
|
269
342
|
const config = await readConfig();
|
|
270
343
|
if (config.enabled) {
|
|
271
|
-
await start(config.
|
|
344
|
+
await start(config.relayUrls);
|
|
272
345
|
}
|
|
273
346
|
} catch (error) {
|
|
274
347
|
logger.warn(`[Relay] startup failed: ${error?.message ?? error}`);
|
|
@@ -293,16 +366,16 @@ export const createRelayService = ({
|
|
|
293
366
|
const demand = await hasRelayDemand();
|
|
294
367
|
const config = await readConfig();
|
|
295
368
|
if (demand) {
|
|
296
|
-
if (!config.enabled) await writeConfig({ enabled: true, relayUrl: config.relayUrl });
|
|
297
|
-
if (
|
|
369
|
+
if (!config.enabled) await writeConfig({ enabled: true, relayUrl: config.relayUrl, extraRelayUrls: config.extraRelayUrls });
|
|
370
|
+
if (hostClients.size === 0) {
|
|
298
371
|
const next = await readConfig();
|
|
299
|
-
await start(next.
|
|
372
|
+
await start(next.relayUrls);
|
|
300
373
|
}
|
|
301
374
|
} else if (config.enabled && !isDesktopRelayHostRuntime()) {
|
|
302
375
|
// Sticky opt-in on ssh-remote (and any future non-desktop host runtime).
|
|
303
|
-
if (
|
|
376
|
+
if (hostClients.size === 0) await start(config.relayUrls);
|
|
304
377
|
} else {
|
|
305
|
-
if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl });
|
|
378
|
+
if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl, extraRelayUrls: config.extraRelayUrls });
|
|
306
379
|
stop();
|
|
307
380
|
}
|
|
308
381
|
} catch (error) {
|
|
@@ -334,19 +407,35 @@ export const createRelayService = ({
|
|
|
334
407
|
}
|
|
335
408
|
const config = await readConfig();
|
|
336
409
|
const identity = await identityRuntime.getRelayIdentity();
|
|
337
|
-
|
|
410
|
+
// `status` is the aggregate across endpoints (kept fresh by each host
|
|
411
|
+
// client's onStatus callback), or the disabled/standby marker when none.
|
|
412
|
+
const live = status;
|
|
413
|
+
const endpoints = config.relayUrls.map((relayUrl) => {
|
|
414
|
+
const client = hostClients.get(relayUrl);
|
|
415
|
+
const entry = client ? client.getStatus() : endpointStatuses.get(relayUrl);
|
|
416
|
+
return {
|
|
417
|
+
relayUrl,
|
|
418
|
+
state: client ? (entry?.state ?? 'connecting') : 'disabled',
|
|
419
|
+
connectedClients: entry?.connectedClients ?? 0,
|
|
420
|
+
...(entry?.lastError ? { lastError: entry.lastError } : {}),
|
|
421
|
+
};
|
|
422
|
+
});
|
|
338
423
|
return {
|
|
339
424
|
enabled: config.enabled,
|
|
340
425
|
hostAllowed: true,
|
|
341
|
-
// Without
|
|
426
|
+
// Without host clients the service is either off or standing by while
|
|
342
427
|
// another local process owns the machine's relay host claim.
|
|
343
|
-
state:
|
|
428
|
+
state: hostClients.size > 0 ? live.state : (status.state === 'standby' ? 'standby' : 'disabled'),
|
|
344
429
|
serverId: identity.serverId,
|
|
345
430
|
// Public E2EE trust anchor. Desktop SSH attach uses this to persist a
|
|
346
431
|
// multi-transport host (local-forward + relay) without a pairing round-trip.
|
|
347
432
|
hostEncPubJwk: identity.hostEncPubJwk,
|
|
348
433
|
connectedClients: live.connectedClients,
|
|
434
|
+
// Primary endpoint (push + default pairing); `relayUrls`/`endpoints` carry
|
|
435
|
+
// the full multi-relay picture.
|
|
349
436
|
relayUrl: config.relayUrl,
|
|
437
|
+
relayUrls: config.relayUrls,
|
|
438
|
+
endpoints,
|
|
350
439
|
relayUrlLocked: config.relayUrlLocked,
|
|
351
440
|
...(live.lastError ? { lastError: live.lastError } : {}),
|
|
352
441
|
};
|
|
@@ -358,12 +447,11 @@ export const createRelayService = ({
|
|
|
358
447
|
// tunnel like any other candidate. Returns null when the host relay is off, so
|
|
359
448
|
// callers only advertise relay when it is actually reachable. Priority is high
|
|
360
449
|
// (tried after LAN/tunnel) since the relay path is the last-resort transport.
|
|
361
|
-
const buildPairingCandidate = async () => {
|
|
362
|
-
const config = await readConfig();
|
|
450
|
+
const buildPairingCandidate = async (relayUrl) => {
|
|
363
451
|
const identity = await identityRuntime.getRelayIdentity();
|
|
364
452
|
return {
|
|
365
453
|
type: 'relay',
|
|
366
|
-
relayUrl
|
|
454
|
+
relayUrl,
|
|
367
455
|
serverId: identity.serverId,
|
|
368
456
|
hostEncPubJwk: identity.hostEncPubJwk,
|
|
369
457
|
priority: 30,
|
|
@@ -374,17 +462,36 @@ export const createRelayService = ({
|
|
|
374
462
|
if (!hostAllowed()) return null;
|
|
375
463
|
const config = await readConfig();
|
|
376
464
|
if (!config.enabled) return null;
|
|
377
|
-
return buildPairingCandidate();
|
|
465
|
+
return buildPairingCandidate(config.relayUrl);
|
|
378
466
|
};
|
|
379
467
|
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
468
|
+
// Candidates for every configured endpoint, used by the connection-candidates
|
|
469
|
+
// refresh so already-paired devices learn the full reachable endpoint set.
|
|
470
|
+
const getPairingCandidates = async () => {
|
|
471
|
+
if (!hostAllowed()) return [];
|
|
472
|
+
const config = await readConfig();
|
|
473
|
+
if (!config.enabled) return [];
|
|
474
|
+
const identity = await identityRuntime.getRelayIdentity();
|
|
475
|
+
return config.relayUrls.map((relayUrl) => ({
|
|
476
|
+
type: 'relay',
|
|
477
|
+
relayUrl,
|
|
478
|
+
serverId: identity.serverId,
|
|
479
|
+
hostEncPubJwk: identity.hostEncPubJwk,
|
|
480
|
+
priority: 30,
|
|
481
|
+
}));
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// Enable the relay host on demand and return the pairing candidate for one
|
|
485
|
+
// endpoint. Creating a relay pairing link IS the demand signal, so the relay
|
|
486
|
+
// turns itself on here rather than requiring a separate manual toggle.
|
|
487
|
+
// Idempotent for an endpoint that already exists; a NEW endpoint (owner
|
|
488
|
+
// request only) is APPENDED as an additional relay rather than replacing the
|
|
489
|
+
// primary, so devices already paired over the old endpoint keep working.
|
|
384
490
|
const ensureEnabledForPairing = async (requestedRelayUrl) => {
|
|
385
491
|
assertHostAllowed();
|
|
386
492
|
const config = await readConfig();
|
|
387
|
-
let
|
|
493
|
+
let targetUrl = config.relayUrl;
|
|
494
|
+
let extraRelayUrls = config.extraRelayUrls;
|
|
388
495
|
if (!config.relayUrlLocked && requestedRelayUrl !== undefined) {
|
|
389
496
|
const canonical = canonicalizeRelayUrl(requestedRelayUrl);
|
|
390
497
|
if (!canonical) {
|
|
@@ -392,33 +499,32 @@ export const createRelayService = ({
|
|
|
392
499
|
error.statusCode = 400;
|
|
393
500
|
throw error;
|
|
394
501
|
}
|
|
395
|
-
relayUrl
|
|
502
|
+
if (canonical === config.relayUrl || config.extraRelayUrls.includes(canonical)) {
|
|
503
|
+
// Existing endpoint: pair over it without touching the endpoint set.
|
|
504
|
+
targetUrl = canonical;
|
|
505
|
+
} else {
|
|
506
|
+
// New endpoint: append as an additional relay (multi-relay). The
|
|
507
|
+
// primary is never replaced here — use /relay/enable for that.
|
|
508
|
+
extraRelayUrls = [...config.extraRelayUrls, canonical];
|
|
509
|
+
targetUrl = canonical;
|
|
510
|
+
}
|
|
396
511
|
}
|
|
397
512
|
|
|
398
|
-
const
|
|
399
|
-
if (!config.enabled ||
|
|
400
|
-
await writeConfig({ enabled: true, relayUrl });
|
|
513
|
+
const endpointsChanged = extraRelayUrls.join('\n') !== config.extraRelayUrls.join('\n');
|
|
514
|
+
if (!config.enabled || endpointsChanged) {
|
|
515
|
+
await writeConfig({ enabled: true, relayUrl: config.relayUrl, extraRelayUrls });
|
|
401
516
|
}
|
|
402
517
|
const next = await readConfig();
|
|
403
518
|
if (next.relayUrl !== config.relayUrl) {
|
|
404
519
|
await notifyRelayUrlChanged();
|
|
405
520
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (!hostClient) {
|
|
414
|
-
const next = await readConfig();
|
|
415
|
-
// Force-claim: creating a pairing link is explicit user intent — the
|
|
416
|
-
// instance the user is pairing against MUST be the one devices reach,
|
|
417
|
-
// even if another local process currently holds the machine's claim
|
|
418
|
-
// (its claim watcher sees the takeover and stands down).
|
|
419
|
-
await start(next.relayUrl, { claim: 'force' });
|
|
420
|
-
}
|
|
421
|
-
return buildPairingCandidate();
|
|
521
|
+
// Force-claim: creating a pairing link is explicit user intent — the
|
|
522
|
+
// instance the user is pairing against MUST be the one devices reach,
|
|
523
|
+
// even if another local process currently holds the machine's claim
|
|
524
|
+
// (its claim watcher sees the takeover and stands down). Also brings up
|
|
525
|
+
// any endpoint that is configured but not live yet.
|
|
526
|
+
await start(next.relayUrls, { claim: 'force' });
|
|
527
|
+
return buildPairingCandidate(targetUrl);
|
|
422
528
|
};
|
|
423
529
|
|
|
424
530
|
const registerRoutes = (app) => {
|
|
@@ -446,14 +552,16 @@ export const createRelayService = ({
|
|
|
446
552
|
}
|
|
447
553
|
relayUrl = canonical;
|
|
448
554
|
}
|
|
449
|
-
await writeConfig({ enabled: true, relayUrl });
|
|
555
|
+
await writeConfig({ enabled: true, relayUrl, extraRelayUrls: current.extraRelayUrls });
|
|
450
556
|
const next = await readConfig();
|
|
451
557
|
if (next.relayUrl !== current.relayUrl) {
|
|
452
558
|
await notifyRelayUrlChanged();
|
|
453
559
|
}
|
|
454
|
-
|
|
560
|
+
// The primary endpoint changed: drop the old control connection before
|
|
561
|
+
// re-establishing (the extras keep their connections).
|
|
562
|
+
if (next.relayUrl !== current.relayUrl) stopHostClient(current.relayUrl);
|
|
455
563
|
// Explicit user action: take the machine's host claim like pairing does.
|
|
456
|
-
await start(
|
|
564
|
+
await start(next.relayUrls, { claim: 'force' });
|
|
457
565
|
res.json(await getStatus());
|
|
458
566
|
} catch (error) {
|
|
459
567
|
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 500;
|
|
@@ -467,7 +575,7 @@ export const createRelayService = ({
|
|
|
467
575
|
return res.status(403).json({ error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
|
|
468
576
|
}
|
|
469
577
|
const current = await readConfig();
|
|
470
|
-
await writeConfig({ enabled: false, relayUrl: current.relayUrl });
|
|
578
|
+
await writeConfig({ enabled: false, relayUrl: current.relayUrl, extraRelayUrls: current.extraRelayUrls });
|
|
471
579
|
stop();
|
|
472
580
|
res.json(await getStatus());
|
|
473
581
|
} catch (error) {
|
|
@@ -475,6 +583,49 @@ export const createRelayService = ({
|
|
|
475
583
|
}
|
|
476
584
|
});
|
|
477
585
|
|
|
586
|
+
// Manage the ADDITIONAL relay endpoints (multi-relay). Body:
|
|
587
|
+
// { relayUrls: string[] } — full replacement list of extra endpoints
|
|
588
|
+
// (primary excluded; an empty array removes every extra endpoint).
|
|
589
|
+
// Owner-only: a UI session or the local desktop shell. Paired remote
|
|
590
|
+
// clients never add or remove a host's relay endpoints — the same boundary
|
|
591
|
+
// that keeps them from re-pointing the primary.
|
|
592
|
+
app.post('/api/openchamber/relay/endpoints', express.json({ limit: '16kb' }), async (req, res) => {
|
|
593
|
+
try {
|
|
594
|
+
if (!hostAllowed()) {
|
|
595
|
+
return res.status(403).json({ error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
|
|
596
|
+
}
|
|
597
|
+
if (!Array.isArray(req.body?.relayUrls)) {
|
|
598
|
+
return res.status(400).json({ error: 'relayUrls must be an array of ws:// or wss:// endpoints' });
|
|
599
|
+
}
|
|
600
|
+
for (const entry of req.body.relayUrls) {
|
|
601
|
+
if (canonicalizeRelayUrl(entry) === null) {
|
|
602
|
+
return res.status(400).json({ error: 'Relay URL must use ws:// or wss://' });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!(await isOwnerRequest(req, res))) {
|
|
606
|
+
return res.status(403).json({ error: 'Only the owner UI session can manage relay endpoints' });
|
|
607
|
+
}
|
|
608
|
+
const current = await readConfig();
|
|
609
|
+
const primary = current.relayUrl;
|
|
610
|
+
const requested = normalizeExtraRelayUrls(req.body.relayUrls).filter((url) => url !== primary);
|
|
611
|
+
if (!current.enabled) {
|
|
612
|
+
// Nothing is hosted yet: just persist; the next pairing/demand cycle
|
|
613
|
+
// brings the endpoints up.
|
|
614
|
+
await writeConfig({ enabled: false, relayUrl: primary, extraRelayUrls: requested });
|
|
615
|
+
return res.json(await getStatus());
|
|
616
|
+
}
|
|
617
|
+
await writeConfig({ enabled: true, relayUrl: primary, extraRelayUrls: requested });
|
|
618
|
+
// Incremental lifecycle: stop removed endpoints, start added ones.
|
|
619
|
+
for (const removed of current.extraRelayUrls.filter((url) => !requested.includes(url))) {
|
|
620
|
+
stopHostClient(removed);
|
|
621
|
+
}
|
|
622
|
+
await start([primary, ...requested], { claim: 'force' });
|
|
623
|
+
res.json(await getStatus());
|
|
624
|
+
} catch (error) {
|
|
625
|
+
res.status(500).json({ error: error?.message ?? 'Failed to update relay endpoints' });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
478
629
|
};
|
|
479
630
|
|
|
480
631
|
return {
|
|
@@ -485,6 +636,7 @@ export const createRelayService = ({
|
|
|
485
636
|
getStatus,
|
|
486
637
|
getServerId,
|
|
487
638
|
getPairingCandidate,
|
|
639
|
+
getPairingCandidates,
|
|
488
640
|
ensureEnabledForPairing,
|
|
489
641
|
};
|
|
490
642
|
};
|
|
@@ -123,7 +123,7 @@ describe('relay service pairing endpoint', () => {
|
|
|
123
123
|
delete process.env.OPENCHAMBER_RELAY_URL;
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
it('
|
|
126
|
+
it('appends a new pairing endpoint (multi-relay) while keeping the primary', async () => {
|
|
127
127
|
const { service, settings } = createTestRelayService();
|
|
128
128
|
|
|
129
129
|
const initial = await service.ensureEnabledForPairing();
|
|
@@ -131,11 +131,18 @@ describe('relay service pairing endpoint', () => {
|
|
|
131
131
|
|
|
132
132
|
const custom = await service.ensureEnabledForPairing('wss://relay.example/custom');
|
|
133
133
|
expect(custom.relayUrl).toBe('wss://relay.example/custom');
|
|
134
|
+
// The primary stays live; the custom endpoint is hosted alongside it so
|
|
135
|
+
// devices paired over the old endpoint keep working.
|
|
134
136
|
expect(relayMocks.starts).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom']);
|
|
135
|
-
expect(relayMocks.stops).toEqual([
|
|
137
|
+
expect(relayMocks.stops).toEqual([]);
|
|
136
138
|
expect(settings.current()).toMatchObject({
|
|
137
|
-
privateRelay: { enabled: true, relayUrl: 'wss://relay.example/custom' },
|
|
139
|
+
privateRelay: { enabled: true, relayUrl: DEFAULT_RELAY_URL, extraRelayUrls: ['wss://relay.example/custom'] },
|
|
138
140
|
});
|
|
141
|
+
|
|
142
|
+
// Pairing again over the same endpoint is idempotent (no duplicate host).
|
|
143
|
+
const again = await service.ensureEnabledForPairing('wss://relay.example/custom');
|
|
144
|
+
expect(again.relayUrl).toBe('wss://relay.example/custom');
|
|
145
|
+
expect(relayMocks.starts).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom']);
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
it('strips query and fragment before persisting a custom endpoint', async () => {
|
|
@@ -144,7 +151,7 @@ describe('relay service pairing endpoint', () => {
|
|
|
144
151
|
const candidate = await service.ensureEnabledForPairing('wss://relay.example/custom?token=secret#frag');
|
|
145
152
|
expect(candidate.relayUrl).toBe('wss://relay.example/custom');
|
|
146
153
|
expect(settings.current()).toMatchObject({
|
|
147
|
-
privateRelay: { enabled: true, relayUrl: 'wss://relay.example/custom' },
|
|
154
|
+
privateRelay: { enabled: true, relayUrl: DEFAULT_RELAY_URL, extraRelayUrls: ['wss://relay.example/custom'] },
|
|
148
155
|
});
|
|
149
156
|
});
|
|
150
157
|
|
|
@@ -198,32 +205,35 @@ describe('relay service pairing endpoint', () => {
|
|
|
198
205
|
expect(relayMocks.starts).toEqual(['wss://relay.example/custom']);
|
|
199
206
|
});
|
|
200
207
|
|
|
201
|
-
it('invokes onRelayUrlChanged only when the canonical URL actually changes', async () => {
|
|
208
|
+
it('invokes onRelayUrlChanged only when the canonical primary URL actually changes', async () => {
|
|
202
209
|
const onRelayUrlChanged = vi.fn(async () => {});
|
|
203
210
|
const { service } = createTestRelayService({ onRelayUrlChanged });
|
|
204
211
|
|
|
205
212
|
await service.ensureEnabledForPairing();
|
|
206
213
|
expect(onRelayUrlChanged).not.toHaveBeenCalled();
|
|
207
214
|
|
|
215
|
+
// Appending an extra endpoint does not touch the primary → the push relay
|
|
216
|
+
// registration (derived from the primary) stays valid.
|
|
208
217
|
await service.ensureEnabledForPairing('wss://relay.example/custom');
|
|
209
|
-
expect(onRelayUrlChanged).
|
|
218
|
+
expect(onRelayUrlChanged).not.toHaveBeenCalled();
|
|
210
219
|
|
|
211
220
|
await service.ensureEnabledForPairing('wss://relay.example/custom?x=1#frag');
|
|
212
|
-
expect(onRelayUrlChanged).
|
|
221
|
+
expect(onRelayUrlChanged).not.toHaveBeenCalled();
|
|
213
222
|
|
|
223
|
+
// Changing the primary via /relay/enable does.
|
|
214
224
|
const app = express();
|
|
215
225
|
service.registerRoutes(app);
|
|
216
226
|
await request(app)
|
|
217
227
|
.post('/api/openchamber/relay/enable')
|
|
218
228
|
.send({ relayUrl: 'wss://relay.example/other' })
|
|
219
229
|
.expect(200);
|
|
220
|
-
expect(onRelayUrlChanged).toHaveBeenCalledTimes(
|
|
230
|
+
expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
|
|
221
231
|
|
|
222
232
|
await request(app)
|
|
223
233
|
.post('/api/openchamber/relay/enable')
|
|
224
234
|
.send({ relayUrl: 'wss://relay.example/other' })
|
|
225
235
|
.expect(200);
|
|
226
|
-
expect(onRelayUrlChanged).toHaveBeenCalledTimes(
|
|
236
|
+
expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
|
|
227
237
|
});
|
|
228
238
|
|
|
229
239
|
it('does not invoke onRelayUrlChanged for an env-pinned relay and still uses the pin', async () => {
|
|
@@ -245,20 +255,93 @@ describe('relay service pairing endpoint', () => {
|
|
|
245
255
|
expect((await service.getStatus()).relayUrl).toBe('wss://pinned.example/ws');
|
|
246
256
|
});
|
|
247
257
|
|
|
248
|
-
it('continues the
|
|
258
|
+
it('continues the primary switch when onRelayUrlChanged fails', async () => {
|
|
249
259
|
const onRelayUrlChanged = vi.fn(async () => {
|
|
250
260
|
throw new Error('push relay unavailable');
|
|
251
261
|
});
|
|
252
262
|
const logger = { warn: vi.fn() };
|
|
253
263
|
const { service } = createTestRelayService({ onRelayUrlChanged, logger });
|
|
264
|
+
const app = express();
|
|
265
|
+
service.registerRoutes(app);
|
|
254
266
|
|
|
255
|
-
await
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
await request(app)
|
|
268
|
+
.post('/api/openchamber/relay/enable')
|
|
269
|
+
.send({ relayUrl: 'wss://relay.example/custom' })
|
|
270
|
+
.expect(200);
|
|
258
271
|
expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
|
|
259
272
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('push token re-register failed'));
|
|
260
273
|
expect(relayMocks.starts).toEqual(['wss://relay.example/custom']);
|
|
261
274
|
});
|
|
275
|
+
|
|
276
|
+
it('exposes every configured endpoint in status and pairing candidates', async () => {
|
|
277
|
+
const { service } = createTestRelayService();
|
|
278
|
+
await service.ensureEnabledForPairing('wss://relay.example/custom');
|
|
279
|
+
|
|
280
|
+
const status = await service.getStatus();
|
|
281
|
+
expect(status.relayUrl).toBe(DEFAULT_RELAY_URL);
|
|
282
|
+
expect(status.relayUrls).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom']);
|
|
283
|
+
expect(status.endpoints.map((entry) => entry.relayUrl)).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom']);
|
|
284
|
+
|
|
285
|
+
const candidates = await service.getPairingCandidates();
|
|
286
|
+
expect(candidates.map((candidate) => candidate.relayUrl)).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom']);
|
|
287
|
+
expect(candidates.every((candidate) => candidate.serverId === 'server-test')).toBe(true);
|
|
288
|
+
|
|
289
|
+
// The single-candidate accessor still yields the primary for legacy callers.
|
|
290
|
+
const primary = await service.getPairingCandidate();
|
|
291
|
+
expect(primary.relayUrl).toBe(DEFAULT_RELAY_URL);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('replaces the additional endpoints through the owner-gated endpoints route', async () => {
|
|
295
|
+
const isOwnerRequest = vi.fn(async () => true);
|
|
296
|
+
const { service, settings } = createTestRelayService({ isOwnerRequest });
|
|
297
|
+
const app = express();
|
|
298
|
+
service.registerRoutes(app);
|
|
299
|
+
|
|
300
|
+
// Seed an enabled relay with one extra endpoint.
|
|
301
|
+
await service.ensureEnabledForPairing('wss://relay.example/custom');
|
|
302
|
+
|
|
303
|
+
const response = await request(app)
|
|
304
|
+
.post('/api/openchamber/relay/endpoints')
|
|
305
|
+
.send({ relayUrls: ['wss://relay.example/custom?x=1', 'wss://relay2.example/ws'] })
|
|
306
|
+
.expect(200);
|
|
307
|
+
expect(response.body.relayUrls).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom', 'wss://relay2.example/ws']);
|
|
308
|
+
expect(settings.current()).toMatchObject({
|
|
309
|
+
privateRelay: {
|
|
310
|
+
enabled: true,
|
|
311
|
+
relayUrl: DEFAULT_RELAY_URL,
|
|
312
|
+
extraRelayUrls: ['wss://relay.example/custom', 'wss://relay2.example/ws'],
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
// Kept endpoint stays live; only the added endpoint starts.
|
|
316
|
+
expect(relayMocks.stops).toEqual([]);
|
|
317
|
+
expect(relayMocks.starts).toEqual([DEFAULT_RELAY_URL, 'wss://relay.example/custom', 'wss://relay2.example/ws']);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('stops removed endpoints and refuses non-owner endpoint management', async () => {
|
|
321
|
+
const isOwnerRequest = vi.fn(async () => false);
|
|
322
|
+
const { service, settings } = createTestRelayService({ isOwnerRequest });
|
|
323
|
+
const app = express();
|
|
324
|
+
service.registerRoutes(app);
|
|
325
|
+
|
|
326
|
+
await request(app)
|
|
327
|
+
.post('/api/openchamber/relay/endpoints')
|
|
328
|
+
.send({ relayUrls: ['wss://relay.example/custom'] })
|
|
329
|
+
.expect(403, { error: 'Only the owner UI session can manage relay endpoints' });
|
|
330
|
+
expect(settings.current()).toEqual({});
|
|
331
|
+
|
|
332
|
+
// Owner flow: removing an extra endpoint stops its host connection.
|
|
333
|
+
const { service: ownerService } = createTestRelayService({ isOwnerRequest: async () => true });
|
|
334
|
+
const ownerApp = express();
|
|
335
|
+
ownerService.registerRoutes(ownerApp);
|
|
336
|
+
await ownerService.ensureEnabledForPairing('wss://relay.example/custom');
|
|
337
|
+
relayMocks.stops.length = 0;
|
|
338
|
+
await request(ownerApp)
|
|
339
|
+
.post('/api/openchamber/relay/endpoints')
|
|
340
|
+
.send({ relayUrls: [] })
|
|
341
|
+
.expect(200);
|
|
342
|
+
expect(relayMocks.stops).toEqual(['wss://relay.example/custom']);
|
|
343
|
+
expect((await ownerService.getStatus()).relayUrls).toEqual([DEFAULT_RELAY_URL]);
|
|
344
|
+
});
|
|
262
345
|
});
|
|
263
346
|
|
|
264
347
|
describe('relay host runtime gate', () => {
|