@openchambery/web 1.19.5-beta.4 → 1.19.5-beta.5

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 (60) hide show
  1. package/dist/assets/{AgentAvatar-CsiLLu1E.js → AgentAvatar-DMpOrMIQ.js} +1 -1
  2. package/dist/assets/{AssistantView-DHpw3EFD.js → AssistantView-yBEtonie.js} +1 -1
  3. package/dist/assets/{ChatContainer-CbvnB-ML.js → ChatContainer-BOi9iz27.js} +4 -4
  4. package/dist/assets/{ContextPanel-C4zeA-ZT.js → ContextPanel-AdZZ4JBG.js} +3 -3
  5. package/dist/assets/{DiagramView-gXuo5jLI.js → DiagramView-bQvW8jaw.js} +1 -1
  6. package/dist/assets/{MarkdownRendererImpl-DLCPhycw.js → MarkdownRendererImpl-CykGqBa-.js} +1 -1
  7. package/dist/assets/{MobileDetailNavigation-B4WQbkQ8.js → MobileDetailNavigation-CE1s2ZEr.js} +1 -1
  8. package/dist/assets/{MobileShareBridge-CgCxx-E_.js → MobileShareBridge-C5w6oHUJ.js} +1 -1
  9. package/dist/assets/{MobileSurface-B9yxZB_q.js → MobileSurface-DMv5Y8M5.js} +1 -1
  10. package/dist/assets/{MultiRunWindow-uyRlJwQl.js → MultiRunWindow-C5_K_mdR.js} +1 -1
  11. package/dist/assets/{OnboardingScreen-Bv4ZepJD.js → OnboardingScreen-DoR_GB8b.js} +1 -1
  12. package/dist/assets/SettingsView-orus6n2g.js +25 -0
  13. package/dist/assets/{SettingsWindow-B0MQVDyg.js → SettingsWindow-BW9mYwHR.js} +1 -1
  14. package/dist/assets/{TerminalView-Dad5AXKb.js → TerminalView-BSOUHyOO.js} +1 -1
  15. package/dist/assets/{ToolOutputDialog-CyBdnNEj.js → ToolOutputDialog-nSZ8-Kjx.js} +1 -1
  16. package/dist/assets/{appThemeRegistry-C5H1Wh0S.js → appThemeRegistry-BqqUte4z.js} +2 -2
  17. package/dist/assets/{clipboard-CdFyntbr.js → clipboard-CadKjjVT.js} +1 -1
  18. package/dist/assets/{dialog-DlS23C-q.js → dialog-Dz2MBeLn.js} +1 -1
  19. package/dist/assets/{es-B2ybWxIT.js → es-BZlHrH2p.js} +1 -1
  20. package/dist/assets/{fr-CpdWiNYi.js → fr-Dkw9WDcW.js} +1 -1
  21. package/dist/assets/{gitApi-CkfllTWP.js → gitApi-DcgZ2eTY.js} +1 -1
  22. package/dist/assets/{insertionBoundaries-Dc9ZkPSR.js → insertionBoundaries-BvWyoeKg.js} +1 -1
  23. package/dist/assets/{ja-r-qxubSE.js → ja-DW5lcaNV.js} +1 -1
  24. package/dist/assets/{ko-DwsRLPip.js → ko-B9WorSEt.js} +1 -1
  25. package/dist/assets/{main-DuW2me4r.js → main-5aHBuDgM.js} +2 -2
  26. package/dist/assets/{main-D6ME_BJZ.js → main-C2UbPCo7.js} +3 -3
  27. package/dist/assets/{miniChat-BLk2pfbb.js → miniChat-CUFkoTCJ.js} +2 -2
  28. package/dist/assets/{mobile-4xN65TJf.js → mobile-B_x6fY62.js} +2 -2
  29. package/dist/assets/{multirun-DA7zxT_S.js → multirun-uwd4aNIT.js} +1 -1
  30. package/dist/assets/{pl-Ch4izaEa.js → pl-CyKNdwrh.js} +1 -1
  31. package/dist/assets/{projectMeta-DvSyhAXz.js → projectMeta-engw2_70.js} +1 -1
  32. package/dist/assets/{pt-BR-CtMhi2sl.js → pt-BR-Ch6qBc5C.js} +1 -1
  33. package/dist/assets/{radio-Box6xH5M.js → radio-BHGZb0sh.js} +1 -1
  34. package/dist/assets/{renderElectronMiniChatApp-Dpk7o2RY.js → renderElectronMiniChatApp-kwVnDxKi.js} +2 -2
  35. package/dist/assets/{renderMobileApp-P5pJW5CN.js → renderMobileApp-Bt-LuBXK.js} +4 -4
  36. package/dist/assets/{runtimeConfig-lhaApt62.js → runtimeConfig-_JcGrrxY.js} +1 -1
  37. package/dist/assets/{runtimeEndpointReset-ClE1EzYZ.js → runtimeEndpointReset-CN0a3Q6V.js} +1 -1
  38. package/dist/assets/{shikiHighlight-BnUluLmq.js → shikiHighlight-CUBUPsOF.js} +1 -1
  39. package/dist/assets/{sortable-tabs-strip-BzSL5abP.js → sortable-tabs-strip-xB36tCMU.js} +1 -1
  40. package/dist/assets/{store-TODQ6GlW.js → store-DtS03LqZ.js} +2 -2
  41. package/dist/assets/{uk-BDy81LTC.js → uk-D1ao_8g2.js} +1 -1
  42. package/dist/assets/{useAppFontEffects-ssYtFjv4.js → useAppFontEffects-w2z4tQjp.js} +2 -2
  43. package/dist/assets/{useDesktopWindowControlsLayout-DQcl_nMs.js → useDesktopWindowControlsLayout-CsOlndNX.js} +1 -1
  44. package/dist/assets/{useEffectiveDirectory-CpNTQVNZ.js → useEffectiveDirectory-DztZ8U3M.js} +1 -1
  45. package/dist/assets/{useMobileNavigationStore-CjAm7B1v.js → useMobileNavigationStore-BzOSycct.js} +4 -4
  46. package/dist/assets/{useSessionAutoCleanup-C-OC_dEL.js → useSessionAutoCleanup-PXfozK-R.js} +1 -1
  47. package/dist/assets/{useWorkerHighlightedLines-B4FmvxRU.js → useWorkerHighlightedLines-B8JOB6wA.js} +1 -1
  48. package/dist/assets/{zh-CN-DreKLxqQ.js → zh-CN-BXlKlFLt.js} +1 -1
  49. package/dist/assets/{zh-TW-CUSfDrKD.js → zh-TW-DJF4ZBft.js} +1 -1
  50. package/dist/index.html +2 -2
  51. package/dist/mini-chat.html +2 -2
  52. package/dist/mobile.html +2 -2
  53. package/package.json +1 -1
  54. package/server/index.js +40 -0
  55. package/server/lib/opencode/core-routes.js +56 -23
  56. package/server/lib/opencode/core-routes.test.js +206 -6
  57. package/server/lib/relay/DOCUMENTATION.md +2 -1
  58. package/server/lib/relay/service.js +223 -71
  59. package/server/lib/relay/service.test.js +96 -13
  60. package/dist/assets/SettingsView-DarnwHmb.js +0 -25
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-BLk2pfbb.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-CUFkoTCJ.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -91,7 +91,7 @@
91
91
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-Bnx0KvaV.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
93
93
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
94
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-lhaApt62.js">
94
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_JcGrrxY.js">
95
95
  <link rel="stylesheet" crossorigin href="/assets/src-CD7-ovJ5.css">
