diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d76f530..6891d045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Remote access: a new private relay lets you reach your instance from anywhere — no open ports and no third-party tunnel. Enable it in Settings → Remote Instances, and pairing links (QR or `openchamber connect-url`) can carry the relay as a fallback so a device off your network connects over an end-to-end-encrypted tunnel. +- Pairing: device pairing links are now single-use, expiring codes redeemed on connect instead of embedding a long-lived token in the QR. - Diff/Chat: added a Last turn mode to the Diff view, and latest-turn changed-file chips in chat now open that snapshot while older turn chips stay read-only. - Chat: if sending a message times out or loses the connection after OpenCode accepted it, the app now keeps the sent message instead of rolling it back as failed. - Mobile: the native app can now check for OpenChamber app updates; Android shows a persistent download toast when an update is available. diff --git a/docs/pairing-v2-implementation-plan.md b/docs/pairing-v2-implementation-plan.md new file mode 100644 index 00000000..1b4e9dc6 --- /dev/null +++ b/docs/pairing-v2-implementation-plan.md @@ -0,0 +1,948 @@ +# Pairing v2 Trusted-Device Issuance Backend Plan + +## Scope + +Implement the Pairing v2 mechanism without UI. + +Included: + +- Backend pairing session runtime. +- Pairing create/redeem/cancel routes. +- Trusted-device token issuance through the existing remote client auth runtime. +- Backward-compatible remote client metadata extension. +- Password/passkey issuance metadata alignment. +- Shared v2 `openchamber://connect` payload helpers. + +Not included: + +- Settings page. +- QR modal. +- Pair Device button. +- Device list UI. +- Translations/copy. +- Relay implementation. +- LAN discovery. +- End-user polished mobile/desktop screens. + +## Naming + +Use the existing `client-auth` domain. + +New module: + +```text +packages/web/server/lib/client-auth/pairing.js +``` + +Existing durable token module remains: + +```text +packages/web/server/lib/client-auth/remote-clients.js +``` + +Conceptual names: + +```text +Remote client +Trusted-device client token +Pairing session +Pairing secret +Pairing redeem +``` + +Deep link stays: + +```text +openchamber://connect +``` + +Versions: + +```text +v=1 => legacy server + long-lived token import +v=2 => one-time pairing handshake +``` + +## New Files + +### 1. `packages/web/server/lib/client-auth/pairing.js` + +Create a new backend runtime module for short-lived pairing sessions. + +Responsibilities: + +```text +createPairingSession +getPairingSession +cancelPairingSession +redeemPairingSession +sweepExpiredSessions +``` + +Store file: + +```text +OPENCHAMBER_DATA_DIR/client-pairing-sessions.json +``` + +Suggested store shape: + +```json +{ + "version": 1, + "sessions": [ + { + "id": "pair_...", + "secretHash": "...", + "createdAt": "...", + "expiresAt": "...", + "usedAt": null, + "cancelledAt": null, + "clientId": null, + "label": "Pair new device", + "fingerprint": "ABCD-1234", + "allowedClientKinds": ["mobile", "desktop"], + "createdByClientId": null + } + ] +} +``` + +Security requirements: + +```text +Persist only secretHash. +Return plaintext secret only from createPairingSession. +Redeem is one-time. +Redeem is expiry-aware. +Redeem is cancellation-aware. +Redeem must be mutation-serialized to avoid double issuance. +No raw token/secret logging. +``` + +Public methods should accept injected dependencies, following `remote-clients.js` style: + +```js +createClientPairingRuntime({ + fsPromises, + path, + crypto, + storePath, + remoteClientAuthRuntime, +}) +``` + +## Existing Files To Update + +### 2. `packages/web/server/lib/client-auth/remote-clients.js` + +Extend trusted-device metadata backward-compatibly. + +Current `createClient` input: + +```js +{ + label, + expiresAt, + clientKind, + dedupeKey, +} +``` + +Extend to: + +```js +{ + label, + expiresAt, + clientKind, + dedupeKey, + authMethod, + pairingId, + deviceName, + devicePlatform, + deviceModel, + appVersion, +} +``` + +Add normalized public fields: + +```text +authMethod +pairingId +deviceName +devicePlatform +deviceModel +appVersion +``` + +Backward compatibility rules: + +```text +Existing remote-clients.json remains valid. +Missing new fields normalize to null. +Existing tokens continue authenticating. +Public client output never exposes tokenHash. +Raw token is returned only from createClient. +``` + +Recommended `authMethod` values: + +```text +pairing +password +passkey +desktop-local +manual +legacy +``` + +Do not force migration for old records. Treat missing `authMethod` as legacy/null. + +### 3. `packages/web/server/index.js` + +Instantiate the new pairing runtime next to `remoteClientAuthRuntime`. + +Existing: + +```js +const remoteClientAuthRuntime = createRemoteClientAuthRuntime({ + fsPromises, + path, + crypto, + storePath: REMOTE_CLIENTS_FILE_PATH, +}); +``` + +Add: + +```js +const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join( + OPENCHAMBER_DATA_DIR, + 'client-pairing-sessions.json', +); +``` + +Then: + +```js +const clientPairingRuntime = createClientPairingRuntime({ + fsPromises, + path, + crypto, + storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH, + remoteClientAuthRuntime, +}); +``` + +Pass `clientPairingRuntime` into `registerAuthAndAccessRoutes` dependencies. + +### 4. `packages/web/server/lib/opencode/core-routes.js` + +Add pairing routes near existing client-auth routes: + +```text +/api/client-auth/clients +``` + +Add: + +```http +POST /api/client-auth/pairing/sessions +DELETE /api/client-auth/pairing/sessions/:id +POST /api/client-auth/pairing/redeem +``` + +Optional, can be deferred: + +```http +GET /api/client-auth/pairing/sessions/:id +``` + +Since UI polling is out of scope, `GET` is not required for this phase. + +#### Route: `POST /api/client-auth/pairing/sessions` + +Purpose: + +```text +Create one short-lived pairing session and return data needed to build QR/deep link. +``` + +Auth: + +```text +Require UI session auth. +Allow desktop-local client only if consistent with existing client-create exception. +Reject arbitrary remote client tokens. +Reject url-token auth. +``` + +Request: + +```json +{ + "label": "Pair new device", + "allowedClientKinds": ["mobile", "desktop"] +} +``` + +Response: + +```json +{ + "pairing": { + "id": "pair_...", + "secret": "one_time_secret", + "expiresAt": "...", + "fingerprint": "ABCD-1234", + "label": "Pair new device" + }, + "server": { + "label": "OpenChamber", + "candidates": [ + { + "type": "lan", + "url": "http://192.168.1.20:4096", + "priority": 10 + }, + { + "type": "tunnel", + "url": "https://abc.ngrok.app", + "priority": 20 + } + ] + } +} +``` + +Headers: + +```http +Cache-Control: no-store +``` + +Note: + +```text +This route does not render QR. +UI can later encode the returned data into openchamber://connect?v=2&p=... +``` + +#### Route: `DELETE /api/client-auth/pairing/sessions/:id` + +Purpose: + +```text +Cancel an unused pairing session. +``` + +Auth: + +```text +Require owner/session auth. +``` + +Behavior: + +```text +Set cancelledAt. +Do not delete immediately. +If already used, cancellation should not revoke the issued client. +``` + +Response: + +```json +{ + "cancelled": true +} +``` + +#### Route: `POST /api/client-auth/pairing/redeem` + +Purpose: + +```text +Exchange pairingId + one-time secret for a trusted-device client token. +``` + +Auth: + +```text +No existing auth required. +The one-time pairing secret is the authentication factor. +``` + +Request: + +```json +{ + "pairingId": "pair_...", + "secret": "one_time_secret", + "clientLabel": "Iryna iPhone", + "clientKind": "mobile", + "deviceName": "Iryna iPhone", + "devicePlatform": "ios", + "deviceModel": "iPhone", + "appVersion": "1.12.0", + "dedupeKey": "optional-stable-device-key" +} +``` + +Server behavior: + +```text +Validate pairing exists. +Validate secret using constant-time comparison. +Validate not expired. +Validate not cancelled. +Validate not used. +Validate clientKind is allowed. +Mark pairing used. +Create remote client through remoteClientAuthRuntime.createClient. +Return clientToken once. +``` + +Create client with: + +```js +{ + label: clientLabel || deviceName || 'Remote client', + clientKind, + dedupeKey, + authMethod: 'pairing', + pairingId, + deviceName, + devicePlatform, + deviceModel, + appVersion, +} +``` + +Response: + +```json +{ + "ok": true, + "server": { + "label": "OpenChamber", + "url": "https://selected-or-current-url", + "fingerprint": "ABCD-1234" + }, + "client": { + "id": "device_...", + "label": "Iryna iPhone", + "clientKind": "mobile", + "authMethod": "pairing", + "createdAt": "..." + }, + "clientToken": "oc_client_..." +} +``` + +Headers: + +```http +Cache-Control: no-store +``` + +Failure response should be generic: + +```json +{ + "error": "Invalid or expired pairing session" +} +``` + +Do not reveal whether id, secret, expiry, used, or cancellation caused failure. + +### 5. `packages/web/server/lib/ui-auth/ui-auth.js` + +Preserve existing password/passkey behavior. + +Only add metadata to client token issuance when `issueClientToken === true`. + +Password issuance should pass: + +```js +authMethod: 'password' +clientKind: req.body?.clientKind +dedupeKey: req.body?.dedupeKey +deviceName: req.body?.deviceName +devicePlatform: req.body?.devicePlatform +deviceModel: req.body?.deviceModel +appVersion: req.body?.appVersion +``` + +Passkey issuance should pass: + +```js +authMethod: 'passkey' +clientKind: req.body?.clientKind +dedupeKey: req.body?.dedupeKey +deviceName: req.body?.deviceName +devicePlatform: req.body?.devicePlatform +deviceModel: req.body?.deviceModel +appVersion: req.body?.appVersion +``` + +Backward compatibility: + +```text +Existing POST /auth/session payload still works. +Existing response shape still works. +Existing clientToken issuance still works. +Password login remains disabled for tunnel/public scope. +``` + +### 6. `packages/ui/src/lib/connectionPayload.ts` + +Extend existing connect payload helpers. + +Keep current v1 behavior: + +```text +openchamber://connect?v=1&server=...&token=...&label=... +``` + +Add v2 payload types and helpers. + +Suggested types: + +```ts +export type ClientConnectionPayloadV1 = { + v: 1; + serverUrl: string; + token: string; + label?: string; +}; + +export type PairingEndpointCandidate = { + type: 'lan' | 'tunnel' | 'relay'; + url: string; + priority?: number; +}; + +export type PairingConnectionPayloadV2 = { + v: 2; + pairingId: string; + secret: string; + label?: string; + fingerprint?: string; + expiresAt?: string; + candidates: PairingEndpointCandidate[]; +}; +``` + +Suggested helpers: + +```ts +encodePairingConnectionPayload(payload: PairingConnectionPayloadV2): string +parsePairingConnectionPayload(value: string): PairingConnectionPayloadV2 | null +``` + +Use deep link format: + +```text +openchamber://connect?v=2&p= +``` + +Validation: + +```text +Require v=2. +Require pairingId. +Require secret. +Require at least one valid http/https candidate. +Reject malformed URL. +Reject oversized payload. +Reject expired payload locally if expiresAt is clearly in the past. +``` + +Do not break current exports used by mobile QR/manual connect. + +### 7. `packages/ui/src/apps/mobileQrScan.ts` + +Update parser only. + +Current scan parser recognizes legacy fields like: + +```text +server +label +``` + +Add support for v2 connect links. + +Output should be able to distinguish: + +```text +legacy v1 token import +pairing v2 payload +plain URL +``` + +Do not implement full mobile UI flow in this scope unless there is already a non-UI callable path. + +### 8. `packages/ui/src/apps/mobileConnections.ts` + +Add non-visual callable mechanism for redeeming pairing payload. + +Add a function conceptually like: + +```ts +redeemPairingConnection(payload: PairingConnectionPayloadV2): Promise +``` + +Responsibilities: + +```text +Try endpoint candidates. +POST /api/client-auth/pairing/redeem. +Persist issued token securely. +Persist connection metadata. +Switch runtime only after token write succeeds. +``` + +No new screens/buttons. + +Existing password flow remains unchanged. + +Candidate selection: + +```text +Normalize candidates. +Probe /health with timeout. +Try candidates by priority. +Prefer HTTPS when priority ties. +If network failure, try next candidate. +If server says invalid/expired/used, stop. +``` + +Mobile native should reuse existing native HTTP fallback path for LAN HTTP. + +### 9. `packages/electron/main.mjs` + +Extend existing connect deep-link handling. + +Current v1 behavior: + +```text +openchamber://connect?v=1&server=...&token=... +``` + +Keep it. + +Add v2 branch: + +```text +openchamber://connect?v=2&p=... +``` + +Behavior: + +```text +Parse v2 payload. +Show confirmation before redeem/write/switch. +Probe candidates. +Redeem pairing secret. +Store returned clientToken in desktop hosts config. +Ask/switch according to existing remote host behavior. +Never show token. +Never write config before confirmation. +``` + +If this phase is strictly backend-only, this file can be deferred. But if desktop app as client must be functionally supported by deep link in this phase, include this change. + +### 10. `packages/electron/preload.mjs` + +No change expected unless a renderer-side desktop API is needed for pairing redeem. + +Prefer keeping pairing redeem in main process only for deep-link handling if desktop v2 is implemented there. + +### 11. `packages/web/server/lib/ui-auth/DOCUMENTATION.md` + +Update module documentation to reflect the unified issuance model: + +```text +Password, passkey, and pairing are issuance methods. +Trusted-device client token is the durable credential. +Pairing v2 uses one-time secrets and issues remote client tokens. +``` + +Optionally add: + +```text +packages/web/server/lib/client-auth/DOCUMENTATION.md +``` + +if the client-auth module needs ownership docs. + +## Route Registration Summary + +Add to `registerAuthAndAccessRoutes`: + +```http +POST /api/client-auth/pairing/sessions +DELETE /api/client-auth/pairing/sessions/:id +POST /api/client-auth/pairing/redeem +``` + +Optional later: + +```http +GET /api/client-auth/pairing/sessions/:id +``` + +Route placement: + +```text +Register before generic OpenCode proxy. +Place near existing /api/client-auth/clients routes. +``` + +## Execution Sequence + +### Step 1: Extend Remote Client Metadata + +Files: + +```text +packages/web/server/lib/client-auth/remote-clients.js +``` + +Do: + +```text +Add metadata normalization. +Extend createClient input. +Extend publicClient output. +Keep old records valid. +Do not change token generation/authentication behavior. +``` + +### Step 2: Add Password/Passkey Metadata Issuance + +Files: + +```text +packages/web/server/lib/ui-auth/ui-auth.js +``` + +Do: + +```text +When issueClientToken is true, pass authMethod='password' from password login. +When issueClientToken is true, pass authMethod='passkey' from passkey auth. +Pass optional device metadata through. +Preserve response shape. +``` + +### Step 3: Create Pairing Runtime Module + +Files: + +```text +packages/web/server/lib/client-auth/pairing.js +``` + +Do: + +```text +Implement session creation. +Implement hashed secret storage. +Implement cancel. +Implement redeem. +Implement expiry/used/cancelled checks. +Integrate remoteClientAuthRuntime.createClient in redeem. +``` + +### Step 4: Instantiate Pairing Runtime + +Files: + +```text +packages/web/server/index.js +``` + +Do: + +```text +Define CLIENT_PAIRING_SESSIONS_FILE_PATH. +Instantiate createClientPairingRuntime. +Pass clientPairingRuntime to registerAuthAndAccessRoutes. +``` + +### Step 5: Add Pairing Routes + +Files: + +```text +packages/web/server/lib/opencode/core-routes.js +``` + +Do: + +```text +Destructure clientPairingRuntime from dependencies. +Add POST /api/client-auth/pairing/sessions. +Add DELETE /api/client-auth/pairing/sessions/:id. +Add POST /api/client-auth/pairing/redeem. +Use correct auth gates. +Set Cache-Control: no-store where secrets/tokens are returned. +Keep error responses generic for redeem. +``` + +### Step 6: Add v2 Payload Helpers + +Files: + +```text +packages/ui/src/lib/connectionPayload.ts +``` + +Do: + +```text +Keep v1 helpers unchanged. +Add v2 payload type. +Add encode v2 helper. +Add parse v2 helper. +Use openchamber://connect?v=2&p=. +Validate candidates. +Reject malformed/expired/oversized payloads. +``` + +### Step 7: Update QR Scan Parser Shape + +Files: + +```text +packages/ui/src/apps/mobileQrScan.ts +``` + +Do: + +```text +Recognize v2 connect payload. +Return structured v2 result. +Do not add new UI. +Do not break v1/manual URL behavior. +``` + +### Step 8: Add Non-UI Mobile Redeem Plumbing + +Files: + +```text +packages/ui/src/apps/mobileConnections.ts +``` + +Do: + +```text +Add callable redeem pairing function. +Try endpoint candidates. +Redeem via /api/client-auth/pairing/redeem. +Persist token before runtime switch. +Reuse existing storage model. +Keep password/manual connect unchanged. +``` + +### Step 9: Add Desktop Deep-Link v2 Handling If In Scope + +Files: + +```text +packages/electron/main.mjs +``` + +Do: + +```text +Extend connect deep-link parser to recognize v2. +Confirm before redeem. +Redeem against candidate endpoint. +Store remote host config with returned token. +Switch only after confirmation and successful storage. +Keep v1 behavior unchanged. +``` + +If desktop client deep-link support is deferred, skip this step and document that v2 backend/shared payload exists but desktop consumer is not wired yet. + +### Step 10: Update Documentation + +Files: + +```text +packages/web/server/lib/ui-auth/DOCUMENTATION.md +``` + +Optionally add: + +```text +packages/web/server/lib/client-auth/DOCUMENTATION.md +``` + +Document: + +```text +Unified trusted-device token issuance. +Pairing v2 flow. +Password/passkey/pairing authMethod values. +Security rules. +Backward compatibility guarantees. +``` + +## Important Non-Goals + +Do not implement: + +```text +Settings page +Pair Device button +QR modal +Device list UI +Translations +Visual design +Relay transport +LAN discovery +Account/cloud sync +Token migration to OS keychain on desktop +``` + +## Backward Compatibility Requirements + +Must remain true: + +```text +Existing v1 openchamber://connect links keep working. +Existing password login with issueClientToken keeps working. +Existing passkey issueClientToken keeps working. +Existing remote-clients.json keeps loading. +Existing client tokens keep authenticating. +Existing mobile saved connections keep working. +Existing desktop remote hosts keep working. +``` + +## Security Requirements + +Must hold: + +```text +No long-lived token in v2 link. +Pairing secret persisted only as hash. +Pairing secret returned only once. +Client token returned only once. +Token hash persisted server-side. +Redeem is one-time. +Redeem is expiry-aware. +Redeem is cancellation-aware. +Redeem errors are generic. +Password login remains disabled for tunnel/public scope. +Pairing session creation requires owner/session auth. +Pairing redeem requires no prior auth but requires valid one-time secret. +Desktop v2 connect confirms before writing host config or switching runtime. +``` diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 3e97f541..163e8889 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -154,6 +154,10 @@ const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000; const LOCAL_HOST_ID = 'local'; const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local'; const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local'; +// Remote hosts get a regular 'desktop' client (NOT 'desktop-local' — that kind +// grants whole-server device management and must never be issued to a desktop +// connecting to someone else's server). +const REMOTE_DESKTOP_CLIENT_KIND = 'desktop'; const ENV_OVERRIDE_HOST_ID = '__env'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md'; const GITHUB_BUG_REPORT_URL = 'https://github.com/openchamber/openchamber/issues/new?template=bug_report.yml'; @@ -486,6 +490,41 @@ const mutateSettingsRoot = (mutator) => { const writeSettingsRoot = async (root) => writeJsonFile(settingsFilePath(), root); +// Stable per-install identifier for this desktop, persisted in settings. Used as +// the client dedupe key on remote hosts so re-authenticating (e.g. after a login +// session expires) reuses the same "OpenChamber Desktop" record instead of +// piling up a new one each time. Different desktops get different ids. +// Display-only device metadata shown in a server's device list ("macOS", +// app version). Never used for auth decisions. +const desktopDeviceMetadata = () => { + const platformMap = { darwin: 'macos', win32: 'windows', linux: 'linux' }; + const devicePlatform = platformMap[process.platform]; + let appVersion; + try { + appVersion = app.getVersion(); + } catch { + appVersion = undefined; + } + return { + ...(devicePlatform ? { devicePlatform } : {}), + ...(appVersion ? { appVersion } : {}), + }; +}; + +const getOrCreateDesktopInstallId = async () => { + const existing = readSettingsRoot().desktopInstallId; + if (typeof existing === 'string' && existing.trim()) return existing.trim(); + const generated = globalThis.crypto.randomUUID(); + await mutateSettingsRoot((root) => { + // Race guard: keep an id another writer may have already persisted. + if (typeof root.desktopInstallId === 'string' && root.desktopInstallId.trim()) return root; + root.desktopInstallId = generated; + return root; + }); + const after = readSettingsRoot().desktopInstallId; + return typeof after === 'string' && after.trim() ? after.trim() : generated; +}; + const normalizeHostUrl = (raw) => { const trimmed = typeof raw === 'string' ? raw.trim() : ''; if (!trimmed) return null; @@ -570,20 +609,56 @@ const isLocalRuntimeUrl = (targetUrl) => { } }; +// A relay host is reached over the E2EE tunnel: it has no http(s) apiUrl, only a +// { relayUrl (ws/wss), serverId, hostEncPubJwk } descriptor. The relay grant is a +// one-time pairing artifact and is never persisted. +const sanitizeHostRelayForStorage = (value) => { + if (!value || typeof value !== 'object') return null; + const relayUrl = typeof value.relayUrl === 'string' ? value.relayUrl.trim() : ''; + const serverId = typeof value.serverId === 'string' ? value.serverId.trim() : ''; + const jwk = value.hostEncPubJwk; + if (!relayUrl || !serverId || !jwk || typeof jwk !== 'object' || Array.isArray(jwk)) return null; + // Minimal EC public JWK shape check so a malformed descriptor is rejected at + // storage time instead of surfacing later as a tunnel handshake failure. + if (typeof jwk.kty !== 'string' || typeof jwk.crv !== 'string' || typeof jwk.x !== 'string') return null; + try { + const parsed = new URL(relayUrl); + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return null; + } catch { + return null; + } + return { relayUrl, serverId, hostEncPubJwk: jwk }; +}; + +// Shared storage shape for a persisted host (direct or relay). Returns null for +// entries that can't be stored (missing id, reserved 'local', or no usable +// transport). +const buildStoredHostEntry = (entry) => { + const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; + if (!id || id === LOCAL_HOST_ID) return null; + const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); + const headerFields = Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}; + const tokenField = clientToken ? { clientToken } : {}; + const labelRaw = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : ''; + + const relay = sanitizeHostRelayForStorage(entry?.relay); + if (relay) { + const url = `relay://${relay.serverId}`; + return { id, label: labelRaw || url, url, ...tokenField, ...headerFields, relay }; + } + + const url = sanitizeHostUrlForStorage(entry?.url); + if (!url) return null; + const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; + return { id, label: labelRaw || url, url, apiUrl, ...tokenField, ...headerFields }; +}; + const readDesktopHostsConfig = () => { const root = readSettingsRoot(); const hostsRaw = Array.isArray(root.desktopHosts) ? root.desktopHosts : []; const hosts = hostsRaw - .map((entry) => { - const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; - const url = sanitizeHostUrlForStorage(entry?.url); - if (!id || id === LOCAL_HOST_ID || !url) return null; - const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; - const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); - const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); - const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url; - return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}), ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}) }; - }) + .map(buildStoredHostEntry) .filter(Boolean); return { @@ -599,22 +674,7 @@ const writeDesktopHostsConfig = async (config) => { await mutateSettingsRoot((root) => { root.desktopHosts = Array.isArray(config?.hosts) ? config.hosts - .map((entry) => { - const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; - const url = sanitizeHostUrlForStorage(entry?.url); - if (!id || id === LOCAL_HOST_ID || !url) return null; - const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; - const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); - const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); - return { - id, - label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, - url, - apiUrl, - ...(clientToken ? { clientToken } : {}), - ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}), - }; - }) + .map(buildStoredHostEntry) .filter(Boolean) : []; root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim() @@ -1582,6 +1642,13 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requ if (!baseUrl) throw new Error('Invalid URL'); if (!candidatePassword) throw new Error('Password is required'); + // Stable client identity so re-login reuses the same device record. Local + // uses the fixed desktop-local identity; remote uses this install's id with a + // regular 'desktop' kind. + const clientIdentity = isLocalRuntimeUrl(baseUrl) + ? { clientKind: LOCAL_DESKTOP_CLIENT_KIND, dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY, ...desktopDeviceMetadata() } + : { clientKind: REMOTE_DESKTOP_CLIENT_KIND, dedupeKey: `desktop:${await getOrCreateDesktopInstallId()}`, ...desktopDeviceMetadata() }; + const loginResponse = await fetch(new URL('/auth/session', `${baseUrl}/`).toString(), { method: 'POST', signal: AbortSignal.timeout(10_000), @@ -1595,10 +1662,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requ trustDevice: trustDevice === true, issueClientToken: true, clientLabel: 'OpenChamber Desktop', - ...(isLocalRuntimeUrl(baseUrl) ? { - clientKind: LOCAL_DESKTOP_CLIENT_KIND, - dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY, - } : {}), + ...clientIdentity, }), }); if (!loginResponse.ok) { @@ -1626,10 +1690,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requ }, body: JSON.stringify({ label: 'OpenChamber Desktop', - ...(isLocalRuntimeUrl(baseUrl) ? { - clientKind: LOCAL_DESKTOP_CLIENT_KIND, - dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY, - } : {}), + ...clientIdentity, }), }); if (!tokenResponse.ok) { @@ -1711,19 +1772,53 @@ const parseDeepLink = (raw) => { } }; -const parseConnectDeepLinkPayload = (raw) => { +const decodeBase64UrlJson = (value) => { + if (typeof value !== 'string' || !value.trim()) return null; + try { + const json = Buffer.from(value.trim(), 'base64url').toString('utf8'); + return JSON.parse(json); + } catch { + return null; + } +}; + +const parseConnectPairingDeepLinkPayload = (raw) => { if (typeof raw !== 'string') return null; try { const url = new URL(raw.trim()); if (url.protocol !== `${DEEP_LINK_PROTOCOL}:` || url.hostname !== 'connect') return null; - const version = url.searchParams.get('v'); - const serverUrl = normalizeHostUrl(url.searchParams.get('server') || ''); - const token = sanitizeClientTokenForStorage(url.searchParams.get('token') || ''); - const label = typeof url.searchParams.get('label') === 'string' - ? url.searchParams.get('label').trim() - : ''; - if (version !== '1' || !serverUrl || !token) return null; - return { serverUrl, token, label: label || serverUrl }; + if (url.searchParams.get('v') !== '2') return null; + const payload = decodeBase64UrlJson(url.searchParams.get('p') || ''); + if (!payload || payload.v !== 2 || typeof payload !== 'object') return null; + const pairingId = typeof payload.pairingId === 'string' ? payload.pairingId.trim() : ''; + const secret = typeof payload.secret === 'string' ? payload.secret.trim() : ''; + if (!pairingId || !secret) return null; + const candidates = Array.isArray(payload.candidates) + ? payload.candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object') return []; + const type = candidate.type === 'lan' || candidate.type === 'tunnel' || candidate.type === 'relay' + ? candidate.type + : null; + const candidateUrl = normalizeHostUrl(candidate.url || ''); + if (!type || !candidateUrl) return []; + const priority = Number.isFinite(candidate.priority) ? candidate.priority : 100; + return [{ type, url: candidateUrl, priority }]; + }) + : []; + if (candidates.length === 0) return null; + const expiresAt = typeof payload.expiresAt === 'string' ? payload.expiresAt.trim() : ''; + if (expiresAt) { + const expiresTime = Date.parse(expiresAt); + if (!Number.isFinite(expiresTime) || expiresTime <= Date.now()) return null; + } + return { + pairingId, + secret, + label: typeof payload.label === 'string' && payload.label.trim() ? payload.label.trim() : 'OpenChamber', + fingerprint: typeof payload.fingerprint === 'string' && payload.fingerprint.trim() ? payload.fingerprint.trim() : '', + expiresAt: expiresAt || null, + candidates: candidates.sort((left, right) => left.priority - right.priority), + }; } catch { return null; } @@ -1731,20 +1826,22 @@ const parseConnectDeepLinkPayload = (raw) => { const importConnectDeepLink = async (payload) => { if (!payload?.serverUrl || !payload?.token) return null; + const serverUrl = normalizeHostUrl(payload.serverUrl); + if (!serverUrl) return null; const config = readDesktopHostsConfig(); const existing = config.hosts.find((host) => { const hostUrl = normalizeHostUrl(host?.url || ''); const apiUrl = normalizeHostUrl(host?.apiUrl || host?.url || ''); - return payload.serverUrl === hostUrl || payload.serverUrl === apiUrl; + return serverUrl === hostUrl || serverUrl === apiUrl; }); const id = existing?.id || `host-${Date.now()}-${Math.random().toString(16).slice(2)}`; const importedHost = { ...(existing || {}), id, - label: payload.label || existing?.label || payload.serverUrl, - url: payload.serverUrl, - apiUrl: payload.serverUrl, + label: payload.label || existing?.label || serverUrl, + url: serverUrl, + apiUrl: serverUrl, clientToken: payload.token, }; const hosts = existing @@ -1759,6 +1856,51 @@ const importConnectDeepLink = async (payload) => { return id; }; +const requestJsonWithTimeout = async (url, init = {}, timeoutMs = 8000) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const data = await response.json().catch(() => null); + return { ok: response.ok, status: response.status, data }; + } finally { + clearTimeout(timer); + } +}; + +const selectPairingCandidateUrl = async (payload) => { + for (const candidate of payload.candidates || []) { + try { + const health = await requestJsonWithTimeout(`${candidate.url.replace(/\/+$/g, '')}/health`, { method: 'GET' }, 3500); + if (health.ok) return candidate.url.replace(/\/+$/g, ''); + } catch { + } + } + return null; +}; + +const redeemConnectPairingDeepLink = async (payload, serverUrl) => { + const response = await requestJsonWithTimeout(`${serverUrl.replace(/\/+$/g, '')}/api/client-auth/pairing/redeem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + pairingId: payload.pairingId, + secret: payload.secret, + clientLabel: 'OpenChamber Desktop', + clientKind: 'desktop', + deviceName: 'OpenChamber Desktop', + ...desktopDeviceMetadata(), + dedupeKey: `desktop:${await getOrCreateDesktopInstallId()}`, + }), + }); + if (!response.ok || !response.data || typeof response.data.clientToken !== 'string') return null; + return { + serverUrl, + token: sanitizeClientTokenForStorage(response.data.clientToken), + label: payload.label || response.data?.server?.label || serverUrl, + }; +}; + const switchToHostById = async (rawId) => { const id = typeof rawId === 'string' ? rawId.trim() : ''; if (!id) return; @@ -1830,20 +1972,37 @@ const dispatchDeepLink = (link) => { if (!link) return; log.info('[electron] dispatching deep-link', { type: link.type, valueLen: link.value?.length || 0 }); if (link.type === 'connect') { - const payload = parseConnectDeepLinkPayload(link.raw); - if (!payload) { - log.warn('[electron] invalid connect deep-link payload'); - return; - } - void confirmConnectDeepLink(payload).then((confirmed) => { - if (!confirmed) { - log.info('[electron] connect deep-link declined by user'); - return; - } - return importConnectDeepLink(payload).then((id) => { + const pairingPayload = parseConnectPairingDeepLinkPayload(link.raw); + if (pairingPayload) { + const previewUrl = pairingPayload.candidates[0]?.url || pairingPayload.label; + void confirmConnectDeepLink({ + serverUrl: previewUrl, + token: 'pairing-v2', + label: pairingPayload.fingerprint ? `${pairingPayload.label} (${pairingPayload.fingerprint})` : pairingPayload.label, + }).then(async (confirmed) => { + if (!confirmed) { + log.info('[electron] connect pairing deep-link declined by user'); + return; + } + const serverUrl = await selectPairingCandidateUrl(pairingPayload); + if (!serverUrl) { + log.warn('[electron] connect pairing deep-link has no reachable candidate'); + return; + } + const importedPayload = await redeemConnectPairingDeepLink(pairingPayload, serverUrl).catch((error) => { + log.warn('[electron] connect pairing redeem failed:', error); + return null; + }); + if (!importedPayload?.token) { + log.warn('[electron] connect pairing redeem returned no client token'); + return; + } + const id = await importConnectDeepLink(importedPayload); if (id) void switchToHostById(id); }); - }); + return; + } + log.warn('[electron] invalid connect deep-link payload'); return; } if (link.type === 'session' && link.value) { @@ -2278,6 +2437,20 @@ const openMainWindow = async () => { const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID ? config.hosts.find((entry) => entry.id === config.defaultHostId) : null; + const relayHost = host && host.relay && typeof host.relay === 'object' ? host : null; + if (relayHost) { + // Relay hosts have no reachable HTTP base. Boot the LOCAL UI with the local + // runtime; the renderer re-opens the E2EE tunnel on startup by reading the + // relay descriptor + token from desktopHosts and calling + // switchRuntimeEndpoint({ relay }). + const localApiBaseUrl = state.sidecarUrl || state.apiBaseUrl || state.localOrigin || ''; + const localToken = resolveStoredClientTokenForUrl(localApiBaseUrl, config) || state.clientToken || ''; + return activateMainWindow(localUiUrl, state.localOrigin, state.bootOutcome, { + apiBaseUrl: localApiBaseUrl, + clientToken: localToken, + requestHeaders: {}, + }); + } const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || ''; const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || ''; const requestHeaders = sanitizeRuntimeRequestHeaders(host?.requestHeaders || {}); @@ -3572,6 +3745,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_local_client_token_get': return readDesktopLocalClientToken(); + case 'desktop_install_id_get': + return getOrCreateDesktopInstallId(); + case 'desktop_host_probe': return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}); diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index 9ef6ac5c..d00b87da 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,17 @@ + void): void => { onResume(); }; + // Belt-and-suspenders resume detection. Capacitor's `appStateChange` is the + // primary signal, but on iOS it can be missed after a long suspend, so the + // webview's own `visibilitychange` is a second trigger — either one flips + // wasInactiveRef and fires onResume exactly once per background→foreground. + const handleVisibility = () => { + if (document.visibilityState === 'hidden') { + wasInactiveRef.current = true; + return; + } + resumeAfterInactive(); + }; + document.addEventListener('visibilitychange', handleVisibility); + cleanup.push(() => document.removeEventListener('visibilitychange', handleVisibility)); + void import('@capacitor/app').then(async ({ App }) => { if (disposed) return; const state = await App.addListener('appStateChange', ({ isActive }) => { @@ -633,12 +647,6 @@ const mobileInputKeyboardProps = { const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; -const getRuntimeClientToken = (): string => { - if (typeof window === 'undefined') return ''; - const token = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__; - return typeof token === 'string' ? token.trim() : ''; -}; - const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -689,6 +697,10 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn if (/^openchamber:\/\//i.test(value.trim())) { const payload = parseConnectionPayload(value); if (payload) { + if ('pairing' in payload) { + void conn.redeemPairingConnection(payload.pairing); + return; + } setServerUrl(payload.url); if (payload.label) setConnectionName(payload.label); if (payload.clientToken) setClientToken(payload.clientToken); @@ -697,7 +709,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn } } setServerUrl(value); - }, []); + }, [conn]); const handleScanQr = React.useCallback(async () => { if (isScanning || isBusy) return; @@ -713,6 +725,9 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn if (result.label || result.clientToken) setAdvancedOpen(true); await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); break; + case 'pairing': + await conn.redeemPairingConnection(result.pairing); + break; case 'permission-denied': conn.setError(t('mobile.connect.scan.permissionDenied')); break; @@ -761,7 +776,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn

{pendingConnection.label}

- {pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url} + {pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}

@@ -886,7 +901,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn key={connection.id} type="button" className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" - onClick={() => void conn.connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })} + onClick={() => void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })} > @@ -894,7 +909,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn {connection.label} - {connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url} + {connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')} @@ -961,6 +976,9 @@ const MobileInstancesSurface: React.FC<{ if (result.label) setLabel(result.label); if (result.clientToken) setClientToken(result.clientToken); break; + case 'pairing': + await conn.redeemPairingConnection(result.pairing); + break; case 'permission-denied': setError(t('mobile.connect.scan.permissionDenied')); break; @@ -980,7 +998,7 @@ const MobileInstancesSurface: React.FC<{ } finally { setIsScanning(false); } - }, [isScanning, setError, t]); + }, [conn, isScanning, setError, t]); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); @@ -1003,11 +1021,7 @@ const MobileInstancesSurface: React.FC<{ if (editingId === id) resetForm(); void removeConnection(id).then((removed) => { if (!removed) return; - // Relay entries have no reachable URL — the runtime key is their identity. - const isActive = removed.relay - ? getRuntimeKey() === relayConnectionRuntimeKey(removed.relay) - : isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl()); - if (isActive) { + if (isActiveRuntimeConnection(removed)) { onActiveConnectionDeleted(); } }); @@ -1027,7 +1041,7 @@ const MobileInstancesSurface: React.FC<{

{pendingConnection.label}

- {pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url} + {pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}

@@ -1073,7 +1087,7 @@ const MobileInstancesSurface: React.FC<{ @@ -1098,14 +1112,14 @@ const MobileInstancesSurface: React.FC<{ {t('mobile.instances.delete')} - ) : connection.mode === 'relay' ? null : ( + ) : !connection.candidates.some((c) => c.kind === 'direct') ? null : ( - - ) : ( - <> -
-
-
- -

{t(stateLabelKey(state))}

-
-

- {(status?.connectedClients ?? 0) === 1 - ? t('settings.remoteInstances.relay.status.clientsOne', { count: 1 }) - : t('settings.remoteInstances.relay.status.clientsMany', { count: status?.connectedClients ?? 0 })} -

- {state === 'error' && status?.lastError ? ( -

{status.lastError}

- ) : null} -
- -
- -
-

{t('settings.remoteInstances.relay.pair.title')}

-
- setPairLabel(event.target.value)} - placeholder={t('settings.remoteInstances.relay.pair.labelPlaceholder')} - disabled={isPairing} - /> - -
- - {!includeToken ? ( -

{t('settings.remoteInstances.relay.pair.noTokenHint')}

- ) : null} - {!isConnected ? ( -

{t('settings.remoteInstances.relay.pair.requiresConnected')}

- ) : null} - {offerUrl ? ( -
-

{t('settings.remoteInstances.relay.pair.linkLabel')}

- {offerUrl} -
- - {offerQrDataUrl ? ( - - ) : null} -
-

{t('settings.remoteInstances.relay.pair.warning')}

-
- ) : null} -

{t('settings.remoteInstances.relay.pair.manageHint')}

-
- - )} - - - - - {t('settings.remoteInstances.relay.pair.qrDialogTitle')} - {t('settings.remoteInstances.relay.pair.qrDialogDescription')} - - {offerQrDataUrl ? ( -
- {t('settings.remoteInstances.relay.pair.qrAlt')} -
- ) : null} -
-
- - ); -}; diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index c3464cfb..7d732b10 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -21,18 +21,19 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; -import { RelaySection } from '@/components/sections/remote-instances/RelaySection'; -import { RELAY_UI_ENABLED } from '@/lib/relay/gate'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Radio } from '@/components/ui/radio'; import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; import { useI18n, type I18nKey } from '@/lib/i18n'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import type { RemoteClientRecord } from '@/lib/api/types'; -import { buildClientConnectionPayload, encodeClientConnectionPayload, parseClientConnectionPayload } from '@/lib/connectionPayload'; +import type { PendingPairingRecord, RemoteClientRecord } from '@/lib/api/types'; +import { buildPairingConnectionPayload, encodePairingConnectionPayload, parsePairingConnectionPayload, type PairingEndpointCandidate } from '@/lib/connectionPayload'; import { desktopSshLogsClear, desktopSshLogs, @@ -43,11 +44,15 @@ import { import { desktopHostsGet, desktopHostsSet, + desktopInstallIdGet, normalizeHostUrl, redactSensitiveUrl, resolveDesktopHostUrl, + relayHostDisplayUrl, type DesktopHost, + type DesktopHostRelay, } from '@/lib/desktopHosts'; +import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch'; @@ -61,6 +66,31 @@ const isPortInUseError = (error: unknown): boolean => { return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use'); }; +// Platform this desktop reports about itself when redeeming a pairing link — +// display-only metadata for the issuing server's device list. +const desktopPlatformName = (): string | undefined => { + if (typeof navigator === 'undefined') return undefined; + const ua = (navigator.userAgent || '').toLowerCase(); + if (ua.includes('mac')) return 'macos'; + if (ua.includes('win')) return 'windows'; + if (ua.includes('linux')) return 'linux'; + return undefined; +}; + +// Friendly label for a device's self-reported platform in the device list. +const devicePlatformLabel = (platform?: string | null): string | null => { + switch ((platform || '').toLowerCase()) { + case 'ios': return 'iOS'; + case 'android': return 'Android'; + case 'macos': + case 'darwin': return 'macOS'; + case 'windows': + case 'win32': return 'Windows'; + case 'linux': return 'Linux'; + default: return null; + } +}; + const phaseLabelKey = (phase?: string): I18nKey => { switch (phase) { case 'config_resolved': @@ -248,6 +278,15 @@ const getRuntimePort = (): number | null => { } }; +const isLoopbackUrl = (value: string): boolean => { + try { + const host = new URL(value).hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; + } catch { + return false; + } +}; + const resolvePairingServerUrl = async (): Promise => { const fallback = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin; if (!isDesktopShell() || !isDesktopLocalOriginActive()) { @@ -394,12 +433,21 @@ export const RemoteInstancesPage: React.FC = () => { const [directEditToken, setDirectEditToken] = React.useState(''); const [directEditHeaders, setDirectEditHeaders] = React.useState([]); const [remoteClients, setRemoteClients] = React.useState([]); + const [pendingPairings, setPendingPairings] = React.useState([]); const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false); const [remoteClientLabel, setRemoteClientLabel] = React.useState(''); - const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState(null); const [remoteClientError, setRemoteClientError] = React.useState(null); const [pairingUrl, setPairingUrl] = React.useState(null); const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState(null); + const [pairingCopied, setPairingCopied] = React.useState(false); + // "Add a device" dialog: a configure phase (name + transport + fallback) then a + // result phase (QR + link). The QR only ever shows inside this dialog. + const [addDeviceOpen, setAddDeviceOpen] = React.useState(false); + const [addDevicePhase, setAddDevicePhase] = React.useState<'configure' | 'result'>('configure'); + const [addDeviceCreating, setAddDeviceCreating] = React.useState(false); + const [addDeviceTransport, setAddDeviceTransport] = React.useState<'local' | 'lan' | 'relay'>('relay'); + const [addDeviceFallback, setAddDeviceFallback] = React.useState(true); + const [transportOptions, setTransportOptions] = React.useState<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean } | null>(null); const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]); const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false); const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com'); @@ -472,27 +520,128 @@ export const RemoteInstancesPage: React.FC = () => { }, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); const importDirectConnectLink = React.useCallback(async () => { - const payload = parseClientConnectionPayload(directConnectLink); + const payload = parsePairingConnectionPayload(directConnectLink); if (!payload) { setDirectError(t('settings.remoteInstances.direct.error.invalidConnectLink')); return; } - const url = normalizeHostUrl(payload.serverUrl); - if (!url) { + // The redeem body is identical across every transport (the desktop is the + // same device however it reaches the server). The install-id dedupe key + // collapses re-pairing / re-auth of this desktop into one device record. + const installId = await desktopInstallIdGet().catch(() => ''); + const redeemBody = JSON.stringify({ + pairingId: payload.pairingId, + secret: payload.secret, + clientLabel: payload.label || 'OpenChamber Desktop', + clientKind: 'desktop', + deviceName: 'OpenChamber Desktop', + devicePlatform: desktopPlatformName(), + ...(installId ? { dedupeKey: `desktop:${installId}` } : {}), + }); + const redeemInit: RequestInit = { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: redeemBody, + }; + const tokenFromResponse = async (response: Response): Promise => { + if (!response.ok) return null; + const body = (await response.json().catch(() => null)) as { clientToken?: unknown } | null; + const token = typeof body?.clientToken === 'string' ? body.clientToken.trim() : ''; + return token || null; + }; + + // Try direct (LAN/tunnel) candidates first — they're cheaper and don't need + // relay infrastructure — then fall back to relay. Ordered by payload priority. + const ordered = [...payload.candidates].sort( + (a, b) => (a.type === 'relay' ? 1 : 0) - (b.type === 'relay' ? 1 : 0), + ); + + let redeemed: + | { kind: 'direct'; url: string; token: string } + | { kind: 'relay'; relay: DesktopHostRelay; token: string } + | null = null; + + for (const candidate of ordered) { + if (candidate.type === 'relay') { + // Open a throwaway E2EE tunnel just to redeem the one-time secret; the + // grant (if any) authorizes admission to the relay for this serverId. + const tunnel = createRelayTunnelClient({ + relayUrl: candidate.relayUrl, + serverId: candidate.serverId, + hostEncPubJwk: candidate.hostEncPubJwk, + ...(candidate.grant ? { grant: candidate.grant } : {}), + }); + try { + const response = await tunnel.fetch('/api/client-auth/pairing/redeem', redeemInit); + const token = await tokenFromResponse(response); + if (token) { + redeemed = { + kind: 'relay', + // grant is intentionally not persisted (one-time pairing artifact). + relay: { relayUrl: candidate.relayUrl, serverId: candidate.serverId, hostEncPubJwk: candidate.hostEncPubJwk }, + token, + }; + break; + } + } catch { + // Relay unreachable / handshake failed — try the next candidate. + } finally { + tunnel.close(); + } + continue; + } + // Direct: the remote instance is a user-provided URL, so a plain + // cross-origin fetch is correct here (not the active runtime). + const candidateUrl = normalizeHostUrl(candidate.url); + if (!candidateUrl) continue; + try { + const response = await fetch(`${candidateUrl}/api/client-auth/pairing/redeem`, redeemInit); + const token = await tokenFromResponse(response); + if (token) { + redeemed = { kind: 'direct', url: candidateUrl, token }; + break; + } + } catch { + // Unreachable candidate — try the next one. + } + } + + if (!redeemed) { setDirectError(t('desktopHostSwitcher.error.invalidUrl')); return; } - const existing = directHosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === url); - if (existing) { - const nextHosts = directHosts.map((host) => host.id === existing.id - ? { ...host, label: payload.label || host.label, url, apiUrl: url, clientToken: payload.token } - : host); - await persistDirectHosts(nextHosts, directDefaultHostId); + + const makeId = (): string => (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `host-${Date.now()}-${Math.random().toString(16).slice(2)}`); + + if (redeemed.kind === 'relay') { + const { relay, token } = redeemed; + // Relay hosts are keyed by serverId (one host per server, regardless of + // which relay routes it), so re-importing updates the existing record. + const existing = directHosts.find((host) => host.relay?.serverId === relay.serverId); + const displayUrl = relayHostDisplayUrl(relay.serverId); + if (existing) { + const nextHosts = directHosts.map((host) => host.id === existing.id + ? { ...host, label: payload.label || host.label, url: displayUrl, apiUrl: undefined, clientToken: token, relay } + : host); + await persistDirectHosts(nextHosts, directDefaultHostId); + } else { + // payload.label is normally the issuing server's hostname; the pseudo-URL + // is only a last-resort display name. + await persistDirectHosts([{ id: makeId(), label: payload.label || displayUrl, url: displayUrl, clientToken: token, relay }, ...directHosts], directDefaultHostId); + } } else { - const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `host-${Date.now()}-${Math.random().toString(16).slice(2)}`; - await persistDirectHosts([{ id, label: payload.label || redactSensitiveUrl(url), url, apiUrl: url, clientToken: payload.token }, ...directHosts], directDefaultHostId); + const { url, token } = redeemed; + const existing = directHosts.find((host) => !host.relay && normalizeHostUrl(host.apiUrl || host.url) === url); + if (existing) { + const nextHosts = directHosts.map((host) => host.id === existing.id + ? { ...host, label: payload.label || host.label, url, apiUrl: url, clientToken: token } + : host); + await persistDirectHosts(nextHosts, directDefaultHostId); + } else { + await persistDirectHosts([{ id: makeId(), label: payload.label || redactSensitiveUrl(url), url, apiUrl: url, clientToken: token }, ...directHosts], directDefaultHostId); + } } setDirectConnectLink(''); setDirectError(null); @@ -567,53 +716,155 @@ export const RemoteInstancesPage: React.FC = () => { await persistDirectHosts(directHosts, id); }, [directHosts, persistDirectHosts]); - const loadRemoteClients = React.useCallback(async () => { + const loadRemoteClients = React.useCallback(async (options?: { silent?: boolean }) => { if (!clientAuth) return; - setRemoteClientsLoading(true); - setRemoteClientError(null); + if (!options?.silent) setRemoteClientsLoading(true); + if (!options?.silent) setRemoteClientError(null); try { - setRemoteClients(await clientAuth.listClients()); + const [clients, pending] = await Promise.all([ + clientAuth.listClients(), + clientAuth.listPendingPairings().catch(() => [] as PendingPairingRecord[]), + ]); + setRemoteClients(clients); + setPendingPairings(pending); } catch (err) { - setRemoteClientError(err instanceof Error ? err.message : String(err)); + // A silent poll must not surface a transient error over the live list. + if (!options?.silent) setRemoteClientError(err instanceof Error ? err.message : String(err)); } finally { - setRemoteClientsLoading(false); + if (!options?.silent) setRemoteClientsLoading(false); } }, [clientAuth]); - React.useEffect(() => { - void loadRemoteClients(); - }, [loadRemoteClients]); - - const createRemoteClient = React.useCallback(async () => { + const cancelPendingPairing = React.useCallback(async (id: string) => { if (!clientAuth) return; - setRemoteClientError(null); try { - const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || undefined }); - setCreatedRemoteClientToken(result.token); - setRemoteClientLabel(''); - await loadRemoteClients(); + await clientAuth.cancelPairing(id); + setPendingPairings((prev) => prev.filter((entry) => entry.id !== id)); + await loadRemoteClients({ silent: true }); } catch (err) { setRemoteClientError(err instanceof Error ? err.message : String(err)); } - }, [clientAuth, loadRemoteClients, remoteClientLabel]); + }, [clientAuth, loadRemoteClients]); + + // Load on mount, then poll while the page is visible so a device that redeems + // a pairing link shows up in the list without reopening settings. + React.useEffect(() => { + if (!clientAuth) return; + void loadRemoteClients(); + const interval = window.setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; + void loadRemoteClients({ silent: true }); + }, 5_000); + return () => window.clearInterval(interval); + }, [clientAuth, loadRemoteClients]); + + // Available direct transports for the create dialog. The server is authoritative + // for LAN reachability (derived from its bind, not the UI origin), so "Local + // network" works even when the UI is opened on localhost. Falls back to the + // client-side guess if the endpoint is unavailable. + const resolveTransportOptions = React.useCallback(async (): Promise<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean }> => { + if (clientAuth?.getPairingTransports) { + try { + const transports = await clientAuth.getPairingTransports(); + return { localUrl: transports.local, lanUrl: transports.lan, relayAvailable: transports.relayAvailable }; + } catch { + // fall through to the client-side guess + } + } + const port = getRuntimePort(); + const localUrl = port ? `http://127.0.0.1:${port}` : (isLoopbackUrl(window.location.origin) ? window.location.origin : null); + let lanUrl: string | null = null; + try { + const resolved = normalizeHostUrl(await resolvePairingServerUrl()); + lanUrl = resolved && !isLoopbackUrl(resolved) ? resolved : null; + } catch { + // keep null + } + return { localUrl, lanUrl, relayAvailable: true }; + }, [clientAuth]); + + const openAddDevice = React.useCallback(async () => { + setRemoteClientError(null); + setPairingUrl(null); + setPairingQrDataUrl(null); + setPairingCopied(false); + setAddDevicePhase('configure'); + setAddDeviceFallback(true); + setAddDeviceOpen(true); + const opts = await resolveTransportOptions(); + setTransportOptions(opts); + // "Anywhere" (relay, with home-network preference) is the right default for + // most people; fall back to narrower options only when relay is unavailable. + setAddDeviceTransport(opts.relayAvailable ? 'relay' : opts.lanUrl ? 'lan' : 'local'); + }, [resolveTransportOptions]); const createPairingLink = React.useCallback(async () => { - if (!clientAuth) return; + if (!clientAuth?.createPairingSession || !transportOptions) return; setRemoteClientError(null); + setAddDeviceCreating(true); try { - const serverUrl = await resolvePairingServerUrl(); - const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' }); - const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' }); - const encoded = encodeClientConnectionPayload(payload); - setCreatedRemoteClientToken(result.token); + const label = remoteClientLabel.trim() || undefined; + // Map the chosen transport (+ fallback) to the per-link candidate request. + let serverUrl: string | undefined; + let includeRelay: boolean; + let includeDirect = true; + if (addDeviceTransport === 'local') { + serverUrl = transportOptions.localUrl ?? undefined; + includeRelay = false; + } else if (addDeviceTransport === 'lan') { + serverUrl = transportOptions.lanUrl ?? undefined; + includeRelay = addDeviceFallback; + } else if (addDeviceFallback && transportOptions.lanUrl) { + // Relay, but prefer the local network when available: carry both. + serverUrl = transportOptions.lanUrl; + includeRelay = true; + } else { + // Relay only. + includeDirect = false; + includeRelay = true; + } + const { pairing, server } = await clientAuth.createPairingSession({ + label, + allowedClientKinds: ['mobile', 'desktop'], + serverUrl, + includeRelay, + includeDirect, + }); + const payload = buildPairingConnectionPayload({ + pairingId: pairing.id, + secret: pairing.secret, + // The typed name (`label`) is the per-device label shown in THIS server's + // device list; it already went to createPairingSession above. The payload + // label is what the paired device names its connection by, which must be + // the issuing server's name (hostname), not the device's own name. + label: server.label, + fingerprint: pairing.fingerprint ?? undefined, + expiresAt: pairing.expiresAt, + candidates: server.candidates as unknown as PairingEndpointCandidate[], + }); + const encoded = encodePairingConnectionPayload(payload); setPairingUrl(encoded); - setPairingQrDataUrl(await QRCode.toDataURL(encoded, { width: 192, margin: 1 })); - setRemoteClientLabel(''); - await loadRemoteClients(); + // Pairing payloads are dense (multiple transport candidates + the relay + // E2EE key), so render at high resolution with low error-correction. + setPairingQrDataUrl(await QRCode.toDataURL(encoded, { width: 1024, margin: 2, errorCorrectionLevel: 'L' })); + setPairingCopied(false); + setAddDevicePhase('result'); + await loadRemoteClients({ silent: true }); } catch (err) { setRemoteClientError(err instanceof Error ? err.message : String(err)); + } finally { + setAddDeviceCreating(false); } - }, [clientAuth, loadRemoteClients, remoteClientLabel]); + }, [clientAuth, transportOptions, addDeviceTransport, addDeviceFallback, remoteClientLabel, loadRemoteClients]); + + const handleCopyPairing = React.useCallback(() => { + if (!pairingUrl) return; + void copyTextToClipboard(pairingUrl).then((result) => { + if (!result.ok) return; + setPairingCopied(true); + window.setTimeout(() => setPairingCopied(false), 2000); + }); + }, [pairingUrl]); const revokeRemoteClient = React.useCallback(async (client: RemoteClientRecord) => { if (!clientAuth) return; @@ -1050,34 +1301,12 @@ export const RemoteInstancesPage: React.FC = () => {

