@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.
Files changed (172) hide show
  1. package/dist/assets/{AgentAvatar-CVTgW_rx.js → AgentAvatar-BceI_qBC.js} +1 -1
  2. package/dist/assets/{AssistantView-D73E__f8.js → AssistantView-lSnZ7082.js} +1 -1
  3. package/dist/assets/{ChatContainer-pWolXKsP.js → ChatContainer-DJLqUdGE.js} +39 -39
  4. package/dist/assets/ContextPanel-CfO8NpOL.js +285 -0
  5. package/dist/assets/{DiagramView-BJBf7KGE.js → DiagramView-_LIlKVbO.js} +1 -1
  6. package/dist/assets/{FadeInOnReveal-Cy3sf2M5.js → FadeInOnReveal-Cj9ZC3Ab.js} +1 -1
  7. package/dist/assets/{Icon-BLWVqBee.js → Icon-Du1LX11Q.js} +1 -1
  8. package/dist/assets/MarkdownRendererImpl-DpapNgFo.js +7 -0
  9. package/dist/assets/{MobileDetailNavigation-CUq86QUr.js → MobileDetailNavigation-Ct_3mr5K.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BMolTtSL.js → MobileShareBridge-7LjBYxHH.js} +2 -2
  11. package/dist/assets/MobileSurface-CuEJc-ya.js +2 -0
  12. package/dist/assets/{MultiRunWindow-Bv3moZF8.js → MultiRunWindow-BEM2Ucj4.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-CTNi2zuA.js → OnboardingScreen-Czusf7rR.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-GQgJusKL.js → OverlayScrollbar-oOLMqq2s.js} +1 -1
  15. package/dist/assets/SettingsView-DUeyhTBn.js +25 -0
  16. package/dist/assets/{SettingsWindow-BMtwRXID.js → SettingsWindow-Qa8HXF_v.js} +1 -1
  17. package/dist/assets/{TerminalView-DwzG7zBO.js → TerminalView-CBSreprw.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-iYAB6WiK.js → ToolOutputDialog-CRIU0XUQ.js} +1 -1
  19. package/dist/assets/{angular-html-BXZQHcPZ.js → angular-html-CtgxlIZT.js} +1 -1
  20. package/dist/assets/{angular-ts-D8x7dmpg.js → angular-ts-DrvebXRV.js} +1 -1
  21. package/dist/assets/{apl-ugbjmfzm.js → apl-ct3-l_ov.js} +1 -1
  22. package/dist/assets/appThemeRegistry-CvvscooO.js +2 -0
  23. package/dist/assets/{astro-BrfzL78y.js → astro-9KCX_vNx.js} +1 -1
  24. package/dist/assets/{blade-D4cYRUdW.js → blade-DHzjLhcB.js} +1 -1
  25. package/dist/assets/{button-CeCkEpvn.js → button-p6vbd3r6.js} +1 -1
  26. package/dist/assets/{c-CMzMVpWc.js → c-CEfQ88z4.js} +1 -1
  27. package/dist/assets/{clipboard-BXC1vZwE.js → clipboard-Ca2PSbr_.js} +1 -1
  28. package/dist/assets/{cobol-DthQPUuS.js → cobol-ffQsCDPB.js} +1 -1
  29. package/dist/assets/{coffee-8Pqo9WtM.js → coffee-DtRj345i.js} +1 -1
  30. package/dist/assets/{collapsible-B-aagADh.js → collapsible-Cimoz0kx.js} +1 -1
  31. package/dist/assets/{cpp-D18L7odM.js → cpp-NWnna9VM.js} +1 -1
  32. package/dist/assets/{crystal-9GeLf5Ic.js → crystal-CnnqEH_e.js} +1 -1
  33. package/dist/assets/{css-CCmJvSl6.js → css-BRwHOW3t.js} +1 -1
  34. package/dist/assets/{desktopHosts-BrrYPqKq.js → desktopHosts-DceeVBky.js} +1 -1
  35. package/dist/assets/{desktopRelayRestore-DR60DATT.js → desktopRelayRestore-B0MRdR5y.js} +1 -1
  36. package/dist/assets/{diagram-Cxma_IKI.js → diagram-DX_MPRs3.js} +1 -1
  37. package/dist/assets/{dialog-wraJHZQ2.js → dialog-B2ieeCSR.js} +1 -1
  38. package/dist/assets/{edge-BNU68miL.js → edge-BmL4YNvW.js} +1 -1
  39. package/dist/assets/{elixir-Clm-oycI.js → elixir-m9Mcv-AW.js} +1 -1
  40. package/dist/assets/{elm-DyuJMdF4.js → elm-Dazsuz2c.js} +1 -1
  41. package/dist/assets/{erb-BGQIoPeF.js → erb-CJ1X7LGG.js} +1 -1
  42. package/dist/assets/{es-DQqFAaWT.js → es-Bj9HwM-0.js} +3 -3
  43. package/dist/assets/{fileQueries-BJKLdYos.js → fileQueries-BDld6h3B.js} +1 -1
  44. package/dist/assets/{fr-CNfGRpc1.js → fr-BgEHZGug.js} +3 -3
  45. package/dist/assets/{git-rebase-ZHVbPCu8.js → git-rebase-DHqm8iFM.js} +1 -1
  46. package/dist/assets/{gitApi-C6XQwHcm.js → gitApi-BVB4hH6A.js} +3 -3
  47. package/dist/assets/{glimmer-js-Dx29eTyZ.js → glimmer-js-529w2Uvl.js} +1 -1
  48. package/dist/assets/{glimmer-ts-7AFohuRx.js → glimmer-ts-B38MdnnB.js} +1 -1
  49. package/dist/assets/{glsl-Nbih-1pG.js → glsl-IFfKD25X.js} +1 -1
  50. package/dist/assets/{graphql-IOG_4fSc.js → graphql-m-L9qWWB.js} +1 -1
  51. package/dist/assets/{hack-DyZU34CD.js → hack-S2S7pq0v.js} +1 -1
  52. package/dist/assets/{haml-mwEldSxy.js → haml-BE8HBwbH.js} +1 -1
  53. package/dist/assets/{handlebars-BRbCg-TR.js → handlebars-DPMWj4Da.js} +1 -1
  54. package/dist/assets/{html-derivative-DpusPW9p.js → html-derivative-D0yehinc.js} +1 -1
  55. package/dist/assets/{html-DH50wxss.js → html-gfIVmLgx.js} +1 -1
  56. package/dist/assets/{http-fM2Ft62a.js → http-Yj1uQSnx.js} +1 -1
  57. package/dist/assets/{hurl-C7ZRA11y.js → hurl-CiAC6gLf.js} +1 -1
  58. package/dist/assets/{input-CyG05w-V.js → input-gaFVNq1P.js} +1 -1
  59. package/dist/assets/insertionBoundaries-Cpj9r_o6.js +4 -0
  60. package/dist/assets/{ja-CQqehnVn.js → ja-clyxf6re.js} +3 -3
  61. package/dist/assets/{java-CuLv1JzP.js → java-lPyr66Qw.js} +1 -1
  62. package/dist/assets/{javascript-e9R9mij1.js → javascript-BlmKDQ-I.js} +1 -1
  63. package/dist/assets/{jinja-BqL4dpCe.js → jinja-DrvjNn4y.js} +1 -1
  64. package/dist/assets/{jison-BU1E246U.js → jison-BZ-k3PlS.js} +1 -1
  65. package/dist/assets/{json-CWUAz4dE.js → json-CaQi8cq5.js} +1 -1
  66. package/dist/assets/{jsx-Tb9QFw4P.js → jsx-kfzB3oTi.js} +1 -1
  67. package/dist/assets/{julia-C6q7nYBJ.js → julia-Bup0jpIM.js} +1 -1
  68. package/dist/assets/{just-CLHVCPQG.js → just-XMsX5iKL.js} +1 -1
  69. package/dist/assets/{ko-kmwi8UJD.js → ko-BZb3gnuq.js} +3 -3
  70. package/dist/assets/{latex-DzR96v9w.js → latex-CHhOHp9u.js} +1 -1
  71. package/dist/assets/{liquid-UjsTutWK.js → liquid-C90FVKxw.js} +1 -1
  72. package/dist/assets/{lua-laWCoC7H.js → lua-BXPwTBAD.js} +1 -1
  73. package/dist/assets/main-BsdsPsHT.js +29 -0
  74. package/dist/assets/{main-OM6EVqFo.js → main-CQLsl_QE.js} +2 -2
  75. package/dist/assets/markdown-shiki.worker-woF2hOAN.js +349 -0
  76. package/dist/assets/{markdown-worker-DqO-GnVj.js → markdown-worker-CoO82KYF.js} +4 -4
  77. package/dist/assets/{markdownTheme-H7_7lbuh.js → markdownTheme-St8zxDvJ.js} +1 -1
  78. package/dist/assets/{marko-Cw81uHQK.js → marko-oFkW-Ef7.js} +1 -1
  79. package/dist/assets/{mdc-5VRIrvpo.js → mdc-Dt17Fv3G.js} +1 -1
  80. package/dist/assets/{miniChat-CWJejsNH.js → miniChat-w-S3l9bR.js} +2 -2
  81. package/dist/assets/mobile-CHDToW-w.js +2 -0
  82. package/dist/assets/multirun-CMEZHI5g.js +4 -0
  83. package/dist/assets/{nginx-OjbRtDTh.js → nginx-LfNI-F2k.js} +1 -1
  84. package/dist/assets/{nim-BqzUQFfL.js → nim-so0j2_Ky.js} +1 -1
  85. package/dist/assets/{perl-Bba2cEgf.js → perl-DQcGQU9O.js} +1 -1
  86. package/dist/assets/{persistence-DE9RnVcA.js → persistence-CLUawIlm.js} +1 -1
  87. package/dist/assets/{php-CkVgF6HF.js → php-Dae1IcCM.js} +1 -1
  88. package/dist/assets/{pl-Blv5o2QA.js → pl-CwZzSvrm.js} +3 -3
  89. package/dist/assets/{platform-MjT8iqKB.js → platform-BrncW9ab.js} +1 -1
  90. package/dist/assets/{projectMeta-DCJQ6I3m.js → projectMeta-ZD-iXSOT.js} +2 -2
  91. package/dist/assets/{pt-BR-BK74t-Bd.js → pt-BR-6ali2BQX.js} +3 -3
  92. package/dist/assets/{pug-Dj44SzNY.js → pug-CaHd5xEa.js} +1 -1
  93. package/dist/assets/{qml-DETHcUPx.js → qml-lLqImktD.js} +1 -1
  94. package/dist/assets/{r-CrbPkwLJ.js → r-C2VuzPsf.js} +1 -1
  95. package/dist/assets/{radio-MAnQ7sDW.js → radio-B-nm8u03.js} +1 -1
  96. package/dist/assets/{razor-56IyEPA5.js → razor-DTpzsAil.js} +1 -1
  97. package/dist/assets/{regexp-BEMDgR1N.js → regexp-DJeMtopQ.js} +1 -1
  98. package/dist/assets/renderElectronMiniChatApp-65t0A6BD.js +2 -0
  99. package/dist/assets/renderMobileApp-a3Joz-yn.js +25 -0
  100. package/dist/assets/{rst-C8BEzC8U.js → rst-c6kvN9mK.js} +1 -1
  101. package/dist/assets/{ruby-DWyeL44t.js → ruby-B68kNTjT.js} +1 -1
  102. package/dist/assets/{runtimeConfig-C2Vygqku.js → runtimeConfig-BGuiq1aJ.js} +1 -1
  103. package/dist/assets/runtimeEndpointReset-HC9CW_hT.js +4 -0
  104. package/dist/assets/{sas-Dt-MhHv8.js → sas-Br3BRi6f.js} +1 -1
  105. package/dist/assets/{scss-jmTM-KWO.js → scss-ugvUHawi.js} +1 -1
  106. package/dist/assets/{shellscript-BgI_n_Bv.js → shellscript-Ci5rJl0t.js} +1 -1
  107. package/dist/assets/{shellsession-DhmUadx_.js → shellsession-Dxn1WKgk.js} +1 -1
  108. package/dist/assets/{shikiHighlight-Bpy8sf5r.js → shikiHighlight-DFiqYozo.js} +1 -1
  109. package/dist/assets/{sortable-tabs-strip-BdbKDDAP.js → sortable-tabs-strip-CxLE1X9x.js} +1 -1
  110. package/dist/assets/{soy-qfQbh7dQ.js → soy-kgnQSRVZ.js} +1 -1
  111. package/dist/assets/{sql-CwyNz0Gh.js → sql-Cx3XaVwp.js} +1 -1
  112. package/dist/assets/src-D-LxNwlu.css +2 -0
  113. package/dist/assets/{stata-D9MlkA-a.js → stata-B5YhL3cp.js} +1 -1
  114. package/dist/assets/{store-UweBeN2y.js → store-BvrCRq6U.js} +3 -3
  115. package/dist/assets/{surrealql-xR93a923.js → surrealql-B8ysTfdK.js} +1 -1
  116. package/dist/assets/{svelte-BA7ahdky.js → svelte-D5-K3GME.js} +1 -1
  117. package/dist/assets/{templ-CxpzFYl8.js → templ-n2DDBrat.js} +1 -1
  118. package/dist/assets/{terminalApi-Bnx0KvaV.js → terminalApi-D2OzR0I3.js} +1 -1
  119. package/dist/assets/{tex-CePKgOWg.js → tex-CpNhXIXy.js} +1 -1
  120. package/dist/assets/{tooltip-B__x-HDJ.js → tooltip-D1a6rKLA.js} +1 -1
  121. package/dist/assets/{ts-tags-DP2fsj53.js → ts-tags-CrtENrUj.js} +1 -1
  122. package/dist/assets/{tsx-CI52QFY8.js → tsx-DEnzboO-.js} +1 -1
  123. package/dist/assets/{twig-BjoebDgo.js → twig-B7PeheP0.js} +1 -1
  124. package/dist/assets/{typescript-cpHrN9Ya.js → typescript-CUlPIybL.js} +1 -1
  125. package/dist/assets/{uk-6I-KXy3R.js → uk-qRqJs4GS.js} +3 -3
  126. package/dist/assets/{url-CTuw7Rjf.js → url-zwuNq3GS.js} +1 -1
  127. package/dist/assets/{useAppFontEffects-BVVxQm3q.js → useAppFontEffects-pF6rKdKM.js} +4 -4
  128. package/dist/assets/{useAssistantUIStore-D2K7ksGj.js → useAssistantUIStore-D699Sbdb.js} +1 -1
  129. package/dist/assets/{useDesktopWindowControlsLayout-BR2bI_26.js → useDesktopWindowControlsLayout-DH7Om-wY.js} +1 -1
  130. package/dist/assets/{useEffectiveDirectory-DdS6rQm9.js → useEffectiveDirectory-BvZFK9wU.js} +1 -1
  131. package/dist/assets/{useFontPreferences-CWZnNRjz.js → useFontPreferences-F2QPNE2V.js} +1 -1
  132. package/dist/assets/{useMobileNavigationStore-DFSb2gzX.js → useMobileNavigationStore-CGOeJb9D.js} +10 -10
  133. package/dist/assets/{useSessionAutoCleanup-BNC1lVkC.js → useSessionAutoCleanup-lBn8oZ8f.js} +1 -1
  134. package/dist/assets/{useUIStore-DOxLVeGb.js → useUIStore-BXjMQrel.js} +1 -1
  135. package/dist/assets/{useWorkerHighlightedLines-B9mnTmvs.js → useWorkerHighlightedLines-Bc1NJlYO.js} +2 -2
  136. package/dist/assets/{utils-C2R0YoC5.js → utils-D94hxQYq.js} +1 -1
  137. package/dist/assets/{vue-DK5KPkZ5.js → vue-C6ZZy2AI.js} +1 -1
  138. package/dist/assets/{vue-html-BlUa8woS.js → vue-html-DsvGlIY2.js} +1 -1
  139. package/dist/assets/{vue-vine-D3hlJyLi.js → vue-vine-C8VLalyn.js} +1 -1
  140. package/dist/assets/{xml-CspvrcYx.js → xml-ChNx2zKg.js} +1 -1
  141. package/dist/assets/{xsl-D10hdnv5.js → xsl-yWKkpZli.js} +1 -1
  142. package/dist/assets/{yaml-C00-kDDU.js → yaml-CENNGtdf.js} +1 -1
  143. package/dist/assets/{zh-CN-DwlzS4yK.js → zh-CN-CwZ0adcY.js} +3 -3
  144. package/dist/assets/{zh-TW-p0VriyfT.js → zh-TW-DC0jiCzA.js} +3 -3
  145. package/dist/index.html +9 -9
  146. package/dist/mini-chat.html +9 -9
  147. package/dist/mobile.html +9 -9
  148. package/package.json +1 -1
  149. package/server/index.js +40 -3
  150. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  151. package/server/lib/notifications/push-runtime.js +4 -14
  152. package/server/lib/notifications/runtime.js +11 -20
  153. package/server/lib/notifications/runtime.test.js +4 -6
  154. package/server/lib/opencode/core-routes.js +56 -23
  155. package/server/lib/opencode/core-routes.test.js +206 -6
  156. package/server/lib/relay/DOCUMENTATION.md +2 -1
  157. package/server/lib/relay/service.js +223 -71
  158. package/server/lib/relay/service.test.js +96 -13
  159. package/dist/assets/ContextPanel-GBq5lSES.js +0 -285
  160. package/dist/assets/MarkdownRendererImpl-BUXY9iuu.js +0 -7
  161. package/dist/assets/MobileSurface-DDRPrlAq.js +0 -2
  162. package/dist/assets/SettingsView-Ip_jVXoD.js +0 -25
  163. package/dist/assets/appThemeRegistry-rHYlrhb5.js +0 -2
  164. package/dist/assets/insertionBoundaries-DG_1_ENK.js +0 -4
  165. package/dist/assets/main-MkZ3xcbE.js +0 -29
  166. package/dist/assets/markdown-shiki.worker-CKKjvdhc.js +0 -12
  167. package/dist/assets/mobile-D-ubuubW.js +0 -2
  168. package/dist/assets/multirun-YB7NFEGX.js +0 -4
  169. package/dist/assets/renderElectronMiniChatApp-BPZ5LfbX.js +0 -2
  170. package/dist/assets/renderMobileApp-pqjbEzlN.js +0 -23
  171. package/dist/assets/runtimeEndpointReset-CuW2UDPM.js +0 -4
  172. 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 tunnels/notifications).