96
96
  </head>
97
97
  <body class="h-full bg-background text-foreground">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-4xN65TJf.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-B_x6fY62.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -80,7 +80,7 @@
80
80
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-Bnx0KvaV.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
82
82
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
83
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-lhaApt62.js">
83
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_JcGrrxY.js">
84
84
  <link rel="stylesheet" crossorigin href="/assets/src-CD7-ovJ5.css">
85
85
  </head>
86
86
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.5-beta.4",
3
+ "version": "1.19.5-beta.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.js CHANGED
@@ -1419,6 +1419,29 @@ async function main(options = {}) {
1419
1419
  // Re-evaluate the relay lifecycle after pairing/device changes (a revoked or
1420
1420
  // redeemed device can flip relay demand on or off).
1421
1421
  reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
1422
+ // Canonical endpoints the host relay currently uses (primary first).
1423
+ // Pairing-session requests from paired desktop clients may carry a relayUrl
1424
+ // only when it matches one of these (an echo of an advertised endpoint,
1425
+ // never a re-point or an extension).
1426
+ getEffectiveRelayUrls: async () => {
1427
+ if (!relayServiceInstance) return [];
1428
+ try {
1429
+ const status = await relayServiceInstance.getStatus();
1430
+ return Array.isArray(status?.relayUrls) ? status.relayUrls : [];
1431
+ } catch {
1432
+ return [];
1433
+ }
1434
+ },
1435
+ // Every configured relay endpoint as a pairing candidate (multi-relay),
1436
+ // for the connection-candidates refresh.
1437
+ getRelayPairingCandidates: async () => {
1438
+ if (!relayServiceInstance) return [];
1439
+ try {
1440
+ return await relayServiceInstance.getPairingCandidates();
1441
+ } catch {
1442
+ return [];
1443
+ }
1444
+ },
1422
1445
  getPairingTransports: async (req) => {
1423
1446
  const transports = resolvePairingTransports(req);
1424
1447
  if (!relayServiceInstance || !transports.relayAvailable) return transports;
@@ -1426,6 +1449,7 @@ async function main(options = {}) {
1426
1449
  return {
1427
1450
  ...transports,
1428
1451
  relayUrl: relay.relayUrl,
1452
+ relayUrls: relay.relayUrls,
1429
1453
  relayUrlLocked: relay.relayUrlLocked,
1430
1454
  };
1431
1455
  },
@@ -1516,6 +1540,22 @@ async function main(options = {}) {
1516
1540
  onRelayUrlChanged: async () => {
1517
1541
  await apnsRuntime.reRegisterAllTokens();
1518
1542
  },
1543
+ // Owner gate for relay-endpoint management: UI session or the local desktop
1544
+ // shell. Paired remote clients (desktops included) are NOT endpoint owners
1545
+ // — adding/removing endpoints is the same trust boundary as re-pointing
1546
+ // the primary endpoint.
1547
+ isOwnerRequest: async (req, res) => {
1548
+ if (typeof uiAuthController?.resolveAuthContext !== 'function') return false;
1549
+ try {
1550
+ const context = await uiAuthController.resolveAuthContext(req, res, { allowClientAuth: true, allowUrlToken: false });
1551
+ if (context?.type === 'session') return true;
1552
+ if (context?.type === 'client') {
1553
+ return context.client?.clientKind === 'desktop-local';
1554
+ }
1555
+ } catch {
1556
+ }
1557
+ return false;
1558
+ },
1519
1559
  });
1520
1560
  relayServiceInstance = relayService;
1521
1561
  relayService.registerRoutes(app);
@@ -390,6 +390,16 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
390
390
  // hostEncPubJwk, priority }) when the host relay is enabled, else null.
391
391
  // Injected lazily because the relay service is constructed after these routes.
392
392
  getRelayPairingCandidate = async () => null,
393
+ // All configured relay endpoints (canonical ws(s)://…), primary first, or
394
+ // [] when relay status is unavailable. Gates remote-bearer pairing requests
395
+ // that carry a relayUrl: paired desktops may only use an endpoint the host
396
+ // already has (echo of any configured endpoint, never a re-point).
397
+ getEffectiveRelayUrls = async () => [],
398
+ // Candidates for EVERY configured relay endpoint (multi-relay), or null /
399
+ // a non-array when only the single-endpoint accessor is available. Feeds
400
+ // the connection-candidates refresh so paired devices learn the full
401
+ // reachable endpoint set.
402
+ getRelayPairingCandidates = async () => null,
393
403
  // Re-evaluate the relay lifecycle after pairing/device changes.
394
404
  reconcileRelay = async () => {},
395
405
  // Returns { local, lan, relayAvailable } — the direct transport URLs the
@@ -424,6 +434,15 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
424
434
  }
