@vantran-se/openchamber-web 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -24
- package/bin/cli.js +1 -3
- package/bin/cli.test.js +40 -16
- package/bin/lib/DOCUMENTATION.md +6 -5
- package/bin/lib/cli-args.js +6 -1
- package/bin/lib/cli-args.test.js +34 -0
- package/bin/lib/cli-lifecycle.js +0 -1
- package/bin/lib/cli-network.js +8 -2
- package/bin/lib/cli-process.js +1 -1
- package/bin/lib/cli-settings-accessors.test.js +21 -13
- package/bin/lib/cli-startup.js +17 -11
- package/bin/lib/cli-startup.test.js +92 -2
- package/bin/lib/commands-connect-url.js +8 -1
- package/bin/lib/commands-schedule.js +8 -2
- package/bin/lib/commands-serve.js +15 -21
- package/bin/lib/commands-update.js +12 -24
- package/bin/lib/commands-update.test.js +14 -46
- package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
- package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
- package/dist/assets/FilesView-BVoD8I2x.js +14 -0
- package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
- package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
- package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
- package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
- package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
- package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
- package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
- package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
- package/dist/assets/PlanView-RCBadcY7.js +5 -0
- package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
- package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
- package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
- package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
- package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
- package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
- package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
- package/dist/assets/de-YKfk5MmC.js +14 -0
- package/dist/assets/es-_gwT0kr-.js +20 -0
- package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
- package/dist/assets/fr-DqzvXmIm.js +18 -0
- package/dist/assets/index-BLaHKdtx.js +1 -0
- package/dist/assets/index-BkMXRrVQ.css +1 -0
- package/dist/assets/index-DxNa7xYB.js +65 -0
- package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
- package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
- package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
- package/dist/assets/ja-B-GFYhQE.js +20 -0
- package/dist/assets/ko-mzksfqDC.js +20 -0
- package/dist/assets/main-C5Xx8dn_.js +2 -0
- package/dist/assets/main-CfBSQM1H.js +990 -0
- package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
- package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
- package/dist/assets/mobile-DeEQd0wZ.js +2 -0
- package/dist/assets/nl-CWQr6NSq.js +20 -0
- package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
- package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
- package/dist/assets/pl-UmRcDUX2.js +20 -0
- package/dist/assets/pt-BR-BSLybvVF.js +20 -0
- package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
- package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
- package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
- package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
- package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
- package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
- package/dist/assets/space-apply-DM_2pRW7.js +1 -0
- package/dist/assets/space-close-Bpomt8z0.js +1 -0
- package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
- package/dist/assets/tr-gaTrX_HK.js +11 -0
- package/dist/assets/uk-CnJF6ykl.js +20 -0
- package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
- package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
- package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
- package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
- package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
- package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
- package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
- package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
- package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
- package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
- package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
- package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
- package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
- package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
- package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
- package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
- package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
- package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
- package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
- package/dist/assets/zh-CN-t2vigSB1.js +20 -0
- package/dist/assets/zh-TW-jAgqBokB.js +20 -0
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +8 -5
- package/server/index.js +80 -36
- package/server/lib/agent-memory/actions.js +1 -6
- package/server/lib/agent-memory/routes.js +1 -2
- package/server/lib/agent-memory/runtime.js +1 -8
- package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
- package/server/lib/agent-tool/runtime.js +3 -6
- package/server/lib/agent-tool/runtime.test.js +0 -2
- package/server/lib/browser-control/DOCUMENTATION.md +6 -0
- package/server/lib/changelog/update-notes.js +1 -1
- package/server/lib/context-obligatory/runtime.js +1 -2
- package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
- package/server/lib/dev-tunnel/client.js +21 -10
- package/server/lib/dev-tunnel/tunnel.test.js +34 -0
- package/server/lib/dispatch-results/runtime.js +1 -1
- package/server/lib/enterprise-mode.d.ts +1 -0
- package/server/lib/enterprise-mode.js +13 -2
- package/server/lib/enterprise-mode.test.js +21 -0
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/index.js +1 -3
- package/server/lib/event-stream/translate-v2.js +1 -9
- package/server/lib/event-stream/translate-v2.test.js +0 -11
- package/server/lib/fs/DOCUMENTATION.md +5 -2
- package/server/lib/fs/routes.js +62 -15
- package/server/lib/fs/routes.test.js +278 -48
- package/server/lib/fs/workspace-file-names.js +104 -0
- package/server/lib/fs/workspace-file-names.test.js +62 -0
- package/server/lib/git/DOCUMENTATION.md +17 -5
- package/server/lib/git/contributor-provenance-storage.js +9 -6
- package/server/lib/git/contributor-provenance-storage.test.js +29 -0
- package/server/lib/git/credential-broker.js +1 -1
- package/server/lib/git/credential-resolver.js +1 -4
- package/server/lib/git/credentials.js +1 -0
- package/server/lib/git/discovery-endpoint.js +1 -1
- package/server/lib/git/identity-provisioning.js +2 -1
- package/server/lib/git/identity-storage.js +3 -4
- package/server/lib/git/lfs-discovery.js +14 -14
- package/server/lib/git/lfs-discovery.test.js +16 -6
- package/server/lib/git/network-operation-plan.js +50 -62
- package/server/lib/git/network-operation-plan.test.js +103 -1
- package/server/lib/git/network-operation-registry.js +4 -6
- package/server/lib/git/network-operation-storage.js +4 -6
- package/server/lib/git/network-operations.js +12 -4
- package/server/lib/git/network-operations.test.js +19 -6
- package/server/lib/git/redaction.js +2 -1
- package/server/lib/git/repository-credential-runtime.js +5 -3
- package/server/lib/git/repository-credential-runtime.test.js +22 -0
- package/server/lib/git/routes.js +31 -137
- package/server/lib/git/routes.test.js +2 -2
- package/server/lib/git/service.js +143 -27
- package/server/lib/git/service.test.js +215 -1
- package/server/lib/git/ssh-credential-storage.js +1 -4
- package/server/lib/git/submodule-discovery.js +1 -1
- package/server/lib/git/worktree-bootstrap-storage.js +3 -4
- package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
- package/server/lib/git/worktree-creation.js +194 -0
- package/server/lib/git/worktree-creation.test.js +155 -0
- package/server/lib/github/DOCUMENTATION.md +11 -6
- package/server/lib/github/auth.js +2 -4
- package/server/lib/github/device-flow.js +2 -2
- package/server/lib/github/gh-cli-credential.js +1 -0
- package/server/lib/github/octokit.js +31 -0
- package/server/lib/github/pr-status.js +35 -18
- package/server/lib/github/pr-status.test.js +271 -3
- package/server/lib/github/reference-search.js +102 -13
- package/server/lib/github/reference-search.test.js +48 -7
- package/server/lib/github/routes.auth.test.js +18 -0
- package/server/lib/github/routes.js +360 -6
- package/server/lib/github/routes.mutations.test.js +239 -0
- package/server/lib/github/routes.test.js +134 -2
- package/server/lib/gitlab/DOCUMENTATION.md +5 -1
- package/server/lib/gitlab/auth-storage.test.js +1 -0
- package/server/lib/gitlab/mappers.js +30 -1
- package/server/lib/gitlab/resources.js +230 -6
- package/server/lib/gitlab/resources.test.js +179 -0
- package/server/lib/gitlab/routes.js +200 -6
- package/server/lib/gitlab/routes.test.js +186 -2
- package/server/lib/gitlab/validation.js +1 -5
- package/server/lib/guests/DOCUMENTATION.md +1 -1
- package/server/lib/guests/oauth.js +2 -2
- package/server/lib/guests/service.js +1 -1
- package/server/lib/guests/sockets.js +1 -1
- package/server/lib/injected-env.d.ts +8 -0
- package/server/lib/injected-env.js +50 -0
- package/server/lib/injected-env.test.js +56 -0
- package/server/lib/linear/DOCUMENTATION.md +5 -6
- package/server/lib/linear/issues.js +62 -4
- package/server/lib/linear/issues.test.js +68 -0
- package/server/lib/linear/oauth.js +5 -5
- package/server/lib/linear/parse.js +2 -9
- package/server/lib/message-queue/DOCUMENTATION.md +23 -1
- package/server/lib/message-queue/runtime.js +85 -12
- package/server/lib/message-queue/runtime.test.js +172 -2
- package/server/lib/notifications/apns-runtime.js +5 -6
- package/server/lib/notifications/apns-runtime.test.js +20 -4
- package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
- package/server/lib/openchamber-control/actions.js +2 -1
- package/server/lib/openchamber-control/service.js +119 -20
- package/server/lib/openchamber-control/service.test.js +166 -0
- package/server/lib/openchamber-sessions/archive-store.js +1 -6
- package/server/lib/openchamber-sessions/routes.js +47 -12
- package/server/lib/openchamber-sessions/routes.test.js +65 -2
- package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
- package/server/lib/opencode/DOCUMENTATION.md +22 -8
- package/server/lib/opencode/agents.js +62 -0
- package/server/lib/opencode/auth-state-runtime.js +3 -1
- package/server/lib/opencode/auth.d.ts +1 -1
- package/server/lib/opencode/auth.js +8 -0
- package/server/lib/opencode/auth.test.js +31 -0
- package/server/lib/opencode/config-entity-routes.js +16 -0
- package/server/lib/opencode/config-entity-v2.test.js +16 -1
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.js +2 -4
- package/server/lib/opencode/feature-routes-runtime.js +89 -49
- package/server/lib/opencode/lifecycle.js +41 -21
- package/server/lib/opencode/lifecycle.test.js +50 -2
- package/server/lib/opencode/model-discovery.js +1 -1
- package/server/lib/opencode/model-discovery.test.js +17 -0
- package/server/lib/opencode/models-metadata.js +0 -2
- package/server/lib/opencode/openchamber-routes.test.js +3 -3
- package/server/lib/opencode/plugins.js +6 -9
- package/server/lib/opencode/provider-source-route.test.js +101 -3
- package/server/lib/opencode/providers-disabled.test.js +59 -0
- package/server/lib/opencode/providers.js +34 -0
- package/server/lib/opencode/proxy.js +11 -3
- package/server/lib/opencode/routes.js +40 -2
- package/server/lib/opencode/settings-helpers.js +27 -1
- package/server/lib/opencode/settings-helpers.test.js +19 -7
- package/server/lib/opencode/settings-registry.json +27 -16
- package/server/lib/opencode/settings-runtime.test.js +18 -0
- package/server/lib/opencode/shared-service-runtime.js +23 -104
- package/server/lib/opencode/shared.js +77 -46
- package/server/lib/opencode/shared.test.js +12 -12
- package/server/lib/opencode/shutdown-runtime.test.js +2 -3
- package/server/lib/opencode/skill-routes.js +123 -38
- package/server/lib/opencode/skill-routes.test.js +201 -0
- package/server/lib/opencode/skills.js +81 -34
- package/server/lib/opencode/skills.test.js +176 -21
- package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
- package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
- package/server/lib/opencode/watcher.test.js +0 -54
- package/server/lib/outbound-proxy.js +52 -0
- package/server/lib/outbound-proxy.test.js +55 -0
- package/server/lib/package-manager.js +23 -17
- package/server/lib/package-manager.test.js +5 -18
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
- package/server/lib/permission-auto-accept/runtime.js +19 -47
- package/server/lib/permission-auto-accept/runtime.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +7 -1
- package/server/lib/project-context/routes.js +1 -2
- package/server/lib/project-context/runtime.js +12 -14
- package/server/lib/project-context/runtime.test.js +78 -2
- package/server/lib/projects/project-config.js +30 -15
- package/server/lib/projects/project-config.test.js +30 -0
- package/server/lib/projects/project-setup.js +1 -2
- package/server/lib/projects/routes.js +1 -2
- package/server/lib/quota/DOCUMENTATION.md +35 -7
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/claude/index.js +1 -1
- package/server/lib/quota/providers/cline-pass.js +1 -1
- package/server/lib/quota/providers/exe-dev.js +1 -1
- package/server/lib/quota/providers/google/index.js +1 -1
- package/server/lib/quota/providers/hyper.js +1 -1
- package/server/lib/quota/providers/kimi.js +116 -36
- package/server/lib/quota/providers/kimi.test.js +128 -3
- package/server/lib/quota/providers/opencode-go.js +144 -21
- package/server/lib/quota/providers/opencode-go.test.js +229 -6
- package/server/lib/quota/providers/openrouter.js +1 -1
- package/server/lib/quota/providers/zenmux.js +1 -1
- package/server/lib/quota/utils/auth.js +1 -0
- package/server/lib/quota/utils/transformers.js +2 -6
- package/server/lib/relay/DOCUMENTATION.md +18 -3
- package/server/lib/relay/e2ee.js +7 -7
- package/server/lib/relay/host-client.js +84 -13
- package/server/lib/relay/host-client.test.js +201 -1
- package/server/lib/relay/identity.js +6 -29
- package/server/lib/relay/identity.test.js +18 -28
- package/server/lib/relay/key-store.js +141 -0
- package/server/lib/relay/key-store.test.js +138 -0
- package/server/lib/relay/service.js +8 -8
- package/server/lib/relay/service.test.js +10 -5
- package/server/lib/relay/signing-key.js +13 -36
- package/server/lib/relay/tunnel-codec.js +10 -10
- package/server/lib/route-module-exports.test.js +22 -0
- package/server/lib/routing/DOCUMENTATION.md +23 -3
- package/server/lib/routing/cache.js +87 -0
- package/server/lib/routing/defaults.js +11 -0
- package/server/lib/routing/history.js +12 -4
- package/server/lib/routing/runtime.js +39 -3
- package/server/lib/routing/runtime.test.js +81 -2
- package/server/lib/routing/store.js +12 -1
- package/server/lib/routing/store.test.js +13 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
- package/server/lib/scheduled-tasks/existing-session.js +119 -0
- package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
- package/server/lib/scheduled-tasks/loops.js +1 -8
- package/server/lib/scheduled-tasks/routes.js +8 -8
- package/server/lib/scheduled-tasks/runtime.js +103 -13
- package/server/lib/scheduled-tasks/runtime.test.js +48 -0
- package/server/lib/scheduled-tasks/service.js +33 -11
- package/server/lib/scheduled-tasks/service.test.js +128 -2
- package/server/lib/scheduled-tasks/session-defaults.js +27 -0
- package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
- package/server/lib/session-assist/context.js +1 -0
- package/server/lib/session-folders/routes.js +2 -2
- package/server/lib/session-goal/objectives.js +1 -1
- package/server/lib/session-knowledge/routes.js +2 -2
- package/server/lib/session-knowledge/runtime.js +1 -2
- package/server/lib/session-work/DOCUMENTATION.md +39 -14
- package/server/lib/session-work/questions.js +31 -2
- package/server/lib/session-work/runtime.js +22 -13
- package/server/lib/session-work/runtime.test.js +43 -26
- package/server/lib/session-work/state.js +10 -4
- package/server/lib/session-work/state.test.js +8 -1
- package/server/lib/shared/DOCUMENTATION.md +89 -0
- package/server/lib/shared/guards.js +53 -0
- package/server/lib/shared/guards.test.js +112 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
- package/server/lib/skills-catalog/cache.js +3 -2
- package/server/lib/skills-catalog/curated-sources.js +5 -1
- package/server/lib/skills-catalog/git.js +107 -20
- package/server/lib/skills-catalog/git.test.js +78 -0
- package/server/lib/skills-catalog/install.js +21 -12
- package/server/lib/skills-catalog/scan.js +25 -15
- package/server/lib/skills-catalog/source.js +26 -1
- package/server/lib/skills-catalog/source.test.js +27 -0
- package/server/lib/source-control/DOCUMENTATION.md +2 -1
- package/server/lib/source-control/audit-storage.js +7 -6
- package/server/lib/source-control/audit-storage.test.js +11 -0
- package/server/lib/source-control/binding-service.js +2 -5
- package/server/lib/source-control/change-request-worktree.js +90 -0
- package/server/lib/source-control/change-request-worktree.test.js +121 -0
- package/server/lib/source-control/mutation-executor.js +1 -1
- package/server/lib/source-control/mutation-storage.js +11 -5
- package/server/lib/source-control/provider-instance.js +1 -2
- package/server/lib/source-control/repository-identity.js +1 -1
- package/server/lib/source-control/routes.js +14 -2
- package/server/lib/spaces/DOCUMENTATION.md +42 -17
- package/server/lib/spaces/command-stream.test.js +15 -0
- package/server/lib/spaces/dispatcher.js +7 -0
- package/server/lib/spaces/dispatcher.test.js +17 -0
- package/server/lib/spaces/hardening.js +2 -2
- package/server/lib/spaces/hardening.test.js +1 -1
- package/server/lib/spaces/host.js +2 -0
- package/server/lib/spaces/journey.js +66 -5
- package/server/lib/spaces/journey.test.js +95 -4
- package/server/lib/spaces/layout.js +1 -1
- package/server/lib/spaces/places/docker-disk.js +38 -14
- package/server/lib/spaces/places/docker-disk.test.js +80 -1
- package/server/lib/spaces/places/docker.js +35 -11
- package/server/lib/spaces/places/fake-docker.js +22 -3
- package/server/lib/spaces/places/memory-place.js +3 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
- package/server/lib/spaces/routes.js +5 -0
- package/server/lib/spaces/routes.test.js +4 -2
- package/server/lib/spaces/run-command.js +9 -2
- package/server/lib/spaces/tools.js +4 -5
- package/server/lib/spaces/tools.test.js +2 -2
- package/server/lib/terminal/runtime.js +18 -6
- package/server/lib/terminal/runtime.test.js +66 -0
- package/server/lib/tts/index.js +3 -0
- package/server/lib/tts/service.js +4 -2
- package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
- package/server/lib/walkthrough/index.js +2 -3
- package/server/lib/walkthrough/pr-source.test.js +22 -0
- package/server/lib/walkthrough/sources.js +6 -1
- package/server/opencode-proxy.test.js +127 -0
- package/bin/lib/commands-startup.test.js +0 -15
- package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
- package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
- package/dist/assets/FilesView-DT0uEYgT.js +0 -17
- package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
- package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
- package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
- package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
- package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
- package/dist/assets/PlanView-DsRWnsOI.js +0 -5
- package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
- package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
- package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
- package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
- package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
- package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
- package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
- package/dist/assets/de-CBla4hjd.js +0 -14
- package/dist/assets/es-BmnZtxTD.js +0 -20
- package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
- package/dist/assets/fr-OVKIBUZO.js +0 -18
- package/dist/assets/index-CKCFKmjF.js +0 -1
- package/dist/assets/index-Dbx4V4GN.css +0 -1
- package/dist/assets/index-vQ1Pg1cr.js +0 -61
- package/dist/assets/ja-6v-yr-gE.js +0 -20
- package/dist/assets/ko-I_ZLvgQ9.js +0 -20
- package/dist/assets/main-CmO4dRtR.js +0 -2
- package/dist/assets/main-b9vqhlYM.js +0 -999
- package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
- package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
- package/dist/assets/mobile-CVtShif9.js +0 -2
- package/dist/assets/nl-DcvIJf46.js +0 -20
- package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
- package/dist/assets/pl-JxCUl0sd.js +0 -20
- package/dist/assets/pt-BR-L89mjja2.js +0 -20
- package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
- package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
- package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
- package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
- package/dist/assets/space-apply-1trGVIl8.js +0 -1
- package/dist/assets/space-close-y7j-NuBv.js +0 -1
- package/dist/assets/tr-ka5J1flt.js +0 -11
- package/dist/assets/uk-PyGe6Q_u.js +0 -20
- package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
- package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
- package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
- package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
- package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
- package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
- package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
- package/dist/assets/zh-TW-Bs9C5cN8.js +0 -20
|
@@ -21,8 +21,9 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
21
21
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
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. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay (`pinnedRelayUrl`: `relayUrl` in the machine policy file, else the `OPENCHAMBER_RELAY_URL` env var; see `../enterprise-mode.js`; must be `ws://`/`wss://`, an invalid value pins nothing); when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. In enterprise mode (`../enterprise-mode.js`) the relay runs only with that pin (`relayBlockedByEnterprise`): `start()` refuses otherwise (every path in passes through it), `enable` answers 403, `getPairingCandidate()` is null, `ensureEnabledForPairing()` throws (the pairing route drops the candidate and direct pairing still works), status carries `blockedByEnterprise`, and `resolvePairingTransports` in `index.js` reports `relayAvailable: false`.
|
|
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
|
-
- `signing-key.js` —
|
|
25
|
-
- `
|
|
24
|
+
- `signing-key.js` — the signing keypair and the routing id derivation, shared with the notifications runtime.
|
|
25
|
+
- `key-store.js` — where both keypairs live: `<data-dir>/relay-identity.json` (`0600`), never `settings.json`, so the private keys do not travel with every copy of the settings file. Installs that kept them in `settings.json` (`relaySigningKey` / `relayEncryptionKey`) have them moved on first load, unchanged so `serverId` and the trust anchor survive, then stripped from settings; an older build sharing the data dir then mints its own identity. A key is minted only when neither file holds it, and both are read strictly so a corrupt or unreadable file fails instead of reading as first run. `index.js` builds one store for the push relay and the private relay, so their concurrent first loads mint one key.
|
|
26
|
+
- `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. A data connection dial that never opens (refused, or no answer within 5 s, doubling to 15 s per failure) is retried after 1 s, doubling to 8 s, for as long as the relay still reports that client and the control connection is up. The relay sends no second `connected` for a waiting client, so without the retry the device waits for the relay's 15 s stuck-control reset or its own 30 s handshake timeout. A data connection that opened and then closed is not retried: the relay closes the client with it, and the client's reconnect arrives as a new `connected`.
|
|
26
27
|
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. A standby watcher waits a 2-minute grace after the claim frees before taking over, so a cleanly restarting host (app update/relaunch) — which reclaims at boot with no wait — always wins the restart window over a bystander instance. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. Instances created with `allowPassiveHost: false` (dev servers via `OPENCHAMBER_RELAY_HOST=off`, the Electron dev shell via `OPENCHAMBER_ELECTRON_DEV`; `OPENCHAMBER_RELAY_HOST=on` overrides) never start the host passively at all — boot, demand reconcile, and watcher takeover leave them in `standby`; only explicit enable/pairing hosts there. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
|
|
27
28
|
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
|
|
28
29
|
- `downstream-scheduler.js` owns end-client delivery credit and round-robin transmission across response streams. It selects plaintext frames before encryption.
|
|
@@ -164,7 +165,20 @@ occur transiently in the field (a same-identity dial taking the leg, relay state
|
|
|
164
165
|
left by a host restart), so the client keeps its wake listeners armed and the
|
|
165
166
|
next `online` event or return to the foreground clears the error and makes one
|
|
166
167
|
fresh attempt. There is still no background loop. Transient failures use the
|
|
167
|
-
existing reconnect/backoff path.
|
|
168
|
+
existing reconnect/backoff path. The client status marks such a parked error
|
|
169
|
+
with `terminal: true`, so callers can tell it apart from a pending reconnect.
|
|
170
|
+
|
|
171
|
+
Mobile connect flows (`packages/ui/src/apps/mobileConnections.ts`, helpers in
|
|
172
|
+
`mobileRelayFetch.ts`) log the relay failure reason (close message or
|
|
173
|
+
`timeout`) in the connect trail. Idempotent probes (`/health`,
|
|
174
|
+
`/auth/session`) wait for the tunnel's own reconnect after a dropped socket and
|
|
175
|
+
ask again within their budget; pairing redeem and password login are sent once.
|
|
176
|
+
Pairing, logging in again, and the session probes of auto-connect and connect
|
|
177
|
+
ride the connected runtime tunnel when the phone is already on that server
|
|
178
|
+
through the relay, and do not open a second tunnel to the same server: the
|
|
179
|
+
relay keeps one client leg per device and server, so a second dial drops the
|
|
180
|
+
live session. A probe dials its own tunnel only when the live one gives no
|
|
181
|
+
answer.
|
|
168
182
|
|
|
169
183
|
Relay mode plugs into the existing client transport layer rather than a parallel path: `runtime-switch` activates the tunnel singleton, `runtime-fetch` routes runtime requests through it, `runtime-url`/`runtime-socket` yield tunnel-backed URLs and sockets, and `runtime-auth` mints the URL-scoped token through the tunnel. Direct-URL connections and the Electron realtime-proxy path are unaffected.
|
|
170
184
|
|
|
@@ -172,6 +186,7 @@ Relay mode plugs into the existing client transport layer rather than a parallel
|
|
|
172
186
|
|
|
173
187
|
- The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
|
|
174
188
|
- Pairing secrets travel in URL fragments only, never in query strings, never logged.
|
|
189
|
+
- Host dial URLs carry the signed relay auth in their query string, and Bun's WebSocket copies the whole URL into its error messages. Relay errors reach logs and the relay status only through `describeRelayError` in `host-client.js`, which strips query strings and userinfo from every URL in the text.
|
|
175
190
|
- The host dispatcher never injects credentials; the server authenticates each tunneled request.
|
|
176
191
|
- The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
|
|
177
192
|
- The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
|
package/server/lib/relay/e2ee.js
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
const subtle = globalThis.crypto.subtle;
|
|
10
10
|
|
|
11
11
|
export const RELAY_PROTOCOL_VERSION = 1;
|
|
12
|
-
|
|
12
|
+
const RELAY_HKDF_INFO = 'openchamber-relay-v1';
|
|
13
13
|
|
|
14
14
|
// Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag].
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const ENCRYPTED_FRAME_VERSION = 1;
|
|
16
|
+
const ENCRYPTED_FRAME_IV_BYTES = 12;
|
|
17
|
+
const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
|
|
18
18
|
export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
|
|
19
19
|
|
|
20
20
|
// Relay-assigned WebSocket close codes (subset the host needs).
|
|
@@ -31,7 +31,7 @@ const GCM_TAG_BYTES = 16;
|
|
|
31
31
|
const IV_PREFIX_BYTES = 4;
|
|
32
32
|
const IV_COUNTER_BYTES = 8;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
class RelayCryptoError extends Error {
|
|
35
35
|
constructor(message) {
|
|
36
36
|
super(message);
|
|
37
37
|
this.name = 'RelayCryptoError';
|
|
@@ -51,7 +51,7 @@ export const exportPublicKeyJwk = async (key) => {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
/** @param {JsonWebKey} jwk */
|
|
54
|
-
|
|
54
|
+
const importEcdhPublicKey = async (jwk) => {
|
|
55
55
|
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
|
|
56
56
|
throw new RelayCryptoError('invalid ECDH public key JWK');
|
|
57
57
|
}
|
|
@@ -79,7 +79,7 @@ export const importEcdhPrivateKey = async (jwk) => {
|
|
|
79
79
|
|
|
80
80
|
// Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
|
|
81
81
|
/** @param {JsonWebKey} jwk */
|
|
82
|
-
|
|
82
|
+
const publicKeyJwkFingerprint = (jwk) =>
|
|
83
83
|
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
|
84
84
|
|
|
85
85
|
export const generateHandshakeNonce = () => {
|
|
@@ -10,10 +10,20 @@ import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e
|
|
|
10
10
|
import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
|
|
11
11
|
import { createTunnelHost } from './tunnel-host.js';
|
|
12
12
|
import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
|
|
13
|
+
import { redactSensitiveText } from '../source-control/url-redaction.js';
|
|
13
14
|
|
|
14
15
|
const BACKOFF_BASE_MS = 1000;
|
|
15
16
|
const BACKOFF_CAP_MS = 30000;
|
|
17
|
+
// A data socket dial that never opens is retried while the relay still reports
|
|
18
|
+
// its client: the relay holds the client and buffers its frames, and sends no
|
|
19
|
+
// new `connected` for it. Without a retry the client waits for the relay's 15 s
|
|
20
|
+
// stuck-control reset, or for its own 30 s handshake timeout when that fails too.
|
|
21
|
+
// The open timeout starts short so a hung dial leaves room for retries inside
|
|
22
|
+
// that 30 s, and doubles per failed dial so a slow network still gets 15 s.
|
|
23
|
+
const DATA_SOCKET_FIRST_OPEN_TIMEOUT_MS = 5000;
|
|
16
24
|
const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
|
|
25
|
+
const DATA_SOCKET_REDIAL_BASE_MS = 1000;
|
|
26
|
+
const DATA_SOCKET_REDIAL_CAP_MS = 8000;
|
|
17
27
|
// Clients send a tunnel Ping at least every ~30s when idle, so a data socket
|
|
18
28
|
// with no inbound traffic for 3 ping intervals belongs to a client that died
|
|
19
29
|
// without a WebSocket close (network loss, battery kill). The relay worker may
|
|
@@ -40,6 +50,11 @@ const resolveBatchWindowMs = (option) => {
|
|
|
40
50
|
return DEFAULT_BATCH_WINDOW_MS;
|
|
41
51
|
};
|
|
42
52
|
|
|
53
|
+
// Bun's WebSocket puts the full dial URL into its error messages, and that URL
|
|
54
|
+
// carries the signed relay auth in its query string. Every relay error passes
|
|
55
|
+
// through here before it reaches a log line or the relay status.
|
|
56
|
+
export const describeRelayError = (error) => redactSensitiveText(error?.message ?? error);
|
|
57
|
+
|
|
43
58
|
/**
|
|
44
59
|
* @param {{
|
|
45
60
|
* relayUrl: string,
|
|
@@ -48,9 +63,10 @@ const resolveBatchWindowMs = (option) => {
|
|
|
48
63
|
* getLocalPort?: () => number,
|
|
49
64
|
* onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
|
|
50
65
|
* logger?: Pick<Console, 'warn'>,
|
|
66
|
+
* createSocket?: (url: string) => WebSocket,
|
|
51
67
|
* }} options
|
|
52
68
|
*/
|
|
53
|
-
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
|
|
69
|
+
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, createSocket = (url) => new WebSocket(url), batchWindowMs, batch, flowControl }) => {
|
|
54
70
|
const { version } = createRequire(import.meta.url)('../../../package.json');
|
|
55
71
|
const platform = process.env.OPENCHAMBER_RUNTIME || 'web';
|
|
56
72
|
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
|
@@ -65,6 +81,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
65
81
|
let consecutiveFailures = 0;
|
|
66
82
|
/** @type {Map<string, { socket: WebSocket, tunnel: ReturnType<typeof createTunnelHost> | null, openTimer: NodeJS.Timeout | null }>} */
|
|
67
83
|
const dataSockets = new Map();
|
|
84
|
+
// Clients the relay reported (`connected`/`sync`) and has not reported gone.
|
|
85
|
+
/** @type {Map<string, { failedDials: number, redialTimer: NodeJS.Timeout | null }>} */
|
|
86
|
+
const announcedClients = new Map();
|
|
68
87
|
|
|
69
88
|
const emitStatus = () => {
|
|
70
89
|
try {
|
|
@@ -97,10 +116,43 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
97
116
|
return url.toString();
|
|
98
117
|
};
|
|
99
118
|
|
|
119
|
+
const announceClient = (connectionId) => {
|
|
120
|
+
if (!announcedClients.has(connectionId)) announcedClients.set(connectionId, { failedDials: 0, redialTimer: null });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const forgetClient = (connectionId) => {
|
|
124
|
+
const client = announcedClients.get(connectionId);
|
|
125
|
+
if (client?.redialTimer) clearTimeout(client.redialTimer);
|
|
126
|
+
announcedClients.delete(connectionId);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const cancelRedials = () => {
|
|
130
|
+
for (const client of announcedClients.values()) {
|
|
131
|
+
if (client.redialTimer) clearTimeout(client.redialTimer);
|
|
132
|
+
client.redialTimer = null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Only a dial that never opened is retried. Once the relay attached a data
|
|
137
|
+
// socket, its close makes the relay close the client too, and the client's
|
|
138
|
+
// reconnect arrives as a new `connected`. With the control socket down the
|
|
139
|
+
// next `sync` decides which clients still wait.
|
|
140
|
+
const scheduleRedial = (connectionId) => {
|
|
141
|
+
const client = announcedClients.get(connectionId);
|
|
142
|
+
if (stopped || !client || client.redialTimer || controlSocket?.readyState !== WebSocket.OPEN) return;
|
|
143
|
+
const delay = Math.min(DATA_SOCKET_REDIAL_BASE_MS * 2 ** client.failedDials, DATA_SOCKET_REDIAL_CAP_MS);
|
|
144
|
+
client.failedDials += 1;
|
|
145
|
+
client.redialTimer = setTimeout(() => {
|
|
146
|
+
client.redialTimer = null;
|
|
147
|
+
openDataSocket(connectionId);
|
|
148
|
+
}, delay);
|
|
149
|
+
};
|
|
150
|
+
|
|
100
151
|
const teardownDataSocket = (connectionId, closeCode, reason) => {
|
|
101
152
|
const entry = dataSockets.get(connectionId);
|
|
102
153
|
if (!entry) return;
|
|
103
154
|
dataSockets.delete(connectionId);
|
|
155
|
+
if (!entry.opened) scheduleRedial(connectionId);
|
|
104
156
|
if (entry.openTimer) clearTimeout(entry.openTimer);
|
|
105
157
|
entry.batcher?.dispose();
|
|
106
158
|
entry.scheduler?.close();
|
|
@@ -118,21 +170,28 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
118
170
|
|
|
119
171
|
const openDataSocket = (connectionId) => {
|
|
120
172
|
if (stopped || dataSockets.has(connectionId)) return;
|
|
173
|
+
const client = announcedClients.get(connectionId);
|
|
174
|
+
if (client?.redialTimer) {
|
|
175
|
+
clearTimeout(client.redialTimer);
|
|
176
|
+
client.redialTimer = null;
|
|
177
|
+
}
|
|
121
178
|
|
|
122
179
|
let socket;
|
|
123
180
|
try {
|
|
124
|
-
socket =
|
|
181
|
+
socket = createSocket(buildSocketUrl('host-data', connectionId));
|
|
125
182
|
} catch (error) {
|
|
126
|
-
logger.warn(`[Relay] host-data dial failed: ${error
|
|
183
|
+
logger.warn(`[Relay] host-data dial failed: ${describeRelayError(error)}`);
|
|
184
|
+
scheduleRedial(connectionId);
|
|
127
185
|
return;
|
|
128
186
|
}
|
|
129
187
|
|
|
130
|
-
const entry = { socket, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
|
|
188
|
+
const entry = { socket, opened: false, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
|
|
131
189
|
dataSockets.set(connectionId, entry);
|
|
190
|
+
const openTimeoutMs = Math.min(DATA_SOCKET_FIRST_OPEN_TIMEOUT_MS * 2 ** (client?.failedDials ?? 0), DATA_SOCKET_OPEN_TIMEOUT_MS);
|
|
132
191
|
entry.openTimer = setTimeout(() => {
|
|
133
192
|
logger.warn('[Relay] host-data socket open timeout');
|
|
134
193
|
teardownDataSocket(connectionId);
|
|
135
|
-
},
|
|
194
|
+
}, openTimeoutMs);
|
|
136
195
|
|
|
137
196
|
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch, flowControl });
|
|
138
197
|
let channel = null;
|
|
@@ -153,7 +212,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
153
212
|
socket.send(encrypted, { binary: true });
|
|
154
213
|
})
|
|
155
214
|
.catch((error) => {
|
|
156
|
-
logger.warn(`[Relay] host-data send failed: ${error
|
|
215
|
+
logger.warn(`[Relay] host-data send failed: ${describeRelayError(error)}`);
|
|
157
216
|
failChannel(RelayCloseCode.ChannelFailure, 'send failed');
|
|
158
217
|
});
|
|
159
218
|
return sendChain;
|
|
@@ -231,7 +290,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
231
290
|
await dispatchFrame(plaintext);
|
|
232
291
|
}
|
|
233
292
|
} catch (error) {
|
|
234
|
-
logger.warn(`[Relay] tunnel frame handling failed: ${error
|
|
293
|
+
logger.warn(`[Relay] tunnel frame handling failed: ${describeRelayError(error)}`);
|
|
235
294
|
failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
|
|
236
295
|
}
|
|
237
296
|
};
|
|
@@ -256,21 +315,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
256
315
|
clearTimeout(entry.openTimer);
|
|
257
316
|
entry.openTimer = null;
|
|
258
317
|
}
|
|
318
|
+
entry.opened = true;
|
|
319
|
+
const current = announcedClients.get(connectionId);
|
|
320
|
+
if (current) current.failedDials = 0;
|
|
259
321
|
emitStatus();
|
|
260
322
|
});
|
|
261
323
|
socket.on('message', (data, isBinary) => {
|
|
262
324
|
processing = processing
|
|
263
325
|
.then(() => handleMessage(data, isBinary))
|
|
264
326
|
.catch((error) => {
|
|
265
|
-
logger.warn(`[Relay] data socket message failed: ${error
|
|
327
|
+
logger.warn(`[Relay] data socket message failed: ${describeRelayError(error)}`);
|
|
266
328
|
failChannel(RelayCloseCode.ChannelFailure, 'internal error');
|
|
267
329
|
});
|
|
268
330
|
});
|
|
269
331
|
socket.on('close', () => {
|
|
270
|
-
|
|
332
|
+
// A retry may already own this connectionId.
|
|
333
|
+
if (dataSockets.get(connectionId) === entry) teardownDataSocket(connectionId);
|
|
271
334
|
});
|
|
272
335
|
socket.on('error', (error) => {
|
|
273
|
-
logger.warn(`[Relay] host-data socket error: ${error
|
|
336
|
+
logger.warn(`[Relay] host-data socket error: ${describeRelayError(error)}`);
|
|
274
337
|
});
|
|
275
338
|
};
|
|
276
339
|
|
|
@@ -284,19 +347,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
284
347
|
if (!message || typeof message !== 'object') return;
|
|
285
348
|
if (message.type === 'sync' && Array.isArray(message.connectionIds)) {
|
|
286
349
|
const wanted = new Set(message.connectionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
|
350
|
+
for (const connectionId of [...announcedClients.keys()]) {
|
|
351
|
+
if (!wanted.has(connectionId)) forgetClient(connectionId);
|
|
352
|
+
}
|
|
287
353
|
for (const connectionId of [...dataSockets.keys()]) {
|
|
288
354
|
if (!wanted.has(connectionId)) teardownDataSocket(connectionId);
|
|
289
355
|
}
|
|
290
356
|
for (const connectionId of wanted) {
|
|
357
|
+
announceClient(connectionId);
|
|
291
358
|
openDataSocket(connectionId);
|
|
292
359
|
}
|
|
293
360
|
return;
|
|
294
361
|
}
|
|
295
362
|
if (message.type === 'connected' && typeof message.connectionId === 'string') {
|
|
363
|
+
announceClient(message.connectionId);
|
|
296
364
|
openDataSocket(message.connectionId);
|
|
297
365
|
return;
|
|
298
366
|
}
|
|
299
367
|
if (message.type === 'disconnected' && typeof message.connectionId === 'string') {
|
|
368
|
+
forgetClient(message.connectionId);
|
|
300
369
|
teardownDataSocket(message.connectionId);
|
|
301
370
|
}
|
|
302
371
|
};
|
|
@@ -318,9 +387,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
318
387
|
|
|
319
388
|
let socket;
|
|
320
389
|
try {
|
|
321
|
-
socket =
|
|
390
|
+
socket = createSocket(buildSocketUrl('host-control'));
|
|
322
391
|
} catch (error) {
|
|
323
|
-
lastError =
|
|
392
|
+
lastError = describeRelayError(error);
|
|
324
393
|
scheduleReconnect();
|
|
325
394
|
return;
|
|
326
395
|
}
|
|
@@ -365,7 +434,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
365
434
|
});
|
|
366
435
|
socket.on('error', (error) => {
|
|
367
436
|
if (controlSocket !== socket) return;
|
|
368
|
-
lastError =
|
|
437
|
+
lastError = describeRelayError(error);
|
|
369
438
|
});
|
|
370
439
|
socket.on('close', (code, reasonBuffer) => {
|
|
371
440
|
clearInterval(pingTimer);
|
|
@@ -377,6 +446,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
377
446
|
}
|
|
378
447
|
// Data sockets ride their own relay connections; the relay keeps clients
|
|
379
448
|
// alive through a 30 s control-reconnect grace window, so leave them up.
|
|
449
|
+
cancelRedials();
|
|
380
450
|
scheduleReconnect();
|
|
381
451
|
});
|
|
382
452
|
};
|
|
@@ -397,6 +467,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
397
467
|
if (stopped) return;
|
|
398
468
|
stopped = true;
|
|
399
469
|
clearInterval(idleSweepTimer);
|
|
470
|
+
cancelRedials();
|
|
400
471
|
if (reconnectTimer) {
|
|
401
472
|
clearTimeout(reconnectTimer);
|
|
402
473
|
reconnectTimer = null;
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// client using the JS e2ee initiator. Verifies the full handshake and a tunneled
|
|
3
3
|
// HTTP GET /health, and asserts only binary frames cross the relay post-handshake.
|
|
4
4
|
|
|
5
|
-
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
|
5
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test';
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
8
9
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
9
10
|
|
|
10
11
|
import { startRelayHost } from './host-client.js';
|
|
@@ -315,3 +316,202 @@ describe('relay host-client integration', () => {
|
|
|
315
316
|
expect(forwarded.filter((f) => f.isBinary).length).toBeGreaterThan(0);
|
|
316
317
|
});
|
|
317
318
|
});
|
|
319
|
+
|
|
320
|
+
// Control-only relay whose host-data upgrades the test scripts: each dial is
|
|
321
|
+
// answered by the next entry of `outcomes` ('reject' -> 503, 'hang' -> no
|
|
322
|
+
// answer, 'accept' -> 101),
|
|
323
|
+
// accepting once the list runs out.
|
|
324
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
325
|
+
|
|
326
|
+
const startScriptedRelay = (outcomes) => {
|
|
327
|
+
const server = http.createServer();
|
|
328
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
329
|
+
const state = { control: null, dials: [], dataSockets: [] };
|
|
330
|
+
// Upgraded sockets leave the server's connection tracking, so a hung one
|
|
331
|
+
// would hold server.close() open: destroy them on stop.
|
|
332
|
+
const hungSockets = [];
|
|
333
|
+
let notifyDial = () => {};
|
|
334
|
+
server.on('upgrade', (req, socket, head) => {
|
|
335
|
+
const url = new URL(req.url, 'http://localhost');
|
|
336
|
+
const role = url.searchParams.get('role');
|
|
337
|
+
if (role === 'host-data') {
|
|
338
|
+
state.dials.push(url.searchParams.get('connectionId'));
|
|
339
|
+
notifyDial();
|
|
340
|
+
const outcome = outcomes.shift() ?? 'accept';
|
|
341
|
+
if (outcome === 'reject') {
|
|
342
|
+
socket.end('HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (outcome === 'hang') {
|
|
346
|
+
socket.on('error', () => {});
|
|
347
|
+
hungSockets.push(socket);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
352
|
+
if (role === 'host-control') state.control = ws;
|
|
353
|
+
else state.dataSockets.push(ws);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
const waitForDials = (count, timeoutMs) => new Promise((resolve) => {
|
|
357
|
+
const check = () => {
|
|
358
|
+
if (state.dials.length >= count) resolve(true);
|
|
359
|
+
};
|
|
360
|
+
notifyDial = check;
|
|
361
|
+
check();
|
|
362
|
+
setTimeout(() => resolve(state.dials.length >= count), timeoutMs);
|
|
363
|
+
});
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
server.listen(0, '127.0.0.1', () => {
|
|
366
|
+
resolve({
|
|
367
|
+
wsUrl: `ws://127.0.0.1:${server.address().port}/`,
|
|
368
|
+
state,
|
|
369
|
+
waitForDials,
|
|
370
|
+
send: (message) => state.control.send(JSON.stringify(message)),
|
|
371
|
+
stop: () => new Promise((r) => {
|
|
372
|
+
for (const client of wss.clients) client.terminate();
|
|
373
|
+
for (const socket of hungSockets) socket.destroy();
|
|
374
|
+
server.closeAllConnections();
|
|
375
|
+
wss.close();
|
|
376
|
+
server.close(() => r());
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
describe('relay host-client data socket retry', () => {
|
|
384
|
+
let relay;
|
|
385
|
+
let host;
|
|
386
|
+
|
|
387
|
+
afterEach(async () => {
|
|
388
|
+
host?.stop();
|
|
389
|
+
host = null;
|
|
390
|
+
await relay?.stop();
|
|
391
|
+
relay = null;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const startHost = async (outcomes) => {
|
|
395
|
+
relay = await startScriptedRelay(outcomes);
|
|
396
|
+
const identity = await buildIdentity();
|
|
397
|
+
let markConnected;
|
|
398
|
+
const connected = new Promise((resolve) => { markConnected = resolve; });
|
|
399
|
+
host = startRelayHost({
|
|
400
|
+
relayUrl: relay.wsUrl,
|
|
401
|
+
identity,
|
|
402
|
+
getLocalPort: () => 1,
|
|
403
|
+
onStatus: (status) => {
|
|
404
|
+
if (status.state === 'connected') markConnected();
|
|
405
|
+
},
|
|
406
|
+
logger: { warn: () => {}, info: () => {} },
|
|
407
|
+
});
|
|
408
|
+
await connected;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
it('dials a waiting client again after a dial the relay refused', async () => {
|
|
412
|
+
await startHost(['reject', 'reject']);
|
|
413
|
+
relay.send({ type: 'connected', connectionId: 'conn-retry' });
|
|
414
|
+
|
|
415
|
+
// No second `connected` arrives: the host retries on its own.
|
|
416
|
+
expect(await relay.waitForDials(3, 6000)).toBe(true);
|
|
417
|
+
expect(relay.state.dials).toEqual(['conn-retry', 'conn-retry', 'conn-retry']);
|
|
418
|
+
await sleep(100);
|
|
419
|
+
expect(host.getStatus().connectedClients).toBe(1);
|
|
420
|
+
}, 10000);
|
|
421
|
+
|
|
422
|
+
it('gives up on a hung dial well inside the client handshake timeout and dials again', async () => {
|
|
423
|
+
await startHost(['hang']);
|
|
424
|
+
relay.send({ type: 'connected', connectionId: 'conn-hung' });
|
|
425
|
+
|
|
426
|
+
expect(await relay.waitForDials(2, 9000)).toBe(true);
|
|
427
|
+
await sleep(100);
|
|
428
|
+
expect(host.getStatus().connectedClients).toBe(1);
|
|
429
|
+
}, 12000);
|
|
430
|
+
|
|
431
|
+
it('stops retrying once the relay reports the client gone', async () => {
|
|
432
|
+
await startHost(['reject', 'reject', 'reject']);
|
|
433
|
+
relay.send({ type: 'connected', connectionId: 'conn-gone' });
|
|
434
|
+
expect(await relay.waitForDials(1, 2000)).toBe(true);
|
|
435
|
+
relay.send({ type: 'disconnected', connectionId: 'conn-gone' });
|
|
436
|
+
|
|
437
|
+
expect(await relay.waitForDials(2, 2500)).toBe(false);
|
|
438
|
+
expect(relay.state.dials).toEqual(['conn-gone']);
|
|
439
|
+
}, 10000);
|
|
440
|
+
|
|
441
|
+
it('leaves a data socket that opened and then closed to the client reconnect', async () => {
|
|
442
|
+
await startHost([]);
|
|
443
|
+
relay.send({ type: 'connected', connectionId: 'conn-opened' });
|
|
444
|
+
expect(await relay.waitForDials(1, 2000)).toBe(true);
|
|
445
|
+
await sleep(100);
|
|
446
|
+
relay.state.dataSockets[0].close(1012, 'gone');
|
|
447
|
+
|
|
448
|
+
expect(await relay.waitForDials(2, 2500)).toBe(false);
|
|
449
|
+
}, 10000);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Bun's WebSocket fails a dial with an error whose message and `url` hold the
|
|
453
|
+
// whole dial URL, signed relay auth included. Node's `ws` never does, so these
|
|
454
|
+
// sockets stand in for Bun.
|
|
455
|
+
class BunLikeFailingSocket extends EventEmitter {
|
|
456
|
+
constructor(url) {
|
|
457
|
+
super();
|
|
458
|
+
this.url = url;
|
|
459
|
+
this.readyState = WebSocket.CONNECTING;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fail(reason) {
|
|
463
|
+
this.readyState = WebSocket.CLOSED;
|
|
464
|
+
this.emit('error', Object.assign(new Error(`WebSocket connection to '${this.url}' failed: ${reason}`), { url: this.url }));
|
|
465
|
+
this.emit('close', 1006, Buffer.alloc(0));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
close() {}
|
|
469
|
+
|
|
470
|
+
terminate() {}
|
|
471
|
+
|
|
472
|
+
ping() {}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
describe('relay host-client error redaction', () => {
|
|
476
|
+
const fakeIdentity = {
|
|
477
|
+
serverId: 'fake-server-id',
|
|
478
|
+
hostEncPrivateKey: null,
|
|
479
|
+
signRelayAuth: () => ({ ts: 1, sig: 'FAKE-SIGNATURE', pk: 'FAKE-PUBLIC-KEY' }),
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
it('keeps the signed relay auth out of logs and status when a dial fails', () => {
|
|
483
|
+
const sockets = [];
|
|
484
|
+
const warnings = [];
|
|
485
|
+
const host = startRelayHost({
|
|
486
|
+
relayUrl: 'wss://relay.example.test/ws',
|
|
487
|
+
identity: fakeIdentity,
|
|
488
|
+
getLocalPort: () => 1,
|
|
489
|
+
logger: { warn: (line) => warnings.push(line) },
|
|
490
|
+
createSocket: (url) => {
|
|
491
|
+
const socket = new BunLikeFailingSocket(url);
|
|
492
|
+
sockets.push(socket);
|
|
493
|
+
return socket;
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const control = sockets[0];
|
|
499
|
+
control.readyState = WebSocket.OPEN;
|
|
500
|
+
control.emit('open');
|
|
501
|
+
control.emit('message', Buffer.from(JSON.stringify({ type: 'connected', connectionId: 'conn-1' })), false);
|
|
502
|
+
expect(sockets[1].url).toContain('sig=FAKE-SIGNATURE');
|
|
503
|
+
sockets[1].fail('Expected 101 status code');
|
|
504
|
+
control.fail('Failed to connect');
|
|
505
|
+
|
|
506
|
+
const lastError = host.getStatus().lastError;
|
|
507
|
+
expect(warnings).toEqual(["[Relay] host-data socket error: WebSocket connection to 'wss://relay.example.test/ws' failed: Expected 101 status code"]);
|
|
508
|
+
expect(lastError).toBe("WebSocket connection to 'wss://relay.example.test/ws' failed: Failed to connect");
|
|
509
|
+
for (const text of [...warnings, lastError]) {
|
|
510
|
+
expect(text).not.toContain('FAKE-SIGNATURE');
|
|
511
|
+
expect(text).not.toContain('FAKE-PUBLIC-KEY');
|
|
512
|
+
}
|
|
513
|
+
} finally {
|
|
514
|
+
host.stop();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// the push relay via signing-key.js — same storage, same serverId) plus a NEW
|
|
3
3
|
// long-lived ECDH P-256 encryption keypair for the E2EE channel (WebCrypto
|
|
4
4
|
// keys are single-purpose, so signing and encryption keys must differ).
|
|
5
|
-
//
|
|
6
|
-
// { privateJwk, publicJwk }`, mirroring the relaySigningKey precedent.
|
|
5
|
+
// Both keypairs are stored by key-store.js.
|
|
7
6
|
|
|
8
7
|
import {
|
|
9
8
|
canonicalPublicJwkString,
|
|
@@ -13,48 +12,26 @@ import {
|
|
|
13
12
|
} from './signing-key.js';
|
|
14
13
|
import { exportPublicKeyJwk, generateEcdhKeyPair, importEcdhPrivateKey } from './e2ee.js';
|
|
15
14
|
|
|
16
|
-
const isJwkPair = (value) => Boolean(value && typeof value === 'object' && value.privateJwk && value.publicJwk);
|
|
17
|
-
|
|
18
15
|
/**
|
|
19
16
|
* @param {{
|
|
20
17
|
* crypto: typeof import('node:crypto'),
|
|
21
|
-
*
|
|
22
|
-
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
|
23
|
-
* readSettingsStrict?: () => Promise<object>,
|
|
18
|
+
* relayKeyStore: ReturnType<typeof import('./key-store.js').createRelayKeyStore>,
|
|
24
19
|
* }} deps
|
|
25
20
|
*/
|
|
26
21
|
export const createRelayIdentityRuntime = (deps) => {
|
|
27
|
-
const { crypto,
|
|
22
|
+
const { crypto, relayKeyStore } = deps;
|
|
28
23
|
|
|
29
24
|
let cachedIdentity = null;
|
|
30
25
|
|
|
31
|
-
const getOrCreateEncryptionKeypair = async () => {
|
|
32
|
-
const settings = await readSettingsFromDiskMigrated();
|
|
33
|
-
const existing = settings?.relayEncryptionKey;
|
|
34
|
-
if (isJwkPair(existing)) {
|
|
35
|
-
return existing;
|
|
36
|
-
}
|
|
37
|
-
// Same regeneration gate as the signing key: never mint a replacement
|
|
38
|
-
// identity key off a swallowed read failure — a new encryption key breaks
|
|
39
|
-
// the E2EE trust anchor pinned by every paired device. Verify "missing" via
|
|
40
|
-
// the strict reader (throws on corrupt/unreadable) before generating.
|
|
41
|
-
let verifiedSettings = settings;
|
|
42
|
-
if (readSettingsStrict) {
|
|
43
|
-
verifiedSettings = await readSettingsStrict();
|
|
44
|
-
const verified = verifiedSettings?.relayEncryptionKey;
|
|
45
|
-
if (isJwkPair(verified)) {
|
|
46
|
-
return verified;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
26
|
+
const getOrCreateEncryptionKeypair = () => relayKeyStore.getOrCreate('encryption', async () => {
|
|
49
27
|
// Loud on purpose: a new encryption key invalidates the E2EE trust anchor of
|
|
50
28
|
// every paired device. Expected exactly once, on first relay use.
|
|
51
29
|
console.warn('[relay-identity] Generating NEW relay encryption keypair (E2EE trust anchor changes; previously paired devices must re-pair)');
|
|
52
30
|
const keyPair = await generateEcdhKeyPair();
|
|
53
31
|
const privateJwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
54
32
|
const publicJwk = await exportPublicKeyJwk(keyPair.publicKey);
|
|
55
|
-
await writeSettingsToDisk({ ...settings, ...(verifiedSettings || {}), relayEncryptionKey: { privateJwk, publicJwk } });
|
|
56
33
|
return { privateJwk, publicJwk };
|
|
57
|
-
};
|
|
34
|
+
});
|
|
58
35
|
|
|
59
36
|
/**
|
|
60
37
|
* @returns {Promise<{
|
|
@@ -66,7 +43,7 @@ export const createRelayIdentityRuntime = (deps) => {
|
|
|
66
43
|
*/
|
|
67
44
|
const getRelayIdentity = async () => {
|
|
68
45
|
if (cachedIdentity) return cachedIdentity;
|
|
69
|
-
const signing = await getOrCreateRelaySigningKeypair({ crypto,
|
|
46
|
+
const signing = await getOrCreateRelaySigningKeypair({ crypto, relayKeyStore });
|
|
70
47
|
const serverId = deriveServerId({ crypto }, signing.publicJwk);
|
|
71
48
|
const encryption = await getOrCreateEncryptionKeypair();
|
|
72
49
|
const hostEncPrivateKey = await importEcdhPrivateKey(encryption.privateJwk);
|