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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user