425
435
  };
426
436
 
437
+ // Which paired clients may act as operators of this server's device fleet:
438
+ // create pairing links, list every device, and revoke other devices. The
439
+ // desktop shell's local client is the in-process operator; a paired `desktop`
440
+ // client is granted the same trust at pairing time (parity with the SSH
441
+ // attach flow, where the connecting desktop authenticates as the operator).
442
+ // Mobile (and any other) paired clients stay scoped to their own record.
443
+ const canManageRemoteClients = (client) =>
444
+ client?.clientKind === 'desktop-local' || client?.clientKind === 'desktop';
445
+
427
446
  const runWithClientManagementAuth = async (req, res, next, handler) => {
428
447
  try {
429
448
  if (typeof uiAuthController.resolveAuthContext === 'function') {
@@ -458,7 +477,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
458
477
  }
459
478
  if (context?.type === 'client') {
460
479
  const client = await clientRecordFromAuthContext(context);
461
- if (client?.clientKind === 'desktop-local') {
480
+ if (canManageRemoteClients(client)) {
462
481
  await handler({ ...context, client });
463
482
  return;
464
483
  }
@@ -779,10 +798,10 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
779
798
  await runWithClientManagementAuth(req, res, next, async (authContext) => {
780
799
  if (authContext.type === 'client') {
781
800
  const client = await clientRecordFromAuthContext(authContext);
782
- // The desktop shell's local client is the trusted operator of this
783
- // server; it manages devices just like a browser UI session. Every
784
- // other client token is scoped to its own record.
785
- if (client?.clientKind !== 'desktop-local') {
801
+ // Operator clients (local desktop shell, paired desktops) manage the
802
+ // whole device fleet; every other client token is scoped to its own
803
+ // record.
804
+ if (!canManageRemoteClients(client)) {
786
805
  return res.json({ clients: client ? [client] : [] });
787
806
  }
788
807
  }
@@ -807,9 +826,9 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
807
826
  await runWithClientManagementAuth(req, res, next, async (authContext) => {
808
827
  if (authContext.type === 'client') {
809
828
  const actingClient = await clientRecordFromAuthContext(authContext);
810
- // The desktop shell's local client manages every device; other client
811
- // tokens may only revoke themselves.
812
- if (actingClient?.clientKind !== 'desktop-local') {
829
+ // Operator clients (local desktop shell, paired desktops) may revoke
830
+ // any device; other client tokens may only revoke themselves.
831
+ if (!canManageRemoteClients(actingClient)) {
813
832
  const clientId = clientIdFromAuthContext(authContext);
814
833
  if (!clientId || clientId !== req.params?.id) {
815
834
  return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
@@ -829,9 +848,10 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
829
848
  await runWithClientManagementAuth(req, res, next, async (authContext) => {
830
849
  if (authContext.type === 'client') {
831
850
  const actingClient = await clientRecordFromAuthContext(authContext);
832
- // Purging revoked devices is a whole-server management action; only the
833
- // trusted desktop shell client (or a UI session) may do it.
834
- if (actingClient?.clientKind !== 'desktop-local') {
851
+ // Purging revoked devices is a whole-server management action; only
852
+ // operator clients (local desktop shell, paired desktops) or a UI
853
+ // session may do it.
854
+ if (!canManageRemoteClients(actingClient)) {
835
855
  return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' });
836
856
  }
837
857
  }
@@ -843,22 +863,25 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
843
863
 
844
864
  app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
845
865
  await runWithClientCreateAuth(req, res, next, async (authContext) => {
846
- // Changing the Host Relay endpoint is an owner-UI or local desktop-shell
847
- // action. Paired remote clients never reach this handler (create-auth only
848
- // admits session + desktop-local); keep the kind check so a future auth
849
- // widening cannot re-point the Host relay from a remote bearer.
850
- if (req.body?.relayUrl !== undefined && authContext?.type === 'client') {
851
- const actingClient = authContext.client || await clientRecordFromAuthContext(authContext);
852
- if (actingClient?.clientKind !== 'desktop-local') {
853
- return res.status(403).json({ error: 'Only the owner UI session can set a custom Relay endpoint' });
854
- }
855
- }
856
866
  const requestedRelayUrl = req.body?.relayUrl === undefined
857
867
  ? undefined
858
868
  : normalizeRequestedRelayUrl(req.body.relayUrl);
859
869
  if (req.body?.relayUrl !== undefined && !requestedRelayUrl) {
860
870
  return res.status(400).json({ error: 'Relay URL must use ws:// or wss://' });
861
871
  }
872
+ // Changing the Host Relay endpoint set is an owner-UI or local
873
+ // desktop-shell action. Paired desktop operators reach this handler too,
874
+ // but may only name an endpoint the host already uses — anything else
875
+ // would re-point (or extend) the Host relay from a remote bearer.
876
+ if (requestedRelayUrl !== undefined && authContext?.type === 'client') {
877
+ const actingClient = authContext.client || await clientRecordFromAuthContext(authContext);
878
+ if (actingClient?.clientKind !== 'desktop-local') {
879
+ const effectiveRelayUrls = await getEffectiveRelayUrls();
880
+ if (!Array.isArray(effectiveRelayUrls) || !effectiveRelayUrls.includes(requestedRelayUrl)) {
881
+ return res.status(403).json({ error: 'Only the owner UI session can set a custom Relay endpoint' });
882
+ }
883
+ }
884
+ }
862
885
  const candidates = await pairingServerCandidates(req, {
863
886
  preferredServerUrl: req.body?.serverUrl,
864
887
  includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
@@ -906,8 +929,18 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
906
929
  if (normalized) candidates.push({ type: 'lan', url: normalized, priority: 10 });
907
930
  }
908
931
  try {
909
- const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: false });
910
- if (relayCandidate) candidates.push(relayCandidate);
932
+ // Multi-relay: a paired device learns EVERY configured endpoint so a
933
+ // lost/expensive domain can fall back to the other (the client still
934
+ // keeps its per-connection instance identity — serverId + relayUrl).
935
+ const relayCandidates = await getRelayPairingCandidates();
936
+ if (Array.isArray(relayCandidates)) {
937
+ for (const relayCandidate of relayCandidates) {
938
+ if (relayCandidate) candidates.push(relayCandidate);
939
+ }
940
+ } else {
941
+ const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: false });
942
+ if (relayCandidate) candidates.push(relayCandidate);
943
+ }
911
944
  } catch {
912
945
  // Relay status failure must not break the direct-candidate refresh.
913
946
  }
@@ -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.