@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
@@ -915,13 +915,17 @@ describe('core-routes', () => {
915
915
  ]);
916
916
  });
917
917
 
918
- it('forbids non-desktop client tokens from setting a custom Relay endpoint', async () => {
919
- const getRelayPairingCandidate = vi.fn();
918
+ it('lets paired desktop clients create pairing sessions like the local owner (relay-attached parity)', async () => {
919
+ const getRelayPairingCandidate = vi.fn(async () => ({
920
+ type: 'relay',
921
+ relayUrl: 'wss://relay.example/ws',
922
+ serverId: 'srv_desktop',
923
+ hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
924
+ priority: 30,
925
+ }));
920
926
  const { app, dependencies } = createPairingRouteApp({
921
927
  getRelayPairingCandidate,
922
928
  uiAuthController: {
923
- // Force a remote desktop client past create-auth so the relayUrl gate is
924
- // exercised independently of the desktop-local create-auth admission rule.
925
929
  resolveAuthContext: vi.fn(async () => ({
926
930
  type: 'client',
927
931
  clientId: 'remote-desktop-1',
@@ -943,15 +947,152 @@ describe('core-routes', () => {
943
947
  },
944
948
  });
945
949
 
950
+ const response = await request(app)
951
+ .post('/api/client-auth/pairing/sessions')
952
+ .set('Host', 'runtime.example')
953
+ .send({ label: 'Pair phone', includeRelay: true })
954
+ .expect(201);
955
+
956
+ expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith({
957
+ label: 'Pair phone',
958
+ allowedClientKinds: undefined,
959
+ createdByClientId: 'remote-desktop-1',
960
+ usesRelay: true,
961
+ });
962
+ expect(response.body.server.candidates).toEqual([
963
+ { type: 'lan', url: 'http://runtime.example', priority: 10 },
964
+ expect.objectContaining({ type: 'relay', relayUrl: 'wss://relay.example/ws' }),
965
+ ]);
966
+ });
967
+
968
+ it('rejects pairing-session creation from mobile client tokens', async () => {
969
+ const { app, dependencies } = createPairingRouteApp({
970
+ uiAuthController: {
971
+ resolveAuthContext: vi.fn(async () => ({
972
+ type: 'client',
973
+ clientId: 'phone-1',
974
+ client: { id: 'phone-1', clientKind: 'mobile' },
975
+ })),
976
+ requireAuth: vi.fn((_req, _res, next) => next()),
977
+ requireSessionAuth: vi.fn((_req, _res, next) => next()),
978
+ handleSessionStatus: vi.fn(),
979
+ handleSessionCreate: vi.fn(),
980
+ handleUrlAuthToken: vi.fn(),
981
+ handlePasskeyStatus: vi.fn(),
982
+ handlePasskeyAuthenticationOptions: vi.fn(),
983
+ handlePasskeyAuthenticationVerify: vi.fn(),
984
+ handlePasskeyRegistrationOptions: vi.fn(),
985
+ handlePasskeyRegistrationVerify: vi.fn(),
986
+ handlePasskeyList: vi.fn(),
987
+ handlePasskeyRevoke: vi.fn(),
988
+ handleResetAuth: vi.fn(),
989
+ },
990
+ });
991
+
946
992
  await request(app)
947
993
  .post('/api/client-auth/pairing/sessions')
948
- .send({ includeRelay: true, relayUrl: 'wss://relay.example/custom' })
994
+ .send({ label: 'Pair phone' })
949
995
  .expect(403, { error: 'Client tokens cannot create remote clients' });
950
996
 
997
+ expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
998
+ });
999
+
1000
+ it('forbids paired desktop clients from re-pointing the Relay endpoint when pairing', async () => {
1001
+ const getRelayPairingCandidate = vi.fn();
1002
+ const getEffectiveRelayUrls = vi.fn(async () => ['wss://relay.example/ws']);
1003
+ const { app, dependencies } = createPairingRouteApp({
1004
+ getRelayPairingCandidate,
1005
+ getEffectiveRelayUrls,
1006
+ uiAuthController: {
1007
+ resolveAuthContext: vi.fn(async () => ({
1008
+ type: 'client',
1009
+ clientId: 'remote-desktop-1',
1010
+ client: { id: 'remote-desktop-1', clientKind: 'desktop' },
1011
+ })),
1012
+ requireAuth: vi.fn((_req, _res, next) => next()),
1013
+ requireSessionAuth: vi.fn((_req, _res, next) => next()),
1014
+ handleSessionStatus: vi.fn(),
1015
+ handleSessionCreate: vi.fn(),
1016
+ handleUrlAuthToken: vi.fn(),
1017
+ handlePasskeyStatus: vi.fn(),
1018
+ handlePasskeyAuthenticationOptions: vi.fn(),
1019
+ handlePasskeyAuthenticationVerify: vi.fn(),
1020
+ handlePasskeyRegistrationOptions: vi.fn(),
1021
+ handlePasskeyRegistrationVerify: vi.fn(),
1022
+ handlePasskeyList: vi.fn(),
1023
+ handlePasskeyRevoke: vi.fn(),
1024
+ handleResetAuth: vi.fn(),
1025
+ },
1026
+ });
1027
+
1028
+ await request(app)
1029
+ .post('/api/client-auth/pairing/sessions')
1030
+ .send({ includeRelay: true, relayUrl: 'wss://attacker.example/ws' })
1031
+ .expect(403, { error: 'Only the owner UI session can set a custom Relay endpoint' });
1032
+
951
1033
  expect(getRelayPairingCandidate).not.toHaveBeenCalled();
952
1034
  expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
953
1035
  });
954
1036
 
1037
+ it('lets paired desktop clients echo the current Relay endpoint when pairing', async () => {
1038
+ const getRelayPairingCandidate = vi.fn(async ({ relayUrl }) => ({
1039
+ type: 'relay',
1040
+ relayUrl,
1041
+ serverId: 'srv_desktop',
1042
+ hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
1043
+ priority: 30,
1044
+ }));
1045
+ const getEffectiveRelayUrls = vi.fn(async () => ['wss://relay.example/ws', 'wss://relay2.example/ws']);
1046
+ const { app } = createPairingRouteApp({
1047
+ getRelayPairingCandidate,
1048
+ getEffectiveRelayUrls,
1049
+ uiAuthController: {
1050
+ resolveAuthContext: vi.fn(async () => ({
1051
+ type: 'client',
1052
+ clientId: 'remote-desktop-1',
1053
+ client: { id: 'remote-desktop-1', clientKind: 'desktop' },
1054
+ })),
1055
+ requireAuth: vi.fn((_req, _res, next) => next()),
1056
+ requireSessionAuth: vi.fn((_req, _res, next) => next()),
1057
+ handleSessionStatus: vi.fn(),
1058
+ handleSessionCreate: vi.fn(),
1059
+ handleUrlAuthToken: vi.fn(),
1060
+ handlePasskeyStatus: vi.fn(),
1061
+ handlePasskeyAuthenticationOptions: vi.fn(),
1062
+ handlePasskeyAuthenticationVerify: vi.fn(),
1063
+ handlePasskeyRegistrationOptions: vi.fn(),
1064
+ handlePasskeyRegistrationVerify: vi.fn(),
1065
+ handlePasskeyList: vi.fn(),
1066
+ handlePasskeyRevoke: vi.fn(),
1067
+ handleResetAuth: vi.fn(),
1068
+ },
1069
+ });
1070
+
1071
+ const response = await request(app)
1072
+ .post('/api/client-auth/pairing/sessions')
1073
+ .send({ includeRelay: true, includeDirect: false, relayUrl: 'wss://relay.example/ws' })
1074
+ .expect(201);
1075
+
1076
+ expect(getRelayPairingCandidate).toHaveBeenCalledWith({
1077
+ ensureEnabled: true,
1078
+ relayUrl: 'wss://relay.example/ws',
1079
+ });
1080
+ expect(response.body.server.candidates).toEqual([
1081
+ expect.objectContaining({ type: 'relay', relayUrl: 'wss://relay.example/ws' }),
1082
+ ]);
1083
+
1084
+ // Multi-relay: any CONFIGURED endpoint is fair game for a paired desktop
1085
+ // (choosing between the host's endpoints is not a re-point).
1086
+ await request(app)
1087
+ .post('/api/client-auth/pairing/sessions')
1088
+ .send({ includeRelay: true, includeDirect: false, relayUrl: 'wss://relay2.example/ws' })
1089
+ .expect(201);
1090
+ expect(getRelayPairingCandidate).toHaveBeenLastCalledWith({
1091
+ ensureEnabled: true,
1092
+ relayUrl: 'wss://relay2.example/ws',
1093
+ });
1094
+ });
1095
+
955
1096
  it('still lets desktop-local create pairing sessions without a custom Relay endpoint', async () => {
956
1097
  const getRelayPairingCandidate = vi.fn(async () => null);
957
1098
  const { app, dependencies } = createPairingRouteApp({
@@ -1287,6 +1428,25 @@ describe('client auth routes', () => {
1287
1428
  expect(response.body.candidates).toEqual([]);
1288
1429
  });
1289
1430
 
1431
+ it('returns every configured relay endpoint in the candidates refresh (multi-relay)', async () => {
1432
+ const app = express();
1433
+ const relayCandidates = [
1434
+ { type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'server-abc', hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, priority: 30 },
1435
+ { type: 'relay', relayUrl: 'wss://relay2.example/ws', serverId: 'server-abc', hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, priority: 30 },
1436
+ ];
1437
+ const dependencies = {
1438
+ ...createDependencies({ resolveAuthContext: async () => ({ type: 'client', clientId: 'client-1' }) }),
1439
+ getDirectCandidateUrls: () => [],
1440
+ getRelayPairingCandidates: async () => relayCandidates,
1441
+ getServerId: async () => 'server-abc',
1442
+ };
1443
+ registerAuthAndAccessRoutes(app, dependencies);
1444
+
1445
+ const response = await request(app).get('/api/client-auth/connection/candidates');
1446
+ expect(response.status).toBe(200);
1447
+ expect(response.body.candidates).toEqual(relayCandidates);
1448
+ });
1449
+
1290
1450
  it('scopes non-desktop client credentials to list and revoke only themselves', async () => {
1291
1451
  const app = express();
1292
1452
  let authContext = { type: 'session' };
@@ -1355,7 +1515,47 @@ describe('client auth routes', () => {
1355
1515
  expect(purged.body.purged).toBe(1);
1356
1516
  });
1357
1517
 
1358
- it('allows only the local desktop client token to create remote client tokens', async () => {
1518
+ it('lets paired desktop clients list and revoke every device (relay-attached parity)', async () => {
1519
+ const app = express();
1520
+ let authContext = { type: 'session' };
1521
+ const dependencies = createDependencies({
1522
+ resolveAuthContext: async () => authContext,
1523
+ });
1524
+ registerAuthAndAccessRoutes(app, dependencies);
1525
+
1526
+ const localDesktop = await request(app)
1527
+ .post('/api/client-auth/clients')
1528
+ .send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
1529
+ const pairedDesktop = await request(app)
1530
+ .post('/api/client-auth/clients')
1531
+ .send({ label: 'Study desktop', clientKind: 'desktop' });
1532
+ const phone = await request(app)
1533
+ .post('/api/client-auth/clients')
1534
+ .send({ label: 'Phone', clientKind: 'mobile' });
1535
+
1536
+ // A paired desktop (connected over relay) manages all devices like the
1537
+ // local desktop shell — SSH-attach parity for relay-attached hosts.
1538
+ authContext = { type: 'client', clientId: pairedDesktop.body.client.id, client: pairedDesktop.body.client };
1539
+
1540
+ const listed = await request(app).get('/api/client-auth/clients');
1541
+ expect(listed.status).toBe(200);
1542
+ const listedIds = listed.body.clients.map((client) => client.id).sort();
1543
+ expect(listedIds).toEqual([
1544
+ localDesktop.body.client.id,
1545
+ pairedDesktop.body.client.id,
1546
+ phone.body.client.id,
1547
+ ].sort());
1548
+
1549
+ const revoked = await request(app).delete(`/api/client-auth/clients/${phone.body.client.id}`);
1550
+ expect(revoked.status).toBe(200);
1551
+ expect(revoked.body.revoked).toBe(true);
1552
+
1553
+ const purged = await request(app).delete('/api/client-auth/clients');
1554
+ expect(purged.status).toBe(200);
1555
+ expect(purged.body.purged).toBeGreaterThanOrEqual(1);
1556
+ });
1557
+
1558
+ it('restricts direct client-token creation to operator clients', async () => {
1359
1559
  const app = express();
1360
1560
  let authContext = { type: 'session' };
1361
1561
  const dependencies = createDependencies({
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
19
19
  ## Entrypoints and structure
20
20
 
21
21
  Host side (`packages/web/server/lib/relay/`):
22
- - `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `OPENCHAMBER_RUNTIME=web` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session or the local `desktop-local` shell client; `/relay/enable` follows its API auth gate. `derivePushSendUrlFromRelayUrl` maps a canonical Relay `wss://`/`ws://` URL to the same host's `https://`/`http://` `/v1/push/send` path (register is `/v1/push/register-token`); `OPENCHAMBER_PUSH_RELAY_URL` on the Host overrides that derivation. The isolated Push process lives in `packages/relay-server/src/push/` and does not share Layer 1 credentials or tunnel state.
22
+ - `service.js` — thin entrypoint: relay config (enabled flag + endpoints), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`, `POST /api/openchamber/relay/endpoints`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. **Multi-relay:** `settings.privateRelay = { enabled, relayUrl, extraRelayUrls }` — the host dials EVERY configured endpoint simultaneously with one identity (same serverId); clients treat `serverId + relayUrl` as the instance identity, so the same machine exposed via two domains appears as two separately named instances. The primary (`relayUrl`) owns push registration and stays the default pairing candidate. A pairing link created for an endpoint the host does not have yet APPENDS it as an extra endpoint (owner request only) instead of replacing the primary, so devices paired over the old domain keep working. `POST /relay/endpoints` (owner session / `desktop-local` only) replaces the extra-endpoint list wholesale and starts/stops host connections incrementally. Paired desktops may pair over any CONFIGURED endpoint but can never add one. Host lifecycle is gated by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, `/relay/endpoints`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `OPENCHAMBER_RUNTIME=web` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session or the local `desktop-local` shell client; `/relay/enable` follows its API auth gate. `derivePushSendUrlFromRelayUrl` maps a canonical Relay `wss://`/`ws://` URL to the same host's `https://`/`http://` `/v1/push/send` path (register is `/v1/push/register-token`); `OPENCHAMBER_PUSH_RELAY_URL` on the Host overrides that derivation. The isolated Push process lives in `packages/relay-server/src/push/` and does not share Layer 1 credentials or tunnel state.
23
23
  - `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
24
24
  - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime. Canonical public JWK hashing matches `packages/relay-server/src/push/crypto.js`.
25
25
  - `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
@@ -59,6 +59,7 @@ See also `packages/web/server/lib/opencode/DOCUMENTATION.md` (“Catalog / OpenC
59
59
  ## Authentication model
60
60
 
61
61
  - The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
62
+ - A paired **desktop** client is a device-fleet operator: through the tunnel it can create pairing links, list every paired device, and revoke other devices (parity with the SSH attach flow, where the connecting desktop authenticates as the operator). Mobile (and any other) paired clients stay scoped to their own record. Re-pointing the Host relay endpoint set from a remote bearer remains forbidden — a paired desktop may name any endpoint the host already has, but adding/removing endpoints (including `relayUrl` in pairing requests for a new domain) is an owner-UI / local-desktop-shell action.
62
63
  - Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
63
64
  - The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
64
65
  - Enabling the relay is explicit opt-in and disabled by default; disabling it severs all relay reachability immediately.