6
- // Routes are registered with the other OpenChamber feature routes, before the
7
- // generic OpenCode proxy, and are covered by the same global UI auth gate.
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
- let hostClient = null;
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: resolveEffectiveRelayUrl({ settings }),
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: normalizeRelayUrl(config.relayUrl) },
217
+ privateRelay: { enabled: config.enabled === true, relayUrl: primary, extraRelayUrls: extras },
180
218
  });
181
219
  };
182
220
 
183
- const stopHostClient = () => {
184
- if (!hostClient) return;
185
- hostClient.stop();
186
- hostClient = null;
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 = (relayUrl) => {
260
+ const ensureClaimWatch = () => {
201
261
  if (!hostLock || claimWatchTimer) return;
202
262
  claimWatchTimer = setInterval(() => {
203
263
  void (async () => {
204
264
  try {
205
- if (hostClient) {
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
- stopHostClient();
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 start(relayUrl);
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
- const start = async (relayUrl, { claim = 'try' } = {}) => {
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
- if (hostClient) return;
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(relayUrl);
310
+ ensureClaimWatch();
243
311
  return;
244
312
  }
245
313
  }
246
314
  const identity = await identityRuntime.getRelayIdentity();
247
- hostClient = startRelayHost({
248
- relayUrl,
249
- identity,
250
- getLocalPort,
251
- logger,
252
- onStatus: (next) => {
253
- status = next;
254
- },
255
- });
256
- status = hostClient.getStatus();
257
- ensureClaimWatch(relayUrl);
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
- stopHostClient();
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.relayUrl);
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 (!hostClient) {
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.relayUrl);
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 (!hostClient) await start(config.relayUrl);
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
- const live = hostClient ? hostClient.getStatus() : status;
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 a host client the service is either off or standing by while
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: hostClient ? live.state : (status.state === 'standby' ? 'standby' : 'disabled'),
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: config.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
- // Enable the relay host on demand and return its pairing candidate. Creating a
381
- // relay pairing link IS the demand signal, so the relay turns itself on here
382
- // rather than requiring a separate manual toggle. Idempotent: a no-op when the
383
- // relay is already enabled and running.
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 relayUrl = config.relayUrl;
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 = canonical;
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 endpointChanged = relayUrl !== config.relayUrl;
399
- if (!config.enabled || endpointChanged) {
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
- if (endpointChanged) {
407
- // One Host identity can have only one live Relay control connection. A
408
- // custom endpoint therefore becomes the authoritative transport before
409
- // its pairing candidate is returned. Stop also clears a standby claim
410
- // watcher whose retry closure still targets the previous endpoint.
411
- stop();
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
- if (hostClient) stop();
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(relayUrl, { claim: 'force' });
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('switches the Host, persists the endpoint, and embeds it in the pairing candidate', async () => {
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([DEFAULT_RELAY_URL]);
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).toHaveBeenCalledTimes(1);
218
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
210
219
 
211
220
  await service.ensureEnabledForPairing('wss://relay.example/custom?x=1#frag');
212
- expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
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(2);
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(2);
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 relay switch when onRelayUrlChanged fails', async () => {
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 expect(service.ensureEnabledForPairing('wss://relay.example/custom')).resolves.toMatchObject({
256
- relayUrl: 'wss://relay.example/custom',
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', () => {