{t('settings.remoteInstances.clientAuth.description')}

-
- setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} /> - -
- {pairingUrl ? ( -
- {pairingQrDataUrl ? {t('settings.remoteInstances.clientAuth.qrAlt')} : null} -
-

{t('settings.remoteInstances.clientAuth.pairingUrl')}

- {pairingUrl} - -
-
- ) : null} - {createdRemoteClientToken ? ( -
-

{t('settings.remoteInstances.clientAuth.createdToken')}

- {createdRemoteClientToken} -
- ) : null}
{revokedClientCount > 0 ? (
@@ -1086,39 +1315,83 @@ export const RemoteInstancesPage: React.FC = () => {
) : null} - {remoteClientsLoading ? ( + {remoteClientsLoading && remoteClients.length === 0 && pendingPairings.length === 0 ? (

{t('settings.remoteInstances.clientAuth.state.loading')}

- ) : remoteClients.length === 0 ? ( + ) : remoteClients.length === 0 && pendingPairings.length === 0 ? (

{t('settings.remoteInstances.clientAuth.state.empty')}

- ) : remoteClients.map((client) => { - const isLocalDesktopClient = client.clientKind === 'desktop-local'; - return ( -
-
-
-

{client.label}

- {isLocalDesktopClient ? ( - - {t('settings.remoteInstances.clientAuth.state.thisDevice')} - - ) : null} + ) : ( + <> + {pendingPairings.map((pending) => ( +
+
+
+ +

{pending.label || t('settings.remoteInstances.clientAuth.field.labelPlaceholder')}

+ {pending.usesRelay ? ( + {t('settings.remoteInstances.clientAuth.state.viaRelay')} + ) : null} +
+

{t('settings.remoteInstances.clientAuth.state.pending')}

-

{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}

+
- -
- ); - })} + ))} + {remoteClients.map((client) => { + const isLocalDesktopClient = client.clientKind === 'desktop-local'; + // Live presence: the server refreshes lastUsedAt on every + // authenticated request (writes throttled to 60s), so a + // device with activity in the last 90s is connected NOW. + // The list polls every 5s, keeping this fresh. + const lastUsedMs = client.lastUsedAt ? Date.parse(client.lastUsedAt) : Number.NaN; + const isOnline = !client.revokedAt + && (isLocalDesktopClient || (Number.isFinite(lastUsedMs) && Date.now() - lastUsedMs < 90_000)); + const statusText = client.revokedAt + ? t('settings.remoteInstances.clientAuth.state.revoked') + : isOnline + ? (client.lastTransport === 'relay' && !isLocalDesktopClient + ? t('settings.remoteInstances.clientAuth.state.connectedRelay') + : t('settings.remoteInstances.clientAuth.state.connectedDirect')) + : client.lastUsedAt + ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) + : t('settings.remoteInstances.clientAuth.neverUsed'); + return ( +
+
+
+ +

{client.label}

+ {devicePlatformLabel(client.devicePlatform) ? ( + + {devicePlatformLabel(client.devicePlatform)} + + ) : null} + {isLocalDesktopClient ? ( + + {t('settings.remoteInstances.clientAuth.state.thisDevice')} + + ) : null} +
+

{statusText}

+
+ +
+ ); + })} + + )}
{remoteClientError ?

{remoteClientError}

: null}
) : null} - {clientAuth && RELAY_UI_ENABLED ? : null} - {showInstanceManagement ?

{t('settings.remoteInstances.direct.title')}

@@ -1265,6 +1538,100 @@ export const RemoteInstancesPage: React.FC = () => { : null} + + + + {addDevicePhase === 'result' ? t('settings.remoteInstances.clientAuth.qrDialogTitle') : t('settings.remoteInstances.clientAuth.actions.addDevice')} + {/* Configure phase: what this dialog will produce. Result phase: what + to do with the QR code that is now on screen. */} + {addDevicePhase === 'result' ? t('settings.remoteInstances.clientAuth.qrScanHint') : t('settings.remoteInstances.clientAuth.addDevice.subtitle')} + + {addDevicePhase === 'configure' ? ( +
{ event.preventDefault(); void createPairingLink(); }}> + setRemoteClientLabel(event.target.value)} + placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} + autoFocus + /> +
+

{t('settings.remoteInstances.clientAuth.addDevice.transportLabel')}

