feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)
Reworks how devices connect to an OpenChamber server, end to end. Pairing v2: - One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links - Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog - Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain) Multi-transport devices: - A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved) - Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch Device management: - Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux) - One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname - Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives Android: - LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
+235
-59
@@ -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 || {});
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- usesCleartextTraffic: OpenChamber connects to user-hosted servers over
|
||||
plain http:// on the local network (LAN transport). Android blocks all
|
||||
cleartext HTTP by default (targetSdk >= 28), which silently failed every
|
||||
LAN probe and forced Android onto relay-only. This mirrors the iOS ATS
|
||||
exceptions (NSAllowsArbitraryLoadsInWebContent + NSAllowsLocalNetworking). -->
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
|
||||
@@ -7,6 +7,14 @@ const config: CapacitorConfig = {
|
||||
server: {
|
||||
androidScheme: 'https',
|
||||
},
|
||||
android: {
|
||||
// The Android WebView serves the app from an https:// origin, so its fetch
|
||||
// and WebSocket calls to plain-http LAN servers (http://192.168.x.x) are
|
||||
// blocked as mixed content even with cleartext allowed in the manifest.
|
||||
// Allow it — LAN transport is a core feature; iOS has no equivalent issue
|
||||
// (capacitor:// scheme) and relay/tunnel traffic is TLS anyway.
|
||||
allowMixedContent: true,
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
// 'none' leaves the WebView at full height; the UI follows the keyboard
|
||||
|
||||
@@ -34,7 +34,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@
|
||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -60,9 +60,9 @@ import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { autoConnectLastInstance, isSameConnectionUrl, relayConnectionRuntimeKey, useMobileConnection, validateActiveRuntimeSession } from './mobileConnections';
|
||||
import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useFontsReady } from './useFontsReady';
|
||||
import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation';
|
||||
@@ -553,6 +553,20 @@ const useNativeMobileLifecycle = (onResume: () => 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
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url}
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 })}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
@@ -894,7 +909,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">
|
||||
{connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url}
|
||||
{connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-right-s" className="size-5 text-muted-foreground" />
|
||||
@@ -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<{
|
||||
<div className="min-w-0">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url}
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1073,7 +1087,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
||||
onClick={() => void connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })}
|
||||
onClick={() => void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })}
|
||||
disabled={isBusy || confirming}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
@@ -1082,7 +1096,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">
|
||||
{connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url}
|
||||
{connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -1098,14 +1112,14 @@ const MobileInstancesSurface: React.FC<{
|
||||
<Icon name="delete-bin" className="size-[18px]" />
|
||||
<span className="typography-ui-label">{t('mobile.instances.delete')}</span>
|
||||
</button>
|
||||
) : connection.mode === 'relay' ? null : (
|
||||
) : !connection.candidates.some((c) => c.kind === 'direct') ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.edit')}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={() => {
|
||||
setEditingId(connection.id);
|
||||
setUrl(connection.url);
|
||||
setUrl(connectionDisplayUrl(connection));
|
||||
setLabel(connection.label);
|
||||
setClientToken(connection.clientToken || '');
|
||||
setError(null);
|
||||
@@ -2612,28 +2626,74 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
// splash so we don't flash the connect screen; 'done' means we either connected or
|
||||
// exhausted the attempt (then the connect screen shows).
|
||||
const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending');
|
||||
// Bumped to force a re-render (and thus a fresh `sdk` prop for SyncProvider)
|
||||
// after a same-device transport swap — reconnects the sync layer in place with
|
||||
// no remount. The value itself is unused; only the re-render matters.
|
||||
const [, bumpTransportSwitch] = React.useReducer((count: number) => count + 1, 0);
|
||||
const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []);
|
||||
const lastNativeResumeSyncEventAtRef = React.useRef(0);
|
||||
const nativeResumeValidationSeqRef = React.useRef(0);
|
||||
|
||||
const handleNativeResume = React.useCallback(() => {
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
if (!apiBaseUrl) return;
|
||||
const validationSeq = nativeResumeValidationSeqRef.current + 1;
|
||||
nativeResumeValidationSeqRef.current = validationSeq;
|
||||
|
||||
void validateActiveRuntimeSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (!isValid) {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
setConnectionEpoch((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
if (!apiBaseUrl) {
|
||||
// Already disconnected — e.g. a previous re-probe ran mid network flux
|
||||
// (Android Wi-Fi switch with no cellular fallback) and found nothing
|
||||
// reachable. When a resume/online signal arrives, silently retry the last
|
||||
// saved instance instead of dead-ending on the connect screen until the
|
||||
// user restarts the app. Success fires runtime-endpoint-changed, which
|
||||
// re-bootstraps everything.
|
||||
void autoConnectLastInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-probe the active device's transports on resume: the network may have
|
||||
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
|
||||
// is now reachable — no re-pairing. A 'switched' outcome already fired the
|
||||
// runtime-endpoint-changed subscription (which re-bootstraps the app), so we
|
||||
// only refresh in place when the transport is 'unchanged'.
|
||||
const refreshInPlace = () => {
|
||||
void initializeApp();
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
||||
};
|
||||
const disconnect = () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
setConnectionEpoch((value) => value + 1);
|
||||
};
|
||||
|
||||
void reprobeActiveConnection().then((outcome) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (outcome === 'no-connection') {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (outcome === 'unreachable') {
|
||||
// Right after a resume or Wi-Fi switch the network is often still
|
||||
// settling (on Android without a SIM there is NO connectivity at all for
|
||||
// a few seconds), so a single fast probe races the network coming up.
|
||||
// Retry once after a grace period before tearing the connection down.
|
||||
window.setTimeout(() => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
void reprobeActiveConnection().then((retry) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (retry === 'switched') return;
|
||||
if (retry === 'unchanged') {
|
||||
refreshInPlace();
|
||||
return;
|
||||
}
|
||||
disconnect();
|
||||
});
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
if (outcome === 'switched') return;
|
||||
|
||||
refreshInPlace();
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
@@ -2646,6 +2706,29 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
useNativeMobileChrome();
|
||||
useNativeMobileLifecycle(handleNativeResume);
|
||||
|
||||
// Network-change re-probe. The resume hook only fires on background→foreground,
|
||||
// but on Android switching Wi-Fi (quick-settings tile) does NOT background the
|
||||
// app — no visibility/appState event ever fires, so the app would sit on a dead
|
||||
// LAN transport instead of hot-switching to relay. The webview's `online` event
|
||||
// fires on connectivity changes (new Wi-Fi, cellular back, airplane off), so
|
||||
// run the same re-probe then. Debounced: the first seconds after `online` the
|
||||
// route is often not usable yet, and rapid offline/online flaps must collapse
|
||||
// into one probe. iOS also gets this (harmless — same seq-guarded operation the
|
||||
// resume path runs; a concurrent duplicate supersedes via the seq ref).
|
||||
React.useEffect(() => {
|
||||
if (!isNativeMobileApp) return;
|
||||
let timer: number | undefined;
|
||||
const handleOnline = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => handleNativeResume(), 1500);
|
||||
};
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isNativeMobileApp, handleNativeResume]);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
@@ -2657,6 +2740,23 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
// stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too.
|
||||
React.useEffect(() => {
|
||||
return subscribeRuntimeEndpointChanged((detail) => {
|
||||
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
|
||||
// that as a transport-only change: rebind the sync layer to the new
|
||||
// transport but keep the user's session/connection state — no reconnecting
|
||||
// screen, no bounce back to the draft. Only a real instance switch (key
|
||||
// change) does the full reset.
|
||||
const sameDevice = Boolean(detail.runtimeKey) && detail.runtimeKey === detail.previousRuntimeKey;
|
||||
if (sameDevice) {
|
||||
// Transport-only swap for the same device: rebind the SDK to the new
|
||||
// transport and force a re-render so SyncProvider receives the new `sdk`
|
||||
// prop. Its event-pipeline + bootstrap effects (keyed on `sdk`) then
|
||||
// reconnect over the new transport WITHOUT remounting — so the message
|
||||
// pagination refs, the open session, and the whole view are preserved.
|
||||
// No key bump, no flash, no bounce to the draft.
|
||||
reconnectAppForTransportSwitch();
|
||||
bumpTransportSwitch();
|
||||
return;
|
||||
}
|
||||
resetAppForRuntimeEndpointChange(detail);
|
||||
setRuntimeEndpointEpoch((epoch) => epoch + 1);
|
||||
setConnectionEpoch((epoch) => epoch + 1);
|
||||
|
||||
@@ -40,7 +40,7 @@ const testRelay: MobileRelayConfig = {
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('entries persisted before relay support normalize to direct mode on read', async () => {
|
||||
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
||||
@@ -50,70 +50,86 @@ describe('mobile connection storage', () => {
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.every((connection) => connection.mode === 'direct')).toBe(true);
|
||||
expect(connections[0]?.relay).toBe(undefined);
|
||||
expect(connections[0]?.clientToken).toBe('tok-a');
|
||||
const home = connections.find((c) => c.id === 'a')!;
|
||||
expect(home.candidates).toEqual([{ kind: 'direct', url: 'http://192.168.1.10:2606' }]);
|
||||
expect(home.clientToken).toBe('tok-a');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay connections round-trip mode and transport config', async () => {
|
||||
test('a relay device round-trips its candidate + token', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
await upsertMobileConnection({
|
||||
label: 'My Desktop',
|
||||
url: 'openchamber://connect?v=1&mode=relay',
|
||||
candidates: [{ kind: 'relay', relay: testRelay }],
|
||||
clientToken: 'oc_client_secret',
|
||||
relay: testRelay,
|
||||
});
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(1);
|
||||
const saved = connections[0]!;
|
||||
expect(saved.mode).toBe('relay');
|
||||
expect(saved.relay).toEqual(testRelay);
|
||||
expect(saved.candidates).toEqual([{ kind: 'relay', relay: testRelay }]);
|
||||
// Web surface: token stays inline like direct connections.
|
||||
expect(saved.clientToken).toBe('oc_client_secret');
|
||||
|
||||
// Persisted metadata carries only the three transport fields — no grant.
|
||||
// Persisted metadata carries only the three transport fields — no grant/token.
|
||||
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as Array<Record<string, unknown>>;
|
||||
expect(raw[0]?.mode).toBe('relay');
|
||||
expect(Object.keys(raw[0]?.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
|
||||
const rawCandidate = (raw[0]?.candidates as Array<Record<string, unknown>>)[0];
|
||||
expect(rawCandidate.kind).toBe('relay');
|
||||
expect(Object.keys(rawCandidate.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay entries with malformed transport config are dropped, direct entries survive', async () => {
|
||||
test('a multi-transport device persists all candidates in order (LAN then relay)', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
await upsertMobileConnection({
|
||||
label: 'Both',
|
||||
candidates: [{ kind: 'direct', url: 'http://192.168.1.5:2606' }, { kind: 'relay', relay: testRelay }],
|
||||
clientToken: 'tok',
|
||||
});
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections[0]?.candidates.map((c) => c.kind)).toEqual(['direct', 'relay']);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy relay entry with malformed transport config is dropped, direct entries survive', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
||||
{ id: 'bad', label: 'Broken', url: 'openchamber://connect', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
|
||||
{ id: 'bad', label: 'Broken', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
|
||||
{ id: 'ok', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10 },
|
||||
]));
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(1);
|
||||
expect(connections[0]?.id).toBe('ok');
|
||||
expect(connections[0]?.mode).toBe('direct');
|
||||
expect(connections[0]?.candidates[0]?.kind).toBe('direct');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay and direct connections dedupe independently', async () => {
|
||||
test('relay and direct devices dedupe independently by candidate identity', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
await upsertMobileConnection({ label: 'Direct', url: 'http://host.example' });
|
||||
await upsertMobileConnection({ label: 'Relay', url: 'openchamber://connect?v=1&mode=relay', relay: testRelay });
|
||||
await upsertMobileConnection({ label: 'Relay renamed', url: 'openchamber://connect?v=1&mode=relay', relay: testRelay });
|
||||
await upsertMobileConnection({ label: 'Direct', candidates: [{ kind: 'direct', url: 'http://host.example' }] });
|
||||
await upsertMobileConnection({ label: 'Relay', candidates: [{ kind: 'relay', relay: testRelay }] });
|
||||
await upsertMobileConnection({ label: 'Relay renamed', candidates: [{ kind: 'relay', relay: testRelay }] });
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.filter((connection) => connection.mode === 'relay')).toHaveLength(1);
|
||||
expect(connections.find((connection) => connection.mode === 'relay')?.label).toBe('Relay renamed');
|
||||
const relayEntries = connections.filter((c) => c.candidates.some((x) => x.kind === 'relay'));
|
||||
expect(relayEntries).toHaveLength(1);
|
||||
expect(relayEntries[0]?.label).toBe('Relay renamed');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildRelayOfferUrl } from '@/lib/relay/offer';
|
||||
import type { RelayOfferV1 } from '@/lib/relay/protocol';
|
||||
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
import { parseConnectionPayload } from './mobileQrScan';
|
||||
|
||||
const baseOffer: RelayOfferV1 = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: 'wss://relay.example/tunnel',
|
||||
serverId: 'srv_test123',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' },
|
||||
};
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
describe('parseConnectionPayload', () => {
|
||||
test('parses direct pairing links unchanged', () => {
|
||||
const payload = parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok&label=Home');
|
||||
expect(payload).toEqual({ url: 'http://192.168.1.10:2606', clientToken: 'tok', label: 'Home' });
|
||||
});
|
||||
|
||||
test('parses bare http(s) URLs unchanged', () => {
|
||||
test('parses bare http(s) URLs', () => {
|
||||
expect(parseConnectionPayload('https://oc.example')).toEqual({ url: 'https://oc.example' });
|
||||
expect(parseConnectionPayload(' http://192.168.1.10:2606 ')).toEqual({ url: 'http://192.168.1.10:2606' });
|
||||
});
|
||||
|
||||
test('rejects non-connection payloads', () => {
|
||||
test('parses a v2 pairing link with direct + relay candidates', () => {
|
||||
const url = encodePairingConnectionPayload(buildPairingConnectionPayload({
|
||||
pairingId: 'pair_abc',
|
||||
secret: 'one-time',
|
||||
label: 'My Desktop',
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
|
||||
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_1', hostEncPubJwk, priority: 30 },
|
||||
],
|
||||
}));
|
||||
const payload = parseConnectionPayload(url);
|
||||
if (!payload || !('pairing' in payload)) throw new Error('expected a pairing payload');
|
||||
expect(payload.pairing.pairingId).toBe('pair_abc');
|
||||
expect(payload.pairing.secret).toBe('one-time');
|
||||
expect(payload.pairing.candidates.map((c) => c.type)).toEqual(['lan', 'relay']);
|
||||
});
|
||||
|
||||
test('rejects non-connection and legacy/relay-offer payloads', () => {
|
||||
expect(parseConnectionPayload('')).toBeNull();
|
||||
expect(parseConnectionPayload('hello world')).toBeNull();
|
||||
expect(parseConnectionPayload('openchamber://connect')).toBeNull();
|
||||
expect(parseConnectionPayload('openchamber://session/abc')).toBeNull();
|
||||
});
|
||||
|
||||
test('recognizes relay offers with embedded token and grant', () => {
|
||||
const url = buildRelayOfferUrl({ ...baseOffer, label: 'My Desktop', token: 'oc_client_secret', grant: 'grant123' });
|
||||
const payload = parseConnectionPayload(url);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.url).toBe(url);
|
||||
expect(payload?.label).toBe('My Desktop');
|
||||
expect(payload?.clientToken).toBe('oc_client_secret');
|
||||
expect(payload?.relay).toEqual({
|
||||
relayUrl: baseOffer.relayUrl,
|
||||
serverId: baseOffer.serverId,
|
||||
hostEncPubJwk: baseOffer.hostEncPubJwk,
|
||||
});
|
||||
expect(payload?.relayGrant).toBe('grant123');
|
||||
});
|
||||
|
||||
test('recognizes token-less relay offers (login-on-first-connect)', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
const payload = parseConnectionPayload(url);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.clientToken).toBe(undefined);
|
||||
expect(payload?.relayGrant).toBe(undefined);
|
||||
expect(payload?.relay?.serverId).toBe(baseOffer.serverId);
|
||||
});
|
||||
|
||||
test('malformed relay offers fall through to direct parsing rules', () => {
|
||||
// mode=relay but no fragment payload → not a valid offer, and no `server`
|
||||
// param either → rejected entirely, exactly like before relay support.
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay')).toBeNull();
|
||||
// Direct link that also carries an unrelated mode param keeps direct parsing.
|
||||
const direct = parseConnectionPayload('openchamber://connect?v=1&mode=relay&server=http%3A%2F%2Fhost.example');
|
||||
expect(direct).toEqual({ url: 'http://host.example' });
|
||||
// Legacy v1 direct links are no longer accepted.
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok')).toBeNull();
|
||||
// Legacy relay-offer format (mode=relay + fragment) is no longer accepted.
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
||||
//
|
||||
// The pairing link format is produced by `openchamber connect-url --qr`:
|
||||
// openchamber://connect?v=1&server=<url>&token=<token>&label=<label>
|
||||
// We also accept a bare http(s) URL so a QR encoding only the server address works.
|
||||
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
|
||||
// secret and a list of transport candidates (lan / tunnel / relay); they are
|
||||
// redeemed server-side over whichever candidate connects first. We also accept a
|
||||
// bare http(s) URL so a QR encoding only the server address works.
|
||||
//
|
||||
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
|
||||
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
|
||||
// at runtime instead of importing the package so the web build stays dependency-free
|
||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
||||
|
||||
import { parseRelayOfferUrl } from '@/lib/relay/offer';
|
||||
|
||||
import type { MobileRelayConfig } from './mobileConnections';
|
||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
export type MobileConnectionPayload = {
|
||||
url: string;
|
||||
clientToken?: string;
|
||||
label?: string;
|
||||
// Present when the payload is a relay pairing offer (openchamber://connect?v=1&mode=relay#offer=...).
|
||||
// `url` then holds the raw offer link so form fields and connect() can round-trip it.
|
||||
relay?: MobileRelayConfig;
|
||||
// One-time relay authorization grant from the offer. Never persisted.
|
||||
relayGrant?: string;
|
||||
};
|
||||
|
||||
export type MobilePairingPayload = {
|
||||
pairing: PairingConnectionPayload;
|
||||
};
|
||||
|
||||
export type QrScanResult =
|
||||
| ({ status: 'ok' } & MobileConnectionPayload)
|
||||
| ({ status: 'pairing' } & MobilePairingPayload)
|
||||
| { status: 'cancelled' }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'permission-denied' }
|
||||
@@ -112,42 +111,13 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
return plugin && typeof plugin.scan === 'function' ? plugin : null;
|
||||
};
|
||||
|
||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | null => {
|
||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^openchamber:\/\//i.test(trimmed)) {
|
||||
// Relay pairing offers are a strict superset format (mode=relay + fragment
|
||||
// payload); try them first. Direct pairing links (?server=...) never match
|
||||
// the relay parser, so existing payloads are untouched.
|
||||
const offer = parseRelayOfferUrl(trimmed);
|
||||
if (offer) {
|
||||
return {
|
||||
url: trimmed,
|
||||
clientToken: offer.token,
|
||||
label: offer.label,
|
||||
relay: {
|
||||
relayUrl: offer.relayUrl,
|
||||
serverId: offer.serverId,
|
||||
hostEncPubJwk: offer.hostEncPubJwk,
|
||||
},
|
||||
relayGrant: offer.grant,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const server = parsed.searchParams.get('server')?.trim();
|
||||
if (!server) return null;
|
||||
const clientToken = parsed.searchParams.get('token')?.trim();
|
||||
const label = parsed.searchParams.get('label')?.trim();
|
||||
return {
|
||||
url: server,
|
||||
clientToken: clientToken || undefined,
|
||||
label: label || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pairing = parsePairingConnectionPayload(trimmed);
|
||||
return pairing ? { pairing } : null;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) return { url: trimmed };
|
||||
@@ -194,6 +164,7 @@ export const scanConnectionQr = async (): Promise<QrScanResult> => {
|
||||
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
} catch (error) {
|
||||
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
|
||||
|
||||
@@ -9,6 +9,20 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
|
||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||
// to the new transport WITHOUT tearing down connection/session state or remounting
|
||||
// the sync layer. `reconnectToRuntimeBaseUrl` swaps in a fresh SDK client; the
|
||||
// caller then forces a re-render so SyncProvider receives it as a new `sdk` prop,
|
||||
// which re-runs its event-pipeline + bootstrap effects (keyed on `sdk`) to
|
||||
// reconnect over the new transport IN PLACE. Message-pagination refs, the open
|
||||
// session, and the whole view are preserved — no reconnecting screen, no flash,
|
||||
// no bounce back to the draft.
|
||||
export const reconnectAppForTransportSwitch = (): void => {
|
||||
disposeTerminalInputTransport();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
resetStreamingState();
|
||||
};
|
||||
|
||||
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
|
||||
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
|
||||
@@ -27,9 +27,11 @@ import {
|
||||
redactSensitiveUrl,
|
||||
resolveDesktopHostUrl,
|
||||
type DesktopHost,
|
||||
type DesktopHostRelay,
|
||||
type HostProbeResult,
|
||||
} from '@/lib/desktopHosts';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import {
|
||||
desktopSshConnect,
|
||||
desktopSshDisconnect,
|
||||
@@ -47,6 +49,26 @@ const runtimeKeyForHost = (host: DesktopHost): string => {
|
||||
return `host:${host.id}`;
|
||||
};
|
||||
|
||||
// Quick reachability check for a relay host: open a throwaway E2EE tunnel and
|
||||
// hit /health. Confirms the relay routes to the (still-online) host before we
|
||||
// commit the runtime switch, so an offline host surfaces as an error instead of
|
||||
// a broken runtime. The steady-state tunnel is opened by switchRuntimeEndpoint.
|
||||
const probeRelayHost = async (relay: DesktopHostRelay): Promise<boolean> => {
|
||||
const tunnel = createRelayTunnelClient({
|
||||
relayUrl: relay.relayUrl,
|
||||
serverId: relay.serverId,
|
||||
hostEncPubJwk: relay.hostEncPubJwk,
|
||||
});
|
||||
try {
|
||||
const response = await tunnel.fetch('/health');
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
tunnel.close();
|
||||
}
|
||||
};
|
||||
|
||||
type HostStatus = {
|
||||
status: HostProbeResult['status'];
|
||||
latencyMs: number;
|
||||
@@ -240,6 +262,15 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
||||
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
|
||||
const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref;
|
||||
|
||||
// Relay hosts share the window origin as their (virtual) API base, so URL
|
||||
// matching can't distinguish them — identify the active relay host by its
|
||||
// stable runtime key instead.
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
const relayMatch = hosts.find((h) => h.relay && runtimeKeyForHost(h) === activeRuntimeKey);
|
||||
if (relayMatch) {
|
||||
return { id: relayMatch.id, label: relayMatch.label, url: relayMatch.url };
|
||||
}
|
||||
|
||||
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
|
||||
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
|
||||
}
|
||||
@@ -484,6 +515,32 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [open]);
|
||||
|
||||
const handleSwitch = React.useCallback(async (host: DesktopHost) => {
|
||||
// Relay hosts have no reachable HTTP origin — they ride the E2EE tunnel.
|
||||
// Activate it in-renderer via switchRuntimeEndpoint({ relay }); the runtime
|
||||
// fetch/socket layers route through the tunnel from the singleton registry.
|
||||
if (host.relay) {
|
||||
setSwitchingHostId(host.id);
|
||||
const reachable = await probeRelayHost(host.relay).catch(() => false);
|
||||
setStatusById((prev) => ({
|
||||
...prev,
|
||||
[host.id]: { status: reachable ? 'ok' : 'unreachable', latencyMs: 0 },
|
||||
}));
|
||||
if (!reachable) {
|
||||
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
||||
setSwitchingHostId(null);
|
||||
return;
|
||||
}
|
||||
switchRuntimeEndpoint({
|
||||
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
|
||||
clientToken: host.clientToken || null,
|
||||
runtimeKey: runtimeKeyForHost(host),
|
||||
relay: host.relay,
|
||||
});
|
||||
onHostSwitched?.();
|
||||
setSwitchingHostId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(host.url) || '');
|
||||
const apiOrigin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(getDesktopHostApiUrl(host)) || '');
|
||||
if (!origin) return;
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
// OpenChamber-owned relay routes (registered before the generic OpenCode proxy).
|
||||
const RELAY_STATUS_ROUTE = '/api/openchamber/relay/status';
|
||||
const RELAY_ENABLE_ROUTE = '/api/openchamber/relay/enable';
|
||||
const RELAY_DISABLE_ROUTE = '/api/openchamber/relay/disable';
|
||||
const RELAY_OFFER_ROUTE = '/api/openchamber/relay/offer';
|
||||
|
||||
const STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
type RelayState = 'disabled' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
||||
|
||||
interface RelayStatus {
|
||||
enabled: boolean;
|
||||
state: RelayState;
|
||||
serverId: string;
|
||||
connectedClients: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
const RELAY_STATES = new Set<string>(['disabled', 'connecting', 'connected', 'reconnecting', 'error']);
|
||||
|
||||
// Authoritative fetch: returns null strictly on fetch/shape failure so callers
|
||||
// keep the previous status instead of treating a blip as "relay disabled".
|
||||
const fetchRelayStatus = async (signal?: AbortSignal): Promise<RelayStatus | null> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await runtimeFetch(RELAY_STATUS_ROUTE, { method: 'GET', signal });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json().catch(() => null)) as Partial<RelayStatus> | null;
|
||||
if (!body || typeof body.enabled !== 'boolean' || typeof body.state !== 'string' || !RELAY_STATES.has(body.state)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
enabled: body.enabled,
|
||||
state: body.state as RelayState,
|
||||
serverId: typeof body.serverId === 'string' ? body.serverId : '',
|
||||
connectedClients: typeof body.connectedClients === 'number' ? body.connectedClients : 0,
|
||||
...(typeof body.lastError === 'string' && body.lastError ? { lastError: body.lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const stateLabelKey = (state: RelayState): I18nKey => {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
return 'settings.remoteInstances.relay.state.connecting';
|
||||
case 'connected':
|
||||
return 'settings.remoteInstances.relay.state.connected';
|
||||
case 'reconnecting':
|
||||
return 'settings.remoteInstances.relay.state.reconnecting';
|
||||
case 'error':
|
||||
return 'settings.remoteInstances.relay.state.error';
|
||||
default:
|
||||
return 'settings.remoteInstances.relay.state.disabled';
|
||||
}
|
||||
};
|
||||
|
||||
const stateDotClass = (state: RelayState): string => {
|
||||
if (state === 'connected') {
|
||||
return 'bg-[var(--status-success)] animate-pulse';
|
||||
}
|
||||
if (state === 'error') {
|
||||
return 'bg-[var(--status-error)] animate-pulse';
|
||||
}
|
||||
if (state === 'connecting' || state === 'reconnecting') {
|
||||
return 'bg-[var(--status-warning)] animate-pulse';
|
||||
}
|
||||
return 'bg-muted-foreground/40';
|
||||
};
|
||||
|
||||
export const RelaySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<RelayStatus | null>(null);
|
||||
const [statusLoaded, setStatusLoaded] = React.useState(false);
|
||||
const [isToggling, setIsToggling] = React.useState(false);
|
||||
const [pairLabel, setPairLabel] = React.useState('');
|
||||
const [includeToken, setIncludeToken] = React.useState(true);
|
||||
const [isPairing, setIsPairing] = React.useState(false);
|
||||
const [offerUrl, setOfferUrl] = React.useState<string | null>(null);
|
||||
const [offerQrDataUrl, setOfferQrDataUrl] = React.useState<string | null>(null);
|
||||
const [qrDialogOpen, setQrDialogOpen] = React.useState(false);
|
||||
|
||||
const refreshStatus = React.useCallback(async (signal?: AbortSignal) => {
|
||||
const next = await fetchRelayStatus(signal);
|
||||
if (signal?.aborted) return;
|
||||
setStatusLoaded(true);
|
||||
// Preserve the last known status on fetch failure; never downgrade to
|
||||
// "disabled" because of a transient network error.
|
||||
if (next) setStatus(next);
|
||||
}, []);
|
||||
|
||||
// Poll only while this section is mounted (page visible) and the document
|
||||
// is visible — no global polling.
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void refreshStatus(controller.signal);
|
||||
const interval = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
void refreshStatus(controller.signal);
|
||||
}, STATUS_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
const handleEnable = React.useCallback(async () => {
|
||||
setIsToggling(true);
|
||||
try {
|
||||
const response = await runtimeFetch(RELAY_ENABLE_ROUTE, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
toast.error(t('settings.remoteInstances.relay.toast.enableFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const handleDisable = React.useCallback(async () => {
|
||||
const confirmed = window.confirm(t('settings.remoteInstances.relay.confirm.disable'));
|
||||
if (!confirmed) return;
|
||||
setIsToggling(true);
|
||||
try {
|
||||
const response = await runtimeFetch(RELAY_DISABLE_ROUTE, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
setOfferUrl(null);
|
||||
setOfferQrDataUrl(null);
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
toast.error(t('settings.remoteInstances.relay.toast.disableFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const handleCreateOffer = React.useCallback(async () => {
|
||||
setIsPairing(true);
|
||||
try {
|
||||
const response = await runtimeFetch(RELAY_OFFER_ROUTE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
includeToken,
|
||||
...(pairLabel.trim() ? { clientLabel: pairLabel.trim() } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as { url?: unknown };
|
||||
if (typeof result.url !== 'string' || !result.url) {
|
||||
throw new Error('Malformed offer response');
|
||||
}
|
||||
setOfferUrl(result.url);
|
||||
// Relay offers are ~500 chars (encryption key JWK + token) — far denser than
|
||||
// direct-pairing QRs. Render at high resolution with low ECC; the fullscreen
|
||||
// dialog then displays it large enough for a phone camera to lock on. A small
|
||||
// inline QR of this density is unscannable (learned the hard way).
|
||||
setOfferQrDataUrl(
|
||||
await QRCode.toDataURL(result.url, { width: 1024, margin: 2, errorCorrectionLevel: 'L' }),
|
||||
);
|
||||
setPairLabel('');
|
||||
} catch (err) {
|
||||
toast.error(t('settings.remoteInstances.relay.toast.offerFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
}, [includeToken, pairLabel, t]);
|
||||
|
||||
const handleCopyOffer = React.useCallback(() => {
|
||||
if (!offerUrl) return;
|
||||
void copyTextToClipboard(offerUrl).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success(t('settings.remoteInstances.relay.toast.linkCopied'));
|
||||
}
|
||||
});
|
||||
}, [offerUrl, t]);
|
||||
|
||||
const enabled = status?.enabled === true;
|
||||
const state: RelayState = status?.state ?? 'disabled';
|
||||
const isConnected = state === 'connected';
|
||||
|
||||
return (
|
||||
<div data-settings-item="remote-instances.relay" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.relay.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.relay.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
{!statusLoaded ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.relay.state.loading')}</p>
|
||||
) : !enabled ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.enableHint')}</p>
|
||||
<Button type="button" size="xs" className="!font-normal shrink-0" onClick={() => void handleEnable()} disabled={isToggling}>
|
||||
{t('settings.remoteInstances.relay.actions.enable')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${stateDotClass(state)}`} />
|
||||
<p className="typography-ui-label text-foreground truncate">{t(stateLabelKey(state))}</p>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{(status?.connectedClients ?? 0) === 1
|
||||
? t('settings.remoteInstances.relay.status.clientsOne', { count: 1 })
|
||||
: t('settings.remoteInstances.relay.status.clientsMany', { count: status?.connectedClients ?? 0 })}
|
||||
</p>
|
||||
{state === 'error' && status?.lastError ? (
|
||||
<p className="typography-micro text-[var(--status-error)] break-all">{status.lastError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal shrink-0" onClick={() => void handleDisable()} disabled={isToggling}>
|
||||
{t('settings.remoteInstances.relay.actions.disable')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.relay.pair.title')}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
className="h-8"
|
||||
value={pairLabel}
|
||||
onChange={(event) => setPairLabel(event.target.value)}
|
||||
placeholder={t('settings.remoteInstances.relay.pair.labelPlaceholder')}
|
||||
disabled={isPairing}
|
||||
/>
|
||||
<Button type="button" size="xs" className="!font-normal shrink-0" onClick={() => void handleCreateOffer()} disabled={isPairing || !isConnected}>
|
||||
{t('settings.remoteInstances.relay.pair.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex w-fit cursor-pointer items-center gap-2 py-0.5">
|
||||
<Switch checked={includeToken} onCheckedChange={(checked) => setIncludeToken(Boolean(checked))} disabled={isPairing} />
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.remoteInstances.relay.pair.includeToken')}</span>
|
||||
</label>
|
||||
{!includeToken ? (
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.pair.noTokenHint')}</p>
|
||||
) : null}
|
||||
{!isConnected ? (
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.pair.requiresConnected')}</p>
|
||||
) : null}
|
||||
{offerUrl ? (
|
||||
<div className="min-w-0 space-y-2 rounded-md border border-[var(--interactive-border)] p-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.relay.pair.linkLabel')}</p>
|
||||
<code className="block select-all break-all typography-code text-foreground">{offerUrl}</code>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyOffer}>
|
||||
<Icon name="file-copy" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
{offerQrDataUrl ? (
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setQrDialogOpen(true)}>
|
||||
<Icon name="scan-2" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.relay.pair.showQr')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="typography-meta text-[var(--status-warning)]">{t('settings.remoteInstances.relay.pair.warning')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.pair.manageHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<Dialog open={qrDialogOpen} onOpenChange={setQrDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.relay.pair.qrDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.relay.pair.qrDialogDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{offerQrDataUrl ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<img
|
||||
src={offerQrDataUrl}
|
||||
alt={t('settings.remoteInstances.relay.pair.qrAlt')}
|
||||
className="w-full max-w-xs rounded-md bg-white p-3"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string> => {
|
||||
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<HeaderDraft[]>([]);
|
||||
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
|
||||
const [pendingPairings, setPendingPairings] = React.useState<PendingPairingRecord[]>([]);
|
||||
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
|
||||
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
|
||||
const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState<string | null>(null);
|
||||
const [remoteClientError, setRemoteClientError] = React.useState<string | null>(null);
|
||||
const [pairingUrl, setPairingUrl] = React.useState<string | null>(null);
|
||||
const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState<string | null>(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<string | null> => {
|
||||
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 = () => {
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input className="h-8" value={remoteClientLabel} onChange={(event) => setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} />
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void createRemoteClient()}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.create')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void createPairingLink()}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.pair')}
|
||||
<div>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void openAddDevice()}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.clientAuth.actions.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
{pairingUrl ? (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-[var(--interactive-border)] p-2 sm:flex-row">
|
||||
{pairingQrDataUrl ? <img src={pairingQrDataUrl} alt={t('settings.remoteInstances.clientAuth.qrAlt')} className="size-48 self-start" /> : null}
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.pairingUrl')}</p>
|
||||
<code className="block select-all break-all typography-code text-foreground">{pairingUrl}</code>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void copyTextToClipboard(pairingUrl)}>
|
||||
<Icon name="file-copy" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{createdRemoteClientToken ? (
|
||||
<div className="space-y-1 rounded-md border border-[var(--interactive-border)] p-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.createdToken')}</p>
|
||||
<code className="block select-all break-all typography-code text-foreground">{createdRemoteClientToken}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{revokedClientCount > 0 ? (
|
||||
<div className="flex justify-end">
|
||||
@@ -1086,39 +1315,83 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{remoteClientsLoading ? (
|
||||
{remoteClientsLoading && remoteClients.length === 0 && pendingPairings.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.loading')}</p>
|
||||
) : remoteClients.length === 0 ? (
|
||||
) : remoteClients.length === 0 && pendingPairings.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.empty')}</p>
|
||||
) : remoteClients.map((client) => {
|
||||
const isLocalDesktopClient = client.clientKind === 'desktop-local';
|
||||
return (
|
||||
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="typography-ui-label text-foreground truncate">{client.label}</p>
|
||||
{isLocalDesktopClient ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
|
||||
</span>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
{pendingPairings.map((pending) => (
|
||||
<div key={`pending-${pending.id}`} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[var(--status-warning)] animate-pulse" />
|
||||
<p className="typography-ui-label text-foreground truncate">{pending.label || t('settings.remoteInstances.clientAuth.field.labelPlaceholder')}</p>
|
||||
{pending.usesRelay ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded shrink-0 leading-none pb-px border border-border/50">{t('settings.remoteInstances.clientAuth.state.viaRelay')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">{t('settings.remoteInstances.clientAuth.state.pending')}</p>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}</p>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void cancelPendingPairing(pending.id)}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{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 (
|
||||
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
client.revokedAt ? 'bg-muted-foreground/20' : isOnline ? 'bg-[var(--status-success)]' : 'bg-muted-foreground/30',
|
||||
)} />
|
||||
<p className="typography-ui-label text-foreground truncate">{client.label}</p>
|
||||
{devicePlatformLabel(client.devicePlatform) ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded shrink-0 leading-none pb-px border border-border/50">
|
||||
{devicePlatformLabel(client.devicePlatform)}
|
||||
</span>
|
||||
) : null}
|
||||
{isLocalDesktopClient ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{clientAuth && RELAY_UI_ENABLED ? <RelaySection /> : null}
|
||||
|
||||
{showInstanceManagement ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
|
||||
@@ -1265,6 +1538,100 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
<Dialog open={addDeviceOpen} onOpenChange={setAddDeviceOpen}>
|
||||
<DialogContent className={addDevicePhase === 'result' ? 'sm:max-w-lg' : 'sm:max-w-md'}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{addDevicePhase === 'result' ? t('settings.remoteInstances.clientAuth.qrDialogTitle') : t('settings.remoteInstances.clientAuth.actions.addDevice')}</DialogTitle>
|
||||
{/* Configure phase: what this dialog will produce. Result phase: what
|
||||
to do with the QR code that is now on screen. */}
|
||||
<DialogDescription>{addDevicePhase === 'result' ? t('settings.remoteInstances.clientAuth.qrScanHint') : t('settings.remoteInstances.clientAuth.addDevice.subtitle')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{addDevicePhase === 'configure' ? (
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); void createPairingLink(); }}>
|
||||
<Input
|
||||
className="h-8"
|
||||
value={remoteClientLabel}
|
||||
onChange={(event) => setRemoteClientLabel(event.target.value)}
|
||||
placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.clientAuth.addDevice.transportLabel')}</p>
|
||||
{/* 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. */}
|
||||
<div role="radiogroup" aria-label={t('settings.remoteInstances.clientAuth.addDevice.transportLabel')} className="space-y-1.5">
|
||||
{([
|
||||
{ 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 (
|
||||
<div
|
||||
key={option.key}
|
||||
className={cn('flex items-start gap-2 py-0.5', option.available ? 'cursor-pointer' : 'opacity-45')}
|
||||
onClick={() => { if (option.available) setAddDeviceTransport(option.key); }}
|
||||
role="presentation"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
disabled={!option.available}
|
||||
onChange={() => setAddDeviceTransport(option.key)}
|
||||
ariaLabel={option.label}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/70')}>{option.label}</p>
|
||||
<p className="typography-meta text-muted-foreground">{option.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{addDeviceTransport === 'lan' ? (
|
||||
<label className="flex w-fit cursor-pointer items-center gap-2 pt-1">
|
||||
<Checkbox checked={addDeviceFallback} onChange={setAddDeviceFallback} ariaLabel={t('settings.remoteInstances.clientAuth.addDevice.fallback.relay')} />
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.addDevice.fallback.relay')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{addDeviceTransport === 'relay' && transportOptions?.lanUrl ? (
|
||||
<label className="flex w-fit cursor-pointer items-center gap-2 pt-1">
|
||||
<Checkbox checked={addDeviceFallback} onChange={setAddDeviceFallback} ariaLabel={t('settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal')} />
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setAddDeviceOpen(false)} disabled={addDeviceCreating}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={addDeviceCreating || !transportOptions}>{t('settings.remoteInstances.clientAuth.addDevice.create')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pairingQrDataUrl ? (
|
||||
<div className="flex justify-center">
|
||||
<img src={pairingQrDataUrl} alt={t('settings.remoteInstances.clientAuth.qrAlt')} className="w-full max-w-[420px] rounded-md bg-white p-4" />
|
||||
</div>
|
||||
) : null}
|
||||
{pairingUrl ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-[var(--interactive-border)] p-2">
|
||||
<code className="min-w-0 flex-1 truncate typography-code text-muted-foreground">{pairingUrl}</code>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal shrink-0" onClick={handleCopyPairing}>
|
||||
<Icon name={pairingCopied ? 'check' : 'file-copy'} className={cn('h-3.5 w-3.5', pairingCopied && 'text-[var(--status-success)]')} />
|
||||
{pairingCopied ? t('settings.remoteInstances.clientAuth.actions.copied') : t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setAddDeviceOpen(false)}>{t('settings.remoteInstances.clientAuth.addDevice.done')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -54,6 +54,9 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ 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]',
|
||||
)}
|
||||
>
|
||||
<Dialog.Description id={descriptionId} className="sr-only">
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientAuthAPI {
|
||||
listClients(): Promise<RemoteClientRecord[]>;
|
||||
createClient(input?: { label?: string }): Promise<RemoteClientCreateResult>;
|
||||
// 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<PairingSessionCreateResult>;
|
||||
purgeRevokedClients(): Promise<RemoteClientPurgeRevokedResult>;
|
||||
revokeClient(id: string): Promise<RemoteClientRevokeResult>;
|
||||
// Pairing links created but not yet redeemed (the "pending devices" list).
|
||||
listPendingPairings(): Promise<PendingPairingRecord[]>;
|
||||
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 {
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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'>): 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;
|
||||
}
|
||||
|
||||
@@ -21,17 +21,44 @@ const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | 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://<serverId>` 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<string, string>;
|
||||
/** 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<string> => {
|
||||
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<string> => {
|
||||
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<string, string> | null }): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
|
||||
@@ -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<void> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'リレーを有効にする',
|
||||
|
||||
@@ -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': '릴레이 켜기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '启用中继',
|
||||
|
||||
@@ -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': '啟用中繼',
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, unknown> => {
|
||||
const clone: Record<string, unknown> = { ...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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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 } : {}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<base64url>`): 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`.
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<object>,
|
||||
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
||||
* remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> },
|
||||
* getLocalPort: () => number,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PairingSessionCreateResult> {
|
||||
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<PairingSessionCreateResult & { error?: string }>(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<PendingPairingRecord[]> {
|
||||
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<RemoteClientRevokeResult> {
|
||||
const response = await runtimeFetch(`/api/client-auth/clients/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user