+ {/* Ordered by how likely a first-time user is to want each option; + "Anywhere" is the default. Every option explains its outcome in + plain words — "relay" appears only inside the description. */} +
+ {([ + { key: 'relay' as const, label: t('settings.remoteInstances.clientAuth.addDevice.transport.relay'), hint: t('settings.remoteInstances.clientAuth.addDevice.transport.relayHint'), available: Boolean(transportOptions?.relayAvailable) }, + { key: 'lan' as const, label: t('settings.remoteInstances.clientAuth.addDevice.transport.lan'), hint: t('settings.remoteInstances.clientAuth.addDevice.transport.lanHint'), available: Boolean(transportOptions?.lanUrl) }, + { key: 'local' as const, label: t('settings.remoteInstances.clientAuth.addDevice.transport.local'), hint: t('settings.remoteInstances.clientAuth.addDevice.transport.localHint'), available: Boolean(transportOptions?.localUrl) }, + ]).map((option) => { + const selected = addDeviceTransport === option.key; + return ( +
{ if (option.available) setAddDeviceTransport(option.key); }} + role="presentation" + > + setAddDeviceTransport(option.key)} + ariaLabel={option.label} + className="mt-0.5" + /> +
+

{option.label}

+

{option.hint}

+
+
+ ); + })} +
+ {addDeviceTransport === 'lan' ? ( + + ) : null} + {addDeviceTransport === 'relay' && transportOptions?.lanUrl ? ( + + ) : null} +
+ {remoteClientError ?

{remoteClientError}

: null} +
+ + +
+
+ ) : ( +
+ {pairingQrDataUrl ? ( +
+ {t('settings.remoteInstances.clientAuth.qrAlt')} +
+ ) : null} + {pairingUrl ? ( +
+ {pairingUrl} + +
+ ) : null} +
+ +
+
+ )} +
+
+ {showInstanceManagement ?
diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index b01f2db2..db2c5ce1 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -97,6 +97,12 @@ function DialogContent({ "transition-all duration-150 ease-out", "data-[starting-style]:opacity-0 data-[starting-style]:scale-[0.98]", "data-[ending-style]:opacity-0 data-[ending-style]:scale-[0.98]", + // When a nested dialog opens on top of this one, dim this popup the + // same way the page behind a dialog is dimmed (Base UI marks the + // parent popup with data-nested-dialog-open). Brightness dims the + // whole popup uniformly — including scrolled content — and animates + // via the existing transition-all. + "data-[nested-dialog-open]:brightness-[0.55] dark:data-[nested-dialog-open]:brightness-[0.4]", className )} {...props} diff --git a/packages/ui/src/components/views/SettingsWindow.tsx b/packages/ui/src/components/views/SettingsWindow.tsx index 8e84e1c6..fcdef578 100644 --- a/packages/ui/src/components/views/SettingsWindow.tsx +++ b/packages/ui/src/components/views/SettingsWindow.tsx @@ -54,6 +54,9 @@ export const SettingsWindow: React.FC = ({ open, onOpenChan 'transition-all duration-150 ease-out', 'data-[starting-style]:opacity-0 data-[starting-style]:scale-[0.98]', 'data-[ending-style]:opacity-0 data-[ending-style]:scale-[0.98]', + // Dim this window when a nested dialog (e.g. "Add a device") opens + // on top of it, mirroring how the page behind a dialog is dimmed. + 'data-[nested-dialog-open]:brightness-[0.55] dark:data-[nested-dialog-open]:brightness-[0.4]', )} > diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 271e247c..8b19606a 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1108,6 +1108,21 @@ export interface RemoteClientRecord { revokedAt: string | null; expiresAt?: string | null; clientKind?: string | null; + authMethod?: string | null; + deviceName?: string | null; + devicePlatform?: string | null; + usesRelay?: boolean; + /** Transport that carried the device's most recent authenticated request. */ + lastTransport?: 'relay' | 'direct' | null; +} + +// A pairing link that has been created but not yet redeemed by a device. +export interface PendingPairingRecord { + id: string; + label?: string; + fingerprint?: string | null; + expiresAt?: string; + usesRelay?: boolean; } export interface RemoteClientCreateResult { @@ -1124,11 +1139,49 @@ export interface RemoteClientPurgeRevokedResult { purged: number; } +export interface PairingSessionCreateResult { + pairing: { + id: string; + label?: string; + fingerprint?: string | null; + expiresAt?: string; + secret: string; + }; + server: { + label: string; + // Transport candidates for the pairing-v2 payload. Shape matches + // PairingEndpointCandidate in `@/lib/connectionPayload` (direct lan/tunnel or + // relay); left as a structural type here so this contract file stays leaf. + candidates: Array>; + }; +} + export interface ClientAuthAPI { listClients(): Promise; createClient(input?: { label?: string }): Promise; + // Creates a one-time pairing session (pairing v2). `serverUrl` is the + // externally reachable URL to advertise as the direct candidate (the desktop + // UI talks to its server over loopback, so it must supply the LAN URL); the + // server folds in a relay candidate when its relay host is enabled. + createPairingSession(input?: { + label?: string; + allowedClientKinds?: Array<'mobile' | 'desktop'>; + serverUrl?: string; + // Per-link transport choice. `includeRelay: true` adds the relay candidate + // and enables the relay host on demand; `false` omits it; omitted keeps the + // legacy "relay only if already enabled" behavior. `includeDirect: false` + // produces a relay-only link (no direct candidate). + includeRelay?: boolean; + includeDirect?: boolean; + }): Promise; purgeRevokedClients(): Promise; revokeClient(id: string): Promise; + // Pairing links created but not yet redeemed (the "pending devices" list). + listPendingPairings(): Promise; + cancelPairing(id: string): Promise<{ cancelled: boolean }>; + // Direct transports the server can be reached on, for the create-device dialog. + // LAN reflects the server's actual bind, independent of the UI origin. + getPairingTransports(): Promise<{ local: string | null; lan: string | null; relayAvailable: boolean }>; } export interface RuntimeAPIs { diff --git a/packages/ui/src/lib/connectionPayload.test.ts b/packages/ui/src/lib/connectionPayload.test.ts new file mode 100644 index 00000000..5d11e173 --- /dev/null +++ b/packages/ui/src/lib/connectionPayload.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildPairingConnectionPayload, + encodePairingConnectionPayload, + parsePairingConnectionPayload, +} from './connectionPayload'; + +const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const; + +describe('connection payload helpers', () => { + test('round-trips v2 pairing payloads with direct candidates', () => { + const payload = buildPairingConnectionPayload({ + pairingId: 'pair_123', + secret: 'one-time-secret', + label: 'Desktop', + fingerprint: 'ABCD-1234', + expiresAt: '2099-01-01T00:00:00.000Z', + candidates: [ + { type: 'lan', url: 'http://192.168.1.20:4096/', priority: 20 }, + { type: 'tunnel', url: 'https://runtime.example/', priority: 10 }, + ], + }); + + const encoded = encodePairingConnectionPayload(payload); + + expect(encoded.startsWith('openchamber://connect?v=2&p=')).toBe(true); + expect(parsePairingConnectionPayload(encoded)).toEqual({ + ...payload, + candidates: [ + { type: 'lan', url: 'http://192.168.1.20:4096', priority: 20 }, + { type: 'tunnel', url: 'https://runtime.example', priority: 10 }, + ], + }); + }); + + test('round-trips a relay candidate (transport, not a URL)', () => { + const payload = buildPairingConnectionPayload({ + pairingId: 'pair_relay', + secret: 'one-time-secret', + candidates: [ + { type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }, + { type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 }, + ], + }); + + const parsed = parsePairingConnectionPayload(encodePairingConnectionPayload(payload)); + expect(parsed?.candidates).toEqual([ + { type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }, + { type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 }, + ]); + }); + + test('relay candidate keeps its path and rejects non-ws relay URLs / bad JWKs', () => { + const withBadRelay = (candidate: Record) => + Buffer.from(JSON.stringify({ v: 2, pairingId: 'pair_1', secret: 's', candidates: [candidate] })).toString('base64url'); + + // https relay URL is not a WebSocket endpoint → candidate dropped → no candidates → null. + expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'https://relay.example/ws', serverId: 'srv', hostEncPubJwk })}`)).toBeNull(); + // Missing serverId. + expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'wss://relay.example/ws', hostEncPubJwk })}`)).toBeNull(); + // Non-P-256 key. + expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk: { kty: 'EC', crv: 'P-384', x: 'a', y: 'b' } })}`)).toBeNull(); + }); + + test('drops a private-key member from a relay JWK (keeps only public coordinates)', () => { + const withKey = Buffer.from(JSON.stringify({ + v: 2, + pairingId: 'pair_1', + secret: 's', + candidates: [{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk: { ...hostEncPubJwk, d: 'PRIVATE' } }], + })).toString('base64url'); + const parsed = parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withKey}`); + expect(parsed?.candidates[0]).toEqual({ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk }); + }); + + test('rejects invalid v2 pairing payloads', () => { + expect(parsePairingConnectionPayload('openchamber://connect?v=1&server=https://runtime.example&token=t')).toBeNull(); + expect(parsePairingConnectionPayload('openchamber://connect?v=2&p=not-json')).toBeNull(); + + const missingSecret = Buffer.from(JSON.stringify({ + v: 2, + pairingId: 'pair_123', + candidates: [{ type: 'lan', url: 'http://runtime.example' }], + })).toString('base64url'); + expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${missingSecret}`)).toBeNull(); + + const invalidCandidate = Buffer.from(JSON.stringify({ + v: 2, + pairingId: 'pair_123', + secret: 'secret', + candidates: [{ type: 'lan', url: 'file:///tmp/socket' }], + })).toString('base64url'); + expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${invalidCandidate}`)).toBeNull(); + + const expired = Buffer.from(JSON.stringify({ + v: 2, + pairingId: 'pair_123', + secret: 'secret', + expiresAt: '2000-01-01T00:00:00.000Z', + candidates: [{ type: 'lan', url: 'http://runtime.example' }], + })).toString('base64url'); + expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/connectionPayload.ts b/packages/ui/src/lib/connectionPayload.ts index 4c636afc..88c7f1c7 100644 --- a/packages/ui/src/lib/connectionPayload.ts +++ b/packages/ui/src/lib/connectionPayload.ts @@ -1,58 +1,213 @@ -export type ClientConnectionPayload = { - v: 1; - serverUrl: string; - token: string; +const MAX_PAIRING_PAYLOAD_LENGTH = 16_384; + +// A pairing candidate is one way to reach the host's HTTP API. `type` +// discriminates the transport: +// - lan / tunnel: reach `url` directly (health-check, then redeem over fetch). +// - relay: no reachable URL — open the E2EE relay tunnel to `serverId` via +// `relayUrl`, trusting `hostEncPubJwk`, then redeem over the tunnel. +// The one-time pairing `secret` (payload level) is the single auth credential, +// redeemed over whichever transport connects first. Relay carries no embedded +// bearer token — that is the v1 sin this format replaces. +export type PairingDirectCandidate = { + type: 'lan' | 'tunnel'; + url: string; + priority?: number; +}; + +export type PairingRelayCandidate = { + type: 'relay'; + relayUrl: string; + serverId: string; + hostEncPubJwk: JsonWebKey; + // One-time relay-infrastructure authorization. Reserved: the v1 relay worker + // ignores it (E2EE + the pairing secret are the actual gates). Plumbed for + // future relay-side per-device/traffic control. Never persisted. + grant?: string; + priority?: number; +}; + +export type PairingEndpointCandidate = PairingDirectCandidate | PairingRelayCandidate; + +export type PairingConnectionPayload = { + v: 2; + pairingId: string; + secret: string; label?: string; + fingerprint?: string; + expiresAt?: string; + candidates: PairingEndpointCandidate[]; }; -export const buildClientConnectionPayload = (input: { - serverUrl: string; - token: string; - label?: string | null; -}): ClientConnectionPayload => ({ - v: 1, - serverUrl: input.serverUrl.trim().replace(/\/+$/, ''), - token: input.token.trim(), - ...(input.label?.trim() ? { label: input.label.trim() } : {}), -}); - -export const encodeClientConnectionPayload = (payload: ClientConnectionPayload): string => { - const params = new URLSearchParams(); - params.set('v', String(payload.v)); - params.set('server', payload.serverUrl); - params.set('token', payload.token); - if (payload.label) params.set('label', payload.label); - return `openchamber://connect?${params.toString()}`; +const globalWithBuffer = globalThis as typeof globalThis & { + Buffer?: { + from: (value: string, encoding?: string) => { toString: (encoding: string) => string }; + }; }; -export const parseClientConnectionPayload = (value: string): ClientConnectionPayload | null => { - const trimmed = value.trim(); - if (!trimmed) return null; +const base64UrlEncode = (value: string): string => { + if (globalWithBuffer.Buffer) { + return globalWithBuffer.Buffer.from(value, 'utf8').toString('base64url'); + } + const bytes = new TextEncoder().encode(value); + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.slice(i, i + 0x8000)); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +}; +const base64UrlDecode = (value: string): string | null => { try { - const url = new URL(trimmed); - if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') { - return null; + if (globalWithBuffer.Buffer) { + return globalWithBuffer.Buffer.from(value, 'base64url').toString('utf8'); } - const version = url.searchParams.get('v'); - const serverUrl = url.searchParams.get('server')?.trim() || ''; - const token = url.searchParams.get('token')?.trim() || ''; - const label = url.searchParams.get('label')?.trim() || ''; - - if (version !== '1' || !serverUrl || !token) { - return null; - } - - try { - const parsedServer = new URL(serverUrl); - if (parsedServer.protocol !== 'http:' && parsedServer.protocol !== 'https:') { - return null; - } - } catch { - return null; - } - - return buildClientConnectionPayload({ serverUrl, token, label }); + const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '='); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); + } catch { + return null; + } +}; + +const normalizeHttpUrl = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + parsed.hash = ''; + return parsed.toString().replace(/\/+$/g, ''); + } catch { + return null; + } +}; + +// Relay endpoints are WebSocket URLs and keep their path (e.g. `/ws`, `/tunnel`), +// so only the fragment is stripped — never the trailing path segment. +const normalizeWsUrl = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return null; + parsed.hash = ''; + return parsed.toString(); + } catch { + return null; + } +}; + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; + +// EC P-256 public JWK (the relay E2EE trust anchor). Strict: only the four +// public-key members are retained; a private `d` or any other member is dropped. +const normalizeEcPublicJwk = (value: unknown): JsonWebKey | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const jwk = value as Record; + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null; + if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null; + return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }; +}; + +const normalizePriority = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +const normalizePairingCandidate = (value: unknown): PairingEndpointCandidate | null => { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const priority = normalizePriority(record.priority); + + if (record.type === 'lan' || record.type === 'tunnel') { + const url = normalizeHttpUrl(record.url); + if (!url) return null; + return priority === undefined ? { type: record.type, url } : { type: record.type, url, priority }; + } + + if (record.type === 'relay') { + const relayUrl = normalizeWsUrl(record.relayUrl); + if (!relayUrl) return null; + const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''; + if (!serverId) return null; + const hostEncPubJwk = normalizeEcPublicJwk(record.hostEncPubJwk); + if (!hostEncPubJwk) return null; + const grant = typeof record.grant === 'string' && record.grant.trim() ? record.grant.trim() : undefined; + return { + type: 'relay', + relayUrl, + serverId, + hostEncPubJwk, + ...(grant ? { grant } : {}), + ...(priority === undefined ? {} : { priority }), + }; + } + + return null; +}; + +const normalizePairingPayload = (value: unknown): PairingConnectionPayload | null => { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + if (record.v !== 2) return null; + const pairingId = typeof record.pairingId === 'string' ? record.pairingId.trim() : ''; + const secret = typeof record.secret === 'string' ? record.secret.trim() : ''; + if (!pairingId || !secret) return null; + const candidates = Array.isArray(record.candidates) + ? record.candidates.map(normalizePairingCandidate).filter((candidate): candidate is PairingEndpointCandidate => Boolean(candidate)) + : []; + if (candidates.length === 0) return null; + const expiresAt = typeof record.expiresAt === 'string' && record.expiresAt.trim() ? record.expiresAt.trim() : undefined; + if (expiresAt) { + const expiresTime = Date.parse(expiresAt); + if (!Number.isFinite(expiresTime) || expiresTime <= Date.now()) return null; + } + const label = typeof record.label === 'string' && record.label.trim() ? record.label.trim() : undefined; + const fingerprint = typeof record.fingerprint === 'string' && record.fingerprint.trim() ? record.fingerprint.trim() : undefined; + return { + v: 2, + pairingId, + secret, + ...(label ? { label } : {}), + ...(fingerprint ? { fingerprint } : {}), + ...(expiresAt ? { expiresAt } : {}), + candidates, + }; +}; + +export const buildPairingConnectionPayload = (input: Omit): PairingConnectionPayload => ({ + v: 2, + pairingId: input.pairingId.trim(), + secret: input.secret.trim(), + ...(input.label?.trim() ? { label: input.label.trim() } : {}), + ...(input.fingerprint?.trim() ? { fingerprint: input.fingerprint.trim() } : {}), + ...(input.expiresAt?.trim() ? { expiresAt: input.expiresAt.trim() } : {}), + candidates: input.candidates, +}); + +export const encodePairingConnectionPayload = (payload: PairingConnectionPayload): string => { + const normalized = normalizePairingPayload(payload); + if (!normalized) throw new Error('Invalid pairing connection payload'); + const params = new URLSearchParams(); + params.set('v', '2'); + params.set('p', base64UrlEncode(JSON.stringify(normalized))); + return `openchamber://connect?${params.toString()}`; +}; + +export const parsePairingConnectionPayload = (value: string): PairingConnectionPayload | null => { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null; + try { + const url = new URL(trimmed); + if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') return null; + if (url.searchParams.get('v') !== '2') return null; + const encoded = url.searchParams.get('p') || ''; + if (!encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null; + const decoded = base64UrlDecode(encoded); + if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null; + return normalizePairingPayload(JSON.parse(decoded) as unknown); } catch { return null; } diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index a566bddb..44916fff 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -21,17 +21,44 @@ const sanitizeRequestHeaders = (headers: unknown): Record | unde return Object.keys(next).length > 0 ? next : undefined; }; +/** + * Private-relay reachability for a host. When present, the host is reached over + * the E2EE relay tunnel (no direct `apiUrl`); `hostEncPubJwk` is the trust anchor + * that pins the tunnel to the real server. The relay admission `grant` is a + * one-time pairing artifact and is intentionally NOT persisted — steady-state + * relay connections route by `serverId` alone (mirrors the mobile app). + */ +export type DesktopHostRelay = { + relayUrl: string; + serverId: string; + hostEncPubJwk: JsonWebKey; +}; + export type DesktopHost = { id: string; label: string; - /** Legacy/UI URL. During migration this may equal apiUrl. */ + /** Legacy/UI URL. During migration this may equal apiUrl. For relay hosts this is a display-only `relay://` pseudo-URL. */ url: string; - /** API endpoint used by packaged Electron UI for this instance. */ + /** API endpoint used by packaged Electron UI for this instance. Absent for relay-only hosts. */ apiUrl?: string; /** Remote client bearer token for packaged-client API access. */ clientToken?: string; /** Extra headers for desktop runtime API requests. */ requestHeaders?: Record; + /** When set, this host is reached over the private relay tunnel. */ + relay?: DesktopHostRelay; +}; + +/** Display-only pseudo-URL for a relay host (never fetched). */ +export const relayHostDisplayUrl = (serverId: string): string => `relay://${serverId}`; + +const parseHostRelay = (value: unknown): DesktopHostRelay | null => { + if (!isRecord(value)) return null; + const relayUrl = readString(value, 'relayUrl') || readString(value, 'relay_url'); + const serverId = readString(value, 'serverId') || readString(value, 'server_id'); + const jwk = value.hostEncPubJwk ?? value.host_enc_pub_jwk; + if (!relayUrl || !serverId || !isRecord(jwk)) return null; + return { relayUrl, serverId, hostEncPubJwk: jwk as JsonWebKey }; }; export type DesktopHostsConfig = { @@ -174,6 +201,7 @@ const parseHost = (value: unknown): DesktopHost | null => { const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url'); const clientToken = readString(value, 'clientToken') || readString(value, 'client_token'); const requestHeaders = sanitizeRequestHeaders(value.requestHeaders); + const relay = parseHostRelay(value.relay); if (!id || !label || !url) return null; return { id, @@ -182,6 +210,7 @@ const parseHost = (value: unknown): DesktopHost | null => { ...(apiUrl ? { apiUrl } : {}), ...(clientToken ? { clientToken } : {}), ...(requestHeaders ? { requestHeaders } : {}), + ...(relay ? { relay } : {}), }; }; @@ -245,6 +274,19 @@ export const desktopLocalClientTokenGet = async (): Promise => { return typeof raw === 'string' ? raw.trim() : ''; }; +/** + * Stable per-install identifier for this desktop. Used as the client dedupe key + * so re-pairing or re-authenticating this desktop reuses its single device + * record on a server instead of piling up duplicates. Empty string when not in + * the desktop shell. + */ +export const desktopInstallIdGet = async (): Promise => { + const invoke = getInvoke(); + if (!invoke) return ''; + const raw = await invoke('desktop_install_id_get').catch(() => null); + return typeof raw === 'string' ? raw.trim() : ''; +}; + export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) { diff --git a/packages/ui/src/lib/desktopRelayRestore.ts b/packages/ui/src/lib/desktopRelayRestore.ts new file mode 100644 index 00000000..3857ae13 --- /dev/null +++ b/packages/ui/src/lib/desktopRelayRestore.ts @@ -0,0 +1,32 @@ +import { isElectronShell } from '@/lib/desktop'; +import { desktopHostsGet } from '@/lib/desktopHosts'; +import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch'; + +/** + * On desktop startup, re-open the E2EE relay tunnel if the default host is a + * relay host. Relay hosts have no reachable HTTP base, so the Electron shell + * boots the LOCAL UI and defers reconnection to the renderer: here we read the + * persisted relay descriptor + client token and activate the tunnel in-process + * via switchRuntimeEndpoint({ relay }). Direct hosts don't need this — the shell + * injects their apiBaseUrl/token as window globals before render. + * + * Safe to call unconditionally; it is a no-op outside the Electron shell and when + * the default host is local or already active. + */ +export const restoreDesktopRelayRuntime = async (): Promise => { + if (!isElectronShell()) return; + const config = await desktopHostsGet().catch(() => null); + const defaultHostId = config?.defaultHostId; + if (!config || !defaultHostId || defaultHostId === 'local') return; + const host = config.hosts.find((entry) => entry.id === defaultHostId); + if (!host?.relay) return; + // Must match runtimeKeyForHost() in DesktopHostSwitcher so switch/resolve agree. + const runtimeKey = `host:${host.id}`; + if (getRuntimeKey() === runtimeKey) return; + switchRuntimeEndpoint({ + apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', + clientToken: host.clientToken || null, + runtimeKey, + relay: host.relay, + }); +}; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 558f6e90..0b744d5a 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -273,21 +273,43 @@ export const settingsDict = { 'settings.remoteInstances.direct.state.empty': 'No other servers added yet.', 'settings.remoteInstances.clientAuth.title': 'Connect to this server', 'settings.remoteInstances.clientAuth.description': 'Create a secure link or token so OpenChamber Desktop can connect to this server.', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Device name (optional)', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Device name — e.g. My iPhone', 'settings.remoteInstances.clientAuth.actions.create': 'Create Token', 'settings.remoteInstances.clientAuth.actions.pair': 'Create Link', 'settings.remoteInstances.clientAuth.actions.revoke': 'Revoke', 'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Clear revoked', 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code', + 'settings.remoteInstances.clientAuth.qrEnlarge': 'Enlarge QR code', + 'settings.remoteInstances.clientAuth.qrScanHint': 'Scan this with the OpenChamber app on your other device. It is single-use and expires.', + 'settings.remoteInstances.clientAuth.qrDialogTitle': 'Scan to connect', + 'settings.remoteInstances.clientAuth.actions.addDevice': 'Add a device', + 'settings.remoteInstances.clientAuth.actions.copied': 'Copied', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Where will you use this device?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Create a one-time QR code that connects another device to this server.', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': 'This computer only', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'For apps running on this same machine.', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Home network only', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Connects directly over your Wi-Fi. Does not work away from this network.', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Anywhere', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Works at home and away. Away traffic goes through OpenChamber Private Relay — an end-to-end encrypted tunnel. No setup needed.', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Also allow the encrypted relay when away from home', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Prefer the direct home connection when available', + 'settings.remoteInstances.clientAuth.addDevice.create': 'Create QR code', + 'settings.remoteInstances.clientAuth.addDevice.done': 'Done', 'settings.remoteInstances.clientAuth.pairingUrl': 'Connection link', 'settings.remoteInstances.clientAuth.createdToken': 'Copy this token now. For security, it will not be shown again.', 'settings.remoteInstances.clientAuth.state.loading': 'Loading tokens...', 'settings.remoteInstances.clientAuth.state.empty': 'No devices connected yet.', 'settings.remoteInstances.clientAuth.state.revoked': 'Revoked', 'settings.remoteInstances.clientAuth.state.thisDevice': 'This device', + 'settings.remoteInstances.clientAuth.state.pending': 'Waiting to connect…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': 'Connected · Local network', + 'settings.remoteInstances.clientAuth.state.connectedRelay': 'Connected · Relay', 'settings.remoteInstances.clientAuth.lastUsed': 'Last used {date}', 'settings.remoteInstances.clientAuth.neverUsed': 'Never used', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': 'Turns on automatically when you pair a device over the relay.', 'settings.remoteInstances.relay.description': 'Let your other devices connect from anywhere without opening ports. Traffic is end-to-end encrypted — the relay cannot read it.', 'settings.remoteInstances.relay.enableHint': 'Nothing is shared until you enable the relay on this server.', 'settings.remoteInstances.relay.actions.enable': 'Enable Relay', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 3a80a151..d1cd5942 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -240,21 +240,43 @@ export const settingsDict = { "settings.remoteInstances.direct.state.empty": "Todavía no se han añadido otros servidores.", "settings.remoteInstances.clientAuth.title": "Conectarse a este servidor", "settings.remoteInstances.clientAuth.description": "Crea un enlace o token seguro para que OpenChamber Desktop pueda conectarse a este servidor.", - "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nombre del dispositivo (opcional)", + "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nombre del dispositivo — p. ej. Mi iPhone", "settings.remoteInstances.clientAuth.actions.create": "Crear token", "settings.remoteInstances.clientAuth.actions.pair": "Crear enlace", "settings.remoteInstances.clientAuth.actions.revoke": "Revocar", "settings.remoteInstances.clientAuth.actions.clearRevoked": "Borrar revocados", "settings.remoteInstances.clientAuth.qrAlt": "OpenChamber connection QR code", + "settings.remoteInstances.clientAuth.qrEnlarge": "Ampliar código QR", + "settings.remoteInstances.clientAuth.qrScanHint": "Escanéalo con la app de OpenChamber en tu otro dispositivo. Es de un solo uso y caduca.", + "settings.remoteInstances.clientAuth.qrDialogTitle": "Escanear para conectar", +"settings.remoteInstances.clientAuth.actions.addDevice": "Añadir un dispositivo", + "settings.remoteInstances.clientAuth.actions.copied": "Copiado", + "settings.remoteInstances.clientAuth.addDevice.transportLabel": "¿Dónde usarás este dispositivo?", + "settings.remoteInstances.clientAuth.addDevice.subtitle": "Crea un código QR de un solo uso que conecta otro dispositivo a este servidor.", + "settings.remoteInstances.clientAuth.addDevice.transport.local": "Solo este equipo", + "settings.remoteInstances.clientAuth.addDevice.transport.localHint": "Para aplicaciones en esta misma máquina.", + "settings.remoteInstances.clientAuth.addDevice.transport.lan": "Solo red doméstica", + "settings.remoteInstances.clientAuth.addDevice.transport.lanHint": "Se conecta directamente por tu Wi-Fi. No funciona fuera de esta red.", + "settings.remoteInstances.clientAuth.addDevice.transport.relay": "En cualquier lugar", + "settings.remoteInstances.clientAuth.addDevice.transport.relayHint": "Funciona en casa y fuera. Fuera de casa el tráfico pasa por OpenChamber Private Relay, un túnel cifrado de extremo a extremo. Sin configuración.", + "settings.remoteInstances.clientAuth.addDevice.fallback.relay": "Permitir también el relay cifrado fuera de casa", + "settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal": "Preferir la conexión doméstica directa cuando esté disponible", + "settings.remoteInstances.clientAuth.addDevice.create": "Crear código QR", + "settings.remoteInstances.clientAuth.addDevice.done": "Listo", "settings.remoteInstances.clientAuth.pairingUrl": "Enlace de conexión", "settings.remoteInstances.clientAuth.createdToken": "Copia este token ahora. Por seguridad, no se volverá a mostrar.", "settings.remoteInstances.clientAuth.state.loading": "Cargando tokens...", "settings.remoteInstances.clientAuth.state.empty": "Todavía no hay dispositivos conectados.", "settings.remoteInstances.clientAuth.state.revoked": "Revocado", "settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo", + "settings.remoteInstances.clientAuth.state.pending": "Esperando conexión…", + "settings.remoteInstances.clientAuth.state.viaRelay": "Relay", + "settings.remoteInstances.clientAuth.state.connectedDirect": "Conectado · Red local", + "settings.remoteInstances.clientAuth.state.connectedRelay": "Conectado · Relay", "settings.remoteInstances.clientAuth.lastUsed": "Último uso {date}", "settings.remoteInstances.clientAuth.neverUsed": "Nunca usado", "settings.remoteInstances.relay.title": "OpenChamber Relay", + "settings.remoteInstances.relay.autoHint": "Se activa automáticamente al vincular un dispositivo por relay.", "settings.remoteInstances.relay.description": "Permite que tus otros dispositivos se conecten desde cualquier lugar sin abrir puertos. El tráfico está cifrado de extremo a extremo: el relay no puede leerlo.", "settings.remoteInstances.relay.enableHint": "No se comparte nada hasta que actives el relay en este servidor.", "settings.remoteInstances.relay.actions.enable": "Activar Relay", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index cc31ab5f..5abbca42 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1781,21 +1781,43 @@ export const settingsDict = { 'settings.remoteInstances.direct.state.empty': 'Aucun autre serveur ajouté pour le moment.', 'settings.remoteInstances.clientAuth.title': 'Se connecter à ce serveur', 'settings.remoteInstances.clientAuth.description': 'Créez un lien ou un token sécurisé pour permettre à OpenChamber Desktop de se connecter à ce serveur.', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nom de l’appareil (facultatif)', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nom du nouvel appareil — ex. Mon iPhone', 'settings.remoteInstances.clientAuth.actions.create': 'Créer un token', 'settings.remoteInstances.clientAuth.actions.pair': 'Créer un lien', 'settings.remoteInstances.clientAuth.actions.revoke': 'Révoquer', 'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Effacer les révocations', 'settings.remoteInstances.clientAuth.qrAlt': 'QR code de connexion OpenChamber', + 'settings.remoteInstances.clientAuth.qrEnlarge': 'Agrandir le QR code', + 'settings.remoteInstances.clientAuth.qrScanHint': "Scannez-le avec l'application OpenChamber sur votre autre appareil. À usage unique et expire.", + 'settings.remoteInstances.clientAuth.qrDialogTitle': 'Scanner pour se connecter', +'settings.remoteInstances.clientAuth.actions.addDevice': 'Ajouter un appareil', + 'settings.remoteInstances.clientAuth.actions.copied': 'Copié', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Où utiliserez-vous cet appareil ?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Créez un code QR à usage unique qui connecte un autre appareil à ce serveur.', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': 'Cet ordinateur uniquement', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'Pour les applications sur cette même machine.', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Réseau domestique uniquement', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Connexion directe via votre Wi-Fi. Ne fonctionne pas hors de ce réseau.', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Partout', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Fonctionne à la maison et en déplacement. En déplacement, le trafic passe par OpenChamber Private Relay — un tunnel chiffré de bout en bout. Aucune configuration.', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Autoriser aussi le relais chiffré en déplacement', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Préférer la connexion domestique directe quand elle est disponible', + 'settings.remoteInstances.clientAuth.addDevice.create': 'Créer le code QR', + 'settings.remoteInstances.clientAuth.addDevice.done': 'Terminé', 'settings.remoteInstances.clientAuth.pairingUrl': 'Lien de connexion', 'settings.remoteInstances.clientAuth.createdToken': 'Copiez ce token maintenant. Pour des raisons de sécurité, il ne sera plus affiché.', 'settings.remoteInstances.clientAuth.state.loading': 'Chargement des tokens...', 'settings.remoteInstances.clientAuth.state.empty': 'Aucun appareil connecté pour le moment.', 'settings.remoteInstances.clientAuth.state.revoked': 'Révoqué', 'settings.remoteInstances.clientAuth.state.thisDevice': 'Cet appareil', + 'settings.remoteInstances.clientAuth.state.pending': 'En attente de connexion…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': 'Connecté · Réseau local', + 'settings.remoteInstances.clientAuth.state.connectedRelay': 'Connecté · Relais', 'settings.remoteInstances.clientAuth.lastUsed': 'Dernière utilisation le {date}', 'settings.remoteInstances.clientAuth.neverUsed': 'Jamais utilisé', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': 'Activé automatiquement lorsque vous associez un appareil via le relais.', 'settings.remoteInstances.relay.description': 'Permettez à vos autres appareils de se connecter depuis n’importe où sans ouvrir de ports. Le trafic est chiffré de bout en bout — le relais ne peut pas le lire.', 'settings.remoteInstances.relay.enableHint': 'Rien n’est partagé tant que vous n’activez pas le relais sur ce serveur.', 'settings.remoteInstances.relay.actions.enable': 'Activer le relais', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index aa4a271b..f9d995f8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -273,21 +273,43 @@ export const settingsDict = { 'settings.remoteInstances.direct.state.empty': 'まだ他のサーバーが追加されていません。', 'settings.remoteInstances.clientAuth.title': 'このサーバーに接続', 'settings.remoteInstances.clientAuth.description': 'OpenChamber Desktop がこのサーバーに接続できるように、安全なリンクまたは Token を作成します。', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'デバイス名(任意)', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'デバイス名 — 例: My iPhone', 'settings.remoteInstances.clientAuth.actions.create': 'Token を作成', 'settings.remoteInstances.clientAuth.actions.pair': 'リンクを作成', 'settings.remoteInstances.clientAuth.actions.revoke': '無効化', 'settings.remoteInstances.clientAuth.actions.clearRevoked': '無効化済みをクリア', 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber 接続 QR コード', + 'settings.remoteInstances.clientAuth.qrEnlarge': 'QR コードを拡大', + 'settings.remoteInstances.clientAuth.qrScanHint': '別のデバイスの OpenChamber アプリでスキャンしてください。1 回限りで期限切れになります。', + 'settings.remoteInstances.clientAuth.qrDialogTitle': 'スキャンして接続', +'settings.remoteInstances.clientAuth.actions.addDevice': 'デバイスを追加', + 'settings.remoteInstances.clientAuth.actions.copied': 'コピーしました', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'このデバイスをどこで使いますか?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': 'このサーバーに別のデバイスを接続する使い捨てQRコードを作成します。', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': 'このコンピュータのみ', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '同じマシン上のアプリ用です。', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': '自宅ネットワークのみ', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Wi-Fi経由で直接接続します。このネットワークの外では使えません。', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'どこでも', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '自宅でも外出先でも使えます。外出先の通信は、エンドツーエンド暗号化トンネルのOpenChamber Private Relayを経由します。設定は不要です。', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '外出先では暗号化リレー経由の接続も許可', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '可能なときは自宅の直接接続を優先', + 'settings.remoteInstances.clientAuth.addDevice.create': 'QRコードを作成', + 'settings.remoteInstances.clientAuth.addDevice.done': '完了', 'settings.remoteInstances.clientAuth.pairingUrl': '接続リンク', 'settings.remoteInstances.clientAuth.createdToken': 'この Token を今すぐコピーしてください。セキュリティのため、再表示されません。', 'settings.remoteInstances.clientAuth.state.loading': 'Token を読み込み中...', 'settings.remoteInstances.clientAuth.state.empty': 'まだデバイスが接続されていません。', 'settings.remoteInstances.clientAuth.state.revoked': '無効化済み', 'settings.remoteInstances.clientAuth.state.thisDevice': 'このデバイス', + 'settings.remoteInstances.clientAuth.state.pending': '接続を待機中…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': '接続中 · ローカルネットワーク', + 'settings.remoteInstances.clientAuth.state.connectedRelay': '接続中 · リレー', 'settings.remoteInstances.clientAuth.lastUsed': '最終使用 {date}', 'settings.remoteInstances.clientAuth.neverUsed': '未使用', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': 'リレー経由でデバイスをペアリングすると自動的に有効になります。', 'settings.remoteInstances.relay.description': 'ポートを開放せずに、他のデバイスからどこからでも接続できます。通信はエンドツーエンドで暗号化され、リレーは内容を読めません。', 'settings.remoteInstances.relay.enableHint': 'このサーバーでリレーを有効にするまで、何も共有されません。', 'settings.remoteInstances.relay.actions.enable': 'リレーを有効にする', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 22f3edd4..3d112f23 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -240,21 +240,43 @@ export const settingsDict = { 'settings.remoteInstances.direct.state.empty': '아직 추가된 다른 서버가 없습니다.', 'settings.remoteInstances.clientAuth.title': '이 서버에 연결', 'settings.remoteInstances.clientAuth.description': 'OpenChamber Desktop이 이 서버에 연결할 수 있도록 안전한 링크나 토큰을 만듭니다.', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '기기 이름(선택 사항)', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '기기 이름 — 예: My iPhone', 'settings.remoteInstances.clientAuth.actions.create': '토큰 만들기', 'settings.remoteInstances.clientAuth.actions.pair': '링크 만들기', 'settings.remoteInstances.clientAuth.actions.revoke': '해지', 'settings.remoteInstances.clientAuth.actions.clearRevoked': '해지된 항목 지우기', 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code', + 'settings.remoteInstances.clientAuth.qrEnlarge': 'QR 코드 확대', + 'settings.remoteInstances.clientAuth.qrScanHint': '다른 기기의 OpenChamber 앱으로 스캔하세요. 일회용이며 만료됩니다.', + 'settings.remoteInstances.clientAuth.qrDialogTitle': '스캔하여 연결', +'settings.remoteInstances.clientAuth.actions.addDevice': '기기 추가', + 'settings.remoteInstances.clientAuth.actions.copied': '복사됨', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': '이 기기를 어디에서 사용하나요?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': '다른 기기를 이 서버에 연결하는 일회용 QR 코드를 만듭니다.', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': '이 컴퓨터 전용', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '같은 컴퓨터의 앱을 위한 옵션입니다.', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': '집 네트워크 전용', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Wi-Fi로 직접 연결합니다. 이 네트워크 밖에서는 작동하지 않습니다.', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': '어디서나', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '집과 밖 어디서나 작동합니다. 밖에서는 종단간 암호화 터널인 OpenChamber Private Relay를 통해 연결됩니다. 설정이 필요 없습니다.', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '밖에서는 암호화 릴레이 연결도 허용', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '가능하면 집에서는 직접 연결 우선', + 'settings.remoteInstances.clientAuth.addDevice.create': 'QR 코드 만들기', + 'settings.remoteInstances.clientAuth.addDevice.done': '완료', 'settings.remoteInstances.clientAuth.pairingUrl': '연결 링크', 'settings.remoteInstances.clientAuth.createdToken': '지금 이 토큰을 복사하세요. 보안을 위해 다시 표시되지 않습니다.', 'settings.remoteInstances.clientAuth.state.loading': '토큰을 불러오는 중...', 'settings.remoteInstances.clientAuth.state.empty': '아직 연결된 기기가 없습니다.', 'settings.remoteInstances.clientAuth.state.revoked': '해지됨', 'settings.remoteInstances.clientAuth.state.thisDevice': '이 기기', + 'settings.remoteInstances.clientAuth.state.pending': '연결 대기 중…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': '연결됨 · 로컬 네트워크', + 'settings.remoteInstances.clientAuth.state.connectedRelay': '연결됨 · 릴레이', 'settings.remoteInstances.clientAuth.lastUsed': '마지막 사용 {date}', 'settings.remoteInstances.clientAuth.neverUsed': '사용한 적 없음', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': '릴레이로 기기를 페어링하면 자동으로 켜집니다.', 'settings.remoteInstances.relay.description': '포트를 열지 않고도 다른 기기가 어디서든 연결할 수 있습니다. 트래픽은 종단 간 암호화되어 릴레이는 내용을 읽을 수 없습니다.', 'settings.remoteInstances.relay.enableHint': '이 서버에서 릴레이를 켜기 전까지는 아무것도 공유되지 않습니다.', 'settings.remoteInstances.relay.actions.enable': '릴레이 켜기', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 76f30188..2f4bf8d7 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1469,21 +1469,43 @@ export const settingsDict = { 'settings.remoteInstances.direct.state.empty': 'Nie dodano jeszcze innych serwerów.', 'settings.remoteInstances.clientAuth.title': 'Połącz z tym serwerem', 'settings.remoteInstances.clientAuth.description': 'Utwórz bezpieczny link lub token, aby OpenChamber Desktop mógł połączyć się z tym serwerem.', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nazwa urządzenia (opcjonalnie)', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nazwa urządzenia — np. Mój iPhone', 'settings.remoteInstances.clientAuth.actions.create': 'Utwórz token', 'settings.remoteInstances.clientAuth.actions.pair': 'Utwórz link', 'settings.remoteInstances.clientAuth.actions.revoke': 'Unieważnij', 'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Wyczyść unieważnione', 'settings.remoteInstances.clientAuth.qrAlt': 'Kod QR połączenia OpenChamber', + 'settings.remoteInstances.clientAuth.qrEnlarge': 'Powiększ kod QR', + 'settings.remoteInstances.clientAuth.qrScanHint': 'Zeskanuj to aplikacją OpenChamber na drugim urządzeniu. Jednorazowy i wygasa.', + 'settings.remoteInstances.clientAuth.qrDialogTitle': 'Zeskanuj, aby połączyć', +'settings.remoteInstances.clientAuth.actions.addDevice': 'Dodaj urządzenie', + 'settings.remoteInstances.clientAuth.actions.copied': 'Skopiowano', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Gdzie będziesz używać tego urządzenia?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Utwórz jednorazowy kod QR, który połączy inne urządzenie z tym serwerem.', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': 'Tylko ten komputer', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'Dla aplikacji na tej samej maszynie.', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Tylko sieć domowa', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Łączy się bezpośrednio przez Wi-Fi. Nie działa poza tą siecią.', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Wszędzie', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Działa w domu i poza nim. Poza domem ruch przechodzi przez OpenChamber Private Relay — szyfrowany end-to-end tunel. Bez konfiguracji.', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Zezwól też na szyfrowany relay poza domem', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Preferuj bezpośrednie połączenie domowe, gdy dostępne', + 'settings.remoteInstances.clientAuth.addDevice.create': 'Utwórz kod QR', + 'settings.remoteInstances.clientAuth.addDevice.done': 'Gotowe', 'settings.remoteInstances.clientAuth.pairingUrl': 'Link połączenia', 'settings.remoteInstances.clientAuth.createdToken': 'Skopiuj ten token teraz. Ze względów bezpieczeństwa nie zostanie pokazany ponownie.', 'settings.remoteInstances.clientAuth.state.loading': 'Ładowanie tokenów...', 'settings.remoteInstances.clientAuth.state.empty': 'Nie podłączono jeszcze żadnych urządzeń.', 'settings.remoteInstances.clientAuth.state.revoked': 'Unieważniony', 'settings.remoteInstances.clientAuth.state.thisDevice': 'To urządzenie', + 'settings.remoteInstances.clientAuth.state.pending': 'Oczekiwanie na połączenie…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': 'Połączono · Sieć lokalna', + 'settings.remoteInstances.clientAuth.state.connectedRelay': 'Połączono · Relay', 'settings.remoteInstances.clientAuth.lastUsed': 'Ostatnio użyto {date}', 'settings.remoteInstances.clientAuth.neverUsed': 'Nigdy nie użyto', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': 'Włącza się automatycznie po sparowaniu urządzenia przez relay.', 'settings.remoteInstances.relay.description': 'Pozwól swoim innym urządzeniom łączyć się z dowolnego miejsca bez otwierania portów. Ruch jest szyfrowany od końca do końca — relay nie może go odczytać.', 'settings.remoteInstances.relay.enableHint': 'Nic nie jest udostępniane, dopóki nie włączysz relay na tym serwerze.', 'settings.remoteInstances.relay.actions.enable': 'Włącz Relay', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index a8942b57..87f2ea65 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -240,21 +240,43 @@ export const settingsDict = { "settings.remoteInstances.direct.state.empty": "Nenhum outro servidor adicionado ainda.", "settings.remoteInstances.clientAuth.title": "Conectar a este servidor", "settings.remoteInstances.clientAuth.description": "Crie um link ou token seguro para que o OpenChamber Desktop possa se conectar a este servidor.", - "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nome do dispositivo (opcional)", + "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nome do dispositivo — ex.: Meu iPhone", "settings.remoteInstances.clientAuth.actions.create": "Criar token", "settings.remoteInstances.clientAuth.actions.pair": "Criar link", "settings.remoteInstances.clientAuth.actions.revoke": "Revogar", "settings.remoteInstances.clientAuth.actions.clearRevoked": "Limpar revogados", "settings.remoteInstances.clientAuth.qrAlt": "OpenChamber connection QR code", + "settings.remoteInstances.clientAuth.qrEnlarge": "Ampliar código QR", + "settings.remoteInstances.clientAuth.qrScanHint": "Escaneie com o app OpenChamber no seu outro dispositivo. É de uso único e expira.", + "settings.remoteInstances.clientAuth.qrDialogTitle": "Escanear para conectar", +"settings.remoteInstances.clientAuth.actions.addDevice": "Adicionar um dispositivo", + "settings.remoteInstances.clientAuth.actions.copied": "Copiado", + "settings.remoteInstances.clientAuth.addDevice.transportLabel": "Onde você vai usar este dispositivo?", + "settings.remoteInstances.clientAuth.addDevice.subtitle": "Crie um código QR de uso único que conecta outro dispositivo a este servidor.", + "settings.remoteInstances.clientAuth.addDevice.transport.local": "Somente este computador", + "settings.remoteInstances.clientAuth.addDevice.transport.localHint": "Para aplicativos nesta mesma máquina.", + "settings.remoteInstances.clientAuth.addDevice.transport.lan": "Somente rede doméstica", + "settings.remoteInstances.clientAuth.addDevice.transport.lanHint": "Conecta diretamente pela sua rede Wi-Fi. Não funciona fora desta rede.", + "settings.remoteInstances.clientAuth.addDevice.transport.relay": "Em qualquer lugar", + "settings.remoteInstances.clientAuth.addDevice.transport.relayHint": "Funciona em casa e fora. Fora de casa o tráfego passa pelo OpenChamber Private Relay, um túnel criptografado de ponta a ponta. Sem configuração.", + "settings.remoteInstances.clientAuth.addDevice.fallback.relay": "Também permitir o relay criptografado fora de casa", + "settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal": "Preferir a conexão doméstica direta quando disponível", + "settings.remoteInstances.clientAuth.addDevice.create": "Criar código QR", + "settings.remoteInstances.clientAuth.addDevice.done": "Concluído", "settings.remoteInstances.clientAuth.pairingUrl": "Link de conexão", "settings.remoteInstances.clientAuth.createdToken": "Copie este token agora. Por segurança, ele não será mostrado novamente.", "settings.remoteInstances.clientAuth.state.loading": "Carregando tokens...", "settings.remoteInstances.clientAuth.state.empty": "Nenhum dispositivo conectado ainda.", "settings.remoteInstances.clientAuth.state.revoked": "Revogado", "settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo", + "settings.remoteInstances.clientAuth.state.pending": "Aguardando conexão…", + "settings.remoteInstances.clientAuth.state.viaRelay": "Relay", + "settings.remoteInstances.clientAuth.state.connectedDirect": "Conectado · Rede local", + "settings.remoteInstances.clientAuth.state.connectedRelay": "Conectado · Relay", "settings.remoteInstances.clientAuth.lastUsed": "Último uso em {date}", "settings.remoteInstances.clientAuth.neverUsed": "Nunca usado", "settings.remoteInstances.relay.title": "OpenChamber Relay", + "settings.remoteInstances.relay.autoHint": "Liga automaticamente ao parear um dispositivo pelo relay.", "settings.remoteInstances.relay.description": "Permita que seus outros dispositivos se conectem de qualquer lugar sem abrir portas. O tráfego é criptografado de ponta a ponta — o relay não consegue lê-lo.", "settings.remoteInstances.relay.enableHint": "Nada é compartilhado até você ativar o relay neste servidor.", "settings.remoteInstances.relay.actions.enable": "Ativar Relay", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index a4d637d4..473ecc91 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -240,21 +240,43 @@ export const settingsDict = { "settings.remoteInstances.direct.state.empty": "Інших серверів ще не додано.", "settings.remoteInstances.clientAuth.title": "Підключення до цього сервера", "settings.remoteInstances.clientAuth.description": "Створіть безпечне посилання або токен, щоб OpenChamber Desktop міг підключитися до цього сервера.", - "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Назва пристрою (необов’язково)", + "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Назва пристрою — напр. Мій iPhone", "settings.remoteInstances.clientAuth.actions.create": "Створити токен", "settings.remoteInstances.clientAuth.actions.pair": "Створити посилання", "settings.remoteInstances.clientAuth.actions.revoke": "Відкликати", "settings.remoteInstances.clientAuth.actions.clearRevoked": "Очистити відкликані", "settings.remoteInstances.clientAuth.qrAlt": "QR-код підключення OpenChamber", + "settings.remoteInstances.clientAuth.qrEnlarge": "Збільшити QR-код", + "settings.remoteInstances.clientAuth.qrScanHint": "Скануй це застосунком OpenChamber на іншому пристрої. Одноразовий і має термін дії.", + "settings.remoteInstances.clientAuth.qrDialogTitle": "Сканувати для підключення", +"settings.remoteInstances.clientAuth.actions.addDevice": "Додати пристрій", + "settings.remoteInstances.clientAuth.actions.copied": "Скопійовано", + "settings.remoteInstances.clientAuth.addDevice.transportLabel": "Де ви будете користуватись цим пристроєм?", + "settings.remoteInstances.clientAuth.addDevice.subtitle": "Створіть одноразовий QR-код, який підключить інший пристрій до цього сервера.", + "settings.remoteInstances.clientAuth.addDevice.transport.local": "Лише цей компʼютер", + "settings.remoteInstances.clientAuth.addDevice.transport.localHint": "Для застосунків на цій самій машині.", + "settings.remoteInstances.clientAuth.addDevice.transport.lan": "Лише домашня мережа", + "settings.remoteInstances.clientAuth.addDevice.transport.lanHint": "Підключається напряму через ваш Wi-Fi. Поза цією мережею не працює.", + "settings.remoteInstances.clientAuth.addDevice.transport.relay": "Будь-де", + "settings.remoteInstances.clientAuth.addDevice.transport.relayHint": "Працює вдома і поза домом. Поза домом трафік іде через OpenChamber Private Relay — наскрізно зашифрований тунель. Нічого налаштовувати не треба.", + "settings.remoteInstances.clientAuth.addDevice.fallback.relay": "Також дозволити зашифрований relay поза домом", + "settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal": "Віддавати перевагу прямому домашньому підключенню, коли доступне", + "settings.remoteInstances.clientAuth.addDevice.create": "Створити QR-код", + "settings.remoteInstances.clientAuth.addDevice.done": "Готово", "settings.remoteInstances.clientAuth.pairingUrl": "Посилання для підключення", "settings.remoteInstances.clientAuth.createdToken": "Скопіюйте цей токен зараз. З міркувань безпеки він більше не показуватиметься.", "settings.remoteInstances.clientAuth.state.loading": "Завантаження токенів...", "settings.remoteInstances.clientAuth.state.empty": "Жоден пристрій ще не підключено.", "settings.remoteInstances.clientAuth.state.revoked": "Відкликано", "settings.remoteInstances.clientAuth.state.thisDevice": "Цей пристрій", + "settings.remoteInstances.clientAuth.state.pending": "Очікує підключення…", + "settings.remoteInstances.clientAuth.state.viaRelay": "Relay", + "settings.remoteInstances.clientAuth.state.connectedDirect": "Підключено · Локальна мережа", + "settings.remoteInstances.clientAuth.state.connectedRelay": "Підключено · Relay", "settings.remoteInstances.clientAuth.lastUsed": "Останнє використання {date}", "settings.remoteInstances.clientAuth.neverUsed": "Ще не використовувався", "settings.remoteInstances.relay.title": "OpenChamber Relay", + "settings.remoteInstances.relay.autoHint": "Вмикається автоматично, коли ти паруєш пристрій через relay.", "settings.remoteInstances.relay.description": "Дозволяє вашим іншим пристроям підключатися звідки завгодно без відкриття портів. Трафік шифрується наскрізно — релей не може його прочитати.", "settings.remoteInstances.relay.enableHint": "Нічого не передається, доки ви не увімкнете релей на цьому сервері.", "settings.remoteInstances.relay.actions.enable": "Увімкнути Relay", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 9de115df..ac0e9b84 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -240,21 +240,43 @@ export const settingsDict = { 'settings.remoteInstances.direct.state.empty': '尚未添加其他服务器。', 'settings.remoteInstances.clientAuth.title': '连接到此服务器', 'settings.remoteInstances.clientAuth.description': '创建安全链接或令牌,让 OpenChamber Desktop 可以连接到此服务器。', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '设备名称(可选)', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '设备名称 — 例如 My iPhone', 'settings.remoteInstances.clientAuth.actions.create': '创建令牌', 'settings.remoteInstances.clientAuth.actions.pair': '创建链接', 'settings.remoteInstances.clientAuth.actions.revoke': '撤销', 'settings.remoteInstances.clientAuth.actions.clearRevoked': '清除已撤销', 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code', + 'settings.remoteInstances.clientAuth.qrEnlarge': '放大二维码', + 'settings.remoteInstances.clientAuth.qrScanHint': '用另一台设备上的 OpenChamber 应用扫描。一次性使用且会过期。', + 'settings.remoteInstances.clientAuth.qrDialogTitle': '扫码连接', +'settings.remoteInstances.clientAuth.actions.addDevice': '添加设备', + 'settings.remoteInstances.clientAuth.actions.copied': '已复制', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': '你会在哪里使用这台设备?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': '创建一次性二维码,把另一台设备连接到此服务器。', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': '仅本机', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '供同一台电脑上的应用使用。', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': '仅家庭网络', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': '通过 Wi-Fi 直接连接。离开此网络后无法使用。', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': '任何地方', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '在家和外出都可用。外出时流量经由 OpenChamber Private Relay(端到端加密隧道)传输,无需配置。', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '外出时也允许通过加密中继连接', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '在家时优先使用直接连接', + 'settings.remoteInstances.clientAuth.addDevice.create': '创建二维码', + 'settings.remoteInstances.clientAuth.addDevice.done': '完成', 'settings.remoteInstances.clientAuth.pairingUrl': '连接链接', 'settings.remoteInstances.clientAuth.createdToken': '请立即复制此令牌。出于安全考虑,它不会再次显示。', 'settings.remoteInstances.clientAuth.state.loading': '正在加载令牌...', 'settings.remoteInstances.clientAuth.state.empty': '尚无已连接设备。', 'settings.remoteInstances.clientAuth.state.revoked': '已撤销', 'settings.remoteInstances.clientAuth.state.thisDevice': '此设备', + 'settings.remoteInstances.clientAuth.state.pending': '等待连接…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': '已连接 · 局域网', + 'settings.remoteInstances.clientAuth.state.connectedRelay': '已连接 · 中继', 'settings.remoteInstances.clientAuth.lastUsed': '上次使用 {date}', 'settings.remoteInstances.clientAuth.neverUsed': '从未使用', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': '通过中继配对设备时自动开启。', 'settings.remoteInstances.relay.description': '无需开放端口,即可让你的其他设备从任何地方连接。流量端到端加密,中继无法读取内容。', 'settings.remoteInstances.relay.enableHint': '在此服务器上启用中继之前,不会共享任何内容。', 'settings.remoteInstances.relay.actions.enable': '启用中继', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index c7c9f1f3..dd3875b6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -246,21 +246,43 @@ 'settings.remoteInstances.direct.state.empty': '尚無直接連線。', 'settings.remoteInstances.clientAuth.title': '用戶端存取 token', 'settings.remoteInstances.clientAuth.description': '建立與管理可讓桌面或遠端用戶端連線的 token。', - 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '裝置或用戶端名稱', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '裝置名稱 — 例如 My iPhone', 'settings.remoteInstances.clientAuth.actions.create': '建立 token', 'settings.remoteInstances.clientAuth.actions.pair': '配對裝置', 'settings.remoteInstances.clientAuth.actions.revoke': '撤銷', 'settings.remoteInstances.clientAuth.actions.clearRevoked': '清除已撤銷', 'settings.remoteInstances.clientAuth.qrAlt': '配對 QR code', + 'settings.remoteInstances.clientAuth.qrEnlarge': '放大 QR code', + 'settings.remoteInstances.clientAuth.qrScanHint': '用另一台裝置上的 OpenChamber 應用程式掃描。一次性使用且會過期。', + 'settings.remoteInstances.clientAuth.qrDialogTitle': '掃碼連線', +'settings.remoteInstances.clientAuth.actions.addDevice': '新增裝置', + 'settings.remoteInstances.clientAuth.actions.copied': '已複製', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': '你會在哪裡使用這台裝置?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': '建立一次性 QR 代碼,將另一台裝置連線到此伺服器。', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': '僅本機', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '供同一台電腦上的應用程式使用。', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': '僅家用網路', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': '透過 Wi-Fi 直接連線。離開此網路後無法使用。', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': '任何地方', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '在家與外出都可用。外出時流量經由 OpenChamber Private Relay(端對端加密隧道)傳輸,無需設定。', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '外出時也允許透過加密中繼連線', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '在家時優先使用直接連線', + 'settings.remoteInstances.clientAuth.addDevice.create': '建立 QR 代碼', + 'settings.remoteInstances.clientAuth.addDevice.done': '完成', 'settings.remoteInstances.clientAuth.pairingUrl': '配對 URL', 'settings.remoteInstances.clientAuth.createdToken': '已建立 token', 'settings.remoteInstances.clientAuth.state.loading': '正在載入用戶端 token...', 'settings.remoteInstances.clientAuth.state.empty': '尚無用戶端 token。', 'settings.remoteInstances.clientAuth.state.revoked': '已撤銷', 'settings.remoteInstances.clientAuth.state.thisDevice': '此裝置', + 'settings.remoteInstances.clientAuth.state.pending': '等待連線…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': '已連線 · 區域網路', + 'settings.remoteInstances.clientAuth.state.connectedRelay': '已連線 · 中繼', 'settings.remoteInstances.clientAuth.lastUsed': '上次使用:{date}', 'settings.remoteInstances.clientAuth.neverUsed': '從未使用', 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': '透過中繼配對裝置時自動開啟。', 'settings.remoteInstances.relay.description': '無需開放連接埠,即可讓你的其他裝置從任何地方連線。流量端對端加密,中繼無法讀取內容。', 'settings.remoteInstances.relay.enableHint': '在此伺服器上啟用中繼之前,不會共享任何內容。', 'settings.remoteInstances.relay.actions.enable': '啟用中繼', diff --git a/packages/ui/src/lib/relay/gate.ts b/packages/ui/src/lib/relay/gate.ts deleted file mode 100644 index ac9534c7..00000000 --- a/packages/ui/src/lib/relay/gate.ts +++ /dev/null @@ -1,21 +0,0 @@ -// openchamber_relay_gate -// -// Feature gate for the private-relay UI — the surfaces for enabling the relay and -// pairing devices through it (Settings → Remote Instances "Relay" section and its -// settings-search entry). The relay transport itself is fully implemented and -// tested; this flag only hides the UI entry points until the feature is ready for -// public release (the connect flow is being unified across LAN / tunnels / relay). -// -// TO UNBLOCK FOR PUBLIC RELEASE: set RELAY_UI_ENABLED to true. Grep this token — -// `openchamber_relay_gate` — to find this file. Nothing else needs to change; the -// gated surfaces read this one constant. Also add a CHANGELOG entry then — the -// relay's changelog note is intentionally held back while this is off. -// -// Note: existing saved relay connections keep working regardless (this gates the -// UI for ADDING/pairing, not the runtime transport). If you also want to hide the -// mobile side of importing a relay link, gate the relay branch in -// packages/ui/src/apps/mobileQrScan.ts / mobileConnections.ts on this same flag. -// Typed as boolean (not the literal `false`) so gated call sites don't trip -// "condition always false" / unreachable-code checks — flipping to true is a -// one-word change with no other edits. -export const RELAY_UI_ENABLED: boolean = false; diff --git a/packages/ui/src/lib/relay/offer.test.ts b/packages/ui/src/lib/relay/offer.test.ts deleted file mode 100644 index 79d818e6..00000000 --- a/packages/ui/src/lib/relay/offer.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { buildRelayOfferUrl, parseRelayOfferUrl, redactOffer } from './offer'; -import type { RelayOfferV1 } from './protocol'; - -const baseOffer: RelayOfferV1 = { - v: 1, - mode: 'relay', - relayUrl: 'wss://relay.example.com/host', - serverId: 'srv_0123456789abcdef', - hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x-coordinate-b64u', y: 'y-coordinate-b64u' }, -}; - -const fullOffer: RelayOfferV1 = { - ...baseOffer, - label: 'My Mac', - token: 'oc_client_secret_token_value', - grant: 'grant-value', -}; - -describe('buildRelayOfferUrl / parseRelayOfferUrl', () => { - test('round-trips a minimal offer', () => { - expect(parseRelayOfferUrl(buildRelayOfferUrl(baseOffer))).toEqual(baseOffer); - }); - - test('round-trips a full offer with optional fields', () => { - expect(parseRelayOfferUrl(buildRelayOfferUrl(fullOffer))).toEqual(fullOffer); - }); - - test('URL has the expected shape', () => { - const url = buildRelayOfferUrl(baseOffer); - expect(url.startsWith('openchamber://connect?v=1&mode=relay#offer=')).toBe(true); - }); - - test('token appears only in the fragment, never in the query string', () => { - const url = buildRelayOfferUrl(fullOffer); - const [beforeFragment, fragment] = url.split('#'); - expect(beforeFragment).toBe('openchamber://connect?v=1&mode=relay'); - expect(beforeFragment.includes(fullOffer.token as string)).toBe(false); - expect(fragment.startsWith('offer=')).toBe(true); - // Token round-trips through the fragment payload. - expect(parseRelayOfferUrl(url)?.token).toBe(fullOffer.token as string); - }); - - const encodeOffer = (value: unknown): string => { - const json = JSON.stringify(value); - const b64 = Buffer.from(json, 'utf8').toString('base64url'); - return `openchamber://connect?v=1&mode=relay#offer=${b64}`; - }; - - test('rejects wrong scheme, host, version, and mode', () => { - const url = buildRelayOfferUrl(baseOffer); - expect(parseRelayOfferUrl(url.replace('openchamber://', 'https://'))).toBeNull(); - expect(parseRelayOfferUrl(url.replace('//connect', '//pair'))).toBeNull(); - expect(parseRelayOfferUrl(url.replace('v=1', 'v=2'))).toBeNull(); - expect(parseRelayOfferUrl(url.replace('mode=relay', 'mode=lan'))).toBeNull(); - expect(parseRelayOfferUrl('not a url')).toBeNull(); - expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay')).toBeNull(); - expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=')).toBeNull(); - expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=!!not-b64url!!')).toBeNull(); - }); - - const without = (key: keyof RelayOfferV1): Record => { - const clone: Record = { ...fullOffer }; - delete clone[key]; - return clone; - }; - - test('rejects wholly when any required field is missing or malformed', () => { - const cases: unknown[] = [ - { ...fullOffer, v: 2 }, - without('v'), - { ...fullOffer, mode: 'direct' }, - without('mode'), - without('relayUrl'), - { ...fullOffer, relayUrl: '' }, - { ...fullOffer, relayUrl: 'not-a-url' }, - { ...fullOffer, relayUrl: 'ftp://relay.example.com' }, - without('serverId'), - { ...fullOffer, serverId: '' }, - { ...fullOffer, serverId: 42 }, - without('hostEncPubJwk'), - { ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, kty: 'RSA' } }, - { ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, crv: 'P-384' } }, - { ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', y: 'y' } }, - { ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x' } }, - { ...fullOffer, hostEncPubJwk: 'jwk' }, - { ...fullOffer, label: '' }, - { ...fullOffer, token: '' }, - { ...fullOffer, token: 123 }, - { ...fullOffer, grant: '' }, - ['array'], - ]; - for (const payload of cases) { - expect(parseRelayOfferUrl(encodeOffer(payload))).toBeNull(); - } - }); - - test('parse strips unknown fields', () => { - const parsed = parseRelayOfferUrl(encodeOffer({ ...baseOffer, extra: 'field' })); - expect(parsed).toEqual(baseOffer); - }); -}); - -describe('redactOffer', () => { - test('masks token, grant, and host public key coordinates', () => { - const redacted = redactOffer(fullOffer); - expect(redacted.token).toBe('[redacted]'); - expect(redacted.grant).toBe('[redacted]'); - expect(redacted.hostEncPubJwk.x).toBe('[redacted]'); - expect(redacted.hostEncPubJwk.y).toBe('[redacted]'); - const serialized = JSON.stringify(redacted); - expect(serialized.includes(fullOffer.token as string)).toBe(false); - expect(serialized.includes(baseOffer.hostEncPubJwk.x as string)).toBe(false); - }); - - test('keeps non-secret fields and omits absent optionals', () => { - const redacted = redactOffer(baseOffer); - expect(redacted.relayUrl).toBe(baseOffer.relayUrl); - expect(redacted.serverId).toBe(baseOffer.serverId); - expect('token' in redacted).toBe(false); - expect('grant' in redacted).toBe(false); - }); - - test('does not mutate the input offer', () => { - const copy = structuredClone(fullOffer); - redactOffer(fullOffer); - expect(fullOffer).toEqual(copy); - }); -}); diff --git a/packages/ui/src/lib/relay/offer.ts b/packages/ui/src/lib/relay/offer.ts deleted file mode 100644 index ba19b60f..00000000 --- a/packages/ui/src/lib/relay/offer.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Relay pairing offer URL codec (spec §Pairing payload). -// The offer JSON travels ONLY in the URL fragment so secrets (token) never -// reach servers, logs, or referrer headers via the query string. -// Shared by: settings UI (build), mobile scan (parse), desktop host import -// (parse), CLI (build). - -import { base64UrlToBytes, bytesToBase64Url } from './crypto'; -import type { RelayOfferV1 } from './protocol'; - -const OFFER_SCHEME = 'openchamber:'; -const OFFER_HOST = 'connect'; -const OFFER_FRAGMENT_KEY = 'offer='; - -const REDACTED = '[redacted]'; - -export const buildRelayOfferUrl = (offer: RelayOfferV1): string => { - const json = JSON.stringify(offer); - const encoded = bytesToBase64Url(new TextEncoder().encode(json)); - return `openchamber://connect?v=1&mode=relay#${OFFER_FRAGMENT_KEY}${encoded}`; -}; - -const isNonEmptyString = (value: unknown): value is string => - typeof value === 'string' && value.length > 0; - -const isValidHttpOrWsUrl = (value: string): boolean => { - try { - const parsed = new URL(value); - return parsed.protocol === 'wss:' || parsed.protocol === 'ws:' || parsed.protocol === 'https:' || parsed.protocol === 'http:'; - } catch { - return false; - } -}; - -const parsePublicKeyJwk = (value: unknown): JsonWebKey | null => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; - const jwk = value as Record; - if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null; - if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null; - return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }; -}; - -// Strict parse: every required field is validated; any malformed or missing -// field rejects the whole offer (returns null, never a partial object). -export const parseRelayOfferUrl = (url: string): RelayOfferV1 | null => { - let parsed: URL; - try { - parsed = new URL(url.trim()); - } catch { - return null; - } - if (parsed.protocol !== OFFER_SCHEME) return null; - // Custom-scheme URLs may surface the authority as hostname or pathname - // depending on the runtime's parser. - const authority = parsed.hostname || parsed.pathname.replace(/^\/*/, '').split(/[/?#]/)[0]; - if (authority !== OFFER_HOST) return null; - if (parsed.searchParams.get('v') !== '1') return null; - if (parsed.searchParams.get('mode') !== 'relay') return null; - - const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash; - if (!fragment.startsWith(OFFER_FRAGMENT_KEY)) return null; - const encoded = fragment.slice(OFFER_FRAGMENT_KEY.length); - if (!encoded) return null; - - let raw: unknown; - try { - raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(encoded))); - } catch { - return null; - } - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null; - const candidate = raw as Record; - - if (candidate.v !== 1) return null; - if (candidate.mode !== 'relay') return null; - if (!isNonEmptyString(candidate.relayUrl) || !isValidHttpOrWsUrl(candidate.relayUrl)) return null; - if (!isNonEmptyString(candidate.serverId)) return null; - const hostEncPubJwk = parsePublicKeyJwk(candidate.hostEncPubJwk); - if (!hostEncPubJwk) return null; - if (candidate.label !== undefined && !isNonEmptyString(candidate.label)) return null; - if (candidate.token !== undefined && !isNonEmptyString(candidate.token)) return null; - if (candidate.grant !== undefined && !isNonEmptyString(candidate.grant)) return null; - - return { - v: 1, - mode: 'relay', - relayUrl: candidate.relayUrl, - serverId: candidate.serverId, - hostEncPubJwk, - ...(candidate.label !== undefined ? { label: candidate.label } : {}), - ...(candidate.token !== undefined ? { token: candidate.token } : {}), - ...(candidate.grant !== undefined ? { grant: candidate.grant } : {}), - }; -}; - -// Safe-for-logging copy: masks the access token and the host public key -// coordinates. Never log a raw offer. -export const redactOffer = (offer: RelayOfferV1): RelayOfferV1 => ({ - ...offer, - hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: REDACTED, y: REDACTED }, - ...(offer.token !== undefined ? { token: REDACTED } : {}), - ...(offer.grant !== undefined ? { grant: REDACTED } : {}), -}); diff --git a/packages/ui/src/lib/relay/protocol.ts b/packages/ui/src/lib/relay/protocol.ts index 18a12d13..6e765fca 100644 --- a/packages/ui/src/lib/relay/protocol.ts +++ b/packages/ui/src/lib/relay/protocol.ts @@ -126,14 +126,3 @@ export const RelayCloseCode = { ChannelFailure: 1011, } as const; -// Pairing payload carried in QR / deep-link URL fragments only. -export interface RelayOfferV1 { - v: 1; - mode: 'relay'; - relayUrl: string; - serverId: string; - hostEncPubJwk: JsonWebKey; - label?: string; - token?: string; - grant?: string; -} diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index c632bb44..f57ad5d2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -1,7 +1,6 @@ import type { I18nKey } from '@/lib/i18n/store'; import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata'; import { getSettingsPageMeta } from './metadata'; -import { RELAY_UI_ENABLED } from '@/lib/relay/gate'; interface SettingsSearchItem { id: string; @@ -430,18 +429,9 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'remote-instances', titleKey: 'settings.remoteInstances.clientAuth.title', descriptionKey: 'settings.remoteInstances.clientAuth.description', - keywords: ['pairing link', 'client token', 'connect desktop', 'remote access'], + keywords: ['pairing link', 'client token', 'connect desktop', 'remote access', 'relay', 'devices', 'connect from anywhere'], isAvailable: (ctx) => !ctx.isVSCode, }, - { - id: 'remote-instances.relay', - page: 'remote-instances', - titleKey: 'settings.remoteInstances.relay.title', - descriptionKey: 'settings.remoteInstances.relay.description', - keywords: ['relay', 'pairing', 'no ports', 'end-to-end encrypted', 'remote access', 'connect from anywhere'], - // Gated by openchamber_relay_gate until the relay UI ships publicly. - isAvailable: (ctx) => !ctx.isVSCode && RELAY_UI_ENABLED, - }, { id: 'remote-instances.direct-hosts', page: 'remote-instances', diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 9c1171da..e94d537d 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -349,7 +349,14 @@ export function useSync() { setMetaFor(sessionID, { loading: true }) try { - const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit + // A resync (no `before`) must fetch at least as many messages as we + // already have on screen. Live events append to the store WITHOUT growing + // m.limit, so reusing the stale m.limit here would under-fetch and make + // the server hand back a spurious "older" cursor — surfacing a phantom + // "load older" button for a session whose full history is already shown + // (e.g. after a reconnect resync following a few new messages). + const storeMessageCount = store.getState().message[sessionID]?.length ?? 0 + const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : Math.max(m.limit, storeMessageCount) let page = await fetchMessages(sessionID, limit, options?.before) // Keep the initial page small for switch performance. Some sessions diff --git a/packages/web/bin/lib/DOCUMENTATION.md b/packages/web/bin/lib/DOCUMENTATION.md index 09f2a6aa..e27e8538 100644 --- a/packages/web/bin/lib/DOCUMENTATION.md +++ b/packages/web/bin/lib/DOCUMENTATION.md @@ -36,7 +36,9 @@ Command modules implement user-facing commands and preserve output contracts acr - `commands-connect-url.js` - Implements `openchamber connect-url`. - Finds or starts a local instance and prints the browser/connect URL according to the selected output mode. - - `--relay` builds an end-to-end-encrypted relay pairing link instead: it mints a client token and an offer from the instance's local relay identity (no server URL, no auto-start). The relay endpoint follows `OPENCHAMBER_RELAY_URL` / the stored setting / the default, matching the running host; clients read it from the offer. + - Emits a **pairing v2** link (`openchamber://connect?v=2&p=`): it creates a one-time pairing session in the shared store (`client-pairing-sessions.json`) and encodes the pairing id + secret + transport candidates. The client redeems the secret over whichever candidate connects first (`/api/client-auth/pairing/redeem`). No standalone token is embedded — the QR itself is the single-use credential. + - The default form advertises the resolved server URL as a direct (lan/tunnel) candidate and folds in a relay candidate when the host relay is enabled, so one link works on-LAN and off-network. + - `--relay` builds a relay-only pairing link (the sole candidate is the relay transport), for sharing with a device that is not on the host's network — no server URL, no auto-start. The relay endpoint follows `OPENCHAMBER_RELAY_URL` / the stored setting / the default, matching the running host; the host must be running with the relay enabled to serve the redeem over the tunnel. - `commands-update.js` - Implements `openchamber update`. diff --git a/packages/web/bin/lib/commands-connect-url.js b/packages/web/bin/lib/commands-connect-url.js index 48fb20f7..198dd5ed 100644 --- a/packages/web/bin/lib/commands-connect-url.js +++ b/packages/web/bin/lib/commands-connect-url.js @@ -13,6 +13,7 @@ import { import { discoverRunningInstances } from './cli-lifecycle.js'; import { getInstanceFilePath, readInstanceOptions } from './cli-process.js'; import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js'; +import { createClientPairingRuntime } from '../../server/lib/client-auth/pairing.js'; import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js'; import { DEFAULT_RELAY_URL } from '../../server/lib/relay/service.js'; import { bytesToBase64Url } from '../../server/lib/relay/e2ee.js'; @@ -28,6 +29,7 @@ import { const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json'; const SETTINGS_FILE_NAME = 'settings.json'; +const PAIRING_SESSIONS_FILE_NAME = 'client-pairing-sessions.json'; function isValidRelayUrl(value) { if (typeof value !== 'string') return false; @@ -69,42 +71,94 @@ function createSettingsAccessors() { return { readSettingsFromDiskMigrated, writeSettingsToDisk }; } -// Builds an end-to-end-encrypted relay pairing link. Reuses the instance's relay -// identity (serverId + encryption public key), generating it if the relay was -// never enabled. The client reads the relay URL from the offer, so no client-side -// configuration is needed. -async function buildRelayConnectionPayload({ token, label }) { +// Resolves the instance's relay identity (serverId + encryption public key, +// generating it if the relay was never enabled) into a pairing-v2 relay +// candidate. Relay is a transport, not a separate link format: the candidate +// carries no token — the client redeems the one-time pairing secret over the +// E2EE tunnel like any other candidate. `enabled` reports whether the host relay +// is actually on (a relay candidate only connects when the host is relaying). +async function buildRelayPairingCandidate() { const accessors = createSettingsAccessors(); const settings = await accessors.readSettingsFromDiskMigrated(); const relayUrl = resolveRelayUrl(settings); const identityRuntime = createRelayIdentityRuntime({ crypto, ...accessors }); const identity = await identityRuntime.getRelayIdentity(); - const offer = { - v: 1, - mode: 'relay', + return { + enabled: settings?.privateRelay?.enabled === true, relayUrl, serverId: identity.serverId, - hostEncPubJwk: identity.hostEncPubJwk, - label, - token, + candidate: { + type: 'relay', + relayUrl, + serverId: identity.serverId, + hostEncPubJwk: identity.hostEncPubJwk, + priority: 30, + }, }; - const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer))); - return { connectUrl: `openchamber://connect?v=1&mode=relay#offer=${encoded}`, relayUrl, serverId: identity.serverId }; } -async function generateRelayConnectUrl(options) { - const label = options.name || os.hostname(); - const runtime = createRemoteClientAuthRuntime({ +// Pairing runtime backed by the same on-disk store the running host reads, so a +// session created here is redeemable by the live server. createPairingSession +// only writes the store (no server needed to mint); redeem is served by the host. +function createCliPairingRuntime() { + const dataDir = getOpenChamberDataDir(); + const remoteClientAuthRuntime = createRemoteClientAuthRuntime({ fsPromises: fs.promises, path, crypto, - storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME), + storePath: path.join(dataDir, REMOTE_CLIENTS_FILE_NAME), }); - const result = await runtime.createClient({ label, clientKind: 'relay' }); - const { connectUrl, relayUrl, serverId } = await buildRelayConnectionPayload({ token: result.token, label }); + return createClientPairingRuntime({ + fsPromises: fs.promises, + path, + crypto, + storePath: path.join(dataDir, PAIRING_SESSIONS_FILE_NAME), + remoteClientAuthRuntime, + }); +} + +// Mirror of encodePairingConnectionPayload in @openchamber/ui (the bin cannot +// import the UI package). Keep in sync: v2 payload → base64url(JSON) in the URL +// query, so the one-time secret rides the link, never the network. +function encodePairingConnectUrl(payload) { + const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(payload))); + return `openchamber://connect?v=2&p=${encoded}`; +} + +function buildPairingPayload({ pairing, label, candidates }) { + return { + v: 2, + pairingId: pairing.id, + secret: pairing.secret, + ...(label ? { label } : {}), + ...(pairing.fingerprint ? { fingerprint: pairing.fingerprint } : {}), + ...(pairing.expiresAt ? { expiresAt: pairing.expiresAt } : {}), + candidates, + }; +} + +// Relay-only pairing link: the sole candidate is the relay transport, for +// sharing with a device that is not on the host's network. Needs no reachable +// server URL, but the host must be running with the relay enabled to serve the +// redeem over the tunnel. +async function generateRelayConnectUrl(options) { + const label = options.name || os.hostname(); + const relay = await buildRelayPairingCandidate(); + const pairingRuntime = createCliPairingRuntime(); + const { pairing } = await pairingRuntime.createPairingSession({ label }); + const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates: [relay.candidate] })); if (isJsonMode(options)) { - printJson({ mode: 'relay', relayUrl, serverId, connectUrl, token: result.token, client: result.client }); + printJson({ + mode: 'relay', + relayUrl: relay.relayUrl, + serverId: relay.serverId, + relayEnabled: relay.enabled, + pairingId: pairing.id, + fingerprint: pairing.fingerprint, + expiresAt: pairing.expiresAt, + connectUrl, + }); return; } @@ -113,15 +167,18 @@ async function generateRelayConnectUrl(options) { return; } - clackIntro('OpenChamber relay connect URL'); + clackIntro('OpenChamber relay pairing link'); logStatus('success', connectUrl); - clackLog.info(`Relay: ${relayUrl}`); - logStatus('info', '[RELAY_ENABLE]', 'Enable the relay on this instance so this link can connect (Settings -> Remote Instances).'); - clackLog.info('Copy this link into another OpenChamber client. The token is shown only once.'); + clackLog.info(`Relay: ${relay.relayUrl}`); + if (pairing.fingerprint) clackLog.info(`Fingerprint: ${pairing.fingerprint}`); + if (!relay.enabled) { + logStatus('info', '[RELAY_ENABLE]', 'Enable the relay on this instance so this link can connect (Settings -> Remote Instances).'); + } + clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.'); if (options.qr === true) { await displayTunnelQrCode(connectUrl); } - clackOutro('relay connect URL generated'); + clackOutro('relay pairing link generated'); } async function resolveConnectUrlServerUrl(options) { @@ -190,15 +247,6 @@ function getOpenChamberDataDir() { : path.join(os.homedir(), '.config', 'openchamber'); } -function buildClientConnectionPayload({ serverUrl, token, label }) { - const params = new URLSearchParams(); - params.set('v', '1'); - params.set('server', serverUrl.trim().replace(/\/+$/, '')); - params.set('token', token.trim()); - if (label?.trim()) params.set('label', label.trim()); - return `openchamber://connect?${params.toString()}`; -} - async function displayTunnelQrCode(url) { try { const qrcode = await import('qrcode-terminal'); @@ -247,18 +295,29 @@ function createConnectUrlCommand({ serveCommand }) { ? { serverUrl: explicitServerUrl, source: 'explicit' } : await resolveConnectUrlServerUrl(options); const serverUrl = resolvedServerUrl.serverUrl; - const label = options.name || `OpenChamber ${serverUrl}`; - const runtime = createRemoteClientAuthRuntime({ - fsPromises: fs.promises, - path, - crypto, - storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME), - }); - const result = await runtime.createClient({ label }); - const connectUrl = buildClientConnectionPayload({ serverUrl, token: result.token, label }); + const label = options.name || os.hostname(); + + // Direct candidate for the reachable server URL, plus the relay transport as + // a fallback candidate when the host relay is enabled — one link that works + // both on the LAN and off-network. + const candidates = [{ type: serverUrl.startsWith('https://') ? 'tunnel' : 'lan', url: serverUrl, priority: 10 }]; + const relay = await buildRelayPairingCandidate(); + if (relay.enabled) candidates.push(relay.candidate); + + const pairingRuntime = createCliPairingRuntime(); + const { pairing } = await pairingRuntime.createPairingSession({ label }); + const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates })); if (isJsonMode(options)) { - printJson({ serverUrl, connectUrl, token: result.token, client: result.client, autoStarted: serverState.autoStarted }); + printJson({ + serverUrl, + connectUrl, + pairingId: pairing.id, + fingerprint: pairing.fingerprint, + expiresAt: pairing.expiresAt, + candidates, + autoStarted: serverState.autoStarted, + }); return; } @@ -267,22 +326,28 @@ function createConnectUrlCommand({ serveCommand }) { return; } - clackIntro('OpenChamber connect URL'); + clackIntro('OpenChamber pairing link'); if (serverState.autoStarted) { logStatus('success', `started OpenChamber on port ${options.port}`); } logStatus('success', connectUrl); clackLog.info(`Server URL: ${serverUrl}`); + if (relay.enabled) { + clackLog.info(`Relay fallback: ${relay.relayUrl}`); + } + if (pairing.fingerprint) { + clackLog.info(`Fingerprint: ${pairing.fingerprint}`); + } if (resolvedServerUrl.source === 'lan-detected') { clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.'); } else if (resolvedServerUrl.source === 'loopback-fallback') { clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.'); } - clackLog.info('Copy this connection link into another OpenChamber client. The token is shown only once.'); + clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.'); if (options.qr === true) { await displayTunnelQrCode(connectUrl); } - clackOutro('connect URL generated'); + clackOutro('pairing link generated'); }; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 434c2c21..ac318200 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -87,6 +87,7 @@ import { createNotificationTemplateRuntime } from './lib/notifications/template- import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js'; +import { createClientPairingRuntime } from './lib/client-auth/pairing.js'; import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js'; import { attachRealtimeProxy } from './lib/realtime-proxy.js'; import { createRelayService } from './lib/relay/service.js'; @@ -282,6 +283,7 @@ const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json'); const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json'); const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json'); const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json'); +const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'client-pairing-sessions.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json'); const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1; @@ -873,6 +875,13 @@ const remoteClientAuthRuntime = createRemoteClientAuthRuntime({ crypto, storePath: REMOTE_CLIENTS_FILE_PATH, }); +const clientPairingRuntime = createClientPairingRuntime({ + fsPromises, + path, + crypto, + storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH, + remoteClientAuthRuntime, +}); const featureRoutesRuntime = createFeatureRoutesRuntime({ clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, }); @@ -1102,6 +1111,34 @@ async function main(options = {}) { || (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 ? process.env.OPENCHAMBER_HOST.trim() : '127.0.0.1'); + + // Pairing transports advertised to the create-device dialog. LAN reachability is + // derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP; + // a specific non-loopback host → that host), NOT from how the UI was opened — so + // "Local network" works even when the UI is opened on localhost, and is absent + // when the server is only bound to loopback (a LAN link would not connect). + const resolvePairingTransports = () => { + const activePort = tunnelRuntimeContext.getActivePort() || port; + const local = `http://127.0.0.1:${activePort}`; + let lanHost = null; + if (isNetworkExposedBindHost(effectiveBindHost)) { + try { + for (const list of Object.values(os.networkInterfaces())) { + for (const entry of (list || [])) { + if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; } + } + if (lanHost) break; + } + } catch { + lanHost = null; + } + } else { + const h = String(effectiveBindHost || '').toLowerCase(); + if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') lanHost = effectiveBindHost; + } + const lan = lanHost ? `http://${lanHost.includes(':') ? `[${lanHost}]` : lanHost}:${activePort}` : null; + return { local, lan, relayAvailable: true }; + }; const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : (typeof process.env.OPENCHAMBER_UI_PASSWORD === 'string' ? process.env.OPENCHAMBER_UI_PASSWORD : null); @@ -1204,6 +1241,11 @@ async function main(options = {}) { server = http.createServer(app); let realtimeProxyRuntime = { stop: () => {} }; + // The relay service is constructed further below (it depends on the tunnel + // runtime's active port). The pairing routes registered here only read the + // relay candidate lazily at request time, so a late-bound holder is enough. + let relayServiceInstance = null; + const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { process, openchamberVersion: OPENCHAMBER_VERSION, @@ -1244,6 +1286,30 @@ async function main(options = {}) { uiPassword, tunnelAuthController, remoteClientAuthRuntime, + clientPairingRuntime, + getRelayPairingCandidate: (options) => { + if (!relayServiceInstance) return null; + // A relay pairing link enables the relay on demand; a plain link only + // advertises relay when it is already on. + return options?.ensureEnabled + ? relayServiceInstance.ensureEnabledForPairing() + : relayServiceInstance.getPairingCandidate(); + }, + // Re-evaluate the relay lifecycle after pairing/device changes (a revoked or + // redeemed device can flip relay demand on or off). + reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()), + getPairingTransports: resolvePairingTransports, + // The display name a paired device shows for THIS server. Devices name the + // connection by the issuing machine's hostname, not the per-device pairing + // label typed by the operator. + getServerLabel: () => { + try { + const name = os.hostname(); + return typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'OpenChamber'; + } catch { + return 'OpenChamber'; + } + }, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, sayTTSCapability, @@ -1297,7 +1363,17 @@ async function main(options = {}) { writeSettingsToDisk, remoteClientAuthRuntime, getLocalPort: () => tunnelRuntimeContext.getActivePort(), + // Relay demand = any paired device or pending pairing session that uses the + // relay transport. Drives the auto on/off lifecycle. + hasRelayDemand: async () => { + const [pendingRelay, deviceRelay] = await Promise.all([ + clientPairingRuntime.hasActiveRelaySession().catch(() => false), + remoteClientAuthRuntime.hasActiveRelayClients().catch(() => false), + ]); + return pendingRelay || deviceRelay; + }, }); + relayServiceInstance = relayService; relayService.registerRoutes(app); await featureRoutesRuntime.registerRoutes(app, { @@ -1410,7 +1486,9 @@ async function main(options = {}) { } // Only opens a relay control socket when the user opted in (config enabled). - void relayService.startIfEnabled(); + // Reconcile the relay lifecycle from demand on startup: run it if any relay + // device/session exists, stop it (and clear a stale enabled flag) otherwise. + void relayService.reconcile(); return { expressApp: app, diff --git a/packages/web/server/lib/client-auth/pairing.js b/packages/web/server/lib/client-auth/pairing.js new file mode 100644 index 00000000..ca8b4ddc --- /dev/null +++ b/packages/web/server/lib/client-auth/pairing.js @@ -0,0 +1,308 @@ +const STORE_VERSION = 1; +const PAIRING_ID_PREFIX = 'pair_'; +const SECRET_BYTES = 32; +const FINGERPRINT_BYTES = 4; +const DEFAULT_TTL_MS = 10 * 60 * 1000; +const MAX_LABEL_LENGTH = 80; +const VALID_CLIENT_KINDS = new Set(['mobile', 'desktop']); +const GENERIC_REDEEM_ERROR = 'Invalid or expired pairing session'; + +const normalizeOptionalString = (value) => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +// Placeholder shown in the pending-devices list when the operator did not type a +// name. It is a DISPLAY default only — the stored label stays null so redeem can +// fall back to the device's own reported name instead of this placeholder. +const PAIRING_LABEL_PLACEHOLDER = 'Pair new device'; + +// The operator's typed device label, capped. Returns null when unset so callers +// can distinguish "no name given" from a real name. +const normalizeStoredLabel = (value) => { + const normalized = normalizeOptionalString(value); + if (!normalized) return null; + return normalized.length > MAX_LABEL_LENGTH ? normalized.slice(0, MAX_LABEL_LENGTH) : normalized; +}; + +const normalizeTimestamp = (value) => { + const normalized = normalizeOptionalString(value); + if (!normalized) return null; + const time = Date.parse(normalized); + return Number.isFinite(time) ? new Date(time).toISOString() : null; +}; + +const normalizeClientKind = (value) => { + const normalized = normalizeOptionalString(value); + return normalized && VALID_CLIENT_KINDS.has(normalized) ? normalized : null; +}; + +const normalizeAllowedClientKinds = (value) => { + if (!Array.isArray(value)) return ['mobile', 'desktop']; + const kinds = value.map(normalizeClientKind).filter(Boolean); + return kinds.length > 0 ? Array.from(new Set(kinds)) : ['mobile', 'desktop']; +}; + +const safeJsonParse = (raw) => { + try { + return JSON.parse(raw); + } catch { + return null; + } +}; + +const constantTimeEqual = (left, right, crypto) => { + if (typeof left !== 'string' || typeof right !== 'string') return false; + const leftBuffer = Buffer.from(left, 'hex'); + const rightBuffer = Buffer.from(right, 'hex'); + if (leftBuffer.length !== rightBuffer.length) return false; + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +}; + +const publicSession = (session) => ({ + id: session.id, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + usedAt: session.usedAt, + cancelledAt: session.cancelledAt, + clientId: session.clientId, + label: session.label || PAIRING_LABEL_PLACEHOLDER, + fingerprint: session.fingerprint, + allowedClientKinds: session.allowedClientKinds, + createdByClientId: session.createdByClientId, + usesRelay: session.usesRelay === true, +}); + +// A pending session is one that can still be redeemed: not used, not cancelled, +// not expired. +const isPendingSession = (session) => !session.usedAt + && !session.cancelledAt + && Number.isFinite(Date.parse(session.expiresAt)) + && Date.parse(session.expiresAt) > Date.now(); + +const redeemError = () => { + const error = new Error(GENERIC_REDEEM_ERROR); + error.statusCode = 400; + return error; +}; + +export const createClientPairingRuntime = ({ + fsPromises, + path, + crypto, + storePath, + remoteClientAuthRuntime, + ttlMs = DEFAULT_TTL_MS, +} = {}) => { + if (!fsPromises || !path || !crypto || !storePath || !remoteClientAuthRuntime) { + throw new Error('createClientPairingRuntime requires fsPromises, path, crypto, storePath, and remoteClientAuthRuntime'); + } + + const nowIso = () => new Date().toISOString(); + const hashSecret = (secret) => crypto.createHash('sha256').update(secret).digest('hex'); + const generateId = () => `${PAIRING_ID_PREFIX}${crypto.randomBytes(12).toString('hex')}`; + const generateSecret = () => crypto.randomBytes(SECRET_BYTES).toString('base64url'); + const generateFingerprint = () => crypto.randomBytes(FINGERPRINT_BYTES).toString('hex').toUpperCase().replace(/^(.{4})(.{4})$/, '$1-$2'); + let storeMutationQueue = Promise.resolve(); + + const withStoreMutation = async (fn) => { + const previous = storeMutationQueue; + let release; + storeMutationQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await fn(); + } finally { + release(); + } + }; + + const normalizeStore = (payload) => ({ + version: STORE_VERSION, + sessions: Array.isArray(payload?.sessions) + ? payload.sessions + .filter((session) => session && typeof session === 'object') + .map((session) => ({ + id: typeof session.id === 'string' ? session.id : generateId(), + secretHash: typeof session.secretHash === 'string' ? session.secretHash : '', + createdAt: typeof session.createdAt === 'string' ? session.createdAt : nowIso(), + expiresAt: normalizeTimestamp(session.expiresAt) || new Date(Date.now() + ttlMs).toISOString(), + usedAt: normalizeTimestamp(session.usedAt), + cancelledAt: normalizeTimestamp(session.cancelledAt), + clientId: normalizeOptionalString(session.clientId), + label: normalizeStoredLabel(session.label), + fingerprint: normalizeOptionalString(session.fingerprint) || generateFingerprint(), + allowedClientKinds: normalizeAllowedClientKinds(session.allowedClientKinds), + createdByClientId: normalizeOptionalString(session.createdByClientId), + usesRelay: session.usesRelay === true, + })) + .filter((session) => session.secretHash.length > 0) + : [], + }); + + const readStore = async () => { + try { + const raw = await fsPromises.readFile(storePath, 'utf8'); + return normalizeStore(safeJsonParse(raw)); + } catch (error) { + if (error?.code === 'ENOENT') return normalizeStore(null); + throw error; + } + }; + + const writeStore = async (store) => { + await fsPromises.mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 }); + await fsPromises.writeFile(storePath, JSON.stringify(normalizeStore(store), null, 2), { mode: 0o600 }); + if (typeof fsPromises.chmod === 'function') { + await fsPromises.chmod(storePath, 0o600).catch(() => {}); + } + }; + + const sweepExpiredSessionsFromStore = (store) => { + const now = Date.now(); + const cutoff = now - ttlMs; + store.sessions = store.sessions.filter((session) => { + const usedAt = Date.parse(session.usedAt || ''); + const cancelledAt = Date.parse(session.cancelledAt || ''); + const inactiveAt = Number.isFinite(usedAt) ? usedAt : cancelledAt; + if (Number.isFinite(inactiveAt)) return inactiveAt >= cutoff; + // Never used or cancelled: drop once the session itself has expired — + // it can no longer be redeemed and would otherwise sit in the store forever. + const expiresAt = Date.parse(session.expiresAt || ''); + return !Number.isFinite(expiresAt) || expiresAt > now; + }); + }; + + const createPairingSession = async ({ label, allowedClientKinds, createdByClientId, usesRelay } = {}) => { + return withStoreMutation(async () => { + const store = await readStore(); + sweepExpiredSessionsFromStore(store); + const secret = generateSecret(); + const session = { + id: generateId(), + secretHash: hashSecret(secret), + createdAt: nowIso(), + expiresAt: new Date(Date.now() + ttlMs).toISOString(), + usedAt: null, + cancelledAt: null, + clientId: null, + label: normalizeStoredLabel(label), + fingerprint: generateFingerprint(), + allowedClientKinds: normalizeAllowedClientKinds(allowedClientKinds), + createdByClientId: normalizeOptionalString(createdByClientId), + usesRelay: usesRelay === true, + }; + store.sessions.push(session); + await writeStore(store); + return { pairing: { ...publicSession(session), secret } }; + }); + }; + + // Sessions that can still be redeemed (link created, device not yet connected). + const listPendingSessions = async () => withStoreMutation(async () => { + const store = await readStore(); + return store.sessions.filter(isPendingSession).map(publicSession); + }); + + // Relay-transport demand from pairing: any still-redeemable relay session. + const hasActiveRelaySession = async () => withStoreMutation(async () => { + const store = await readStore(); + return store.sessions.some((session) => session.usesRelay === true && isPendingSession(session)); + }); + + const getPairingSession = async (id) => { + const normalizedId = normalizeOptionalString(id); + if (!normalizedId) return null; + return withStoreMutation(async () => { + const store = await readStore(); + const session = store.sessions.find((entry) => entry.id === normalizedId); + return session ? publicSession(session) : null; + }); + }; + + const cancelPairingSession = async (id) => { + const normalizedId = normalizeOptionalString(id); + if (!normalizedId) return { cancelled: false }; + return withStoreMutation(async () => { + const store = await readStore(); + const session = store.sessions.find((entry) => entry.id === normalizedId); + if (!session) return { cancelled: false }; + if (!session.cancelledAt) session.cancelledAt = nowIso(); + await writeStore(store); + return { cancelled: true, pairing: publicSession(session) }; + }); + }; + + const redeemPairingSession = async ({ + pairingId, + secret, + clientLabel, + clientKind, + deviceName, + devicePlatform, + deviceModel, + appVersion, + dedupeKey, + } = {}) => { + const normalizedId = normalizeOptionalString(pairingId); + const normalizedSecret = normalizeOptionalString(secret); + const normalizedKind = normalizeClientKind(clientKind) || 'mobile'; + if (!normalizedId || !normalizedSecret) throw redeemError(); + + return withStoreMutation(async () => { + const store = await readStore(); + const session = store.sessions.find((entry) => entry.id === normalizedId); + if (!session) throw redeemError(); + if (session.cancelledAt || session.usedAt) throw redeemError(); + if (Date.parse(session.expiresAt) <= Date.now()) throw redeemError(); + if (!session.allowedClientKinds.includes(normalizedKind)) throw redeemError(); + if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError(); + + // The operator's typed pairing label is THIS server's name for the device + // (shown in the device list). It wins over the device's self-reported + // label; fall back to that only when no pairing label was set. + const label = normalizeOptionalString(session.label) + || normalizeOptionalString(clientLabel) + || normalizeOptionalString(deviceName) + || 'Remote client'; + const result = await remoteClientAuthRuntime.createClient({ + label, + clientKind: normalizedKind, + dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`, + authMethod: 'pairing', + pairingId: session.id, + deviceName, + devicePlatform, + deviceModel, + appVersion, + usesRelay: session.usesRelay === true, + }); + session.usedAt = nowIso(); + session.clientId = result.client?.id || null; + await writeStore(store); + return { pairing: publicSession(session), client: result.client, token: result.token }; + }); + }; + + const sweepExpiredSessions = async () => withStoreMutation(async () => { + const store = await readStore(); + const before = store.sessions.length; + sweepExpiredSessionsFromStore(store); + const purged = before - store.sessions.length; + if (purged > 0) await writeStore(store); + return { purged }; + }); + + return { + createPairingSession, + getPairingSession, + listPendingSessions, + hasActiveRelaySession, + cancelPairingSession, + redeemPairingSession, + sweepExpiredSessions, + }; +}; diff --git a/packages/web/server/lib/client-auth/pairing.test.js b/packages/web/server/lib/client-auth/pairing.test.js new file mode 100644 index 00000000..0b20aa38 --- /dev/null +++ b/packages/web/server/lib/client-auth/pairing.test.js @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +import { createClientPairingRuntime } from './pairing.js'; + +const makeRuntime = async (options = {}) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-pairing-test-')); + const createdClients = []; + const remoteClientAuthRuntime = options.remoteClientAuthRuntime || { + createClient: vi.fn(async (input) => { + const client = { + id: `client-${createdClients.length + 1}`, + label: input.label, + clientKind: input.clientKind, + authMethod: input.authMethod, + pairingId: input.pairingId, + deviceName: input.deviceName ?? null, + }; + createdClients.push(client); + return { client, token: `token-${createdClients.length}` }; + }), + }; + const runtime = createClientPairingRuntime({ + fsPromises: fs, + path, + crypto, + storePath: path.join(dir, 'pairing.json'), + remoteClientAuthRuntime, + ttlMs: options.ttlMs ?? 10 * 60 * 1000, + }); + return { dir, runtime, remoteClientAuthRuntime, createdClients }; +}; + +describe('client auth pairing runtime', () => { + it('redeems a pairing session once and propagates client metadata', async () => { + const { runtime, remoteClientAuthRuntime } = await makeRuntime(); + const created = await runtime.createPairingSession({ allowedClientKinds: ['mobile'] }); + + const result = await runtime.redeemPairingSession({ + pairingId: created.pairing.id, + secret: created.pairing.secret, + clientLabel: 'Iryna iPhone', + clientKind: 'mobile', + deviceName: 'Iryna iPhone', + dedupeKey: 'device-key', + }); + + expect(result.token).toBe('token-1'); + expect(result.client).toMatchObject({ + label: 'Iryna iPhone', + clientKind: 'mobile', + authMethod: 'pairing', + pairingId: created.pairing.id, + deviceName: 'Iryna iPhone', + }); + expect(remoteClientAuthRuntime.createClient).toHaveBeenCalledWith(expect.objectContaining({ + authMethod: 'pairing', + pairingId: created.pairing.id, + clientKind: 'mobile', + dedupeKey: 'device-key', + })); + + await expect(runtime.redeemPairingSession({ + pairingId: created.pairing.id, + secret: created.pairing.secret, + clientKind: 'mobile', + })).rejects.toThrow('Invalid or expired pairing session'); + }); + + it('rejects expired, cancelled, wrong-secret, and disallowed-kind redemption', async () => { + const { runtime: expiredRuntime } = await makeRuntime({ ttlMs: -1000 }); + const expired = await expiredRuntime.createPairingSession(); + await expect(expiredRuntime.redeemPairingSession({ + pairingId: expired.pairing.id, + secret: expired.pairing.secret, + clientKind: 'mobile', + })).rejects.toThrow('Invalid or expired pairing session'); + + const { runtime } = await makeRuntime(); + const cancelled = await runtime.createPairingSession(); + await runtime.cancelPairingSession(cancelled.pairing.id); + await expect(runtime.redeemPairingSession({ + pairingId: cancelled.pairing.id, + secret: cancelled.pairing.secret, + clientKind: 'mobile', + })).rejects.toThrow('Invalid or expired pairing session'); + + const wrongSecret = await runtime.createPairingSession(); + await expect(runtime.redeemPairingSession({ + pairingId: wrongSecret.pairing.id, + secret: 'wrong', + clientKind: 'mobile', + })).rejects.toThrow('Invalid or expired pairing session'); + + const desktopOnly = await runtime.createPairingSession({ allowedClientKinds: ['desktop'] }); + await expect(runtime.redeemPairingSession({ + pairingId: desktopOnly.pairing.id, + secret: desktopOnly.pairing.secret, + clientKind: 'mobile', + })).rejects.toThrow('Invalid or expired pairing session'); + }); + + it('does not consume the pairing session if client issuance fails', async () => { + const createClient = vi.fn() + .mockRejectedValueOnce(new Error('disk failed')) + .mockResolvedValueOnce({ client: { id: 'client-1' }, token: 'token-1' }); + const { runtime } = await makeRuntime({ remoteClientAuthRuntime: { createClient } }); + const created = await runtime.createPairingSession(); + + await expect(runtime.redeemPairingSession({ + pairingId: created.pairing.id, + secret: created.pairing.secret, + clientKind: 'mobile', + })).rejects.toThrow('disk failed'); + + await expect(runtime.redeemPairingSession({ + pairingId: created.pairing.id, + secret: created.pairing.secret, + clientKind: 'mobile', + })).resolves.toMatchObject({ token: 'token-1' }); + expect(createClient).toHaveBeenLastCalledWith(expect.objectContaining({ + dedupeKey: `pairing:${created.pairing.id}`, + })); + }); + + it('sweeps expired never-used sessions from the store on the next create', async () => { + const { dir, runtime } = await makeRuntime({ ttlMs: -1000 }); + // Immediately expired (negative TTL), never used or cancelled. + const expired = await runtime.createPairingSession({ label: 'stale' }); + + // The next create sweeps the store; only the fresh session should remain. + const storePath = path.join(dir, 'pairing.json'); + await runtime.createPairingSession({ label: 'fresh' }); + const store = JSON.parse(await fs.readFile(storePath, 'utf8')); + const ids = store.sessions.map((session) => session.id); + expect(ids).not.toContain(expired.pairing.id); + expect(ids).toHaveLength(1); + }); +}); diff --git a/packages/web/server/lib/client-auth/remote-clients.js b/packages/web/server/lib/client-auth/remote-clients.js index e16f391b..dcb26751 100644 --- a/packages/web/server/lib/client-auth/remote-clients.js +++ b/packages/web/server/lib/client-auth/remote-clients.js @@ -25,6 +25,15 @@ const normalizeOptionalString = (value) => { return trimmed.length > 0 ? trimmed : null; }; +const normalizeMetadata = (client) => ({ + authMethod: normalizeOptionalString(client.authMethod), + pairingId: normalizeOptionalString(client.pairingId), + deviceName: normalizeOptionalString(client.deviceName), + devicePlatform: normalizeOptionalString(client.devicePlatform), + deviceModel: normalizeOptionalString(client.deviceModel), + appVersion: normalizeOptionalString(client.appVersion), +}); + const safeJsonParse = (raw) => { try { return JSON.parse(raw); @@ -77,6 +86,9 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP expiresAt: normalizeTimestamp(client.expiresAt), clientKind: normalizeOptionalString(client.clientKind), dedupeKey: normalizeOptionalString(client.dedupeKey), + usesRelay: client.usesRelay === true, + lastTransport: client.lastTransport === 'relay' || client.lastTransport === 'direct' ? client.lastTransport : null, + ...normalizeMetadata(client), })) .filter((client) => client.tokenHash.length > 0) : [], @@ -108,6 +120,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP revokedAt: client.revokedAt, expiresAt: client.expiresAt, clientKind: client.clientKind, + authMethod: client.authMethod, + pairingId: client.pairingId, + deviceName: client.deviceName, + devicePlatform: client.devicePlatform, + deviceModel: client.deviceModel, + appVersion: client.appVersion, + usesRelay: client.usesRelay === true, + lastTransport: client.lastTransport ?? null, }); const listClients = async () => { @@ -117,7 +137,34 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP }); }; - const createClient = async ({ label, expiresAt, clientKind, dedupeKey } = {}) => { + // Relay-transport demand from paired devices: any non-revoked, non-expired + // client that was paired over the relay. + const hasActiveRelayClients = async () => { + return withStoreMutation(async () => { + const store = await readStore(); + const now = Date.now(); + return store.clients.some((client) => { + if (client.usesRelay !== true) return false; + if (client.revokedAt) return false; + const expires = Date.parse(client.expiresAt || ''); + return !Number.isFinite(expires) || expires > now; + }); + }); + }; + + const createClient = async ({ + label, + expiresAt, + clientKind, + dedupeKey, + authMethod, + pairingId, + deviceName, + devicePlatform, + deviceModel, + appVersion, + usesRelay, + } = {}) => { return withStoreMutation(async () => { const store = await readStore(); const normalizedDedupeKey = normalizeOptionalString(dedupeKey); @@ -132,6 +179,13 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP expiresAt: normalizeTimestamp(expiresAt), clientKind: normalizeOptionalString(clientKind), dedupeKey: normalizedDedupeKey, + authMethod: normalizeOptionalString(authMethod), + pairingId: normalizeOptionalString(pairingId), + deviceName: normalizeOptionalString(deviceName), + devicePlatform: normalizeOptionalString(devicePlatform), + deviceModel: normalizeOptionalString(deviceModel), + appVersion: normalizeOptionalString(appVersion), + usesRelay: usesRelay === true, }; if (normalizedDedupeKey) { store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey); @@ -177,10 +231,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP }); }; - const authenticateBearerToken = async (token) => { + const authenticateBearerToken = async (token, req) => { if (typeof token !== 'string' || !token.startsWith(TOKEN_PREFIX)) { return null; } + // Which transport carried this request: the relay tunnel proxy stamps every + // forwarded request with x-openchamber-relay-connection; anything else is a + // direct (local/LAN/tunnel-URL) request. Display-only device metadata. + const transport = req?.headers?.['x-openchamber-relay-connection'] ? 'relay' : 'direct'; return withStoreMutation(async () => { const tokenHash = hashToken(token); const store = await readStore(); @@ -189,8 +247,11 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null; const now = Date.now(); const lastUsedAt = Date.parse(client.lastUsedAt || ''); - if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS) { + // Write on the throttle interval — or immediately when the transport + // changed, so a LAN⇄relay switch is visible right away, not a minute late. + if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS || client.lastTransport !== transport) { client.lastUsedAt = new Date(now).toISOString(); + client.lastTransport = transport; await writeStore(store); } return { ok: true, clientId: client.id, sessionToken: client.id, client: publicClient(client) }; @@ -201,6 +262,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP authenticateBearerToken, createClient, listClients, + hasActiveRelayClients, purgeRevokedClients, revokeClient, }; diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 49d43e98..9e11f171 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -22,6 +22,11 @@ export const createBootstrapRuntime = (dependencies) => { uiPassword, tunnelAuthController, remoteClientAuthRuntime, + clientPairingRuntime, + getRelayPairingCandidate, + reconcileRelay, + getPairingTransports, + getServerLabel, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, sayTTSCapability, @@ -82,6 +87,11 @@ export const createBootstrapRuntime = (dependencies) => { tunnelAuthController, uiAuthController, remoteClientAuthRuntime, + clientPairingRuntime, + getRelayPairingCandidate, + reconcileRelay, + getPairingTransports, + getServerLabel, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, }); diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 1c203ed1..d6ee822b 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -358,9 +358,26 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { tunnelAuthController, uiAuthController, remoteClientAuthRuntime, + clientPairingRuntime, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, + // Returns the relay pairing candidate ({ type:'relay', relayUrl, serverId, + // hostEncPubJwk, priority }) when the host relay is enabled, else null. + // Injected lazily because the relay service is constructed after these routes. + getRelayPairingCandidate = async () => null, + // Re-evaluate the relay lifecycle after pairing/device changes. + reconcileRelay = async () => {}, + // Returns { local, lan, relayAvailable } — the direct transport URLs the + // server can actually be reached on (LAN derived from the server bind, not + // the UI origin), for the create-device dialog. + getPairingTransports = () => ({ local: null, lan: null, relayAvailable: true }), + // Display name a paired device shows for THIS server (issuing machine's + // hostname), distinct from the per-device pairing label typed by the operator. + getServerLabel = () => 'OpenChamber', } = dependencies; + const PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; + const PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS = 10; + const pairingRedeemAttempts = new Map(); const runWithUiAuth = async (req, res, next, handler, options = {}) => { try { @@ -440,6 +457,112 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { return clients.find((client) => client.id === clientId) || null; }; + const requestOrigin = (req) => { + const forwardedProto = typeof req.headers?.['x-forwarded-proto'] === 'string' + ? req.headers['x-forwarded-proto'].split(',')[0].trim() + : ''; + const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http'); + const host = typeof req.headers?.host === 'string' ? req.headers.host.trim() : ''; + if (!host) return null; + return `${protocol}://${host}`; + }; + + const requestIp = (req) => { + // Do not use req.ip here: Express rewrites it from X-Forwarded-For when + // trust proxy is enabled, and redeem is unauthenticated before this limit. + return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown'; + }; + + const pairingIdFromRequest = (req) => { + const raw = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : ''; + return raw || 'missing'; + }; + + const checkPairingRedeemRateLimit = (req) => { + const now = Date.now(); + const key = `${requestIp(req)}:${pairingIdFromRequest(req)}`; + for (const [entryKey, entry] of pairingRedeemAttempts.entries()) { + if (!entry || now - entry.firstAttemptAt >= PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) { + pairingRedeemAttempts.delete(entryKey); + } + } + const entry = pairingRedeemAttempts.get(key); + if (!entry) { + pairingRedeemAttempts.set(key, { count: 1, firstAttemptAt: now }); + return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - 1, reset: Math.ceil((now + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000) }; + } + const reset = Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000); + if (entry.count >= PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS) { + return { + allowed: false, + remaining: 0, + reset, + retryAfter: Math.max(1, Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS - now) / 1000)), + }; + } + entry.count += 1; + return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - entry.count, reset }; + }; + + const clearPairingRedeemRateLimit = (req) => { + pairingRedeemAttempts.delete(`${requestIp(req)}:${pairingIdFromRequest(req)}`); + }; + + const normalizeCandidateUrl = (value) => { + if (typeof value !== 'string' || !value.trim()) return null; + try { + const parsed = new URL(value.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + parsed.hash = ''; + parsed.search = ''; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return null; + } + }; + + // `preferredServerUrl` is the caller-supplied externally reachable URL (the + // desktop UI reaches its own server over loopback, so the request origin is not + // scannable — it passes the LAN URL instead). Falls back to the request origin + // for remote callers where the Host header IS the reachable address. + // + // `includeRelay` is the per-link transport choice from the create-link dialog: + // true → add the relay candidate, enabling the relay host on demand; + // false → direct only, never relay; + // undefined → legacy: advertise relay only if it is already enabled. + // `includeDirect === false` produces a relay-only link (no direct candidate). + const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => { + const candidates = []; + if (includeDirect) { + const direct = normalizeCandidateUrl(preferredServerUrl) || requestOrigin(req); + if (direct) { + let type = 'lan'; + try { + const parsed = new URL(direct); + type = parsed.protocol === 'https:' ? 'tunnel' : 'lan'; + } catch { + } + candidates.push({ type, url: direct, priority: 10 }); + } + } + // The client races candidates and falls back to relay only if the direct URL + // is unreachable (relay carries a higher priority number). + if (includeRelay !== false) { + try { + const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: includeRelay === true }); + if (relayCandidate) candidates.push(relayCandidate); + } catch { + // A relay enable/status failure must not break direct pairing. + } + } + return candidates; + }; + + const sendPairingRedeemError = (res, error) => { + const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400; + res.status(statusCode).json({ error: 'Invalid or expired pairing session' }); + }; + const requireApiAuth = async (req, res, next) => { // Preview proxy requests carry a target-scoped capability token that the // preview proxy validates against the registered target id/TTL. Let those @@ -588,7 +711,12 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { await runWithClientManagementAuth(req, res, next, async (authContext) => { if (authContext.type === 'client') { const client = await clientRecordFromAuthContext(authContext); - return res.json({ clients: client ? [client] : [] }); + // The desktop shell's local client is the trusted operator of this + // server; it manages devices just like a browser UI session. Every + // other client token is scoped to its own record. + if (client?.clientKind !== 'desktop-local') { + return res.json({ clients: client ? [client] : [] }); + } } const clients = await remoteClientAuthRuntime.listClients(); res.json({ clients }); @@ -610,24 +738,136 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { app.delete('/api/client-auth/clients/:id', async (req, res, next) => { await runWithClientManagementAuth(req, res, next, async (authContext) => { if (authContext.type === 'client') { - const clientId = clientIdFromAuthContext(authContext); - if (!clientId || clientId !== req.params?.id) { - return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' }); + const actingClient = await clientRecordFromAuthContext(authContext); + // The desktop shell's local client manages every device; other client + // tokens may only revoke themselves. + if (actingClient?.clientKind !== 'desktop-local') { + const clientId = clientIdFromAuthContext(authContext); + if (!clientId || clientId !== req.params?.id) { + return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' }); + } } } const result = await remoteClientAuthRuntime.revokeClient(req.params?.id); if (!result.revoked) { return res.status(404).json({ revoked: false, error: 'Client not found' }); } + void reconcileRelay(); res.json(result); }); }); app.delete('/api/client-auth/clients', async (req, res, next) => { - await runWithUiAuth(req, res, next, async () => { + await runWithClientManagementAuth(req, res, next, async (authContext) => { + if (authContext.type === 'client') { + const actingClient = await clientRecordFromAuthContext(authContext); + // Purging revoked devices is a whole-server management action; only the + // trusted desktop shell client (or a UI session) may do it. + if (actingClient?.clientKind !== 'desktop-local') { + return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' }); + } + } const result = await remoteClientAuthRuntime.purgeRevokedClients(); + void reconcileRelay(); res.json(result); - }, { sessionOnly: true }); + }); + }); + + app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => { + await runWithClientCreateAuth(req, res, next, async (authContext) => { + const candidates = await pairingServerCandidates(req, { + preferredServerUrl: req.body?.serverUrl, + includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined, + includeDirect: req.body?.includeDirect !== false, + }); + const usesRelay = candidates.some((candidate) => candidate.type === 'relay'); + const result = await clientPairingRuntime.createPairingSession({ + label: req.body?.label, + allowedClientKinds: req.body?.allowedClientKinds, + createdByClientId: clientIdFromAuthContext(authContext), + usesRelay, + }); + void reconcileRelay(); + res.setHeader('Cache-Control', 'no-store'); + res.status(201).json({ + ...result, + server: { label: getServerLabel(), candidates }, + }); + }); + }); + + // Direct transports the server can be reached on (for the create-device dialog). + app.get('/api/client-auth/pairing/transports', async (req, res, next) => { + await runWithClientCreateAuth(req, res, next, async () => { + res.setHeader('Cache-Control', 'no-store'); + res.json(getPairingTransports()); + }); + }); + + // Pending pairing sessions (link created, device not yet connected) for the + // "pending devices" list. Secrets are never included. + app.get('/api/client-auth/pairing/sessions', async (req, res, next) => { + await runWithClientCreateAuth(req, res, next, async () => { + const pending = await clientPairingRuntime.listPendingSessions(); + res.setHeader('Cache-Control', 'no-store'); + res.json({ pending }); + }); + }); + + app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => { + await runWithClientCreateAuth(req, res, next, async () => { + const result = await clientPairingRuntime.cancelPairingSession(req.params?.id); + if (!result.cancelled) { + return res.status(404).json({ cancelled: false, error: 'Pairing session not found' }); + } + void reconcileRelay(); + res.json(result); + }); + }); + + app.post('/api/client-auth/pairing/redeem', express.json({ limit: '64kb' }), async (req, res, next) => { + try { + const rateLimit = checkPairingRedeemRateLimit(req); + res.setHeader('X-RateLimit-Limit', PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS); + res.setHeader('X-RateLimit-Remaining', rateLimit.remaining); + res.setHeader('X-RateLimit-Reset', rateLimit.reset); + if (!rateLimit.allowed) { + res.setHeader('Retry-After', rateLimit.retryAfter); + return res.status(429).json({ error: 'Invalid or expired pairing session' }); + } + const result = await clientPairingRuntime.redeemPairingSession({ + pairingId: req.body?.pairingId, + secret: req.body?.secret, + clientLabel: req.body?.clientLabel, + clientKind: req.body?.clientKind, + deviceName: req.body?.deviceName, + devicePlatform: req.body?.devicePlatform, + deviceModel: req.body?.deviceModel, + appVersion: req.body?.appVersion, + dedupeKey: req.body?.dedupeKey, + }); + clearPairingRedeemRateLimit(req); + // The session became a device: relay demand may have moved from the pending + // session to the paired device (or a non-relay redeem may drop it). + void reconcileRelay(); + res.setHeader('Cache-Control', 'no-store'); + res.json({ + ok: true, + server: { + label: getServerLabel(), + url: requestOrigin(req), + fingerprint: result.pairing?.fingerprint || null, + }, + client: result.client, + clientToken: result.token, + }); + } catch (error) { + if (error?.message === 'Invalid or expired pairing session') { + sendPairingRedeemError(res, error); + return; + } + next(error); + } }); app.get('/connect', async (req, res) => { diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index 384f7f03..84986868 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -1,9 +1,13 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import express from 'express'; import request from 'supertest'; import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js'; describe('core-routes', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => { const app = express(); let shutdownOpts = null; @@ -225,6 +229,206 @@ describe('core-routes', () => { } }); + const createPairingRouteApp = (overrides = {}) => { + const app = express(); + const dependencies = { + express, + tunnelAuthController: { + classifyRequestScope: () => 'local', + requireTunnelSession: vi.fn(), + getTunnelSessionFromRequest: vi.fn(), + clearTunnelSessionCookie: vi.fn(), + exchangeBootstrapToken: vi.fn(), + }, + uiAuthController: { + resolveAuthContext: vi.fn(async () => ({ type: 'session', token: 'session-token' })), + requireAuth: vi.fn((_req, _res, next) => next()), + requireSessionAuth: vi.fn((_req, _res, next) => next()), + handleSessionStatus: vi.fn(), + handleSessionCreate: vi.fn(), + handleUrlAuthToken: vi.fn(), + handlePasskeyStatus: vi.fn(), + handlePasskeyAuthenticationOptions: vi.fn(), + handlePasskeyAuthenticationVerify: vi.fn(), + handlePasskeyRegistrationOptions: vi.fn(), + handlePasskeyRegistrationVerify: vi.fn(), + handlePasskeyList: vi.fn(), + handlePasskeyRevoke: vi.fn(), + handleResetAuth: vi.fn(), + }, + remoteClientAuthRuntime: { + listClients: vi.fn(async () => []), + createClient: vi.fn(), + revokeClient: vi.fn(), + purgeRevokedClients: vi.fn(), + }, + clientPairingRuntime: { + createPairingSession: vi.fn(async () => ({ pairing: { id: 'pair_1', secret: 'secret', expiresAt: '2099-01-01T00:00:00.000Z', fingerprint: 'ABCD-1234' } })), + cancelPairingSession: vi.fn(async () => ({ cancelled: true })), + redeemPairingSession: vi.fn(async () => ({ + pairing: { fingerprint: 'ABCD-1234' }, + client: { id: 'client-1', label: 'Phone', authMethod: 'pairing' }, + token: 'oc_client_token', + })), + }, + readSettingsFromDiskMigrated: vi.fn(async () => ({})), + normalizeTunnelSessionTtlMs: vi.fn(), + ...overrides, + }; + registerAuthAndAccessRoutes(app, dependencies); + return { app, dependencies }; + }; + + it('creates pairing sessions behind owner auth and returns no-store payload data', async () => { + const { app, dependencies } = createPairingRouteApp(); + + const response = await request(app) + .post('/api/client-auth/pairing/sessions') + .set('Host', 'runtime.example') + .send({ label: 'Pair phone', allowedClientKinds: ['mobile'] }) + .expect(201); + + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.body.pairing).toMatchObject({ id: 'pair_1', secret: 'secret' }); + expect(response.body.server.candidates).toEqual([{ type: 'lan', url: 'http://runtime.example', priority: 10 }]); + expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith({ + label: 'Pair phone', + allowedClientKinds: ['mobile'], + createdByClientId: null, + usesRelay: false, + }); + }); + + it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => { + const { app } = createPairingRouteApp(); + + const response = await request(app) + .post('/api/client-auth/pairing/sessions') + .set('Host', 'runtime.example') + .send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' }) + .expect(201); + + expect(response.body.server.candidates).toEqual([ + { type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 }, + ]); + }); + + it('folds in a relay candidate when the host relay is enabled', async () => { + const relayCandidate = { + type: 'relay', + relayUrl: 'wss://relay.example/ws', + serverId: 'srv_1', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' }, + priority: 30, + }; + const { app } = createPairingRouteApp({ getRelayPairingCandidate: vi.fn(async () => relayCandidate) }); + + const response = await request(app) + .post('/api/client-auth/pairing/sessions') + .set('Host', 'runtime.example') + .send({ label: 'Pair phone' }) + .expect(201); + + expect(response.body.server.candidates).toEqual([ + { type: 'lan', url: 'http://runtime.example', priority: 10 }, + relayCandidate, + ]); + }); + + it('still returns the direct candidate when the relay candidate lookup throws', async () => { + const { app } = createPairingRouteApp({ + getRelayPairingCandidate: vi.fn(async () => { throw new Error('relay status read failed'); }), + }); + + const response = await request(app) + .post('/api/client-auth/pairing/sessions') + .set('Host', 'runtime.example') + .send({ label: 'Pair phone' }) + .expect(201); + + expect(response.body.server.candidates).toEqual([{ type: 'lan', url: 'http://runtime.example', priority: 10 }]); + }); + + it('requires owner auth before creating or cancelling pairing sessions', async () => { + const { app, dependencies } = createPairingRouteApp({ + uiAuthController: { + resolveAuthContext: vi.fn(async () => null), + requireAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })), + requireSessionAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })), + }, + }); + + await request(app).post('/api/client-auth/pairing/sessions').send({}).expect(401); + await request(app).delete('/api/client-auth/pairing/sessions/pair_1').expect(401); + expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled(); + expect(dependencies.clientPairingRuntime.cancelPairingSession).not.toHaveBeenCalled(); + }); + + it('redeems pairing sessions with no-store response and generic errors', async () => { + const { app, dependencies } = createPairingRouteApp(); + + const response = await request(app) + .post('/api/client-auth/pairing/redeem') + .set('Host', 'runtime.example') + .send({ pairingId: 'pair_1', secret: 'secret', clientKind: 'mobile', deviceName: 'Phone' }) + .expect(200); + + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.body).toMatchObject({ + ok: true, + server: { label: 'OpenChamber', url: 'http://runtime.example', fingerprint: 'ABCD-1234' }, + client: { id: 'client-1', authMethod: 'pairing' }, + clientToken: 'oc_client_token', + }); + expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledWith(expect.objectContaining({ + pairingId: 'pair_1', + secret: 'secret', + clientKind: 'mobile', + deviceName: 'Phone', + })); + + dependencies.clientPairingRuntime.redeemPairingSession.mockRejectedValueOnce(new Error('Invalid or expired pairing session')); + await request(app) + .post('/api/client-auth/pairing/redeem') + .send({ pairingId: 'pair_2', secret: 'wrong' }) + .expect(400, { error: 'Invalid or expired pairing session' }); + }); + + it('rate limits pairing redeem attempts by socket address and pairingId, then resets after the window', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const { app, dependencies } = createPairingRouteApp(); + app.set('trust proxy', true); + dependencies.clientPairingRuntime.redeemPairingSession.mockRejectedValue(new Error('Invalid or expired pairing session')); + + // The X-Forwarded-For headers below are deliberate spoof attempts: the rate + // limiter buckets by socket address (not forwarded headers), so rotating the + // header must NOT reset the counter or evade the lockout. + for (let index = 0; index < 10; index += 1) { + await request(app) + .post('/api/client-auth/pairing/redeem') + .set('X-Forwarded-For', `203.0.113.${index}`) + .send({ pairingId: 'pair_rate', secret: `wrong-${index}` }) + .expect(400, { error: 'Invalid or expired pairing session' }); + } + + const locked = await request(app) + .post('/api/client-auth/pairing/redeem') + .set('X-Forwarded-For', '203.0.113.10') + .send({ pairingId: 'pair_rate', secret: 'wrong-locked' }) + .expect(429, { error: 'Invalid or expired pairing session' }); + expect(locked.headers['retry-after']).toBe('300'); + expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(10); + + vi.setSystemTime(new Date('2026-01-01T00:05:01Z')); + await request(app) + .post('/api/client-auth/pairing/redeem') + .set('X-Forwarded-For', '203.0.113.10') + .send({ pairingId: 'pair_rate', secret: 'wrong-after-reset' }) + .expect(400, { error: 'Invalid or expired pairing session' }); + expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(11); + }); + it('should let preview proxy credentials reach preview proxy validation', async () => { const app = express(); const requireAuth = vi.fn((_req, res) => res.status(401).type('text/plain').send('Authentication required')); @@ -364,11 +568,9 @@ describe('client auth routes', () => { const listedAfterPurge = await request(app).get('/api/client-auth/clients'); expect(listedAfterPurge.body.clients).toHaveLength(0); - expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalled(); - expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled(); }); - it('allows client credentials to list and revoke only the authenticated client', async () => { + it('scopes non-desktop client credentials to list and revoke only themselves', async () => { const app = express(); let authContext = { type: 'session' }; const dependencies = createDependencies({ @@ -383,20 +585,57 @@ describe('client auth routes', () => { .post('/api/client-auth/clients') .send({ label: 'Other device' }); - authContext = { type: 'client', clientId: current.body.client.id, client: current.body.client }; + // A regular (non-desktop-local) client token only sees and manages itself. + authContext = { type: 'client', clientId: other.body.client.id, client: other.body.client }; const listed = await request(app).get('/api/client-auth/clients'); expect(listed.status).toBe(200); - expect(listed.body.clients).toEqual([current.body.client]); + expect(listed.body.clients).toEqual([other.body.client]); - const denied = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`); + const denied = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`); expect(denied.status).toBe(403); expect(denied.body.revoked).toBe(false); - const revoked = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`); + const deniedPurge = await request(app).delete('/api/client-auth/clients'); + expect(deniedPurge.status).toBe(403); + + const revoked = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`); expect(revoked.status).toBe(200); expect(revoked.body.revoked).toBe(true); - expect(revoked.body.client.id).toBe(current.body.client.id); + expect(revoked.body.client.id).toBe(other.body.client.id); + }); + + it('lets the local desktop client list and revoke every device', async () => { + const app = express(); + let authContext = { type: 'session' }; + const dependencies = createDependencies({ + resolveAuthContext: async () => authContext, + }); + registerAuthAndAccessRoutes(app, dependencies); + + const desktop = await request(app) + .post('/api/client-auth/clients') + .send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' }); + const other = await request(app) + .post('/api/client-auth/clients') + .send({ label: 'Other device' }); + + // The trusted desktop shell client manages all devices like a UI session. + authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client }; + + const listed = await request(app).get('/api/client-auth/clients'); + expect(listed.status).toBe(200); + const listedIds = listed.body.clients.map((client) => client.id).sort(); + expect(listedIds).toEqual([desktop.body.client.id, other.body.client.id].sort()); + + const revoked = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`); + expect(revoked.status).toBe(200); + expect(revoked.body.revoked).toBe(true); + expect(revoked.body.client.id).toBe(other.body.client.id); + + const purged = await request(app).delete('/api/client-auth/clients'); + expect(purged.status).toBe(200); + expect(purged.body.purged).toBe(1); }); it('allows only the local desktop client token to create remote client tokens', async () => { diff --git a/packages/web/server/lib/relay/DOCUMENTATION.md b/packages/web/server/lib/relay/DOCUMENTATION.md index e4f56f8c..e4e5979b 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1; ## Entrypoints and structure Host side (`packages/web/server/lib/relay/`): -- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable,offer}`), 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 via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing offer, and status, so paired clients inherit the endpoint automatically from the offer. +- `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 via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. - `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. - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime. - `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. @@ -32,7 +32,8 @@ Client side (`packages/ui/src/lib/relay/`): - `tunnel-codec.ts` — Layer 3 frame codec, fragmentation, and outbound frame batching. - `tunnel-client.ts` — the client tunnel: exposes a `fetch()`-compatible and a WebSocket-compatible surface backed by the encrypted tunnel. - `tunnel-payloads.ts`, `runtime-tunnel.ts`, `runtime-socket.ts` — payload helpers, the active-tunnel singleton, and the shared "open a runtime WebSocket the right way" helper. -- `offer.ts` — the pairing payload builder/parser (secrets travel in URL fragments only). + +Relay is not a separate link format: it is one transport candidate inside the unified **pairing v2** payload (`packages/ui/src/lib/connectionPayload.ts`). A relay candidate is `{ type: 'relay', relayUrl, serverId, hostEncPubJwk }` — no embedded token; the client redeems the one-time pairing secret over the tunnel like any other candidate. ## What travels the tunnel @@ -52,7 +53,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one ## End-to-end flow (overview) -1. **Pairing.** The host builds an offer describing the relay endpoint, its routing id, and its encryption public key, rendered as a QR code / deep link. Secrets are carried in the URL fragment so they never reach any server. The client imports it and stores the connection. +1. **Pairing.** The host issues a pairing-v2 link (QR / deep link) carrying a one-time secret and a list of transport candidates. When the relay is enabled, one candidate is the relay transport (its endpoint, routing id, and encryption public key — the E2EE trust anchor). The client redeems the secret over the first reachable candidate; over the relay candidate it opens the E2EE tunnel first, then redeems through it, and stores the connection. 2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits. 3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection. 4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read. diff --git a/packages/web/server/lib/relay/host-client.js b/packages/web/server/lib/relay/host-client.js index 0817cb62..52c2a1a9 100644 --- a/packages/web/server/lib/relay/host-client.js +++ b/packages/web/server/lib/relay/host-client.js @@ -12,6 +12,14 @@ import { createTunnelHost } from './tunnel-host.js'; const BACKOFF_BASE_MS = 1000; const BACKOFF_CAP_MS = 30000; const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000; +// Clients send a tunnel Ping at least every ~30s when idle, so a data socket +// with no inbound traffic for 3 ping intervals belongs to a client that died +// without a WebSocket close (network loss, battery kill). The relay worker may +// not notice the dead client leg for a long time, so the host must reap these +// itself — both to free resources and to keep the "N devices connected" status +// honest instead of counting ghosts. +const DATA_SOCKET_IDLE_TIMEOUT_MS = 90_000; +const DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS = 30_000; const DEFAULT_BATCH_WINDOW_MS = 150; // Resolve the frame-batching flush window: explicit option wins, then env, then @@ -103,7 +111,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on return; } - const entry = { socket, tunnel: null, openTimer: null, batcher: null }; + const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() }; dataSockets.set(connectionId, entry); entry.openTimer = setTimeout(() => { logger.warn('[Relay] host-data socket open timeout'); @@ -141,6 +149,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on const handleMessage = async (data, isBinary) => { const current = dataSockets.get(connectionId); if (current !== entry) return; + // Any inbound message (including the client's keepalive Ping) proves the + // client is alive; the idle sweeper reaps sockets this stops updating. + entry.lastActivityAt = Date.now(); if (!isBinary) { const action = await handshake.handleText(data.toString('utf8')); @@ -298,9 +309,22 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on }); }; + // Reap data sockets whose client went silent (no frames, no keepalive pings) + // — a dead phone leg the relay worker hasn't noticed yet. + const idleSweepTimer = setInterval(() => { + const now = Date.now(); + for (const [connectionId, entry] of [...dataSockets.entries()]) { + if (now - entry.lastActivityAt <= DATA_SOCKET_IDLE_TIMEOUT_MS) continue; + logger.info(`[Relay] reaping idle data socket connectionId=${connectionId}`); + teardownDataSocket(connectionId, 1001, 'client idle timeout'); + } + }, DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS); + if (typeof idleSweepTimer.unref === 'function') idleSweepTimer.unref(); + const stop = () => { if (stopped) return; stopped = true; + clearInterval(idleSweepTimer); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; diff --git a/packages/web/server/lib/relay/service.js b/packages/web/server/lib/relay/service.js index 07c940ed..0967275a 100644 --- a/packages/web/server/lib/relay/service.js +++ b/packages/web/server/lib/relay/service.js @@ -15,7 +15,6 @@ import express from 'express'; import { createRelayIdentityRuntime } from './identity.js'; import { startRelayHost } from './host-client.js'; -import { bytesToBase64Url } from './e2ee.js'; export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws'; @@ -49,21 +48,20 @@ const envRelayUrlOverride = () => { /** * @param {{ * crypto: typeof import('node:crypto'), - * os: typeof import('node:os'), * readSettingsFromDiskMigrated: () => Promise, * writeSettingsToDisk: (settings: object) => Promise, - * remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> }, * getLocalPort: () => number, * logger?: Pick, * }} deps */ export const createRelayService = ({ crypto, - os, readSettingsFromDiskMigrated, writeSettingsToDisk, - remoteClientAuthRuntime, getLocalPort, + // Returns true when any paired device or pending pairing session uses the + // relay transport. The relay lifecycle is driven purely by this demand. + hasRelayDemand = async () => false, logger = console, }) => { const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk }); @@ -125,6 +123,28 @@ export const createRelayService = ({ } }; + // Drive the relay lifecycle from demand: run it when a device or pending + // session uses the relay, stop it when none remain. Called on startup and after + // pairing/device changes, so the operator never toggles it manually. + const reconcile = async () => { + try { + const demand = await hasRelayDemand(); + const config = await readConfig(); + if (demand) { + if (!config.enabled) await writeConfig({ enabled: true, relayUrl: config.relayUrl }); + if (!hostClient) { + const next = await readConfig(); + await start(next.relayUrl); + } + } else { + if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl }); + stop(); + } + } catch (error) { + logger.warn(`[Relay] reconcile failed: ${error?.message ?? error}`); + } + }; + const getStatus = async () => { const config = await readConfig(); const identity = await identityRuntime.getRelayIdentity(); @@ -140,29 +160,44 @@ export const createRelayService = ({ }; }; - const buildOffer = async ({ includeToken = false, clientLabel } = {}) => { + // Pairing candidate for the unified connection payload (pairing v2). Relay is + // just another transport: it carries the relay route + E2EE trust anchor, no + // embedded token — the client redeems the one-time pairing secret over the + // tunnel like any other candidate. Returns null when the host relay is off, so + // callers only advertise relay when it is actually reachable. Priority is high + // (tried after LAN/tunnel) since the relay path is the last-resort transport. + const buildPairingCandidate = async () => { const config = await readConfig(); const identity = await identityRuntime.getRelayIdentity(); - const offer = { - v: 1, - mode: 'relay', + return { + type: 'relay', relayUrl: config.relayUrl, serverId: identity.serverId, hostEncPubJwk: identity.hostEncPubJwk, - label: os.hostname(), + priority: 30, }; - if (includeToken) { - const label = typeof clientLabel === 'string' && clientLabel.trim().length > 0 - ? clientLabel.trim() - : 'Relay client'; - const { token } = await remoteClientAuthRuntime.createClient({ label, clientKind: 'relay' }); - offer.token = token; + }; + + const getPairingCandidate = async () => { + const config = await readConfig(); + if (!config.enabled) return null; + return buildPairingCandidate(); + }; + + // Enable the relay host on demand and return its pairing candidate. Creating a + // relay pairing link IS the demand signal, so the relay turns itself on here + // rather than requiring a separate manual toggle. Idempotent: a no-op when the + // relay is already enabled and running. + const ensureEnabledForPairing = async () => { + const config = await readConfig(); + if (!config.enabled) { + await writeConfig({ enabled: true, relayUrl: config.relayUrl }); } - const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer))); - return { - offer, - url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`, - }; + if (!hostClient) { + const next = await readConfig(); + await start(next.relayUrl); + } + return buildPairingCandidate(); }; const registerRoutes = (app) => { @@ -198,24 +233,15 @@ export const createRelayService = ({ } }); - app.post('/api/openchamber/relay/offer', express.json({ limit: '16kb' }), async (req, res) => { - try { - const result = await buildOffer({ - includeToken: req.body?.includeToken === true, - clientLabel: req.body?.clientLabel, - }); - res.json(result); - } catch (error) { - res.status(500).json({ error: error?.message ?? 'Failed to build relay offer' }); - } - }); }; return { registerRoutes, startIfEnabled, + reconcile, stop, getStatus, - buildOffer, + getPairingCandidate, + ensureEnabledForPairing, }; }; diff --git a/packages/web/server/lib/ui-auth/DOCUMENTATION.md b/packages/web/server/lib/ui-auth/DOCUMENTATION.md index ddcc1eec..887119f6 100644 --- a/packages/web/server/lib/ui-auth/DOCUMENTATION.md +++ b/packages/web/server/lib/ui-auth/DOCUMENTATION.md @@ -3,9 +3,15 @@ ## Purpose This module owns OpenChamber UI authentication for browser access, including password session auth, WebAuthn passkeys, and trusted-device session handling. +Trusted-device access has one durable credential model: a remote client bearer token stored by `packages/web/server/lib/client-auth/remote-clients.js`. Password, passkey, and Pairing v2 are issuance methods for that credential, not separate credential systems. Issued client tokens are returned once, stored server-side only as hashes, and are later authenticated via `Authorization: Bearer oc_client_...`. + +Pairing v2 is implemented by `packages/web/server/lib/client-auth/pairing.js`. It stores short-lived one-time pairing sessions with hashed secrets, exposes create/cancel/redeem routes under `/api/client-auth/pairing/*`, and redeems a valid pairing secret into the same remote client token used by password/passkey trusted-device flows. + ## Entrypoints and structure - `packages/web/server/lib/ui-auth/ui-auth.js`: UI auth controller runtime, cookie/session issuance, rate limiting, and auth route handlers. - `packages/web/server/lib/ui-auth/ui-passkeys.js`: passkey store and WebAuthn registration/authentication verification helpers. +- `packages/web/server/lib/client-auth/remote-clients.js`: trusted-device client token storage, bearer authentication, last-used tracking, and revocation. +- `packages/web/server/lib/client-auth/pairing.js`: short-lived Pairing v2 sessions and one-time secret redemption into trusted-device client tokens. ## Public exports (ui-auth.js) - `createUiAuth({ password, cookieName, sessionTtlMs, readSettingsFromDiskMigrated })`: creates UI auth controller with methods: diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index fb58a526..050b5dbf 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -829,6 +829,11 @@ export const createUiAuth = ({ expiresAt: new Date(Date.now() + ttlMs).toISOString(), clientKind: req.body?.clientKind, dedupeKey: req.body?.dedupeKey, + authMethod: 'password', + deviceName: req.body?.deviceName, + devicePlatform: req.body?.devicePlatform, + deviceModel: req.body?.deviceModel, + appVersion: req.body?.appVersion, }); } res.setHeader('Cache-Control', 'no-store'); @@ -892,6 +897,11 @@ export const createUiAuth = ({ expiresAt: new Date(Date.now() + ttlMs).toISOString(), clientKind: req.body?.clientKind, dedupeKey: req.body?.dedupeKey, + authMethod: 'passkey', + deviceName: req.body?.deviceName, + devicePlatform: req.body?.devicePlatform, + deviceModel: req.body?.deviceModel, + appVersion: req.body?.appVersion, }); } res.json({ diff --git a/packages/web/src/api/clientAuth.ts b/packages/web/src/api/clientAuth.ts index 7503a327..0760997b 100644 --- a/packages/web/src/api/clientAuth.ts +++ b/packages/web/src/api/clientAuth.ts @@ -1,5 +1,7 @@ import type { ClientAuthAPI, + PairingSessionCreateResult, + PendingPairingRecord, RemoteClientCreateResult, RemoteClientPurgeRevokedResult, RemoteClientRecord, @@ -37,6 +39,61 @@ export const createWebClientAuthAPI = (): ClientAuthAPI => ({ return payload; }, + async createPairingSession(input = {}): Promise { + const response = await runtimeFetch('/api/client-auth/pairing/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + label: input.label ?? '', + ...(input.allowedClientKinds ? { allowedClientKinds: input.allowedClientKinds } : {}), + ...(input.serverUrl ? { serverUrl: input.serverUrl } : {}), + ...(typeof input.includeRelay === 'boolean' ? { includeRelay: input.includeRelay } : {}), + ...(typeof input.includeDirect === 'boolean' ? { includeDirect: input.includeDirect } : {}), + }), + }); + const payload = await jsonOrNull(response); + if (!response.ok || typeof payload?.pairing?.secret !== 'string' || !payload?.server) { + throw new Error(payload?.error || response.statusText || 'Failed to create pairing session'); + } + return payload; + }, + + async listPendingPairings(): Promise { + const response = await runtimeFetch('/api/client-auth/pairing/sessions', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await jsonOrNull<{ pending?: PendingPairingRecord[]; error?: string }>(response); + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to load pending pairings'); + } + return Array.isArray(payload.pending) ? payload.pending : []; + }, + + async getPairingTransports(): Promise<{ local: string | null; lan: string | null; relayAvailable: boolean }> { + const response = await runtimeFetch('/api/client-auth/pairing/transports', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await jsonOrNull<{ local?: string | null; lan?: string | null; relayAvailable?: boolean; error?: string }>(response); + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to load pairing transports'); + } + return { local: payload.local ?? null, lan: payload.lan ?? null, relayAvailable: payload.relayAvailable !== false }; + }, + + async cancelPairing(id: string): Promise<{ cancelled: boolean }> { + const response = await runtimeFetch(`/api/client-auth/pairing/sessions/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + const payload = await jsonOrNull<{ cancelled?: boolean; error?: string }>(response); + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to cancel pairing'); + } + return { cancelled: payload.cancelled === true }; + }, + async revokeClient(id: string): Promise { const response = await runtimeFetch(`/api/client-auth/clients/${encodeURIComponent(id)}`, { method: 'DELETE', diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 7622e6d4..35bd6c40 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,6 +1,7 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; +import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; import { createWebAPIs } from './api'; @@ -48,5 +49,8 @@ export const createConfiguredWebAPIs = () => { void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); } installRuntimeFetchBridge(); + // Desktop only: if the default host is a relay host, re-open its tunnel now + // that the fetch bridge is installed. No-op elsewhere. + void restoreDesktopRelayRuntime().catch(() => {}); return createWebAPIs({ urls }); };