diff --git a/apps/account-directory/src/directory.ts b/apps/account-directory/src/directory.ts index b8944796e..1394a5e9d 100644 --- a/apps/account-directory/src/directory.ts +++ b/apps/account-directory/src/directory.ts @@ -96,6 +96,24 @@ function text(value: string, status = 200): Response { }); } +function withServerTiming( + response: Response, + authDurationMs: number, + dbDurationMs: number, +): Response { + const duration = (value: number) => Math.max(0, value).toFixed(2); + const headers = new Headers(response.headers); + headers.set( + "server-timing", + `auth;dur=${duration(authDurationMs)}, db;dur=${duration(dbDurationMs)}`, + ); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -338,14 +356,24 @@ async function handleRegister(request: Request, env: Env, userId: string): Promi return json(machineRecord(row)); } -async function handleList(request: Request, env: Env, userId: string): Promise { +async function handleList( + request: Request, + env: Env, + userId: string, + authDurationMs: number, +): Promise { if (request.method !== "GET") return text("method not allowed", 405); + const dbStartedAt = performance.now(); const rows = (await env.DB.prepare(` select user_id, machine_key, device_id, name, platform, device_type, pubkey, reachable_endpoints, last_seen_at, created_at from machines where user_id = ? + -- 500 is the machine-directory cap, matching the client's effective cap. + order by last_seen_at desc + limit 500 `).bind(userId).all()).results ?? []; + const dbDurationMs = performance.now() - dbStartedAt; const now = Date.now(); const windowMs = onlineWindowMs(env); const machines = rows.map((row) => ({ @@ -355,7 +383,7 @@ async function handleList(request: Request, env: Env, userId: string): Promise(sql: string, values: unknown[]): T[] { - if (!sql.toLowerCase().includes("from machines")) return []; + const normalized = sql.toLowerCase(); + if (!normalized.includes("from machines")) return []; const [userId] = values; - return this.rows.filter((row) => row.user_id === userId) as T[]; + let rows = this.rows.filter((row) => row.user_id === userId); + if (normalized.includes("order by last_seen_at desc")) { + rows = [...rows].sort((left, right) => + Number(right.last_seen_at ?? 0) - Number(left.last_seen_at ?? 0) + ); + } + if (normalized.includes("limit 500")) rows = rows.slice(0, 500); + return rows as T[]; } run(sql: string, values: unknown[]): number { @@ -977,6 +985,7 @@ describe("machine directory", () => { }), env); expect(allowed.status).toBe(200); expect(allowed.headers.get("access-control-allow-origin")).toBe("https://app.ade.dev"); + expect(allowed.headers.get("access-control-expose-headers")).toBe("Server-Timing"); const hostilePreflight = await handleRequest(new Request("https://directory.test/account/machines", { method: "OPTIONS", @@ -1046,6 +1055,33 @@ describe("machine directory", () => { lastSeenAt: now - 120_000, reachableEndpoints: [{ kind: "lan", host: "mac.local", port: 8787 }], })); + expect(response.headers.get("server-timing")).toMatch( + /^auth;dur=\d+\.\d{2}, db;dur=\d+\.\d{2}$/, + ); + }); + + it("returns only the 500 most recently seen machines", async () => { + const env = makeEnv(); + const token = await mintToken({ sub: "user_1" }); + env.DB.rows = Array.from({ length: 501 }, (_, index) => ({ + user_id: "user_1", + machine_key: `machine-${index}`, + device_id: `device-${index}`, + name: `Machine ${index}`, + platform: "macOS", + device_type: "desktop", + pubkey: null, + reachable_endpoints: "[]", + last_seen_at: index, + created_at: index, + })); + + const response = await handleRequest(request("GET", "/account/machines", token), env); + const body = await response.json() as { machines: Array<{ machineKey: string }> }; + + expect(body.machines).toHaveLength(500); + expect(body.machines[0]?.machineKey).toBe("machine-500"); + expect(body.machines[499]?.machineKey).toBe("machine-1"); }); it("honors an environment override for the online window", async () => { diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index 9ebde9e94..c26927093 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_MACHINE_HEARTBEAT_MS, + ACCOUNT_MACHINE_RELAY_STATE_POLL_MS, type AccountMachineRegistrationSnapshot, buildAccountMachineRegistration, createBrainAccountMachinePublisherService, @@ -125,8 +126,8 @@ afterEach(() => { }); describe("account machine publisher health", () => { - it("defaults the account-directory heartbeat to 60 seconds", () => { - expect(ACCOUNT_MACHINE_HEARTBEAT_MS).toBe(60_000); + it("defaults the account-directory heartbeat to 30 seconds", () => { + expect(ACCOUNT_MACHINE_HEARTBEAT_MS).toBe(30_000); }); it("records a successful publication with its endpoint count and timestamps", async () => { @@ -319,6 +320,119 @@ describe("account machine publisher health", () => { expect(fetchImpl).toHaveBeenCalledTimes(2); service.dispose(); }); + + it("coalesces relay readiness changes into a publish and resets the heartbeat", async () => { + vi.useFakeTimers(); + const current = routeSnapshot(); + const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ signedIn: true, sessionReadState: "available" as const }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + service.start(); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_RELAY_STATE_POLL_MS); + expect(fetchImpl).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_HEARTBEAT_MS - 1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(fetchImpl).toHaveBeenCalledTimes(3); + service.dispose(); + }); + + it("publishes at the first relay poll when the startup snapshot was unavailable", async () => { + vi.useFakeTimers(); + let ready = false; + const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ signedIn: true, sessionReadState: "available" as const }), + getSnapshot: async () => ready ? routeSnapshot() : null, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + service.start(); + await vi.advanceTimersByTimeAsync(0); + expect(service.getPublisherHealth().state).toBe("no_active_sync_scope"); + expect(fetchImpl).not.toHaveBeenCalled(); + + ready = true; + await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_RELAY_STATE_POLL_MS); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(service.getPublisherHealth().state).toBe("published"); + service.dispose(); + }); + + it("publishes when the reachable endpoint set changes", async () => { + vi.useFakeTimers(); + const current = snapshot(); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 200 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ signedIn: true, sessionReadState: "available" as const }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + service.start(); + await vi.advanceTimersByTimeAsync(0); + current.pairingConnectInfo!.addressCandidates.push({ + kind: "lan", + host: "192.168.1.21", + }); + await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_RELAY_STATE_POLL_MS); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + const registration = JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body)); + expect(registration.reachableEndpoints).toEqual([ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "lan", host: "192.168.1.21", port: 8787 }, + ]); + expect(service.getPublisherHealth().reachableEndpointCount).toBe(2); + service.dispose(); + }); + + it("does not publish relay transitions while signed out", async () => { + vi.useFakeTimers(); + const current = routeSnapshot(); + const getAccessToken = vi.fn(async () => "account-token"); + const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const service = createAccountMachinePublisherService({ + getAccessToken, + getAccountStatus: () => ({ signedIn: false, sessionReadState: "missing" as const }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + service.start(); + await vi.advanceTimersByTimeAsync(0); + current.routeHealth.relay.relayBridgeValidated = false; + await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_RELAY_STATE_POLL_MS * 2); + + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(service.getPublisherHealth().state).toBe("account_signed_out"); + service.dispose(); + }); }); describe("brain account machine publisher directory policy", () => { diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 747232eb2..ab53d1e91 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -21,7 +21,8 @@ import { resolveOfficialAccountDirectoryBaseUrl, } from "./sharedAccountAuthService"; -export const ACCOUNT_MACHINE_HEARTBEAT_MS = 60_000; +export const ACCOUNT_MACHINE_HEARTBEAT_MS = 30_000; +export const ACCOUNT_MACHINE_RELAY_STATE_POLL_MS = 2_000; const DEFAULT_REQUEST_TIMEOUT_MS = 8_000; export type AccountMachineRegistration = { @@ -53,6 +54,14 @@ type PublisherAccountStatus = Pick & { sessionReadState: AccountSessionReadState; }; +function isPublisherSignedOut( + status: PublisherAccountStatus | null, +): status is PublisherAccountStatus { + return status !== null + && !status.signedIn + && status.source !== "env-token"; +} + function validatedRelayUrl(raw: string, machineKey: string): string | null { let url: URL; try { @@ -158,6 +167,20 @@ export function buildAccountMachineRegistration(args: { }; } +function relayPublishStateSignature( + snapshot: AccountMachineRegistrationSnapshot, + registration: AccountMachineRegistration, +): string { + const reachableEndpoints = registration.reachableEndpoints + .map((endpoint) => JSON.stringify(endpoint)) + .sort(); + return JSON.stringify({ + relayControlConnected: snapshot.routeHealth.relay.relayControlConnected, + relayBridgeValidated: snapshot.routeHealth.relay.relayBridgeValidated, + reachableEndpoints, + }); +} + export function createAccountMachinePublisherService(options: { getAccessToken: () => Promise; getAccountStatus?: () => PublisherAccountStatus; @@ -168,18 +191,26 @@ export function createAccountMachinePublisherService(options: { subscribeToSignIn?: (listener: () => void) => (() => void); fetchImpl?: typeof fetch; heartbeatMs?: number; + relayStatePollMs?: number; requestTimeoutMs?: number; now?: () => number; logger?: AccountMachinePublisherLogger; }) { const heartbeatMs = Math.max(1_000, Math.floor(options.heartbeatMs ?? ACCOUNT_MACHINE_HEARTBEAT_MS)); + const relayStatePollMs = Math.max( + 250, + Math.floor(options.relayStatePollMs ?? ACCOUNT_MACHINE_RELAY_STATE_POLL_MS), + ); const requestTimeoutMs = Math.max(250, Math.floor(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS)); const now = options.now ?? Date.now; let started = false; let disposed = false; - let timer: ReturnType | null = null; + let heartbeatTimer: ReturnType | null = null; + let relayStatePollTimer: ReturnType | null = null; let activeController: AbortController | null = null; let inFlight: Promise | null = null; + let triggeredPublishPending = false; + let lastRelayPublishStateSignature: string | null = null; let lastWarning: string | null = null; let unsubscribeSignIn: (() => void) | null = null; let health = createSyncAccountDirectoryHealth( @@ -187,6 +218,12 @@ export function createAccountMachinePublisherService(options: { "Account-directory publishing has not started.", ); + const observeRelayPublishState = (signature: string): boolean => { + const changed = lastRelayPublishStateSignature !== signature; + lastRelayPublishStateSignature = signature; + return changed; + }; + const warnOnce = (code: string, meta: Record = {}): void => { if (lastWarning === code) return; lastWarning = code; @@ -326,6 +363,7 @@ export function createAccountMachinePublisherService(options: { }); return; } + observeRelayPublishState(relayPublishStateSignature(snapshot, registration)); const reachableEndpointCount = registration.reachableEndpoints.length; let accountStatus: PublisherAccountStatus | null = null; @@ -340,11 +378,7 @@ export function createAccountMachinePublisherService(options: { }); return; } - if ( - accountStatus - && !accountStatus.signedIn - && accountStatus.source !== "env-token" - ) { + if (isPublisherSignedOut(accountStatus)) { const unreadable = accountStatus.sessionReadState === "unreadable"; recordOutcome(unreadable ? "token_unreadable" : "account_signed_out", { attemptAt, @@ -464,24 +498,89 @@ export function createAccountMachinePublisherService(options: { return current; }; + const clearHeartbeatTimer = (): void => { + if (heartbeatTimer) clearTimeout(heartbeatTimer); + heartbeatTimer = null; + }; + const schedule = (): void => { - if (!started || disposed || timer) return; - timer = setTimeout(() => { - timer = null; + if (!started || disposed || heartbeatTimer) return; + heartbeatTimer = setTimeout(() => { + heartbeatTimer = null; void publishNow().finally(schedule); }, heartbeatMs); - timer.unref?.(); + heartbeatTimer.unref?.(); }; - const publishAfterCurrentAttempt = (): void => { + const requestTriggeredPublish = (): void => { + // A relay-state write becomes the new heartbeat anchor, avoiding a second + // directory write when the old 30-second deadline arrives. + clearHeartbeatTimer(); + if (triggeredPublishPending) return; + triggeredPublishPending = true; const current = inFlight; - if (!current) { - void publishNow(); + const run = (): void => { + triggeredPublishPending = false; + if (disposed) return; + clearHeartbeatTimer(); + void publishNow().finally(() => { + clearHeartbeatTimer(); + schedule(); + }); + }; + if (current) void current.then(run, run); + else run(); + }; + + const inspectRelayPublishState = async (): Promise => { + if (!started || disposed || options.isSyncEnabled?.() === false) return; + try { + const accountStatus = options.getAccountStatus?.() ?? null; + if (isPublisherSignedOut(accountStatus)) return; + } catch { return; } - void current.finally(() => { - if (!disposed) void publishNow(); + + let snapshot: AccountMachineRegistrationSnapshot | null; + try { + snapshot = await options.getSnapshot(); + } catch { + return; + } + if (disposed || !snapshot) return; + + let machineKey = ""; + try { + machineKey = options.getMachineKey().trim(); + } catch { + return; + } + if (!machineKey) return; + const registration = buildAccountMachineRegistration({ + machineKey, + snapshot, + packageChannel: process.env.ADE_PACKAGE_CHANNEL, }); + if (!registration) return; + + const signature = relayPublishStateSignature(snapshot, registration); + // A first valid observation also publishes: startup may have run before an + // active sync scope existed, and waiting for the heartbeat would re-create + // the directory's 30-second connected-machine delay. + if (observeRelayPublishState(signature)) { + requestTriggeredPublish(); + } + }; + + const scheduleRelayStatePoll = (): void => { + if (!started || disposed || relayStatePollTimer) return; + relayStatePollTimer = setTimeout(() => { + relayStatePollTimer = null; + // The 2-second observation window also debounces/coalesces rapid tunnel + // readiness changes without coupling this publisher to the tunnel client. + void inspectRelayPublishState().finally(scheduleRelayStatePoll); + }, relayStatePollMs); + relayStatePollTimer.unref?.(); }; return { @@ -490,17 +589,17 @@ export function createAccountMachinePublisherService(options: { started = true; unsubscribeSignIn = options.subscribeToSignIn?.(() => { // If sign-in races an older signed-out attempt, run once more after it - // settles instead of coalescing away the auth transition until the - // next scheduled heartbeat. - publishAfterCurrentAttempt(); + // settles instead of coalescing away the auth transition for 30s. + requestTriggeredPublish(); }) ?? null; void publishNow().finally(schedule); + scheduleRelayStatePoll(); }, publishNow, requestPublishAfterCurrentAttempt(): void { - publishAfterCurrentAttempt(); + requestTriggeredPublish(); }, getPublisherHealth(): SyncAccountDirectoryHealth { @@ -510,8 +609,9 @@ export function createAccountMachinePublisherService(options: { dispose(): void { disposed = true; started = false; - if (timer) clearTimeout(timer); - timer = null; + clearHeartbeatTimer(); + if (relayStatePollTimer) clearTimeout(relayStatePollTimer); + relayStatePollTimer = null; activeController?.abort(); activeController = null; unsubscribeSignIn?.(); diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts index 6eeac8b11..d8266b9a2 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import { createServer } from "node:http"; -import { WebSocketServer } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; import { + CONTROL_PING_INTERVAL_MS, + CONTROL_PONG_DEADLINE_MS, computeBackoffMs, createSyncTunnelClientService, parseControlMessage, + RELAY_CLOSE_BRIDGE_REJECTED, + RELAY_CLOSE_HOST_UNAVAILABLE, } from "./syncTunnelClientService"; +import type { SyncLoopbackProbeResult } from "./syncLoopbackProbe"; import type { SyncCloudRelayStore } from "./syncCloudRelayStore"; import { createSharedSyncListener } from "./sharedSyncListener"; import { @@ -60,9 +65,9 @@ describe("parseControlMessage", () => { expect(parseControlMessage(JSON.stringify({ t: "open" }))).toBeNull(); }); - it("passes ping/pong and rejects junk", () => { - expect(parseControlMessage(JSON.stringify({ t: "ping" }))).toEqual({ t: "ping" }); - expect(parseControlMessage(JSON.stringify({ t: "pong" }))).toEqual({ t: "pong" }); + it("rejects JSON ping/pong and junk", () => { + expect(parseControlMessage(JSON.stringify({ t: "ping" }))).toBeNull(); + expect(parseControlMessage(JSON.stringify({ t: "pong" }))).toBeNull(); expect(parseControlMessage("not json")).toBeNull(); expect(parseControlMessage(JSON.stringify({ t: "other" }))).toBeNull(); }); @@ -254,11 +259,263 @@ describe("createSyncTunnelClientService", () => { signedIn = false; releaseClaim(new Response(null, { status: 204 })); await starting; - expect(service.getStatus().connected).toBe(false); - expect(service.getStatus().lastError).toBe("Sign in to ADE to use ADE Relay."); + await vi.waitFor(() => { + expect(service.getStatus().connected).toBe(false); + expect(service.getStatus().lastError).toBe("Sign in to ADE to use ADE Relay."); + }); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + } + }); + + it("keeps account polling from bypassing reconnect backoff after a failed claim", async () => { + const originalFetch = globalThis.fetch; + const random = vi.spyOn(Math, "random").mockReturnValue(0.999999); + const fetchMock = vi.fn(async () => new Response(null, { status: 503 })); + globalThis.fetch = fetchMock; + const service = createSyncTunnelClientService({ + getSyncPort: () => 8787, + getRelayBridgeProof: () => "e".repeat(43), + accountStatusPollMs: 5, + configStore: fakeStore(), + }); + + try { + await service.start(); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(service.getStatus()).toMatchObject({ + connected: false, + lastError: "claim failed (503)", + }); + } finally { + await service.dispose(); + random.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("gates an open immediately when sign-out happens before the next account poll", async () => { + const local = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + local.once("listening", resolve); + local.once("error", reject); + }); + const localAddress = local.address(); + const localPort = typeof localAddress === "object" && localAddress ? localAddress.port : 0; + let localConnections = 0; + local.on("connection", () => { localConnections += 1; }); + + const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + relay.once("listening", resolve); + relay.once("error", reject); + }); + const relayAddress = relay.address(); + const relayPort = typeof relayAddress === "object" && relayAddress ? relayAddress.port : 0; + let controlSocket: WebSocket | null = null; + const relayConnections: string[] = []; + const controlMessages: unknown[] = []; + relay.on("connection", (socket, request) => { + relayConnections.push(request.url ?? ""); + if (!request.url?.includes("/pipe/")) { + controlSocket = socket; + socket.on("message", (raw) => controlMessages.push(JSON.parse(raw.toString()) as unknown)); + } + }); + + let signedIn = true; + const expectedNonce = "c".repeat(32); + const loopbackProbe = vi.fn(async (port: number) => ({ + ok: true as const, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + })); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const service = createSyncTunnelClientService({ + getSyncPort: () => localPort, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + isAccountSignedIn: () => signedIn, + accountStatusPollMs: 10_000, + configStore: fakeStore(`http://127.0.0.1:${relayPort}`), + loopbackProbe, + }); + + try { + await service.start(); + await vi.waitFor(() => { + expect(service.getStatus().relayBridgeValidated).toBe(true); + expect(controlSocket).not.toBeNull(); + }); + const probesBeforeSignOut = loopbackProbe.mock.calls.length; + + signedIn = false; + (controlSocket as unknown as WebSocket).send(JSON.stringify({ t: "open", id: "abcdef01" })); + + await vi.waitFor(() => { + expect(controlMessages).toContainEqual({ + t: "reject", + id: "abcdef01", + code: RELAY_CLOSE_HOST_UNAVAILABLE, + reason: "bridge validation failed", + }); + }); + expect(loopbackProbe).toHaveBeenCalledTimes(probesBeforeSignOut); + expect(relayConnections).toHaveLength(1); + expect(localConnections).toBe(0); + expect(service.getStatus().activeTunnels).toBe(0); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + await new Promise((resolve) => local.close(() => resolve())); + await new Promise((resolve) => relay.close(() => resolve())); + } + }); + + it("does not open a queued tunnel from a superseded control socket", async () => { + const local = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + local.once("listening", resolve); + local.once("error", reject); + }); + const localAddress = local.address(); + const localPort = typeof localAddress === "object" && localAddress ? localAddress.port : 0; + let localConnections = 0; + local.on("connection", () => { localConnections += 1; }); + + const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + relay.once("listening", resolve); + relay.once("error", reject); + }); + const relayAddress = relay.address(); + const relayPort = typeof relayAddress === "object" && relayAddress ? relayAddress.port : 0; + const controlSockets: WebSocket[] = []; + let pipeConnections = 0; + relay.on("connection", (socket, request) => { + if (request.url?.includes("/pipe/")) { + pipeConnections += 1; + } else { + controlSockets.push(socket); + } + }); + + const expectedNonce = "c".repeat(32); + let probeCalls = 0; + let finishQueuedProbe!: (result: SyncLoopbackProbeResult) => void; + const queuedProbe = new Promise((resolve) => { + finishQueuedProbe = resolve; + }); + const loopbackProbe = vi.fn(async (port: number): Promise => { + probeCalls += 1; + if (probeCalls === 1) { + return { + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }; + } + return await queuedProbe; + }); + const originalFetch = globalThis.fetch; + const random = vi.spyOn(Math, "random").mockReturnValue(0); + globalThis.fetch = async () => new Response(null, { status: 204 }); + const service = createSyncTunnelClientService({ + getSyncPort: () => localPort, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(`http://127.0.0.1:${relayPort}`), + loopbackProbe, + }); + + try { + await service.start(); + await vi.waitFor(() => { + expect(service.getStatus().relayBridgeValidated).toBe(true); + expect(controlSockets).toHaveLength(1); + }); + controlSockets[0].send(JSON.stringify({ t: "open", id: "abcdef01" })); + await vi.waitFor(() => expect(probeCalls).toBe(2)); + + controlSockets[0].close(); + await vi.waitFor(() => expect(controlSockets).toHaveLength(2)); + finishQueuedProbe({ + ok: true, + port: localPort, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(pipeConnections).toBe(0); + expect(localConnections).toBe(0); + expect(service.getStatus().activeTunnels).toBe(0); + } finally { + await service.dispose(); + random.mockRestore(); + globalThis.fetch = originalFetch; + await new Promise((resolve) => local.close(() => resolve())); + await new Promise((resolve) => relay.close(() => resolve())); + } + }); + + it("terminates and reconnects a control socket that misses its native pong deadline", async () => { + const relay = new WebSocketServer({ host: "127.0.0.1", port: 0, autoPong: false }); + await new Promise((resolve, reject) => { + relay.once("listening", resolve); + relay.once("error", reject); + }); + const address = relay.address(); + const relayPort = typeof address === "object" && address ? address.port : 0; + let connections = 0; + let pings = 0; + relay.on("connection", (socket) => { + connections += 1; + socket.on("ping", () => { pings += 1; }); + }); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + const random = vi.spyOn(Math, "random").mockReturnValue(0); + globalThis.fetch = fetchMock; + const service = createSyncTunnelClientService({ + getSyncPort: () => null, + getRelayBridgeProof: () => null, + controlPingIntervalMs: 10, + controlPongDeadlineMs: 10, + configStore: fakeStore(`http://127.0.0.1:${relayPort}`), + }); + + try { + expect(CONTROL_PING_INTERVAL_MS).toBe(30_000); + expect(CONTROL_PONG_DEADLINE_MS).toBe(10_000); + await service.start(); + await vi.waitFor(() => { + expect(connections).toBeGreaterThanOrEqual(2); + }, { timeout: 1_000 }); + expect(pings).toBeGreaterThanOrEqual(1); + expect(fetchMock).toHaveBeenCalledTimes(1); } finally { await service.dispose(); + random.mockRestore(); globalThis.fetch = originalFetch; + await new Promise((resolve) => relay.close(() => resolve())); } }); @@ -612,9 +869,13 @@ describe("createSyncTunnelClientService", () => { const address = relay.address(); const relayPort = typeof address === "object" && address ? address.port : 0; const connections: string[] = []; + const controlMessages: unknown[] = []; relay.on("connection", (socket, request) => { connections.push(request.url ?? ""); if (connections.length === 1) { + socket.on("message", (raw) => { + controlMessages.push(JSON.parse(raw.toString()) as unknown); + }); socket.send(JSON.stringify({ t: "open", id: "abcdef01" })); } }); @@ -651,6 +912,14 @@ describe("createSyncTunnelClientService", () => { activeTunnels: 0, relayBridgeValidated: false, }); + await vi.waitFor(() => { + expect(controlMessages).toContainEqual({ + t: "reject", + id: "abcdef01", + code: RELAY_CLOSE_BRIDGE_REJECTED, + reason: "bridge validation failed", + }); + }); expect(service.getStatus().lastFailureAt).not.toBeNull(); } finally { await service.dispose(); @@ -659,6 +928,275 @@ describe("createSyncTunnelClientService", () => { } }); + it("rejects an open as host-unavailable when the local sync socket cannot connect", async () => { + const unavailable = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + unavailable.once("listening", resolve); + unavailable.once("error", reject); + }); + const unavailableAddress = unavailable.address(); + const unavailablePort = typeof unavailableAddress === "object" && unavailableAddress + ? unavailableAddress.port + : 0; + await new Promise((resolve) => unavailable.close(() => resolve())); + + const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + relay.once("listening", resolve); + relay.once("error", reject); + }); + const relayAddress = relay.address(); + const relayPort = typeof relayAddress === "object" && relayAddress ? relayAddress.port : 0; + const connections: string[] = []; + const controlMessages: unknown[] = []; + relay.on("connection", (socket, request) => { + connections.push(request.url ?? ""); + if (!request.url?.includes("/pipe/")) { + socket.on("message", (raw) => { + controlMessages.push(JSON.parse(raw.toString()) as unknown); + }); + socket.send(JSON.stringify({ t: "open", id: "abcdef01" })); + } + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const expectedNonce = "c".repeat(32); + const service = createSyncTunnelClientService({ + getSyncPort: () => unavailablePort, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(`http://127.0.0.1:${relayPort}`), + loopbackProbe: async (port) => ({ + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }), + }); + + try { + await service.start(); + await vi.waitFor(() => { + expect(controlMessages).toContainEqual({ + t: "reject", + id: "abcdef01", + code: RELAY_CLOSE_HOST_UNAVAILABLE, + reason: "host sync listener unavailable", + }); + }); + expect(connections.some((url) => url.includes("/pipe/abcdef01"))).toBe(true); + expect(service.getStatus().activeTunnels).toBe(0); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + await new Promise((resolve) => relay.close(() => resolve())); + } + }); + + it("probes serially on every open and constructs no tunnel when validation fails", async () => { + const local = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + local.once("listening", resolve); + local.once("error", reject); + }); + const localAddress = local.address(); + const localPort = typeof localAddress === "object" && localAddress ? localAddress.port : 0; + let localConnections = 0; + const localFrames: string[] = []; + local.on("connection", (socket) => { + localConnections += 1; + socket.on("message", (raw) => localFrames.push(raw.toString())); + }); + + const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + relay.once("listening", resolve); + relay.once("error", reject); + }); + const relayAddress = relay.address(); + const relayPort = typeof relayAddress === "object" && relayAddress ? relayAddress.port : 0; + let controlSocket: WebSocket | null = null; + let pipeConnections = 0; + const controlMessages: unknown[] = []; + relay.on("connection", (socket, request) => { + if (request.url?.includes("/pipe/")) { + pipeConnections += 1; + } else { + controlSocket = socket; + socket.on("message", (raw) => { + controlMessages.push(JSON.parse(raw.toString()) as unknown); + }); + } + }); + + const expectedNonce = "c".repeat(32); + let probeCalls = 0; + let finishOpenProbe!: (result: SyncLoopbackProbeResult) => void; + const openProbe = new Promise((resolve) => { + finishOpenProbe = resolve; + }); + const loopbackProbe = vi.fn(async (port: number): Promise => { + probeCalls += 1; + if (probeCalls === 1) { + return { + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }; + } + return await openProbe; + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const service = createSyncTunnelClientService({ + getSyncPort: () => localPort, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(`http://127.0.0.1:${relayPort}`), + loopbackProbe, + }); + + try { + await service.start(); + await vi.waitFor(() => { + expect(service.getStatus().relayBridgeValidated).toBe(true); + expect(controlSocket).not.toBeNull(); + }); + (controlSocket as unknown as WebSocket).send(JSON.stringify({ t: "open", id: "abcdef01" })); + await vi.waitFor(() => { + expect(probeCalls).toBe(2); + }); + expect(pipeConnections).toBe(0); + expect(localConnections).toBe(0); + expect(localFrames).toEqual([]); + expect(service.getStatus().activeTunnels).toBe(0); + + finishOpenProbe({ + ok: false, + port: localPort, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: "d".repeat(32), + checkedAt: new Date().toISOString(), + reason: "listener identity changed", + }); + await vi.waitFor(() => { + expect(controlMessages).toContainEqual({ + t: "reject", + id: "abcdef01", + code: RELAY_CLOSE_BRIDGE_REJECTED, + reason: "bridge validation failed", + }); + }); + expect(pipeConnections).toBe(0); + expect(localConnections).toBe(0); + expect(localFrames).toEqual([]); + expect(service.getStatus().activeTunnels).toBe(0); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + await new Promise((resolve) => local.close(() => resolve())); + await new Promise((resolve) => relay.close(() => resolve())); + } + }); + + it("preserves application close codes and reasons across the pipe/local bridge", async () => { + const local = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + local.once("listening", resolve); + local.once("error", reject); + }); + const localAddress = local.address(); + const localPort = typeof localAddress === "object" && localAddress ? localAddress.port : 0; + const localSockets: WebSocket[] = []; + const localCloses: Array<{ code: number; reason: string }> = []; + local.on("connection", (socket) => { + const index = localSockets.push(socket) - 1; + socket.on("close", (code, reason) => { + localCloses[index] = { code, reason: reason.toString() }; + }); + }); + + const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + relay.once("listening", resolve); + relay.once("error", reject); + }); + const relayAddress = relay.address(); + const relayPort = typeof relayAddress === "object" && relayAddress ? relayAddress.port : 0; + let controlSocket: WebSocket | null = null; + const pipeSockets: WebSocket[] = []; + const pipeCloses: Array<{ code: number; reason: string }> = []; + relay.on("connection", (socket, request) => { + if (request.url?.includes("/pipe/")) { + const index = pipeSockets.push(socket) - 1; + socket.on("close", (code, reason) => { + pipeCloses[index] = { code, reason: reason.toString() }; + }); + } else { + controlSocket = socket; + } + }); + const expectedNonce = "c".repeat(32); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const service = createSyncTunnelClientService({ + getSyncPort: () => localPort, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(`http://127.0.0.1:${relayPort}`), + loopbackProbe: async (port) => ({ + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }), + }); + + try { + await service.start(); + await vi.waitFor(() => { + expect(service.getStatus().relayBridgeValidated).toBe(true); + expect(controlSocket).not.toBeNull(); + }); + (controlSocket as unknown as WebSocket).send(JSON.stringify({ t: "open", id: "abcdef01" })); + await vi.waitFor(() => { + expect(pipeSockets).toHaveLength(1); + expect(localSockets).toHaveLength(1); + }); + localSockets[0]?.close(4666, "local app close"); + await vi.waitFor(() => { + expect(pipeCloses[0]).toEqual({ code: 4666, reason: "local app close" }); + }); + + (controlSocket as unknown as WebSocket).send(JSON.stringify({ t: "open", id: "abcdef02" })); + await vi.waitFor(() => { + expect(pipeSockets).toHaveLength(2); + expect(localSockets).toHaveLength(2); + }); + pipeSockets[1]?.close(4777, "pipe app close"); + await vi.waitFor(() => { + expect(localCloses[1]).toEqual({ code: 4777, reason: "pipe app close" }); + expect(service.getStatus().activeTunnels).toBe(0); + }); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + await new Promise((resolve) => local.close(() => resolve())); + await new Promise((resolve) => relay.close(() => resolve())); + } + }); + it("does not open a pipe when the account lease expires during loopback validation", async () => { const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 }); await new Promise((resolve, reject) => { diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts index 0efd452a9..847e4fa39 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts @@ -67,6 +67,9 @@ type SyncTunnelClientArgs = { loopbackProbe?: (port: number, expectedNonce: string) => Promise; /** Test seam for account-session lifecycle reconciliation. */ accountStatusPollMs?: number; + /** Test seams; production uses the exported protocol-liveness defaults. */ + controlPingIntervalMs?: number; + controlPongDeadlineMs?: number; /** Requests a fresh directory publication after a confirmed-409 identity rotation opens. */ onIdentityRotated?: () => void | Promise; }; @@ -74,6 +77,11 @@ type SyncTunnelClientArgs = { const BACKOFF_BASE_MS = 1_000; const BACKOFF_CAP_MS = 60_000; const ACCOUNT_STATUS_POLL_MS = 1_000; +export const CONTROL_PING_INTERVAL_MS = 30_000; +export const CONTROL_PONG_DEADLINE_MS = 10_000; +export const RELAY_CLOSE_PARTNER_CLOSED = 4000; +export const RELAY_CLOSE_HOST_UNAVAILABLE = 4501; +export const RELAY_CLOSE_BRIDGE_REJECTED = 4507; export const RELAY_SIGN_IN_REQUIRED_MESSAGE = "Sign in to ADE to use ADE Relay."; const MAX_UNEXPECTED_RESPONSE_BODY_BYTES = 512; const MAX_CONFIRMED_CONFLICT_ROTATIONS = 1; @@ -104,7 +112,7 @@ export function computeBackoffMs(attempt: number, random: () => number = Math.ra return Math.floor(random() * ceiling); } -export type ControlMessage = { t: "open"; id: string } | { t: "pong" } | { t: "ping" }; +export type ControlMessage = { t: "open"; id: string }; /** Parses a host control frame; returns null for anything unrecognized. */ export function parseControlMessage(raw: string): ControlMessage | null { @@ -120,8 +128,6 @@ export function parseControlMessage(raw: string): ControlMessage | null { const id = (parsed as { id?: unknown }).id; return typeof id === "string" && /^[a-f0-9]{8,32}$/i.test(id) ? { t: "open", id } : null; } - if (t === "pong") return { t: "pong" }; - if (t === "ping") return { t: "ping" }; return null; } @@ -129,6 +135,22 @@ function nowSeconds(): string { return String(Math.floor(Date.now() / 1000)); } +const MAX_CLOSE_REASON_BYTES = 123; + +function applicationCloseCode(code: unknown, fallback = RELAY_CLOSE_PARTNER_CLOSED): number { + return typeof code === "number" && Number.isInteger(code) && code >= 4000 && code <= 4999 + ? code + : fallback; +} + +function sanitizedCloseReason(reason: unknown, fallback: string): string { + const raw = typeof reason === "string" ? reason : Buffer.isBuffer(reason) ? reason.toString("utf8") : ""; + const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim() || fallback; + const encoded = Buffer.from(clean, "utf8"); + if (encoded.byteLength <= MAX_CLOSE_REASON_BYTES) return clean; + return encoded.subarray(0, MAX_CLOSE_REASON_BYTES).toString("utf8").replace(/\uFFFD$/, "").trimEnd(); +} + /** * Brain-side tunnel client. While the sync host and account lease are valid, * keeps a signed control WebSocket open to the relay; for each `{t:"open", id}` @@ -139,11 +161,13 @@ function nowSeconds(): string { export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncTunnelClientService { const log = args.logger ?? {}; let control: WebSocket | null = null; + let stopControlLiveness: (() => void) | null = null; let reconnectTimer: NodeJS.Timeout | null = null; let accountStatusTimer: NodeJS.Timeout | null = null; let connectingControl = false; let accountLeaseCheckInFlight: Promise | null = null; let accountLeaseUserId: string | null = args.getAccountLease ? null : "legacy"; + let accountEligible: boolean | null = null; let attempt = 0; let started = false; let stopped = false; @@ -155,7 +179,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT let lastFailureAt: string | null = null; let lastControlOpenAt: string | null = null; let lastBridgeValidationAt: string | null = null; - let bridgeValidationInFlight: Promise | null = null; + let bridgeValidationTail: Promise = Promise.resolve(); let claimedIdentity: { relayOrigin: string; machineKey: string } | null = null; let accountLeaseExpiresAtMs: number | null = null; let consecutiveAccountLeaseFailures = 0; @@ -170,6 +194,11 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT lastFailureAt = new Date().toISOString(); }; + const clearBridgeValidation = (): void => { + validatedPort = null; + validatedLoopbackNonce = null; + }; + const recordControlFailure = (reason: string): void => { lastControlError = reason; recordFailure(reason); @@ -181,9 +210,17 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT }; const relayHttpUrl = (): string => args.configStore.getRelayUrl() || defaultRelayUrl(); - const accountSignedIn = (): boolean => + const computeAccountEligibility = (): boolean => (args.isAccountSignedIn?.() ?? true) && (!args.getAccountLease || accountLeaseUserId != null); + const accountSignedIn = (): boolean => { + if (accountEligible !== true) return false; + try { + return args.isAccountSignedIn?.() ?? true; + } catch { + return false; + } + }; const clearReconnect = (): void => { if (reconnectTimer) { @@ -302,23 +339,59 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const sig = signRelayHmacHex(id.secret, buildHostSignatureBase(id.machineKey, ts)); const wsBase = httpToWsUrl(relayHttpUrl()); const url = `${wsBase}/host/${id.machineKey}?ts=${ts}&sig=${sig}`; - const socket = new WebSocket(url); + let socket: WebSocket; + try { + socket = new WebSocket(url); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + recordControlFailure(reason); + log.warn?.("sync_tunnel.control_error", { error: reason }); + reconnectAfterAttempt = true; + return; + } const socketState: ControlSocketState = { opened: false, failureReason: null }; controlSocketStates.set(socket, socketState); control = socket; + let socketLivenessStop: (() => void) | null = null; armOpenDeadline(socket, () => { + if (control !== socket) return; const reason = "relay control socket connect timed out"; socketState.failureReason = reason; recordControlFailure(reason); }); socket.on("open", () => { + if (stopped || !accountSignedIn() || control !== socket) { + try { + socket.close(1000, "account lease unavailable"); + } catch { + // already closing + } + return; + } socketState.opened = true; attempt = 0; + clearReconnect(); connected = true; lastError = null; lastControlError = null; lastControlOpenAt = new Date().toISOString(); + socketLivenessStop = armControlLiveness( + socket, + () => { + if (control !== socket) return; + recordControlFailure("relay control socket missed pong"); + log.warn?.("sync_tunnel.control_pong_timeout"); + try { + socket.terminate(); + } catch { + // already dead + } + }, + args.controlPingIntervalMs ?? CONTROL_PING_INTERVAL_MS, + args.controlPongDeadlineMs ?? CONTROL_PONG_DEADLINE_MS, + ); + stopControlLiveness = socketLivenessStop; log.info?.("sync_tunnel.control_open", { machineKey: id.machineKey, openedAt: lastControlOpenAt, @@ -326,11 +399,21 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT void validateCurrentBridge(); }); socket.on("message", (raw: RawData) => { + if (control !== socket) return; const message = parseControlMessage(rawToText(raw)); - if (message?.t === "open") void openTunnel(id, message.id); + if (message?.t === "open") void openTunnel(id, message.id, socket); }); socket.on("unexpected-response", (request, response) => { captureUnexpectedResponseBody(response, (body) => { + if (control !== socket) { + try { + request.destroy(); + socket.terminate(); + } catch { + // superseded socket is already closed + } + return; + } const status = response.statusCode ?? 0; const reason = `Relay control upgrade failed with HTTP ${status}${body ? `: ${body}` : "."}`; socketState.failureReason = reason; @@ -354,6 +437,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT }); }); socket.on("error", (error: Error) => { + if (control !== socket) return; const reason = socketState.failureReason ?? error.message; if (!socketState.failureReason) { socketState.failureReason = reason; @@ -366,6 +450,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT }); }); socket.on("close", (code: number, rawReason: Buffer) => { + socketLivenessStop?.(); + if (stopControlLiveness === socketLivenessStop) stopControlLiveness = null; const reason = rawReason.toString("utf8").trim(); const wasCurrent = control === socket; log.info?.("sync_tunnel.control_close", { @@ -389,6 +475,11 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT scheduleReconnect(); } }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + recordControlFailure(reason); + log.warn?.("sync_tunnel.control_connect_failed", { error: reason }); + reconnectAfterAttempt = true; } finally { connectingControl = false; if (reconnectAfterAttempt) scheduleReconnect(); @@ -397,19 +488,20 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const runBridgeValidation = async (): Promise => { if (!accountSignedIn()) { + clearBridgeValidation(); recordFailure(RELAY_SIGN_IN_REQUIRED_MESSAGE); return false; } const port = args.getSyncPort(); if (port == null) { + clearBridgeValidation(); recordFailure("Relay bridge refused because the ADE sync listener is not bound."); log.warn?.("sync_tunnel.no_sync_port"); return false; } const expectedLoopbackNonce = args.getExpectedLoopbackNonce?.() ?? null; if (!expectedLoopbackNonce) { - validatedPort = null; - validatedLoopbackNonce = null; + clearBridgeValidation(); recordFailure("Relay bridge refused because the ADE sync listener identity is unavailable."); log.warn?.("sync_tunnel.no_loopback_identity", { port }); return false; @@ -421,8 +513,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT loopbackProbe, ); if (stopped || !accountSignedIn()) { - validatedPort = null; - validatedLoopbackNonce = null; + clearBridgeValidation(); if (!stopped) recordFailure(RELAY_SIGN_IN_REQUIRED_MESSAGE); return false; } @@ -430,8 +521,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT args.getSyncPort() !== port || (args.getExpectedLoopbackNonce?.() ?? null) !== expectedLoopbackNonce ) { - validatedPort = null; - validatedLoopbackNonce = null; + clearBridgeValidation(); recordFailure("Relay bridge refused because the ADE sync listener changed during validation."); return false; } @@ -452,8 +542,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT } return true; } catch (error) { - validatedPort = null; - validatedLoopbackNonce = null; + clearBridgeValidation(); const reason = `Relay bridge refused because 127.0.0.1:${port} is not the ADE sync listener: ${error instanceof Error ? error.message : String(error)}`; recordFailure(reason); log.warn?.("sync_tunnel.loopback_validation_failed", { @@ -466,69 +555,134 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const validateCurrentBridge = (): Promise => { if (stopped) return Promise.resolve(false); - if (bridgeValidationInFlight) return bridgeValidationInFlight; - const current = runBridgeValidation() + // Every caller gets a fresh probe, but probes run one at a time. Inbound + // opens must never share a cached/in-flight success before constructing the + // authenticated local socket. + const current = bridgeValidationTail + .then(runBridgeValidation, runBridgeValidation) .catch((error) => { const reason = error instanceof Error ? error.message : String(error); recordFailure(reason); log.warn?.("sync_tunnel.bridge_validation_failed", { error: reason }); return false; - }) - .finally(() => { - if (bridgeValidationInFlight === current) bridgeValidationInFlight = null; }); - bridgeValidationInFlight = current; + bridgeValidationTail = current.then(() => undefined, () => undefined); return current; }; - const openTunnel = async (id: MachineIdentity, connectionId: string): Promise => { - // Keep validation on every inbound open as defense in depth even though - // control-open and listener-ready lifecycle events validate proactively. - if (!await validateCurrentBridge()) return; + const sendControlReject = ( + controlSocket: WebSocket, + connectionId: string, + code: number, + reason: string, + ): void => { + if (control !== controlSocket || controlSocket.readyState !== WebSocket.OPEN) return; + try { + controlSocket.send(JSON.stringify({ + t: "reject", + id: connectionId, + code: applicationCloseCode(code, RELAY_CLOSE_BRIDGE_REJECTED), + reason: sanitizedCloseReason(reason, "bridge rejected"), + })); + } catch { + // A dead control socket is handled by the native ping/pong state machine. + } + }; + + const openTunnel = async ( + id: MachineIdentity, + connectionId: string, + controlSocket: WebSocket, + ): Promise => { + if (!await validateCurrentBridge()) { + sendControlReject( + controlSocket, + connectionId, + args.getSyncPort() == null || !accountSignedIn() + ? RELAY_CLOSE_HOST_UNAVAILABLE + : RELAY_CLOSE_BRIDGE_REJECTED, + args.getSyncPort() == null ? "host sync listener unavailable" : "bridge validation failed", + ); + return; + } + if (control !== controlSocket) return; + const port = validatedPort; - if (port == null || args.getSyncPort() !== port) { + if ( + port == null + || args.getSyncPort() !== port + || (args.getExpectedLoopbackNonce?.() ?? null) !== validatedLoopbackNonce + || !accountSignedIn() + ) { + clearBridgeValidation(); recordFailure("Relay bridge refused because the ADE sync listener changed during validation."); + sendControlReject(controlSocket, connectionId, RELAY_CLOSE_BRIDGE_REJECTED, "bridge identity changed"); return; } const relayBridgeProof = args.getRelayBridgeProof(); if (!relayBridgeProof) { - validatedPort = null; - validatedLoopbackNonce = null; + clearBridgeValidation(); recordFailure("Relay bridge refused because the local bridge credential is unavailable."); log.warn?.("sync_tunnel.no_bridge_credential", { connectionId, port }); + sendControlReject(controlSocket, connectionId, RELAY_CLOSE_BRIDGE_REJECTED, "bridge credential unavailable"); return; } const ts = nowSeconds(); const sig = signRelayHmacHex(id.secret, buildPipeSignatureBase(id.machineKey, connectionId, ts)); const pipeUrl = `${httpToWsUrl(relayHttpUrl())}/host/${id.machineKey}/pipe/${connectionId}?ts=${ts}&sig=${sig}`; - const pipe = new WebSocket(pipeUrl); - const local = new WebSocket(`ws://127.0.0.1:${String(port)}`, { - headers: { [SYNC_RELAY_BRIDGE_PROOF_HEADER]: relayBridgeProof }, - }); + if (control !== controlSocket) return; + let pipe: WebSocket | null = null; + let local: WebSocket | null = null; + try { + pipe = new WebSocket(pipeUrl); + local = new WebSocket(`ws://127.0.0.1:${String(port)}`, { + headers: { [SYNC_RELAY_BRIDGE_PROOF_HEADER]: relayBridgeProof }, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + recordFailure(reason); + if (pipe) safeCloseWebSocket(pipe, RELAY_CLOSE_BRIDGE_REJECTED, "bridge setup failed"); + sendControlReject( + controlSocket, + connectionId, + pipe ? RELAY_CLOSE_HOST_UNAVAILABLE : RELAY_CLOSE_BRIDGE_REJECTED, + pipe ? "host sync listener unavailable" : "relay pipe unavailable", + ); + return; + } + + const tunnel: Tunnel = { pipe, local, connectionId }; + tunnels.add(tunnel); + let pipeOpen = false; + let localOpen = false; + let rejected = false; + const bridgeReady = (): boolean => pipeOpen && localOpen; + const rejectOpen = (code: number, reason: string): void => { + if (rejected) return; + rejected = true; + sendControlReject(controlSocket, connectionId, code, reason); + }; + const closeBoth = (sourceCode: number, sourceReason: unknown): void => { + if (!tunnels.delete(tunnel)) return; + const code = applicationCloseCode(sourceCode); + const reason = sanitizedCloseReason(sourceReason, "partner closed"); + safeCloseWebSocket(pipe, code, reason); + safeCloseWebSocket(local, code, reason); + log.debug?.("sync_tunnel.closed", { connectionId, code }); + }; + armOpenDeadline(pipe, () => { recordFailure("relay pipe connect timed out"); + rejectOpen(RELAY_CLOSE_BRIDGE_REJECTED, "relay pipe unavailable"); }); armOpenDeadline(local, () => { recordFailure("local sync socket connect timed out"); + rejectOpen(RELAY_CLOSE_HOST_UNAVAILABLE, "host sync listener unavailable"); }); - const tunnel: Tunnel = { pipe, local, connectionId }; - tunnels.add(tunnel); - const closeBoth = (): void => { - if (!tunnels.delete(tunnel)) return; - try { - pipe.close(); - } catch { - // ignore - } - try { - local.close(); - } catch { - // ignore - } - log.debug?.("sync_tunnel.closed", { connectionId }); - }; + pipe.on("open", () => { pipeOpen = true; }); + local.on("open", () => { localOpen = true; }); // Byte-for-byte forwarding in both directions; the `isBinary` flag preserves // text-vs-binary framing so the sync protocol rides through unmodified. @@ -536,28 +690,44 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT // instant this pipe opens — usually BEFORE the local socket to the sync // host has finished connecting — so each direction buffers until its // target opens rather than dropping those first frames. - const forwardOrBuffer = makeBufferedForwarder(() => closeBoth()); + const forwardOrBuffer = makeBufferedForwarder(() => { + if (!bridgeReady()) rejectOpen(RELAY_CLOSE_BRIDGE_REJECTED, "bridge frame buffer overflow"); + closeBoth(RELAY_CLOSE_BRIDGE_REJECTED, "bridge frame buffer overflow"); + }); pipe.on("message", (data: RawData, isBinary: boolean) => forwardOrBuffer(local, data, isBinary)); local.on("message", (data: RawData, isBinary: boolean) => forwardOrBuffer(pipe, data, isBinary)); - pipe.on("close", closeBoth); - local.on("close", closeBoth); + pipe.on("close", (code: number, reason: Buffer) => { + if (!bridgeReady()) { + rejectOpen(applicationCloseCode(code, RELAY_CLOSE_BRIDGE_REJECTED), "relay pipe unavailable"); + } + closeBoth(code, reason); + }); + local.on("close", (code: number, reason: Buffer) => { + if (!bridgeReady()) rejectOpen(RELAY_CLOSE_HOST_UNAVAILABLE, "host sync listener unavailable"); + closeBoth(code, reason); + }); pipe.on("error", (error: Error) => { recordFailure(error.message); - closeBoth(); + if (!bridgeReady()) rejectOpen(RELAY_CLOSE_BRIDGE_REJECTED, "relay pipe unavailable"); + closeBoth(RELAY_CLOSE_PARTNER_CLOSED, "relay pipe error"); }); local.on("error", (error: Error) => { recordFailure(error.message); - closeBoth(); + if (!bridgeReady()) rejectOpen(RELAY_CLOSE_HOST_UNAVAILABLE, "host sync listener unavailable"); + closeBoth(RELAY_CLOSE_PARTNER_CLOSED, "host sync listener error"); }); + log.debug?.("sync_tunnel.open", { connectionId }); }; const closeRelayConnections = (controlReason: string): void => { - if (control) { - const socket = control; + stopControlLiveness?.(); + stopControlLiveness = null; + const socket = control; + control = null; + if (socket) { const state = controlSocketStates.get(socket); if (state && !state.failureReason) state.failureReason = controlReason; - control = null; try { if (socket.readyState === WebSocket.CONNECTING) { socket.terminate(); @@ -570,37 +740,30 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT } for (const tunnel of [...tunnels]) { tunnels.delete(tunnel); - try { - tunnel.pipe.close(); - } catch { - // ignore - } - try { - tunnel.local.close(); - } catch { - // ignore - } + safeCloseWebSocket(tunnel.pipe, RELAY_CLOSE_PARTNER_CLOSED, "host unavailable"); + safeCloseWebSocket(tunnel.local, RELAY_CLOSE_PARTNER_CLOSED, "host unavailable"); } connected = false; - validatedPort = null; - validatedLoopbackNonce = null; + clearBridgeValidation(); }; - const reconcileAccountEligibility = (): void => { + const reconcileAccountEligibility = async (): Promise => { if (stopped || !started) return; - if (!accountSignedIn()) { + const nextEligibility = computeAccountEligibility(); + accountEligible = nextEligibility; + if (!nextEligibility) { clearReconnect(); lastError = RELAY_SIGN_IN_REQUIRED_MESSAGE; closeRelayConnections("account lease unavailable"); return; } if (lastError === RELAY_SIGN_IN_REQUIRED_MESSAGE) lastError = null; - if (!control && !connectingControl && !reconnectTimer) void connectControl(); + if (!control && !connectingControl && !reconnectTimer) await connectControl(); }; const refreshAccountLease = async (): Promise => { if (!args.getAccountLease) { - reconcileAccountEligibility(); + await reconcileAccountEligibility(); return; } if (accountLeaseCheckInFlight) return await accountLeaseCheckInFlight; @@ -635,7 +798,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT closeRelayConnections("account lease refresh failed or expired"); } } - reconcileAccountEligibility(); + await reconcileAccountEligibility(); })(); accountLeaseCheckInFlight = check; try { @@ -650,13 +813,13 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT if (started) return; started = true; stopped = false; + accountEligible = null; accountStatusTimer = setInterval( () => { void refreshAccountLease(); }, args.accountStatusPollMs ?? ACCOUNT_STATUS_POLL_MS, ); accountStatusTimer.unref?.(); await refreshAccountLease(); - await connectControl(); }, async stop(): Promise { @@ -667,6 +830,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT clearInterval(accountStatusTimer); accountStatusTimer = null; } + accountEligible = null; closeRelayConnections("service stopped"); }, @@ -797,6 +961,63 @@ function armOpenDeadline(socket: WebSocket, onTimeout: () => void): void { socket.once("error", clear); } +/** Native protocol ping/pong liveness; no JSON frames reach or wake the DO. */ +function armControlLiveness( + socket: WebSocket, + onMiss: () => void, + pingIntervalMs: number, + pongDeadlineMs: number, +): () => void { + let stopped = false; + let pongDeadline: NodeJS.Timeout | null = null; + const clearPongDeadline = (): void => { + if (!pongDeadline) return; + clearTimeout(pongDeadline); + pongDeadline = null; + }; + const onPong = (): void => { + clearPongDeadline(); + }; + const pingTimer = setInterval(() => { + if (stopped || socket.readyState !== WebSocket.OPEN || pongDeadline) return; + pongDeadline = setTimeout(() => { + pongDeadline = null; + if (!stopped) onMiss(); + }, pongDeadlineMs); + pongDeadline.unref?.(); + try { + socket.ping(); + } catch { + clearPongDeadline(); + onMiss(); + } + }, pingIntervalMs); + pingTimer.unref?.(); + socket.on("pong", onPong); + + const stop = (): void => { + if (stopped) return; + stopped = true; + clearInterval(pingTimer); + clearPongDeadline(); + socket.off("pong", onPong); + }; + socket.once("close", stop); + return stop; +} + +function safeCloseWebSocket(socket: WebSocket, code: number, reason: string): void { + try { + if (socket.readyState === WebSocket.OPEN) { + socket.close(applicationCloseCode(code), sanitizedCloseReason(reason, "partner closed")); + } else if (socket.readyState === WebSocket.CONNECTING) { + socket.terminate(); + } + } catch { + // already closing/dead + } +} + /** Frames buffered per still-connecting target before the pair is torn down. */ export const MAX_PENDING_TUNNEL_FRAMES = 64; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 70ec3af45..1ad07ef5d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "dev:browser-bridge": "node ./scripts/browser-runtime-bridge.mjs", "export:browser-mock-ade": "node ./scripts/export-browser-mock-ade-snapshot.mjs", "build": "tsup && vite build", - "build:webclient": "vite build --config vite.webclient.config.ts --configLoader runner", + "build:webclient": "vite build --config vite.webclient.config.ts --configLoader runner && node ./scripts/check-webclient-entry.mjs", "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", "dist:mac": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac --publish never", "dist:mac:dir": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", diff --git a/apps/desktop/scripts/check-webclient-entry.mjs b/apps/desktop/scripts/check-webclient-entry.mjs new file mode 100644 index 000000000..0305e8849 --- /dev/null +++ b/apps/desktop/scripts/check-webclient-entry.mjs @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const outputDir = path.resolve(scriptDir, "../dist/web-client"); +const indexPath = path.join(outputDir, "index.html"); +const maxRawBytes = 1000 * 1024; +const forbiddenChunkName = /monaco|graph|terminal|markdown|xterm|katex|pdf/i; + +function readAttribute(tag, name) { + const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i")); + return match?.[1] ?? match?.[2] ?? match?.[3] ?? null; +} + +function localOutputPath(reference) { + const url = new URL(reference, "https://webclient.invalid/"); + if (url.origin !== "https://webclient.invalid") return null; + + const outputPath = path.resolve(outputDir, `.${decodeURIComponent(url.pathname)}`); + const relativePath = path.relative(outputDir, outputPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error(`Webclient entry reference escapes the output directory: ${reference}`); + } + return outputPath; +} + +if (!fs.existsSync(indexPath)) { + throw new Error(`Webclient entry check could not find ${indexPath}`); +} + +const html = fs.readFileSync(indexPath, "utf8"); +const checkedReferences = []; +const referencedJavaScript = new Set(); + +for (const match of html.matchAll(/<(script|link)\b[^>]*>/gi)) { + const tagName = match[1].toLowerCase(); + const tag = match[0]; + const relation = readAttribute(tag, "rel")?.toLowerCase().split(/\s+/) ?? []; + const isCheckedLink = tagName === "link" + && (relation.includes("modulepreload") || relation.includes("stylesheet")); + if (tagName !== "script" && !isCheckedLink) continue; + + const reference = readAttribute(tag, tagName === "script" ? "src" : "href"); + if (!reference) continue; + checkedReferences.push(reference); + + const chunkName = path.basename(new URL(reference, "https://webclient.invalid/").pathname); + if (forbiddenChunkName.test(chunkName)) { + throw new Error(`Webclient entry references forbidden heavy chunk: ${reference}`); + } + + const isJavaScript = tagName === "script" || relation.includes("modulepreload"); + const outputPath = isJavaScript ? localOutputPath(reference) : null; + if (tagName === "script" && !outputPath) { + throw new Error(`Webclient entry references external JavaScript: ${reference}`); + } + if (outputPath) referencedJavaScript.add(outputPath); +} + +if (referencedJavaScript.size === 0) { + throw new Error("Webclient entry check found no local JavaScript references"); +} + +let totalRawBytes = Buffer.byteLength(html); +for (const javaScriptPath of referencedJavaScript) { + if (!fs.existsSync(javaScriptPath)) { + throw new Error(`Webclient entry references missing JavaScript: ${javaScriptPath}`); + } + totalRawBytes += fs.statSync(javaScriptPath).size; +} + +const totalRawKilobytes = totalRawBytes / 1024; +console.log( + `Webclient entry graph: ${totalRawKilobytes.toFixed(1)} KB raw ` + + `(index.html + ${referencedJavaScript.size} JS file${referencedJavaScript.size === 1 ? "" : "s"})`, +); + +if (totalRawBytes > maxRawBytes) { + throw new Error( + `Webclient entry graph exceeds 1000 KB raw: ${totalRawKilobytes.toFixed(1)} KB`, + ); +} + +if (checkedReferences.length === 0) { + throw new Error("Webclient entry check found no script, modulepreload, or stylesheet references"); +} diff --git a/apps/desktop/src/renderer/webclient.html b/apps/desktop/src/renderer/webclient.html index 20ffc060d..b9be8649c 100644 --- a/apps/desktop/src/renderer/webclient.html +++ b/apps/desktop/src/renderer/webclient.html @@ -3,10 +3,58 @@ + + ADE + -
+
+
+ ADE + +
+
diff --git a/apps/desktop/src/renderer/webclient/public/_headers b/apps/desktop/src/renderer/webclient/public/_headers index a88796a3f..86964c614 100644 --- a/apps/desktop/src/renderer/webclient/public/_headers +++ b/apps/desktop/src/renderer/webclient/public/_headers @@ -4,3 +4,7 @@ Referrer-Policy: no-referrer X-Frame-Options: DENY Permissions-Policy: camera=(), microphone=(), geolocation=() + Cache-Control: no-cache + +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/apps/desktop/src/renderer/webclient/shell/MachinePicker.tsx b/apps/desktop/src/renderer/webclient/shell/MachinePicker.tsx index 58347769f..de1c44192 100644 --- a/apps/desktop/src/renderer/webclient/shell/MachinePicker.tsx +++ b/apps/desktop/src/renderer/webclient/shell/MachinePicker.tsx @@ -62,12 +62,16 @@ function EnvironmentButton({ function SavedEnvironmentList({ environments, + state, onSelect, + onRetry, }: { environments: WebClientEnvironmentRecord[]; + state: "loading" | "ready" | "unavailable"; onSelect: (environment: WebClientEnvironmentRecord) => void; + onRetry: () => void; }) { - if (environments.length === 0) return null; + if (state === "ready" && environments.length === 0) return null; return (
@@ -78,7 +82,18 @@ function SavedEnvironmentList({ Existing connections saved in this browser remain available.
- {environments.map((environment) => ( + {state === "loading" ? ( +
+ Loading saved environments… +
+ ) : state === "unavailable" ? ( +
+ + Browser storage unavailable + + +
+ ) : environments.map((environment) => ( ))}
@@ -91,21 +106,27 @@ export function MachinePicker({ account, relayAccess, connectingMachineKey, + directoryLoading = false, + savedEnvironmentsState = "ready", onSelect, onSelectAccountMachine, onSignIn, onSignOut, onRetryDirectory, + onRetrySavedEnvironments = () => undefined, }: { environments: WebClientEnvironmentRecord[]; account: BrowserAccountSnapshot; relayAccess: WebRelayAccess; connectingMachineKey: string | null; + directoryLoading?: boolean; + savedEnvironmentsState?: "loading" | "ready" | "unavailable"; onSelect: (environment: WebClientEnvironmentRecord) => void; onSelectAccountMachine: (machine: AdeAccountMachine) => void; onSignIn: () => void; onSignOut: () => void; onRetryDirectory: () => void; + onRetrySavedEnvironments?: () => void; }) { const signedIn = browserAccountIsSignedIn(account.state); const directEnvironments = environments.filter((environment) => ( @@ -127,9 +148,7 @@ export function MachinePicker({ title="Sign in to ADE" subtitle="Sign in to open your machines in the browser." > - {account.state === "loading" ? ( -
Checking your account…
- ) : signInAvailable ? ( + {signInAvailable ? ( - {account.state === "directory_unavailable" ? ( + {directoryLoading ? ( +
+ Loading your Macs… +
+ ) : account.state === "directory_unavailable" ? (
We couldn't load your machines. Your saved connections still work. @@ -208,7 +236,12 @@ export function MachinePicker({
)} - + ); } diff --git a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx index 65e00555c..58ea00514 100644 --- a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx +++ b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx @@ -103,7 +103,8 @@ function readStashedTarget(): DeeplinkTarget | null { } type Phase = - | { kind: "loading" } + | { kind: "signing-in" } + | { kind: "booting"; message: string } | { kind: "machine-picker" } | { kind: "connecting"; name: string } | { kind: "auth-required"; message: string } @@ -111,6 +112,26 @@ type Phase = | { kind: "ready"; AppRoot: React.ComponentType } | { kind: "error"; message: string; canRetry: boolean }; +type SavedEnvironmentsState = "loading" | "ready" | "unavailable"; + +function accountEnvironmentOwner(snapshot: BrowserAccountSnapshot): string | null { + return ( + snapshot.state === "signed_in" || snapshot.state === "directory_unavailable" + ) + ? snapshot.userId + : null; +} + +function environmentsVisibleToOwner( + environments: WebClientEnvironmentRecord[], + ownerUserId: string | null, +): WebClientEnvironmentRecord[] { + return environments.filter((environment) => ( + environment.accountOwnerUserId == null + || environment.accountOwnerUserId === ownerUserId + )); +} + function relayAccessFromAccount( account: BrowserAccountSnapshot, getAccessToken: () => Promise, @@ -137,11 +158,18 @@ export function WebClientRoot({ accountClient?: BrowserAccountClient; }) { const [accountClient] = useState(() => providedAccountClient ?? new BrowserAccountClient()); - const [phase, setPhase] = useState({ kind: "loading" }); + const [phase, setPhase] = useState(() => ( + window.location.pathname === "/account/callback" + ? { kind: "signing-in" } + : { kind: "machine-picker" } + )); const [status, setStatus] = useState(() => client.getStatus()); - const [environments, setEnvironments] = useState([]); + const [storedEnvironments, setStoredEnvironments] = useState([]); + const [savedEnvironmentsState, setSavedEnvironmentsState] = useState("loading"); + const [savedEnvironmentsOwner, setSavedEnvironmentsOwner] = useState(null); const [catalog, setCatalog] = useState([]); const [account, setAccount] = useState(() => accountClient.getSnapshot()); + const [directoryLoading, setDirectoryLoading] = useState(false); const [connectingAccountMachineKey, setConnectingAccountMachineKey] = useState(null); const adapterRef = useRef(null); @@ -150,28 +178,83 @@ export function WebClientRoot({ const fatalRebootRef = useRef(false); const phaseIsReadyRef = useRef(false); const connectingAccountMachineRef = useRef(null); + const accountRef = useRef(account); + const savedEnvironmentsLoadRef = useRef(0); + const privacyReadyOwnerRef = useRef<{ ownerUserId: string | null } | null>(null); + const pendingEnvironmentRefreshRef = useRef<{ + ownerUserId: string | null; + environments: WebClientEnvironmentRecord[]; + } | null>(null); phaseIsReadyRef.current = phase.kind === "ready"; + accountRef.current = account; + const currentAccountOwner = accountEnvironmentOwner(account); + const environments = savedEnvironmentsState === "ready" + && savedEnvironmentsOwner === currentAccountOwner + ? storedEnvironments + : []; const relayAccess = useMemo( () => relayAccessFromAccount(account, () => accountClient.getAccessToken()), [account, accountClient], ); const refreshEnvironments = useCallback(async () => { - const list = await client.listEnvironments(); - setEnvironments(list); - return list; + try { + const list = await client.listEnvironments(); + const ownerUserId = accountEnvironmentOwner(accountRef.current); + const visibleList = environmentsVisibleToOwner(list, ownerUserId); + pendingEnvironmentRefreshRef.current = { ownerUserId, environments: visibleList }; + if (privacyReadyOwnerRef.current?.ownerUserId === ownerUserId) { + setStoredEnvironments(visibleList); + setSavedEnvironmentsOwner(ownerUserId); + setSavedEnvironmentsState("ready"); + } + return visibleList; + } catch (error) { + setStoredEnvironments([]); + setSavedEnvironmentsOwner(accountEnvironmentOwner(accountRef.current)); + setSavedEnvironmentsState("unavailable"); + throw error; + } }, [client]); const applyAccountPrivacy = useCallback(async ( snapshot: BrowserAccountSnapshot, ): Promise => { - const currentOwnerUserId = browserAccountIsSignedIn(snapshot.state) - ? snapshot.userId - : null; - await client.pruneAccountOwnedEnvironments(currentOwnerUserId); + const loadId = ++savedEnvironmentsLoadRef.current; + const currentOwnerUserId = accountEnvironmentOwner(snapshot); + privacyReadyOwnerRef.current = null; + pendingEnvironmentRefreshRef.current = null; + setStoredEnvironments([]); + setSavedEnvironmentsOwner(currentOwnerUserId); + setSavedEnvironmentsState("loading"); setAccount(snapshot); - return await refreshEnvironments(); - }, [client, refreshEnvironments]); + try { + const result = await client.pruneAccountOwnedEnvironments(currentOwnerUserId); + const survivingEnvironments = result.environments; + if (savedEnvironmentsLoadRef.current === loadId) { + privacyReadyOwnerRef.current = { ownerUserId: currentOwnerUserId }; + const pendingRefresh = pendingEnvironmentRefreshRef.current as { + ownerUserId: string | null; + environments: WebClientEnvironmentRecord[]; + } | null; + const visibleEnvironments = pendingRefresh?.ownerUserId === currentOwnerUserId + ? pendingRefresh.environments + : environmentsVisibleToOwner(survivingEnvironments, currentOwnerUserId); + setStoredEnvironments(visibleEnvironments); + setSavedEnvironmentsOwner(currentOwnerUserId); + setSavedEnvironmentsState("ready"); + } + return survivingEnvironments; + } catch { + if (savedEnvironmentsLoadRef.current === loadId) { + privacyReadyOwnerRef.current = null; + setStoredEnvironments([]); + setSavedEnvironmentsOwner(currentOwnerUserId); + setSavedEnvironmentsState("unavailable"); + } + return []; + } + }, [client]); const showMachinePicker = useCallback(() => { fatalRebootRef.current = false; @@ -227,8 +310,11 @@ export function WebClientRoot({ setPhase({ kind: "ready", AppRoot }); }, [accountClient, client]); - const afterConnect = useCallback(async () => { - const activeEnv = (await client.listEnvironments()).find((environment) => environment.envId === client.getStatus().selectedEnvId) ?? null; + const afterConnect = useCallback(async (environmentSeed?: WebClientEnvironmentRecord[]) => { + const availableEnvironments = environmentSeed ?? await client.listEnvironments(); + const activeEnv = availableEnvironments.find( + (environment) => environment.envId === client.getStatus().selectedEnvId, + ) ?? null; let projects: SyncMobileProjectSummary[] = []; try { projects = (await client.getProjectCatalog()).projects; @@ -260,7 +346,9 @@ export function WebClientRoot({ setPhase({ kind: "connecting", name: environment.machineName }); try { await client.connect(environment.envId, relayAccess); - await afterConnect(); + setPhase({ kind: "booting", message: "Opening your workspace…" }); + const refreshed = await refreshEnvironments(); + await afterConnect(refreshed); } catch (error) { if (error instanceof WebRelayAuthRequiredError) { setPhase({ kind: "auth-required", message: error.message }); @@ -269,7 +357,7 @@ export function WebClientRoot({ const message = error instanceof Error ? error.message : String(error); setPhase({ kind: "error", message, canRetry: true }); } - }, [client, relayAccess, afterConnect]); + }, [client, relayAccess, afterConnect, refreshEnvironments]); const connectToAccountMachine = useCallback(async (machine: AdeAccountMachine) => { if (connectingAccountMachineRef.current) return; @@ -290,8 +378,9 @@ export function WebClientRoot({ relayBaseUrls: accountClient.getRelayBaseUrls(), getRelayAccountToken: () => accountClient.getAccessToken(), }); - await refreshEnvironments(); - await afterConnect(); + setPhase({ kind: "booting", message: "Opening your workspace…" }); + const refreshed = await refreshEnvironments(); + await afterConnect(refreshed); } catch (error) { const snapshot = accountClient.getSnapshot(); await applyAccountPrivacy(snapshot); @@ -310,6 +399,8 @@ export function WebClientRoot({ useEffect(() => { if (bootedRef.current) return; bootedRef.current = true; + let disposed = false; + let accountProgressInterval: number | null = null; void (async () => { // Older native clients may still open the retired web pairing URL. Treat // it as the normal sign-in entry and scrub the obsolete payload. @@ -326,15 +417,58 @@ export function WebClientRoot({ stashedTargetRef.current = readStashedTarget(); } + let privacyStartedFor: string | null | undefined; + const beginPrivacyLoad = (snapshot: BrowserAccountSnapshot) => { + const ownerUserId = accountEnvironmentOwner(snapshot); + if (privacyStartedFor === ownerUserId) return; + privacyStartedFor = ownerUserId; + void applyAccountPrivacy(snapshot); + }; + if (path === "/account/callback") { - setAccount((current) => ({ ...current, state: "loading", message: null })); + // bootstrap() performs token exchange and then the directory request. + // Observe the account snapshot so the UI can distinguish those stages + // without coupling either one to browser storage. + accountProgressInterval = window.setInterval(() => { + const pendingSnapshot = accountClient.getSnapshot(); + if ( + pendingSnapshot.userId + && (pendingSnapshot.state === "signed_in" || pendingSnapshot.state === "directory_unavailable") + ) { + setAccount(pendingSnapshot); + setDirectoryLoading(true); + setPhase({ kind: "machine-picker" }); + beginPrivacyLoad(pendingSnapshot); + } + }, 150); } + const accountSnapshot = await accountClient.bootstrap(); - await applyAccountPrivacy(accountSnapshot); + if (disposed) return; + if (accountProgressInterval != null) { + window.clearInterval(accountProgressInterval); + accountProgressInterval = null; + } + setAccount(accountSnapshot); + setDirectoryLoading(false); + setPhase({ kind: "machine-picker" }); + beginPrivacyLoad(accountSnapshot); + })().catch(() => { + if (accountProgressInterval != null) { + window.clearInterval(accountProgressInterval); + accountProgressInterval = null; + } + if (disposed) return; + const accountSnapshot = accountClient.getSnapshot(); + setAccount(accountSnapshot); + setDirectoryLoading(false); setPhase({ kind: "machine-picker" }); - })().catch((error) => { - setPhase({ kind: "error", message: error instanceof Error ? error.message : String(error), canRetry: false }); + void applyAccountPrivacy(accountSnapshot); }); + return () => { + disposed = true; + if (accountProgressInterval != null) window.clearInterval(accountProgressInterval); + }; }, [accountClient, applyAccountPrivacy]); // ---- Live status subscription ------------------------------------------ @@ -464,16 +598,29 @@ export function WebClientRoot({ }, [accountClient, applyAccountPrivacy, client, showMachinePicker]); const onRetryDirectory = useCallback(() => { - setAccount((current) => ({ ...current, state: "loading", message: null })); - void accountClient.loadMachines().then(applyAccountPrivacy).catch(() => { - void applyAccountPrivacy(accountClient.getSnapshot()); + setDirectoryLoading(true); + void accountClient.loadMachines().then((snapshot) => { + setAccount(snapshot); + setDirectoryLoading(false); + void applyAccountPrivacy(snapshot); + }).catch(() => { + const snapshot = accountClient.getSnapshot(); + setAccount(snapshot); + setDirectoryLoading(false); + void applyAccountPrivacy(snapshot); }); }, [accountClient, applyAccountPrivacy]); + const onRetrySavedEnvironments = useCallback(() => { + void applyAccountPrivacy(accountRef.current); + }, [applyAccountPrivacy]); + // ---- Render ------------------------------------------------------------- switch (phase.kind) { - case "loading": - return ; + case "signing-in": + return ; + case "booting": + return ; case "connecting": return ; case "machine-picker": @@ -483,11 +630,14 @@ export function WebClientRoot({ account={account} relayAccess={relayAccess} connectingMachineKey={connectingAccountMachineKey} + directoryLoading={directoryLoading} + savedEnvironmentsState={savedEnvironmentsState} onSelect={(environment) => void connectTo(environment)} onSelectAccountMachine={(machine) => void connectToAccountMachine(machine)} onSignIn={onAccountSignIn} onSignOut={onAccountSignOut} onRetryDirectory={onRetryDirectory} + onRetrySavedEnvironments={onRetrySavedEnvironments} /> ); case "auth-required": @@ -517,13 +667,13 @@ export function WebClientRoot({ projects={phase.projects} machineName={status.hostName} onPick={(project) => { - setPhase({ kind: "connecting", name: project.displayName }); + setPhase({ kind: "booting", message: `Opening ${project.displayName}…` }); void enterProject(project, phase.projects).catch((error) => { setPhase({ kind: "error", message: error instanceof Error ? error.message : String(error), canRetry: false }); }); }} onOpenChats={() => { - setPhase({ kind: "connecting", name: "Chats" }); + setPhase({ kind: "booting", message: "Opening Chats…" }); void enterChats(phase.projects).catch((error) => { setPhase({ kind: "error", message: error instanceof Error ? error.message : String(error), canRetry: false }); }); @@ -579,11 +729,11 @@ export function WebClientRoot({ } } -function Connecting({ name }: { name: string | null }) { +function ProgressScreen({ title, message }: { title: string; message: string }) { return (
- Connecting… + {message}
); } + +function Connecting({ name }: { name: string }) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx index 33c15f683..9d40f019a 100644 --- a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx +++ b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx @@ -3,12 +3,20 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { BrowserAccountClient, BrowserAccountSnapshot } from "../../account/client"; -import type { AdeSyncClient, AdeSyncClientStatus } from "../../sync"; +import type { + AdeSyncClient, + AdeSyncClientStatus, + WebClientEnvironmentPruneResult, + WebClientEnvironmentRecord, +} from "../../sync"; import { WebClientRoot } from "../WebClientRoot"; -afterEach(cleanup); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); const signedOutAccount: BrowserAccountSnapshot = { state: "signed_out", @@ -35,23 +43,87 @@ const idleStatus: AdeSyncClientStatus = { selectedEnvId: null, }; +const accountMachine = { + machineKey: "machine-key", + deviceId: "directory-device", + name: "Directory Studio", + platform: "macOS", + deviceType: "desktop", + reachableEndpoints: [{ kind: "relay" as const, url: "wss://relay.example/connect/machine-key" }], + lastSeenAt: Date.now(), + online: true, +}; + +function signedInAccount(): BrowserAccountSnapshot { + return { + ...signedOutAccount, + state: "signed_in", + userId: "account-current", + email: "owner@example.test", + machines: [accountMachine], + }; +} + +function savedEnvironment(overrides: Partial = {}): WebClientEnvironmentRecord { + return { + envId: "saved-local", + machineName: "Current saved Mac", + hostDeviceId: "saved-device", + accountOwnerUserId: null, + relayUrl: null, + machineKeyUrl: null, + addressCandidates: [], + explicitWssEndpoints: ["wss://saved.example.test/sync"], + port: 8787, + pairedDeviceId: "browser-device", + secret: "secret", + dpopKeys: {} as CryptoKeyPair, + siteId: "site-id", + localDeviceId: "browser-device", + localDeviceName: "ADE Web", + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function pruneResult( + environments: WebClientEnvironmentRecord[], + removedIds: string[] = [], +): WebClientEnvironmentPruneResult { + return { removedIds, environments }; +} + +function syncClient(overrides: Record = {}): AdeSyncClient { + return { + getStatus: () => idleStatus, + listEnvironments: vi.fn(async () => []), + pruneAccountOwnedEnvironments: vi.fn(async () => pruneResult([])), + subscribe: vi.fn(() => () => undefined), + onProjectCatalog: vi.fn(() => () => undefined), + ...overrides, + } as unknown as AdeSyncClient; +} + +function browserAccountClient( + snapshot: BrowserAccountSnapshot, + overrides: Record = {}, +): BrowserAccountClient { + return { + getSnapshot: () => snapshot, + bootstrap: vi.fn(async () => snapshot), + ...overrides, + } as unknown as BrowserAccountClient; +} + describe("WebClientRoot entry routes", () => { it("retires /pair by scrubbing its payload and showing account sign-in", async () => { - const client = { - getStatus: () => idleStatus, - listEnvironments: vi.fn(async () => []), - pruneAccountOwnedEnvironments: vi.fn(async () => []), - subscribe: vi.fn(() => () => undefined), - onProjectCatalog: vi.fn(() => () => undefined), - } as unknown as AdeSyncClient; - const accountClient = { - getSnapshot: () => signedOutAccount, - bootstrap: vi.fn(async () => signedOutAccount), - } as unknown as BrowserAccountClient; + window.history.replaceState(null, "", "/pair#legacy-pairing-payload"); + const client = syncClient(); + const accountClient = browserAccountClient(signedOutAccount); render(); - expect(await screen.findByRole("heading", { name: "Sign in to ADE" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Sign in to ADE" })).toBeTruthy(); await waitFor(() => { expect(window.location.pathname).toBe("/"); expect(window.location.hash).toBe(""); @@ -59,4 +131,125 @@ describe("WebClientRoot entry routes", () => { expect(screen.queryByText(/pairing code/i)).toBeNull(); expect(screen.queryByText(/pairing link/i)).toBeNull(); }); + + it("renders sign-in immediately while browser storage is still pending", async () => { + window.history.replaceState(null, "", "/"); + const never = new Promise(() => undefined); + const prune = vi.fn(() => never); + const client = syncClient({ pruneAccountOwnedEnvironments: prune }); + + render(); + + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + await waitFor(() => expect(prune).toHaveBeenCalledWith(null)); + expect(screen.getByText("Loading saved environments…")).toBeTruthy(); + expect(screen.queryByText("Starting ADE")).toBeNull(); + }); + + it("keeps storage failures non-fatal and retries saved-environment loading", async () => { + const prune = vi.fn() + .mockRejectedValueOnce(new Error("IndexedDB blocked")) + .mockResolvedValueOnce(pruneResult([])); + const client = syncClient({ pruneAccountOwnedEnvironments: prune }); + + render(); + + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(await screen.findByText("Browser storage unavailable")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(prune).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText("Browser storage unavailable")).toBeNull()); + }); + + it("shows callback progress, then directory rows without waiting for account pruning", async () => { + window.history.replaceState(null, "", "/account/callback?code=test&state=test"); + let currentSnapshot = signedOutAccount; + let resolveDirectory!: (snapshot: BrowserAccountSnapshot) => void; + const directoryResponse = new Promise((resolve) => { + resolveDirectory = resolve; + }); + const bootstrap = vi.fn(() => { + currentSnapshot = { ...signedInAccount(), machines: [] }; + return directoryResponse; + }); + const never = new Promise(() => undefined); + const listEnvironments = vi.fn(async () => [savedEnvironment({ + envId: "foreign", + machineName: "Previous account Mac", + accountOwnerUserId: "account-previous", + })]); + const client = syncClient({ + listEnvironments, + pruneAccountOwnedEnvironments: vi.fn(() => never), + }); + + render( currentSnapshot, + })} + />); + + expect(screen.getByRole("heading", { name: "Signing in…" })).toBeTruthy(); + expect(await screen.findByText("Loading your Macs…")).toBeTruthy(); + currentSnapshot = signedInAccount(); + resolveDirectory(currentSnapshot); + + expect(await screen.findByRole("button", { name: /Directory Studio.*Ready/i })).toBeTruthy(); + expect(screen.getByText("Loading saved environments…")).toBeTruthy(); + expect(screen.queryByText("Previous account Mac")).toBeNull(); + expect(listEnvironments).not.toHaveBeenCalled(); + }); + + it("publishes only the environments returned after privacy pruning", async () => { + window.history.replaceState(null, "", "/account/callback?code=test&state=test"); + let resolvePrune!: (result: WebClientEnvironmentPruneResult) => void; + const prune = vi.fn(() => new Promise((resolve) => { + resolvePrune = resolve; + })); + const client = syncClient({ + listEnvironments: vi.fn(async () => [savedEnvironment({ + envId: "foreign", + machineName: "Previous account Mac", + accountOwnerUserId: "account-previous", + })]), + pruneAccountOwnedEnvironments: prune, + }); + + render(); + + expect(await screen.findByRole("button", { name: /Directory Studio.*Ready/i })).toBeTruthy(); + expect(screen.queryByText("Previous account Mac")).toBeNull(); + expect(screen.queryByText("Current saved Mac")).toBeNull(); + + resolvePrune(pruneResult([savedEnvironment()], ["foreign"])); + + expect(await screen.findByRole("button", { name: /Current saved Mac/i })).toBeTruthy(); + expect(screen.queryByText("Previous account Mac")).toBeNull(); + expect(client.listEnvironments).not.toHaveBeenCalled(); + }); + + it("passes the post-pair refresh into afterConnect instead of listing twice", async () => { + const environment = savedEnvironment({ envId: "paired" }); + const listEnvironments = vi.fn(async () => [environment]); + const client = syncClient({ + listEnvironments, + pairWithAccountMachine: vi.fn(async () => environment), + getProjectCatalog: vi.fn(async () => ({ projects: [] })), + }); + const accountClient = browserAccountClient(signedInAccount(), { + getAccessToken: vi.fn(async () => "account-token"), + captureSessionLease: vi.fn(() => ({ userId: "account-current", generation: 1 })), + isSessionLeaseCurrent: vi.fn(() => true), + getRelayBaseUrls: vi.fn(() => ["wss://relay.example"]), + }); + + render(); + const machine = await screen.findByRole("button", { name: /Directory Studio.*Ready/i }); + fireEvent.click(machine); + + expect(await screen.findByRole("heading", { name: "Open a project" })).toBeTruthy(); + expect(listEnvironments).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index 2e9a8dbdd..d897272a6 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -9,9 +9,18 @@ import type { } from "../../../../shared/types/sync"; import type { AdeAccountMachine } from "../../../../shared/types/account"; import { AdeSyncClient } from "../client"; -import type { WebSocketLike } from "../connection"; +import { SyncConnection, type WebSocketLike } from "../connection"; import { deriveBrowserSyncEndpoints } from "../endpoints"; -import { MemoryStorage, WebClientEnvStore, type WebClientEnvironmentRecord } from "../envStore"; +import { + IndexedDbOpenError, + IndexedDbStorage, + MemoryStorage, + WEB_TRUST_RESET_VERSION, + WebClientEnvStore, + type WebClientEnvironmentRecord, + type WebClientStorageArea, + type WebClientStorageTransaction, +} from "../envStore"; import { WebRelayAuthRequiredError, type WebRelayAccess } from "../relayPolicy"; import { generateDpopKeyPair, exportPublicKeyX963Base64, rawEcdsaSignatureToDer, signDpopProof } from "../dpop"; import { randomHex } from "../ids"; @@ -133,15 +142,19 @@ class ScriptedSocket implements WebSocketLike { onclose: ((event: CloseEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; readonly sent: SyncEnvelope[] = []; + readonly closeHistory: Array<{ code: number; reason: string }> = []; + closedWith: { code: number; reason: string } | null = null; constructor( readonly url: string, private readonly onClientEnvelope: (socket: ScriptedSocket, envelope: SyncEnvelope) => void | Promise, + openDelayMs = 0, ) { setTimeout(() => { + if (this.readyState !== 0) return; this.readyState = 1; this.onopen?.(new Event("open")); - }, 0); + }, openDelayMs); } send(data: string): void { @@ -152,6 +165,8 @@ class ScriptedSocket implements WebSocketLike { } close(code = 1000, reason = ""): void { + this.closedWith = { code, reason }; + this.closeHistory.push(this.closedWith); this.readyState = 3; this.onclose?.({ code, reason } as CloseEvent); } @@ -161,18 +176,48 @@ class ScriptedSocket implements WebSocketLike { } } -function createSocketFactory(handler: (socket: ScriptedSocket, envelope: SyncEnvelope) => void | Promise) { +function createSocketFactory( + handler: (socket: ScriptedSocket, envelope: SyncEnvelope) => void | Promise, + options: { openDelayMs?: number | ((socketIndex: number) => number) } = {}, +) { const sockets: ScriptedSocket[] = []; return { sockets, factory(url: string): WebSocketLike { - const socket = new ScriptedSocket(url, handler); + const socketIndex = sockets.length; + const openDelayMs = typeof options.openDelayMs === "function" + ? options.openDelayMs(socketIndex) + : options.openDelayMs; + const socket = new ScriptedSocket(url, handler, openDelayMs); sockets.push(socket); return socket; }, }; } +class VisibilityDocument { + visibilityState: DocumentVisibilityState = "visible"; + private readonly listeners = new Set<() => void>(); + + readonly document = { + get visibilityState() { + return (this as { owner: VisibilityDocument }).owner.visibilityState; + }, + owner: this, + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + if (typeof listener === "function") this.listeners.add(listener as () => void); + }, + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + if (typeof listener === "function") this.listeners.delete(listener as () => void); + }, + } as unknown as Document; + + setVisibility(state: DocumentVisibilityState): void { + this.visibilityState = state; + for (const listener of this.listeners) listener(); + } +} + class DelayedPutStorage extends MemoryStorage { delayNextEnvironmentPut = false; private pausedPut: Promise | null = null; @@ -203,6 +248,23 @@ class DelayedPutStorage extends MemoryStorage { } } +class TransactionCountingStorage extends MemoryStorage { + readonly transactions: Array<{ areas: WebClientStorageArea[]; mode: IDBTransactionMode }> = []; + + override async transaction( + areas: WebClientStorageArea[], + mode: IDBTransactionMode, + operation: (transaction: WebClientStorageTransaction) => Promise, + ): Promise { + this.transactions.push({ areas: [...areas], mode }); + return await super.transaction(areas, mode, operation); + } + + resetTransactions(): void { + this.transactions.length = 0; + } +} + async function flush(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -425,6 +487,156 @@ describe("browser sync endpoints and storage", () => { }); expect(await store.listEnvironments()).toHaveLength(1); }); + + it("rejects blocked IndexedDB opens with a typed error and permits retry", async () => { + const open = vi.fn(() => { + const request = {} as IDBOpenDBRequest; + setTimeout(() => request.onblocked?.(new Event("blocked") as IDBVersionChangeEvent), 0); + return request; + }); + vi.stubGlobal("indexedDB", { open }); + try { + const storage = new IndexedDbStorage({ openTimeoutMs: 100 }); + + await expect(storage.list("environments")).rejects.toEqual(expect.objectContaining({ + name: "IndexedDbOpenError", + code: "blocked", + })); + await expect(storage.list("environments")).rejects.toBeInstanceOf(IndexedDbOpenError); + expect(open).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("reopens IndexedDB and reruns the trust reset after versionchange", async () => { + const firstEnvironment = await makeEnvironment(new MemoryStorage(), { envId: "first" }); + const secondEnvironment = await makeEnvironment(new MemoryStorage(), { envId: "second" }); + const transactions: IDBTransactionMode[][] = [[], []]; + + const successfulRequest = (value: T): IDBRequest => { + const request = { result: value } as IDBRequest; + queueMicrotask(() => request.onsuccess?.(new Event("success"))); + return request; + }; + const databases = [[firstEnvironment], [secondEnvironment]].map((environments, index) => ({ + close: vi.fn(), + objectStoreNames: { contains: () => true }, + onversionchange: null, + transaction: (_areas: string[], mode: IDBTransactionMode) => { + transactions[index].push(mode); + const transaction = { + abort: vi.fn(), + error: null, + objectStore: (area: WebClientStorageArea) => ({ + delete: vi.fn(), + get: () => successfulRequest( + area === "meta" ? WEB_TRUST_RESET_VERSION : undefined, + ), + getAll: () => successfulRequest(environments), + put: vi.fn(), + }), + onabort: null, + oncomplete: null, + onerror: null, + } as unknown as IDBTransaction; + setTimeout(() => transaction.oncomplete?.(new Event("complete")), 0); + return transaction; + }, + })) as unknown as IDBDatabase[]; + let nextDatabase = 0; + const open = vi.fn(() => { + const request = { result: databases[nextDatabase++] } as IDBOpenDBRequest; + setTimeout(() => request.onsuccess?.(new Event("success")), 0); + return request; + }); + vi.stubGlobal("indexedDB", { open }); + + try { + const storage = new IndexedDbStorage({ openTimeoutMs: 100 }); + const store = new WebClientEnvStore(storage); + + await expect(store.listEnvironments()).resolves.toEqual([firstEnvironment]); + databases[0].onversionchange?.(new Event("versionchange") as IDBVersionChangeEvent); + await expect(store.listEnvironments()).resolves.toEqual([secondEnvironment]); + + expect(open).toHaveBeenCalledTimes(2); + expect(databases[0].close).toHaveBeenCalledOnce(); + expect(transactions).toEqual([ + ["readwrite", "readonly"], + ["readwrite", "readonly"], + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("times out an IndexedDB open that never settles", async () => { + vi.stubGlobal("indexedDB", { + open: vi.fn(() => ({} as IDBOpenDBRequest)), + }); + try { + const storage = new IndexedDbStorage({ openTimeoutMs: 5 }); + + await expect(storage.list("environments")).rejects.toEqual(expect.objectContaining({ + name: "IndexedDbOpenError", + code: "timeout", + })); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("batches the one-time trust reset into one readwrite transaction", async () => { + const storage = new TransactionCountingStorage(); + await storage.put("environments", "legacy", await makeEnvironment(new MemoryStorage(), { + envId: "legacy", + })); + await storage.put("meta", "selectedEnvId", "legacy"); + await storage.delete("meta", "machineTrustResetVersion"); + storage.resetTransactions(); + + const store = new WebClientEnvStore(storage); + await expect(store.listEnvironments()).resolves.toEqual([]); + + expect(storage.transactions.filter((transaction) => transaction.mode === "readwrite")).toEqual([{ + areas: ["environments", "meta"], + mode: "readwrite", + }]); + await expect(storage.get("meta", "machineTrustResetVersion")).resolves.toBe(WEB_TRUST_RESET_VERSION); + }); + + it("prunes account records and returns survivors from one readwrite transaction", async () => { + const storage = new TransactionCountingStorage(); + const store = new WebClientEnvStore(storage); + await store.saveEnvironment(await makeEnvironment(new MemoryStorage(), { + envId: "local", + accountOwnerUserId: null, + })); + await store.saveEnvironment(await makeEnvironment(new MemoryStorage(), { + envId: "current", + accountOwnerUserId: "account-current", + })); + await store.saveEnvironment(await makeEnvironment(new MemoryStorage(), { + envId: "foreign", + accountOwnerUserId: "account-previous", + })); + await store.setSelectedEnvId("foreign"); + storage.resetTransactions(); + + const result = await store.pruneAccountOwnedEnvironments("account-current"); + + expect(result.removedIds).toEqual(["foreign"]); + expect(result.environments.map((environment) => environment.envId).sort()).toEqual([ + "current", + "local", + ]); + expect(storage.transactions).toEqual([{ + areas: ["environments", "meta"], + mode: "readwrite", + }]); + await expect(store.getSelectedEnvId()).resolves.toBeNull(); + }); }); describe("browser sync connection and client", () => { @@ -434,6 +646,307 @@ describe("browser sync connection and client", () => { vi.unstubAllGlobals(); }); + it("closes a stale visible connection and enters the normal reconnect path", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage, { dpopPublicKeyX963: null }); + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-account-token", + ); + await vi.advanceTimersByTimeAsync(0); + await connecting; + + await vi.advanceTimersByTimeAsync(75_000); + + expect(script.sockets[0]?.closedWith).toEqual({ + code: 4008, + reason: "Inbound connection stale", + }); + expect(connection.getStatus()).toMatchObject({ + state: "reconnecting", + error: "Connection became unresponsive. Reconnecting.", + }); + expect(script.sockets).toHaveLength(1); + connection.dispose(); + }); + + it("does not close a stale connection while the document is hidden", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage, { dpopPublicKeyX963: null }); + const visibility = new VisibilityDocument(); + vi.useFakeTimers(); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const connection = new SyncConnection({ + socketFactory: script.factory, + document: visibility.document, + }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-account-token", + ); + await vi.advanceTimersByTimeAsync(0); + await connecting; + visibility.setVisibility("hidden"); + + await vi.advanceTimersByTimeAsync(90_000); + + expect(script.sockets[0]?.readyState).toBe(1); + expect(script.sockets[0]?.closedWith).toBeNull(); + expect(connection.getStatus().state).toBe("connected"); + connection.dispose(); + }); + + it("closes a stale hidden connection on visibility resume and bypasses backoff", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage, { dpopPublicKeyX963: null }); + const visibility = new VisibilityDocument(); + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const connection = new SyncConnection({ + socketFactory: script.factory, + document: visibility.document, + }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-account-token", + ); + await vi.advanceTimersByTimeAsync(0); + await connecting; + visibility.setVisibility("hidden"); + await vi.advanceTimersByTimeAsync(90_000); + + visibility.setVisibility("visible"); + expect(script.sockets[0]?.closedWith?.code).toBe(4008); + for (let attempt = 0; attempt < 20 && connection.getStatus().state !== "connected"; attempt += 1) { + await vi.advanceTimersByTimeAsync(1); + await flushMicrotasks(); + } + + expect(script.sockets).toHaveLength(2); + expect(connection.getStatus().state).toBe("connected"); + connection.dispose(); + }); + + it("allows a five-second transport open and a separate hello response within twelve seconds", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage, { dpopPublicKeyX963: null }); + vi.useFakeTimers(); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type !== "hello") return; + setTimeout(() => { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + }, 11_000); + }, { openDelayMs: 5_000 }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-account-token", + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(script.sockets[0]?.readyState).toBe(1); + await vi.advanceTimersByTimeAsync(11_000); + await connecting; + + expect(connection.getStatus().state).toBe("connected"); + expect(script.sockets[0]?.closeHistory).toEqual([]); + connection.dispose(); + }); + + it("applies the same split open and hello deadlines to account adoption", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage, { dpopPublicKeyX963: null }); + vi.useFakeTimers(); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type !== "hello") return; + const peer = (envelope.payload as { peer: SyncPeerMetadata }).peer; + setTimeout(() => { + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: { + ...helloOk(), + accountPairing: { deviceId: peer.deviceId, secret: "paired-secret" }, + }, + }); + }, 11_000); + }, { openDelayMs: 5_000 }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const pairing = connection.pairWithAccount({ + endpoints: [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + peer: { ...hostPeer, deviceId: "browser-device", deviceType: "browser" }, + accountToken: "account-token", + createDpop: async () => ({ timestamp: 1, nonce: "nonce", signature: "signature" }), + expectedHostDeviceId: hostPeer.deviceId, + existingPairing: null, + buildEnvironment: () => environment, + }); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(11_000); + + await expect(pairing).resolves.toMatchObject({ endpoint: pairingPayload.relayUrl }); + expect(connection.getStatus().state).toBe("connected"); + expect(script.sockets[0]?.closeHistory).toEqual([]); + connection.dispose(); + }); + + it("fails a transport that does not open within eight seconds", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage, { dpopPublicKeyX963: null }); + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0); + const script = createSocketFactory(() => undefined, { openDelayMs: 8_001 }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const outcome = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-account-token", + ).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(8_000); + + await expect(outcome).resolves.toMatchObject({ + message: expect.stringContaining("Timed out opening"), + }); + expect(script.sockets[0]?.closeHistory).toContainEqual({ + code: 4000, + reason: "Transport open timeout", + }); + expect(connection.getStatus()).toMatchObject({ + state: "reconnecting", + error: expect.stringContaining("Timed out opening"), + }); + connection.dispose(); + }); + + it("starts paired proof and Relay token preparation before the socket opens", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + vi.useFakeTimers(); + const digest = vi.spyOn(crypto.subtle, "digest"); + const relayTokenProvider = vi.fn(async () => "relay-account-token"); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }, { openDelayMs: 5_000 }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + relayTokenProvider, + ); + await flushMicrotasks(); + + expect(script.sockets[0]?.readyState).toBe(0); + expect(digest).toHaveBeenCalled(); + expect(relayTokenProvider).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(5_000); + await connecting; + expect(script.sockets[0]?.sent[0]?.payload).toMatchObject({ + auth: { + dpop: expect.any(Object), + relayAccountToken: "relay-account-token", + }, + }); + connection.dispose(); + }); + + it.each([ + { code: 4501, expected: "This Mac appears to be offline" }, + { code: 4507, expected: "Your Mac couldn't accept the connection. Retrying…" }, + { code: 4503, expected: "Too many active connections to this Mac" }, + { code: 4502, expected: "Connection lost. Reconnecting." }, + { code: 4000, expected: "Connection lost. Reconnecting." }, + { code: 4505, expected: "Connection lost. Reconnecting." }, + { code: 4506, expected: "Connection lost. Reconnecting." }, + ])("maps Relay close code $code and keeps reconnecting", async ({ code, expected }) => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + vi.spyOn(Math, "random").mockReturnValue(0); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + const closeEvents: Array<{ code: number; reason: string }> = []; + connection.on("close", (event) => closeEvents.push(event)); + + await connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-account-token", + ); + script.sockets[0]?.close(code, "relay detail"); + + expect(closeEvents).toEqual([{ code, reason: "relay detail" }]); + expect(connection.getStatus()).toMatchObject({ + state: "reconnecting", + error: expected, + }); + connection.dispose(); + }); + + it("rejects an in-flight command immediately when the transport is lost without replaying it", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const client = new AdeSyncClient({ storage, socketFactory: script.factory, document: null }); + + const connecting = client.connect(environment.envId, signedInRelayAccess); + await vi.advanceTimersByTimeAsync(0); + await connecting; + const command = client.sendCommand("chat.send", { text: "once" }); + await flushMicrotasks(); + expect(script.sockets[0]?.sent.filter((envelope) => envelope.type === "command")).toHaveLength(1); + + script.sockets[0]?.close(4505, "superseded"); + await expect(command).rejects.toMatchObject({ + code: "connection_lost_outcome_unknown", + details: { closeCode: 4505, reason: "superseded" }, + }); + await vi.advanceTimersByTimeAsync(1_000); + await flushMicrotasks(); + + expect(script.sockets).toHaveLength(2); + expect(script.sockets[1]?.sent.some((envelope) => envelope.type === "command")).toBe(false); + client.dispose(); + }); + it("does not open ADE Relay while signed out", async () => { const storage = new MemoryStorage(); const environment = await makeEnvironment(storage, { addressCandidates: [] }); diff --git a/apps/desktop/src/renderer/webclient/sync/client.ts b/apps/desktop/src/renderer/webclient/sync/client.ts index aa21e88cf..dd5820a6e 100644 --- a/apps/desktop/src/renderer/webclient/sync/client.ts +++ b/apps/desktop/src/renderer/webclient/sync/client.ts @@ -45,6 +45,7 @@ import { IndexedDbStorage, WebClientEnvStore, type WebClientEnvironmentRecord, + type WebClientEnvironmentPruneResult, type WebClientStorage, } from "./envStore"; import { randomHex, uuid } from "./ids"; @@ -183,13 +184,15 @@ export class AdeSyncClient { storage?: WebClientStorage; socketFactory?: WebSocketFactory; connection?: SyncConnection; - connectTimeoutMs?: number; + transportOpenTimeoutMs?: number; + authenticatedHelloTimeoutMs?: number; document?: Document | null; } = {}) { this.envStore = new WebClientEnvStore(options.storage ?? new IndexedDbStorage()); this.connection = options.connection ?? new SyncConnection({ socketFactory: options.socketFactory, - connectTimeoutMs: options.connectTimeoutMs, + transportOpenTimeoutMs: options.transportOpenTimeoutMs, + authenticatedHelloTimeoutMs: options.authenticatedHelloTimeoutMs, document: options.document, }); this.connection.on("statusChanged", () => this.emitStatus()); @@ -209,6 +212,13 @@ export class AdeSyncClient { this.connection.on("pairingRejected", ({ envId }) => { void this.removeEnvironment(envId); }); + this.connection.on("close", ({ code, reason }) => { + this.rejectPendingCommands(new AdeSyncError( + "Connection lost — outcome unknown. Check the current state before retrying.", + "connection_lost_outcome_unknown", + { closeCode: code, reason }, + )); + }); this.connection.on("brainStatus", (payload) => this.emit("brainStatus", payload)); this.connection.on("tablesChanged", (tables) => this.emit("tablesChanged", tables)); this.connection.on("projectCatalog", (payload) => { @@ -415,11 +425,12 @@ export class AdeSyncClient { async pruneAccountOwnedEnvironments( currentOwnerUserId: string | null, - ): Promise { - const removedIds = await this.envStore.pruneAccountOwnedEnvironments( + ): Promise { + const result = await this.envStore.pruneAccountOwnedEnvironments( currentOwnerUserId, ); - return await this.finishAccountEnvironmentRemoval(removedIds); + await this.finishAccountEnvironmentRemoval(result.removedIds); + return result; } private async finishAccountEnvironmentRemoval( @@ -926,11 +937,7 @@ export class AdeSyncClient { } private rejectAllPending(error: Error): void { - for (const [requestId, pending] of this.pendingCommands) { - clearTimeout(pending.timer); - this.pendingCommands.delete(requestId); - pending.reject(error); - } + this.rejectPendingCommands(error); for (const [requestId, pending] of this.pendingFiles) { clearTimeout(pending.timer); this.pendingFiles.delete(requestId); @@ -949,6 +956,14 @@ export class AdeSyncClient { this.rejectPendingProjectCatalog(error); } + private rejectPendingCommands(error: Error): void { + for (const [requestId, pending] of this.pendingCommands) { + clearTimeout(pending.timer); + this.pendingCommands.delete(requestId); + pending.reject(error); + } + } + private async persistCurrentEnvironment( update: (environment: WebClientEnvironmentRecord) => WebClientEnvironmentRecord, ): Promise { diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 5beb1801b..03513863a 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -26,8 +26,11 @@ import { } from "./wireProtocol"; const SOCKET_OPEN = 1; -const DEFAULT_CONNECT_TIMEOUT_MS = 4_000; +const DEFAULT_TRANSPORT_OPEN_TIMEOUT_MS = 8_000; +const DEFAULT_AUTHENTICATED_HELLO_TIMEOUT_MS = 12_000; const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000; +const INBOUND_STALE_MS = 75_000; +const INBOUND_STALE_CHECK_INTERVAL_MS = 15_000; const BACKOFF_MIN_MS = 1_000; const BACKOFF_MAX_MS = 30_000; const MAX_CONSECUTIVE_AUTH_FAILURES = 5; @@ -78,6 +81,11 @@ export type SyncConnectionEvents = { error: Error; }; +type PreparedPairedHelloAuth = Promise<{ + dpop: SyncDpopProof | null; + relayAccountToken: string; +}>; + export type AccountPairAndConnectArgs = { endpoints: BrowserDialCandidate[]; peer: SyncPeerMetadata; @@ -133,6 +141,7 @@ export class SyncConnection { private shouldReconnect = false; private reconnectTimer: ReturnType | null = null; private heartbeatTimer: ReturnType | null = null; + private inboundStaleTimer: ReturnType | null = null; private heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS; private backoffMs = BACKOFF_MIN_MS; private consecutiveAuthFailures = 0; @@ -164,16 +173,25 @@ export class SyncConnection { error: null, }; private readonly socketFactory: WebSocketFactory; - private readonly connectTimeoutMs: number; + private readonly transportOpenTimeoutMs: number; + private readonly authenticatedHelloTimeoutMs: number; private readonly documentRef: Document | null; constructor(options: { socketFactory?: WebSocketFactory; - connectTimeoutMs?: number; + transportOpenTimeoutMs?: number; + authenticatedHelloTimeoutMs?: number; document?: Document | null; } = {}) { this.socketFactory = options.socketFactory ?? createDefaultSocket; - this.connectTimeoutMs = Math.max(250, Math.floor(options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)); + this.transportOpenTimeoutMs = Math.max( + 250, + Math.floor(options.transportOpenTimeoutMs ?? DEFAULT_TRANSPORT_OPEN_TIMEOUT_MS), + ); + this.authenticatedHelloTimeoutMs = Math.max( + 250, + Math.floor(options.authenticatedHelloTimeoutMs ?? DEFAULT_AUTHENTICATED_HELLO_TIMEOUT_MS), + ); this.documentRef = options.document ?? (typeof document === "undefined" ? null : document); this.documentRef?.addEventListener("visibilitychange", this.handleVisibilityChange); } @@ -246,8 +264,13 @@ export class SyncConnection { this.reconnectTimer = null; } if (this.ws) { + const socket = this.ws; + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; try { - this.ws.close(options.code ?? 1000, options.reason ?? "Client disconnect"); + socket.close(options.code ?? 1000, options.reason ?? "Client disconnect"); } catch { // ignore } @@ -312,59 +335,80 @@ export class SyncConnection { private async connectEndpoint(environment: WebClientEnvironmentRecord, candidate: BrowserDialCandidate): Promise { const endpoint = candidate.url; + const preparedAuth = this.preparePairedHelloAuth( + environment, + browserEndpointRequiresRelayAccess(candidate), + ); + // Authentication preparation starts with the dial. Observe an early + // rejection until onopen awaits the same promise so it cannot be reported + // as unhandled while the transport is still opening. + void preparedAuth.catch(() => undefined); const socket = this.socketFactory(endpoint); this.ws = socket; this.status.endpoint = endpoint; this.emit("statusChanged", this.getStatus()); let settled = false; await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`Timed out connecting to ${endpoint}.`)); - try { - socket.close(4000, "Connect timeout"); - } catch { - // ignore + let helloTimeout: ReturnType | null = null; + const clearDeadlines = () => { + clearTimeout(openTimeout); + if (helloTimeout) clearTimeout(helloTimeout); + }; + const fail = (error: Error, closeReason?: string) => { + if (settled) return; + settled = true; + clearDeadlines(); + if (closeReason) { + socket.onclose = null; + try { + socket.close(4000, closeReason); + } catch { + // Ignore close failures while abandoning this candidate. + } } - }, this.connectTimeoutMs); + reject(error); + }; + const openTimeout = setTimeout(() => { + fail(new Error(`Timed out opening ${endpoint}.`), "Transport open timeout"); + }, this.transportOpenTimeoutMs); socket.onopen = () => { - void this.sendHello(environment, browserEndpointRequiresRelayAccess(candidate)).catch(reject); + if (settled) return; + clearTimeout(openTimeout); + helloTimeout = setTimeout(() => { + fail(new Error(`Timed out authenticating with ${endpoint}.`), "Authenticated hello timeout"); + }, this.authenticatedHelloTimeoutMs); + void this.sendHello(socket, environment, preparedAuth).catch((error) => { + fail(error instanceof Error ? error : new Error(String(error)), "Hello preparation failed"); + }); }; socket.onmessage = (event) => { void this.handleMessage(asMessageEvent(event.data), { onHelloOk: (payload) => { if (settled) return; if (payload.brain?.deviceId?.trim() !== environment.hostDeviceId) { - settled = true; - clearTimeout(timeout); - reject(new Error("Connected machine identity did not match the stored pairing.")); + fail(new Error("Connected machine identity did not match the stored pairing.")); return; } settled = true; - clearTimeout(timeout); + clearDeadlines(); this.finishConnected(environment, endpoint, payload); resolve(); }, onHelloError: (payload) => { if (settled) return; - settled = true; - clearTimeout(timeout); - reject(this.handleAuthFailure(environment, payload)); + fail(this.handleAuthFailure(environment, payload)); }, + }).catch((error) => { + fail(error instanceof Error ? error : new Error(String(error))); }); }; socket.onerror = () => { - if (!settled) { - settled = true; - clearTimeout(timeout); - reject(new Error(`WebSocket failed for ${endpoint}.`)); - } + fail(new Error(`WebSocket failed for ${endpoint}.`)); }; socket.onclose = (event) => { if (!settled) { - settled = true; - clearTimeout(timeout); - reject(new Error("Connection closed before authentication completed.")); + fail(this.errorForClose(event)); return; } this.handleClose(event); @@ -383,18 +427,35 @@ export class SyncConnection { this.setStatus({ state: "connecting", endpoint, error: null }); let settled = false; return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + let helloTimeout: ReturnType | null = null; + const clearDeadlines = () => { + clearTimeout(openTimeout); + if (helloTimeout) clearTimeout(helloTimeout); + }; + const fail = (error: Error, closeReason?: string) => { if (settled) return; settled = true; - reject(new Error("Timed out connecting to the account machine.")); - try { - socket.close(4000, "Account pairing timeout"); - } catch { - // Ignore close failures after timeout. + clearDeadlines(); + if (closeReason) { + socket.onclose = null; + try { + socket.close(4000, closeReason); + } catch { + // Ignore close failures after a deadline. + } } - }, this.connectTimeoutMs * 2); + reject(error); + }; + const openTimeout = setTimeout(() => { + fail(new Error("Timed out opening the account machine connection."), "Account transport open timeout"); + }, this.transportOpenTimeoutMs); socket.onopen = () => { + if (settled) return; + clearTimeout(openTimeout); + helloTimeout = setTimeout(() => { + fail(new Error("Timed out authenticating with the account machine."), "Account hello timeout"); + }, this.authenticatedHelloTimeoutMs); const payload: SyncHelloPayload = { peer: args.peer, auth: { @@ -420,45 +481,36 @@ export class SyncConnection { hostDeviceId !== args.expectedHostDeviceId || !pairing ) { - settled = true; - clearTimeout(timeout); - reject(new Error("Account machine identity did not match the verified directory record.")); + fail(new Error("Account machine identity did not match the verified directory record.")); return; } let environment: WebClientEnvironmentRecord; try { environment = args.buildEnvironment(payload, endpoint, pairing); } catch (error) { - settled = true; - clearTimeout(timeout); - reject(error); + fail(error instanceof Error ? error : new Error(String(error))); return; } settled = true; - clearTimeout(timeout); + clearDeadlines(); this.finishConnected(environment, endpoint, payload); this.shouldReconnect = true; resolve({ environment, helloOk: payload, endpoint }); }, onHelloError: (payload) => { if (settled) return; - settled = true; - clearTimeout(timeout); - reject(new Error(payload.message || "Account authentication was rejected.")); + fail(new Error(payload.message || "Account authentication was rejected.")); }, + }).catch((error) => { + fail(error instanceof Error ? error : new Error(String(error))); }); }; socket.onerror = () => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(new Error("The secure machine connection failed.")); + fail(new Error("The secure machine connection failed.")); }; socket.onclose = (event) => { if (!settled) { - settled = true; - clearTimeout(timeout); - reject(new Error("Connection closed before account authentication completed.")); + fail(this.errorForClose(event)); return; } this.handleClose(event); @@ -466,17 +518,32 @@ export class SyncConnection { }); } - private async sendHello(environment: WebClientEnvironmentRecord, throughRelay: boolean): Promise { - if (!this.ws || this.ws.readyState !== SOCKET_OPEN) return; + private preparePairedHelloAuth( + environment: WebClientEnvironmentRecord, + throughRelay: boolean, + ): PreparedPairedHelloAuth { const dpop = environment.dpopPublicKeyX963 - ? await signDpopProof({ + ? signDpopProof({ privateKey: environment.dpopKeys.privateKey, publicKeyX963Base64: environment.dpopPublicKeyX963, deviceId: environment.pairedDeviceId, secret: environment.secret, }) - : null; - const relayAccountToken = await this.getRelayAccountToken(throughRelay); + : Promise.resolve(null); + const relayAccountToken = this.getRelayAccountToken(throughRelay); + return Promise.all([dpop, relayAccountToken]).then(([preparedDpop, preparedRelayAccountToken]) => ({ + dpop: preparedDpop, + relayAccountToken: preparedRelayAccountToken, + })); + } + + private async sendHello( + socket: WebSocketLike, + environment: WebClientEnvironmentRecord, + preparedAuth: PreparedPairedHelloAuth, + ): Promise { + const { dpop, relayAccountToken } = await preparedAuth; + if (socket !== this.ws || socket.readyState !== SOCKET_OPEN) return; const payload: SyncHelloPayload = { peer: { deviceId: environment.localDeviceId, @@ -495,7 +562,7 @@ export class SyncConnection { ...(relayAccountToken ? { relayAccountToken } : {}), }, }; - this.ws.send(encodeEnvelopeText({ type: "hello", requestId: "hello", payload })); + socket.send(encodeEnvelopeText({ type: "hello", requestId: "hello", payload })); } private async getRelayAccountToken(throughRelay: boolean): Promise { @@ -582,6 +649,7 @@ export class SyncConnection { error: null, }); this.startHeartbeat(helloOk.heartbeatIntervalMs); + this.startInboundStaleWatchdog(); this.emit("helloOk", helloOk); if (helloOk.projects) this.emit("projectCatalog", { projects: helloOk.projects }); } @@ -638,21 +706,84 @@ export class SyncConnection { }, this.heartbeatIntervalMs * 2); } - private handleClose(event: CloseEvent): void { + private startInboundStaleWatchdog(): void { + if (this.inboundStaleTimer) clearInterval(this.inboundStaleTimer); + this.inboundStaleTimer = setInterval(() => { + this.closeIfInboundStale(false); + }, INBOUND_STALE_CHECK_INTERVAL_MS); + } + + private closeIfInboundStale(bypassBackoff: boolean): boolean { + if (!this.isConnected() || !visible(this.documentRef)) return false; + const lastSeenAtMs = this.status.lastSeenAt ? Date.parse(this.status.lastSeenAt) : Number.NaN; + if (!Number.isFinite(lastSeenAtMs) || Date.now() - lastSeenAtMs < INBOUND_STALE_MS) return false; + const socket = this.ws; + if (!socket) return false; + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; + try { + socket.close(4008, "Inbound connection stale"); + } catch { + // The local close is still authoritative for reconnect bookkeeping. + } + this.handleClose( + { code: 4008, reason: "Inbound connection stale" } as CloseEvent, + bypassBackoff, + ); + return true; + } + + private errorForClose(event: Pick): SyncConnectionError { + switch (event.code) { + case 4501: + return new SyncConnectionError("This Mac appears to be offline", "relay_host_offline"); + case 4507: + return new SyncConnectionError("Your Mac couldn't accept the connection. Retrying…", "relay_bridge_rejected"); + case 4503: + return new SyncConnectionError("Too many active connections to this Mac", "relay_capacity"); + case 4502: + return new SyncConnectionError("Connection lost. Reconnecting.", "relay_idle"); + case 4505: + return new SyncConnectionError("Connection lost. Reconnecting.", "relay_superseded"); + case 4506: + return new SyncConnectionError("Connection lost. Reconnecting.", "relay_buffer_overflow"); + case 4000: + return new SyncConnectionError("Connection lost. Reconnecting.", "relay_partner_closed"); + case 4008: + return new SyncConnectionError("Connection became unresponsive. Reconnecting.", "connection_stale"); + default: + return new SyncConnectionError(event.reason || "Connection lost. Reconnecting.", "connection_closed"); + } + } + + private handleClose(event: CloseEvent, bypassBackoff = false): void { this.stopTimers(); this.ws = null; this.latestHello = null; this.emit("close", { code: event.code, reason: event.reason }); if (this.intentionalClose) return; - this.setStatus({ state: "disconnected", connectedAt: null, error: event.reason || null }); - if (this.shouldReconnect) this.scheduleReconnect(); + this.setStatus({ + state: "disconnected", + connectedAt: null, + error: this.errorForClose(event).message, + }); + if (this.shouldReconnect) { + this.scheduleReconnect( + bypassBackoff ? this.visibilityReconnectDelayMs() : 0, + bypassBackoff, + ); + } } - private scheduleReconnect(minimumDelayMs = 0): void { + private scheduleReconnect(minimumDelayMs = 0, bypassBackoff = false): void { if (!this.environment || !visible(this.documentRef) || this.reconnectTimer) return; const jitter = Math.floor(Math.random() * 350); - const delay = Math.max(minimumDelayMs, Math.min(BACKOFF_MAX_MS, this.backoffMs) + jitter); - this.backoffMs = Math.min(BACKOFF_MAX_MS, this.backoffMs * 2); + const delay = bypassBackoff + ? minimumDelayMs + : Math.max(minimumDelayMs, Math.min(BACKOFF_MAX_MS, this.backoffMs) + jitter); + if (!bypassBackoff) this.backoffMs = Math.min(BACKOFF_MAX_MS, this.backoffMs * 2); this.setStatus({ state: "reconnecting" }); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; @@ -663,14 +794,22 @@ export class SyncConnection { }, delay); } - private readonly handleVisibilityChange = () => { - if (!visible(this.documentRef) || this.isConnected() || !this.environment || !this.shouldReconnect) return; - if (this.reconnectTimer || this.status.state === "connecting" || this.status.state === "reconnecting") return; + private visibilityReconnectDelayMs(): number { const elapsedSinceDialMs = Date.now() - this.lastDialStartedAtMs; - const debounceDelayMs = elapsedSinceDialMs < VISIBILITY_RECONNECT_DEBOUNCE_MS + return elapsedSinceDialMs < VISIBILITY_RECONNECT_DEBOUNCE_MS ? VISIBILITY_RECONNECT_DEBOUNCE_MS - elapsedSinceDialMs : 0; - this.scheduleReconnect(debounceDelayMs); + } + + private readonly handleVisibilityChange = () => { + if (!visible(this.documentRef)) return; + if (this.isConnected()) { + this.closeIfInboundStale(true); + return; + } + if (!this.environment || !this.shouldReconnect) return; + if (this.reconnectTimer || this.status.state === "connecting" || this.status.state === "reconnecting") return; + this.scheduleReconnect(this.visibilityReconnectDelayMs()); }; private cleanupSocket(): void { @@ -694,6 +833,10 @@ export class SyncConnection { clearTimeout(this.heartbeatTimer); this.heartbeatTimer = null; } + if (this.inboundStaleTimer) { + clearInterval(this.inboundStaleTimer); + this.inboundStaleTimer = null; + } } private setStatus(patch: Partial): void { diff --git a/apps/desktop/src/renderer/webclient/sync/envStore.test.ts b/apps/desktop/src/renderer/webclient/sync/envStore.test.ts index 4a969e526..82c382c4f 100644 --- a/apps/desktop/src/renderer/webclient/sync/envStore.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/envStore.test.ts @@ -46,12 +46,18 @@ describe("web-client trust reset migration", () => { const request = {} as IDBOpenDBRequest; const getRequest = {} as IDBRequest; const close = vi.fn(); + const transaction = { + abort: vi.fn(), + error: null, + objectStore: () => ({ get: () => getRequest }), + onabort: null, + oncomplete: null, + onerror: null, + } as unknown as IDBTransaction; const db = { close, onversionchange: null, - transaction: () => ({ - objectStore: () => ({ get: () => getRequest }), - }), + transaction: () => transaction, } as unknown as IDBDatabase; const storage = new IndexedDbStorage({ indexedDb: { open: () => request }, @@ -64,6 +70,7 @@ describe("web-client trust reset migration", () => { await vi.waitFor(() => expect(getRequest.onsuccess).toBeTypeOf("function")); Object.defineProperty(getRequest, "result", { value: undefined }); getRequest.onsuccess?.(new Event("success")); + transaction.oncomplete?.(new Event("complete")); await expect(pendingRead).resolves.toBeNull(); db.onversionchange?.(new Event("versionchange") as IDBVersionChangeEvent); @@ -138,18 +145,23 @@ describe("web-client trust reset migration", () => { accountOwnerUserId: "account-a", }); - await expect(store.pruneAccountOwnedEnvironments("account-b")).resolves.toEqual([ - "stale", - ]); + await expect(store.pruneAccountOwnedEnvironments("account-b")).resolves.toEqual({ + removedIds: ["stale"], + environments: expect.arrayContaining([ + expect.objectContaining({ envId: "manual" }), + expect.objectContaining({ envId: "current" }), + ]), + }); await expect(store.listEnvironments()).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ envId: "manual" }), expect.objectContaining({ envId: "current" }), ]), ); - await expect(store.pruneAccountOwnedEnvironments(null)).resolves.toEqual([ - "current", - ]); + await expect(store.pruneAccountOwnedEnvironments(null)).resolves.toEqual({ + removedIds: ["current"], + environments: [expect.objectContaining({ envId: "manual" })], + }); await expect(store.listEnvironments()).resolves.toEqual([ expect.objectContaining({ envId: "manual" }), ]); diff --git a/apps/desktop/src/renderer/webclient/sync/envStore.ts b/apps/desktop/src/renderer/webclient/sync/envStore.ts index 89ff2fa13..379c9eada 100644 --- a/apps/desktop/src/renderer/webclient/sync/envStore.ts +++ b/apps/desktop/src/renderer/webclient/sync/envStore.ts @@ -10,6 +10,19 @@ export type WebClientStorage = { put(area: WebClientStorageArea, key: string, value: T): Promise; delete(area: WebClientStorageArea, key: string): Promise; list(area: WebClientStorageArea): Promise; + transaction( + areas: WebClientStorageArea[], + mode: IDBTransactionMode, + operation: (transaction: WebClientStorageTransaction) => Promise, + ): Promise; + setVersionChangeHandler?(handler: () => void): void; +}; + +export type WebClientStorageTransaction = { + get(area: WebClientStorageArea, key: string): Promise; + put(area: WebClientStorageArea, key: string, value: T): void; + delete(area: WebClientStorageArea, key: string): void; + list(area: WebClientStorageArea): Promise; }; export type WebClientEnvironmentRecord = { @@ -43,6 +56,45 @@ const DB_UPGRADE_BLOCKED_TIMEOUT_MS = 5_000; const SELECTED_ENV_ID_KEY = "selectedEnvId"; export const WEB_TRUST_RESET_VERSION = 1; const TRUST_RESET_VERSION_KEY = "machineTrustResetVersion"; +export const INDEXED_DB_OPEN_TIMEOUT_MS = 4_000; + +export type IndexedDbOpenErrorCode = "blocked" | "timeout" | "failed"; + +export class IndexedDbOpenError extends Error { + readonly name = "IndexedDbOpenError"; + + constructor( + message: string, + readonly code: IndexedDbOpenErrorCode, + options: { cause?: unknown } = {}, + ) { + super(message, options); + } +} + +export type WebClientEnvironmentPruneResult = { + readonly removedIds: string[]; + /** Records that survived this same pruning transaction. */ + readonly environments: WebClientEnvironmentRecord[]; +}; + +function sortEnvironments(records: WebClientEnvironmentRecord[]): WebClientEnvironmentRecord[] { + return records.sort((left, right) => { + const leftTime = Date.parse(left.lastConnectedAt ?? left.createdAt); + const rightTime = Date.parse(right.lastConnectedAt ?? right.createdAt); + return rightTime - leftTime; + }); +} + +function pruneResult( + removedIds: string[], + environments: WebClientEnvironmentRecord[], +): WebClientEnvironmentPruneResult { + return { + removedIds: [...removedIds], + environments: sortEnvironments(environments), + }; +} function openRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { @@ -61,56 +113,113 @@ function transactionDone(transaction: IDBTransaction): Promise { export class IndexedDbStorage implements WebClientStorage { private dbPromise: Promise | null = null; + private versionChangeHandler: (() => void) | null = null; constructor(private readonly options: { indexedDb?: Pick; upgradeBlockedTimeoutMs?: number; + openTimeoutMs?: number; } = {}) {} + setVersionChangeHandler(handler: () => void): void { + this.versionChangeHandler = handler; + } + async get(area: WebClientStorageArea, key: string): Promise { - const store = await this.store(area, "readonly"); - const result = await openRequest(store.get(key)); - return (result ?? null) as T | null; + return await this.transaction([area], "readonly", async (transaction) => ( + await transaction.get(area, key) + )); } async put(area: WebClientStorageArea, key: string, value: T): Promise { - const db = await this.open(); - const transaction = db.transaction(area, "readwrite"); - transaction.objectStore(area).put(value, key); - await transactionDone(transaction); + await this.transaction([area], "readwrite", async (transaction) => { + transaction.put(area, key, value); + }); } async delete(area: WebClientStorageArea, key: string): Promise { - const db = await this.open(); - const transaction = db.transaction(area, "readwrite"); - transaction.objectStore(area).delete(key); - await transactionDone(transaction); + await this.transaction([area], "readwrite", async (transaction) => { + transaction.delete(area, key); + }); } async list(area: WebClientStorageArea): Promise { - const store = await this.store(area, "readonly"); - const result = await openRequest(store.getAll()); - return result as T[]; + return await this.transaction([area], "readonly", async (transaction) => ( + await transaction.list(area) + )); } - private async store(area: WebClientStorageArea, mode: IDBTransactionMode): Promise { + async transaction( + areas: WebClientStorageArea[], + mode: IDBTransactionMode, + operation: (transaction: WebClientStorageTransaction) => Promise, + ): Promise { const db = await this.open(); - return db.transaction(area, mode).objectStore(area); + const idbTransaction = db.transaction(areas, mode); + const done = transactionDone(idbTransaction); + const transaction: WebClientStorageTransaction = { + get: async (area: WebClientStorageArea, key: string) => { + const result = await openRequest(idbTransaction.objectStore(area).get(key)); + return (result ?? null) as Value | null; + }, + put: (area: WebClientStorageArea, key: string, value: Value) => { + idbTransaction.objectStore(area).put(value, key); + }, + delete: (area, key) => { + idbTransaction.objectStore(area).delete(key); + }, + list: async (area: WebClientStorageArea) => ( + await openRequest(idbTransaction.objectStore(area).getAll()) as Value[] + ), + }; + try { + const result = await operation(transaction); + await done; + return result; + } catch (error) { + try { + idbTransaction.abort(); + } catch { + // The transaction may already have failed or completed. + } + await done.catch(() => undefined); + throw error; + } } private async open(): Promise { if (!this.dbPromise) { - this.dbPromise = new Promise((resolve, reject) => { - const request = (this.options.indexedDb ?? indexedDB).open(DB_NAME, DB_VERSION); + const attempt = new Promise((resolve, reject) => { + let request: IDBOpenDBRequest; + try { + request = (this.options.indexedDb ?? indexedDB).open(DB_NAME, DB_VERSION); + } catch (error) { + reject(new IndexedDbOpenError( + "Browser storage is unavailable.", + "failed", + { cause: error }, + )); + return; + } let settled = false; let blockedTimer: ReturnType | null = null; const clearBlockedTimer = () => { - if (blockedTimer) clearTimeout(blockedTimer); + if (blockedTimer) globalThis.clearTimeout(blockedTimer); blockedTimer = null; }; - const rejectOpen = (error: Error) => { + const openTimer = globalThis.setTimeout(() => { + if (settled) return; + settled = true; + clearBlockedTimer(); + reject(new IndexedDbOpenError( + "Opening browser storage timed out.", + "timeout", + )); + }, this.options.openTimeoutMs ?? INDEXED_DB_OPEN_TIMEOUT_MS); + const rejectOpen = (error: IndexedDbOpenError) => { if (settled) return; settled = true; + globalThis.clearTimeout(openTimer); clearBlockedTimer(); reject(error); }; @@ -122,15 +231,17 @@ export class IndexedDbStorage implements WebClientStorage { }; request.onblocked = () => { if (blockedTimer || settled) return; + globalThis.clearTimeout(openTimer); const requestedTimeout = this.options.upgradeBlockedTimeoutMs; const timeoutMs = typeof requestedTimeout === "number" && Number.isFinite(requestedTimeout) && requestedTimeout >= 0 ? requestedTimeout : DB_UPGRADE_BLOCKED_TIMEOUT_MS; - blockedTimer = setTimeout(() => { - rejectOpen(new Error( + blockedTimer = globalThis.setTimeout(() => { + rejectOpen(new IndexedDbOpenError( "ADE browser storage couldn't be upgraded. Close other ADE tabs and try again.", + "blocked", )); }, timeoutMs); }; @@ -141,16 +252,26 @@ export class IndexedDbStorage implements WebClientStorage { return; } settled = true; + globalThis.clearTimeout(openTimer); clearBlockedTimer(); - db.onversionchange = () => db.close(); + db.onversionchange = () => { + db.close(); + if (this.dbPromise === attempt) { + this.dbPromise = null; + this.versionChangeHandler?.(); + } + }; resolve(db); }; - request.onerror = () => rejectOpen( - request.error ?? new Error("Failed to open ADE web client IndexedDB."), - ); - }).catch((error) => { - this.dbPromise = null; - throw error; + request.onerror = () => rejectOpen(new IndexedDbOpenError( + "Failed to open ADE web client browser storage.", + "failed", + { cause: request.error }, + )); + }); + this.dbPromise = attempt; + void attempt.catch(() => { + if (this.dbPromise === attempt) this.dbPromise = null; }); } return await this.dbPromise; @@ -179,25 +300,61 @@ export class MemoryStorage implements WebClientStorage { async list(area: WebClientStorageArea): Promise { return Array.from(this.areas[area].values()) as T[]; } + + async transaction( + areas: WebClientStorageArea[], + _mode: IDBTransactionMode, + operation: (transaction: WebClientStorageTransaction) => Promise, + ): Promise { + const snapshots = new Map(areas.map((area) => [area, new Map(this.areas[area])])); + const transaction: WebClientStorageTransaction = { + get: async (area: WebClientStorageArea, key: string) => ( + (this.areas[area].get(key) ?? null) as Value | null + ), + put: (area: WebClientStorageArea, key: string, value: Value) => { + this.areas[area].set(key, value); + }, + delete: (area, key) => { + this.areas[area].delete(key); + }, + list: async (area: WebClientStorageArea) => ( + Array.from(this.areas[area].values()) as Value[] + ), + }; + try { + return await operation(transaction); + } catch (error) { + for (const [area, snapshot] of snapshots) this.areas[area] = snapshot; + throw error; + } + } } export class WebClientEnvStore { private trustResetPromise: Promise | null = null; - constructor(private readonly storage: WebClientStorage = new IndexedDbStorage()) {} + constructor(private readonly storage: WebClientStorage = new IndexedDbStorage()) { + this.storage.setVersionChangeHandler?.(() => { + this.trustResetPromise = null; + }); + } private async ensureTrustReset(): Promise { if (!this.trustResetPromise) { - this.trustResetPromise = (async () => { - const completedVersion = await this.storage.get("meta", TRUST_RESET_VERSION_KEY); - if (completedVersion === WEB_TRUST_RESET_VERSION) return; - const environments = await this.storage.list("environments"); - for (const environment of environments) { - await this.storage.delete("environments", environment.envId); - } - await this.storage.delete("meta", SELECTED_ENV_ID_KEY); - await this.storage.put("meta", TRUST_RESET_VERSION_KEY, WEB_TRUST_RESET_VERSION); - })().catch((error) => { + this.trustResetPromise = this.storage.transaction( + ["environments", "meta"], + "readwrite", + async (transaction) => { + const completedVersion = await transaction.get("meta", TRUST_RESET_VERSION_KEY); + if (completedVersion === WEB_TRUST_RESET_VERSION) return; + const environments = await transaction.list("environments"); + for (const environment of environments) { + transaction.delete("environments", environment.envId); + } + transaction.delete("meta", SELECTED_ENV_ID_KEY); + transaction.put("meta", TRUST_RESET_VERSION_KEY, WEB_TRUST_RESET_VERSION); + }, + ).catch((error) => { this.trustResetPromise = null; throw error; }); @@ -208,11 +365,7 @@ export class WebClientEnvStore { async listEnvironments(): Promise { await this.ensureTrustReset(); const records = await this.storage.list("environments"); - return records.sort((left, right) => { - const leftTime = Date.parse(left.lastConnectedAt ?? left.createdAt); - const rightTime = Date.parse(right.lastConnectedAt ?? right.createdAt); - return rightTime - leftTime; - }); + return sortEnvironments(records); } async getEnvironment(envId: string): Promise { @@ -241,30 +394,38 @@ export class WebClientEnvStore { async removeAccountOwnedEnvironments(ownerUserIdValue: string): Promise { const ownerUserId = ownerUserIdValue.trim(); if (!ownerUserId) return []; - return await this.pruneAccountOwnedEnvironments(ownerUserId, { removeCurrent: true }); + return (await this.pruneAccountOwnedEnvironments(ownerUserId, { removeCurrent: true })).removedIds; } async pruneAccountOwnedEnvironments( currentOwnerUserIdValue: string | null, options: { removeCurrent?: boolean } = {}, - ): Promise { + ): Promise { await this.ensureTrustReset(); const currentOwnerUserId = currentOwnerUserIdValue?.trim() || null; - const environments = await this.storage.list("environments"); - const removedIds = environments - .filter((environment) => environment.accountOwnerUserId != null) - .filter((environment) => options.removeCurrent - ? environment.accountOwnerUserId === currentOwnerUserId - : environment.accountOwnerUserId !== currentOwnerUserId) - .map((environment) => environment.envId); - for (const envId of removedIds) { - await this.storage.delete("environments", envId); - } - const selected = await this.storage.get("meta", SELECTED_ENV_ID_KEY); - if (selected && removedIds.includes(selected)) { - await this.storage.delete("meta", SELECTED_ENV_ID_KEY); - } - return removedIds; + return await this.storage.transaction( + ["environments", "meta"], + "readwrite", + async (transaction) => { + const environments = await transaction.list("environments"); + const removedIds = environments + .filter((environment) => environment.accountOwnerUserId != null) + .filter((environment) => options.removeCurrent + ? environment.accountOwnerUserId === currentOwnerUserId + : environment.accountOwnerUserId !== currentOwnerUserId) + .map((environment) => environment.envId); + const removedIdSet = new Set(removedIds); + for (const envId of removedIds) transaction.delete("environments", envId); + const selected = await transaction.get("meta", SELECTED_ENV_ID_KEY); + if (selected && removedIdSet.has(selected)) { + transaction.delete("meta", SELECTED_ENV_ID_KEY); + } + return pruneResult( + removedIds, + environments.filter((environment) => !removedIdSet.has(environment.envId)), + ); + }, + ); } async getSelectedEnvId(): Promise { diff --git a/apps/desktop/vite.webclient.config.ts b/apps/desktop/vite.webclient.config.ts index eae0e38d8..a4c65d847 100644 --- a/apps/desktop/vite.webclient.config.ts +++ b/apps/desktop/vite.webclient.config.ts @@ -76,35 +76,6 @@ export default defineConfig({ rollupOptions: { input: { index: path.resolve(__dirname, "src/renderer/webclient.html"), - }, - output: { - // Keep this in sync with vite.config.ts until the desktop/web-client targets share a build helper. - manualChunks(id) { - const normalized = id.replace(/\\/g, "/"); - if (!normalized.includes("/node_modules/")) return undefined; - if (normalized.includes("/node_modules/monaco-editor/")) return "vendor-monaco"; - if ( - normalized.includes("/node_modules/@xyflow/react/") - || normalized.includes("/node_modules/dagre/") - || normalized.includes("/node_modules/framer-motion/") - || normalized.includes("/node_modules/motion/") - ) { - return "vendor-graph"; - } - if ( - normalized.includes("/node_modules/@xterm/") - || normalized.includes("/node_modules/xterm/") - ) { - return "vendor-terminal"; - } - if ( - normalized.includes("/node_modules/react-markdown/") - || normalized.includes("/node_modules/remark-gfm/") - ) { - return "vendor-markdown"; - } - return undefined; - } } } } diff --git a/apps/ios/ADE/Services/SyncRecoveryPolicy.swift b/apps/ios/ADE/Services/SyncRecoveryPolicy.swift index 0af46c64d..566a7ec42 100644 --- a/apps/ios/ADE/Services/SyncRecoveryPolicy.swift +++ b/apps/ios/ADE/Services/SyncRecoveryPolicy.swift @@ -125,3 +125,27 @@ func syncSocketCompletionAction( ? .recoverTransport(closeCodeRawValue: closeCodeRawValue) : .failHandshake } + +func syncSocketCloseError(closeCodeRawValue: Int, reason: String?) -> NSError { + let message: String + switch closeCodeRawValue { + case 4001: + message = "The machine stopped responding. Reconnecting now." + case 4506: + message = "The relay connection was interrupted. Reconnecting now." + case 4507: + message = "The machine couldn't accept the relay connection. Reconnecting now." + default: + if let trimmedReason = reason?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmedReason.isEmpty { + message = trimmedReason + } else { + message = "The connection to the machine was interrupted. Reconnecting now." + } + } + return NSError( + domain: "ADE", + code: 24, + userInfo: [NSLocalizedDescriptionKey: message] + ) +} diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 238451042..c859d1e72 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1614,18 +1614,9 @@ private final class SyncSocketSessionDelegate: NSObject, URLSessionWebSocketDele reason: Data? ) { let reasonMessage = reason.flatMap { String(data: $0, encoding: .utf8) } - let message: String - if closeCode.rawValue == 4001 { - message = "The machine stopped responding. Reconnecting now." - } else if let reasonMessage, !reasonMessage.isEmpty { - message = reasonMessage - } else { - message = "The connection to the machine was interrupted. Reconnecting now." - } - let error = NSError( - domain: "ADE", - code: 24, - userInfo: [NSLocalizedDescriptionKey: message] + let error = syncSocketCloseError( + closeCodeRawValue: Int(closeCode.rawValue), + reason: reasonMessage ) Task { @MainActor [weak service] in service?.handleSocketDidComplete( diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index d7d5a4d1b..fe60ef7d8 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -275,6 +275,59 @@ final class SyncRecoveryPolicyTests: XCTestCase { ) } + func testRelayApplicationCloseCodesRemainTransportFailures() { + let byteCapped = syncSocketCloseError( + closeCodeRawValue: 4506, + reason: "pre-pipe buffer overflow" + ) + XCTAssertEqual( + byteCapped.localizedDescription, + "The relay connection was interrupted. Reconnecting now." + ) + XCTAssertNil(byteCapped.userInfo["ADEErrorCode"]) + + let bridgeRejected = syncSocketCloseError( + closeCodeRawValue: 4507, + reason: "bridge rejected" + ) + XCTAssertEqual( + bridgeRejected.localizedDescription, + "The machine couldn't accept the relay connection. Reconnecting now." + ) + XCTAssertNil(bridgeRejected.userInfo["ADEErrorCode"]) + XCTAssertNotEqual( + SyncUserFacingError.message(for: bridgeRejected), + "This phone is no longer paired with this machine. Pair again from Settings." + ) + XCTAssertEqual( + syncSocketCompletionAction( + isCurrentSocket: true, + completedWhileOpening: false, + canSendLiveRequests: true, + closeCodeRawValue: 4507 + ), + .recoverTransport(closeCodeRawValue: 4507) + ) + } + + func testUnknownApplicationCloseCodeDegradesToGenericTransportRecovery() { + let error = syncSocketCloseError(closeCodeRawValue: 4999, reason: nil) + XCTAssertEqual( + error.localizedDescription, + "The connection to the machine was interrupted. Reconnecting now." + ) + XCTAssertNil(error.userInfo["ADEErrorCode"]) + XCTAssertEqual( + syncSocketCompletionAction( + isCurrentSocket: true, + completedWhileOpening: false, + canSendLiveRequests: true, + closeCodeRawValue: 4999 + ), + .recoverTransport(closeCodeRawValue: 4999) + ) + } + @MainActor func testAttemptedLiveChatSendTimeoutNeverEntersDurableQueue() async throws { let pendingOperationsKey = "ade.sync.pendingOperations" diff --git a/apps/tunnel-relay/README.md b/apps/tunnel-relay/README.md index f490a7fc3..af86c1ae0 100644 --- a/apps/tunnel-relay/README.md +++ b/apps/tunnel-relay/README.md @@ -30,7 +30,6 @@ different trust model and lifecycle. It uses Durable Objects with SQLite storage 1. The brain claims a `machineKey` and opens a signed **control** socket (`/host/:machineKey`). Only one control socket per machine; a newer one supersedes the old (close `4505`). -- `4506` client — pre-pipe frame buffer overflow (phone should reconnect) 2. A client dials `/connect/:machineKey` (no **Worker-level** client auth — the `machineKey` is unguessable). ADE's bridged sync listener still requires a paired/PIN/DPoP handshake plus a fresh same-account proof for every Relay @@ -38,7 +37,13 @@ different trust model and lifecycle. It uses Durable Objects with SQLite storage a short `connectionId`, holds the phone socket, and signals the brain `{t:"open", id}` over the control socket. 3. The brain opens a signed **pipe** socket (`/host/:machineKey/pipe/:id`) plus - a local socket to its sync server, and pipes bytes between them. + a local socket to its sync server, and pipes bytes between them. If it cannot + service the open, it sends `{t:"reject", id, code, reason}` on the control + socket so the DO can close the waiting client immediately. Older DOs ignore + this unknown message type; newer DOs still work with older brains that do + not send rejections. A successful listener validation is reusable for 30 + seconds: pipe and local dials start immediately while revalidation runs in + parallel, and a failed revalidation tears the new bridge down. 4. The DO pairs the phone socket and the pipe socket by `connectionId` and relays every frame (text and binary) verbatim in both directions. No frame wrapping — the sync protocol, including its chunked/binary frames, is @@ -86,8 +91,9 @@ What the relay's position does **not** grant: concurrent-tunnel cap until the 10-minute idle sweep closes them — a bounded, self-healing DoS. They still cannot authenticate to the brain. - Early phone frames are buffered in DO memory (bounded, 64 frames) while the - host dials the pipe socket. A hibernation eviction inside that sub-second - window drops them; the phone's reconnect/resync path recovers. + host dials the pipe socket. The same per-connection buffer also has a 256 KiB + total-byte cap. A hibernation eviction inside that sub-second window drops + the frames; the phone's reconnect/resync path recovers. Treat the relay like any other network hop you don't fully control: fine for transport, not a place to rely on for confidentiality until E2E payload @@ -99,16 +105,29 @@ socket and active Relay peers. A connecting client must present a fresh same-account token inside its ADE hello; the relay Worker does not validate or store that token, but it can read it because TLS terminates at Cloudflare. +The brain sends a native WebSocket protocol ping every 30 seconds and requires +a pong within 10 seconds. A missed pong terminates the control socket and enters +the same jittered reconnect state machine as any other disconnect. These are +protocol control frames handled at the Cloudflare edge: they do not become JSON +`{t:"ping"}` messages, wake a hibernated DO, or add billed DO messages. + ## Close codes | Code | Where | Meaning | |---|---|---| -| `4501` | phone `/connect` | No host control socket registered ("host offline") | +| `4501` | phone `/connect` | Host unavailable: no usable control socket or local sync listener | | `4502` | pipe/phone | Idle > 10 min, closed by the alarm sweep | | `4503` | phone `/connect` | Machine already at 16 concurrent tunnels | | `4504` | pipe | Pipe arrived but its phone had already disconnected | | `4505` | host control | Replaced by a newer host control socket | -| `4000` | pipe/phone | Partner side of the tunnel closed (clean teardown) | +| `4506` | phone `/connect` | Pre-pipe frame buffer exceeded 64 frames or 256 KiB | +| `4507` | phone/pipe | Brain rejected the open because bridge validation/setup failed | +| `4000` | pipe/phone/local bridge | Partner closed without an application close code | + +For pipe/phone and brain pipe/local boundaries, application close codes in +`4000`–`4999` and sanitized reasons (bounded to the WebSocket 123-byte limit) +are preserved. Non-application close codes fall back to `4000`. Reject messages +use their valid application code, or `4507` when the supplied code is invalid. Auth failures on `/host` and `/host/.../pipe` are rejected **before** the upgrade with an HTTP status (`401` bad/expired signature or unknown machine, @@ -118,8 +137,11 @@ upgrade with an HTTP status (`401` bad/expired signature or unknown machine, Pipe pairs with no traffic for 10 minutes are closed by a Durable Object `alarm` that runs every 5 minutes (last-activity is tracked in each socket's -hibernation attachment, throttled to a 60s write granularity). The brain -reconnects its control socket with jittered exponential backoff (1s → 60s cap). +hibernation attachment, throttled to a 60s write granularity). Alarms are only +scheduled or rescheduled while at least one non-control socket exists, so an +idle machine with only its hibernated control socket causes no recurring alarm +wakes. The brain reconnects its control socket with jittered exponential +backoff (first retry within about 1s, 60s cap, reset after a successful open). ## Observability @@ -127,7 +149,7 @@ reconnects its control socket with jittered exponential backoff (1s → 60s cap) in the dashboard (Workers → **ade-tunnel-relay** → Logs) and stream live via `npx wrangler tail ade-tunnel-relay`. Only lifecycle/rejection events are logged — never per-frame, so a busy tunnel stays cheap: `host_registered`, -`connect_rejected` (`reason: host_offline | too_many`), `auth_failed` +`connect_rejected` (`reason: host_offline | too_many | bridge_rejected`), `auth_failed` (`role: host | pipe`). Cost is already gated by design (signed upgrades, one control socket per machine, the max-tunnels cap, and the idle sweep above), so no request-budget limiter is needed here — unlike `apps/push-relay`, whose diff --git a/apps/tunnel-relay/src/tunnelDo.ts b/apps/tunnel-relay/src/tunnelDo.ts index 0fd5d3594..a0f4972c1 100644 --- a/apps/tunnel-relay/src/tunnelDo.ts +++ b/apps/tunnel-relay/src/tunnelDo.ts @@ -1,6 +1,7 @@ import { buildHostSignatureBase, buildPipeSignatureBase, + CONNECTION_ID_PATTERN, constantTimeEqual, DEFAULT_MAX_TUNNELS_PER_MACHINE, generateConnectionId, @@ -11,12 +12,14 @@ import { } from "./relay"; // Application close codes must live in 4000-4999. Documented in the README. -const CLOSE_HOST_OFFLINE = 4501; // phone connected but no host control socket -const CLOSE_IDLE = 4502; // pipe pair idle past IDLE_MS, swept by the alarm -const CLOSE_TOO_MANY = 4503; // machine already at MAX_TUNNELS_PER_MACHINE -const CLOSE_CLIENT_GONE = 4504; // pipe arrived but its phone had already left -const CLOSE_CONTROL_REPLACED = 4505; // a newer host control socket took over -const CLOSE_PARTNER_CLOSED = 4000; // the other end of the tunnel closed cleanly +export const CLOSE_PARTNER_CLOSED = 4000; // the other end closed without an application code +export const CLOSE_HOST_OFFLINE = 4501; // no usable host control/bridge socket +export const CLOSE_IDLE = 4502; // pipe pair idle past IDLE_MS, swept by the alarm +export const CLOSE_TOO_MANY = 4503; // machine already at MAX_TUNNELS_PER_MACHINE +export const CLOSE_CLIENT_GONE = 4504; // pipe arrived but its phone had already left +export const CLOSE_CONTROL_REPLACED = 4505; // a newer host control socket took over +export const CLOSE_PRE_PIPE_BUFFER_OVERFLOW = 4506; // bounded early-frame buffer overflow +export const CLOSE_BRIDGE_REJECTED = 4507; // host rejected an open it could not service const SWEEP_INTERVAL_MS = 5 * 60 * 1000; const IDLE_MS = 10 * 60 * 1000; @@ -28,6 +31,28 @@ const ACTIVITY_WRITE_THROTTLE_MS = 60 * 1000; // tunnel via {t:"open"} and then dials the pipe). Buffer those first frames so // none are dropped, bounded so a phone that never gets a pipe can't grow it. const MAX_BUFFERED_CLIENT_FRAMES = 64; +const MAX_BUFFERED_CLIENT_BYTES = 256 * 1024; +const MAX_CLOSE_REASON_BYTES = 123; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function applicationCloseCode(code: unknown, fallback: number): number { + return typeof code === "number" && Number.isInteger(code) && code >= 4000 && code <= 4999 + ? code + : fallback; +} + +function sanitizedCloseReason(reason: unknown, fallback: string): string { + const raw = typeof reason === "string" ? reason : ""; + const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim() || fallback; + const encoded = textEncoder.encode(clean); + if (encoded.byteLength <= MAX_CLOSE_REASON_BYTES) return clean; + return textDecoder.decode(encoded.slice(0, MAX_CLOSE_REASON_BYTES)).replace(/\uFFFD$/, "").trimEnd(); +} + +function bufferedFrameBytes(message: string | ArrayBuffer): number { + return typeof message === "string" ? textEncoder.encode(message).byteLength : message.byteLength; +} /** * Structured single-line log. View live with `wrangler tail ade-tunnel-relay`, @@ -62,7 +87,10 @@ export class TunnelDurableObject implements DurableObject { // Frames a phone sent before its pipe attached, keyed by connectionId. Held // in memory only: the client + control sockets keep the object resident // during the sub-second open handshake, so this survives until the flush. - private readonly pendingClientFrames = new Map(); + private readonly pendingClientFrames = new Map< + string, + { frames: (string | ArrayBuffer)[]; bytes: number } + >(); constructor( private readonly state: DurableObjectState, @@ -184,7 +212,7 @@ export class TunnelDurableObject implements DurableObject { const buffered = this.pendingClientFrames.get(id); if (!buffered) return; this.pendingClientFrames.delete(id); - for (const frame of buffered) { + for (const frame of buffered.frames) { try { server.send(frame); } catch { @@ -229,8 +257,16 @@ export class TunnelDurableObject implements DurableObject { try { control.send(JSON.stringify({ t: "open", id })); } catch { - // Control socket died between the lookup and now; the phone socket will be - // swept or closed when the (never-arriving) pipe times out. + // The control socket died between lookup and signaling. Do not leave the + // phone occupying a tunnel slot while it waits for a pipe that cannot come. + logTunnel("connect_rejected", { reason: "host_offline" }); + const client = this.clientForId(id); + try { + client?.close(CLOSE_HOST_OFFLINE, "host offline"); + } catch { + // already closing + } + return response; } await this.ensureSweepScheduled(); return response; @@ -278,12 +314,36 @@ export class TunnelDurableObject implements DurableObject { if (!att) return; if (att.role === "control") { - // Control frames are signaling only; keep-alive pings get a pong, the - // rest are ignored so the protocol can grow without breaking old hosts. + // Native WebSocket ping/pong frames are handled by the Cloudflare edge + // without waking this object. The JSON ping branch remains only for old + // hosts; unknown types stay ignored for forward/backward compatibility. if (typeof message === "string") { try { - const parsed = JSON.parse(message) as { t?: string }; - if (parsed?.t === "ping") ws.send(JSON.stringify({ t: "pong" })); + const parsed = JSON.parse(message) as { + t?: unknown; + id?: unknown; + code?: unknown; + reason?: unknown; + }; + if (parsed?.t === "ping") { + ws.send(JSON.stringify({ t: "pong" })); + } else if ( + parsed?.t === "reject" + && typeof parsed.id === "string" + && CONNECTION_ID_PATTERN.test(parsed.id) + ) { + const client = this.clientForId(parsed.id); + if (!client) return; + const code = applicationCloseCode(parsed.code, CLOSE_BRIDGE_REJECTED); + const reason = sanitizedCloseReason(parsed.reason, "bridge rejected"); + this.pendingClientFrames.delete(parsed.id); + logTunnel("connect_rejected", { reason: "bridge_rejected", code }); + try { + client.close(code, reason); + } catch { + // already closing + } + } } catch { // ignore malformed control frames } @@ -300,19 +360,21 @@ export class TunnelDurableObject implements DurableObject { } } else if (att.role === "client" && att.id) { // Pipe not attached yet — hold the frame so the phone's hello isn't lost. - const buffered = this.pendingClientFrames.get(att.id) ?? []; - if (buffered.length >= MAX_BUFFERED_CLIENT_FRAMES) { + const buffered = this.pendingClientFrames.get(att.id) ?? { frames: [], bytes: 0 }; + const nextBytes = buffered.bytes + bufferedFrameBytes(message); + if (buffered.frames.length >= MAX_BUFFERED_CLIENT_FRAMES || nextBytes > MAX_BUFFERED_CLIENT_BYTES) { // Dropping mid-stream would corrupt the byte-for-byte sync protocol; // fail the tunnel loudly so the phone reconnects instead of hanging. this.pendingClientFrames.delete(att.id); try { - ws.close(4506, "pre-pipe buffer overflow"); + ws.close(CLOSE_PRE_PIPE_BUFFER_OVERFLOW, "pre-pipe buffer overflow"); } catch { // already closing } return; } - buffered.push(message); + buffered.frames.push(message); + buffered.bytes = nextBytes; this.pendingClientFrames.set(att.id, buffered); } this.touch(ws, att); @@ -324,22 +386,24 @@ export class TunnelDurableObject implements DurableObject { ws.serializeAttachment({ ...att, ts: now } satisfies SocketAttachment); } - async webSocketClose(ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): Promise { - this.teardownPartner(ws); + async webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): Promise { + this.teardownPartner(ws, code, reason); } async webSocketError(ws: WebSocket, _error: unknown): Promise { - this.teardownPartner(ws); + this.teardownPartner(ws, CLOSE_PARTNER_CLOSED, "partner error"); } - private teardownPartner(ws: WebSocket): void { + private teardownPartner(ws: WebSocket, sourceCode: number, sourceReason: string): void { const att = ws.deserializeAttachment() as SocketAttachment | null; if (!att || att.role === "control") return; if (att.id) this.pendingClientFrames.delete(att.id); const partner = this.partnerOf(ws, att); if (partner) { + const code = applicationCloseCode(sourceCode, CLOSE_PARTNER_CLOSED); + const reason = sanitizedCloseReason(sourceReason, "partner closed"); try { - partner.close(CLOSE_PARTNER_CLOSED, "partner closed"); + partner.close(code, reason); } catch { // already closing } @@ -347,7 +411,11 @@ export class TunnelDurableObject implements DurableObject { } private async ensureSweepScheduled(): Promise { - if ((await this.state.storage.getAlarm()) === null) { + const hasNonControlSocket = this.state.getWebSockets().some((ws) => { + const att = ws.deserializeAttachment() as SocketAttachment | null; + return att?.role === "client" || att?.role === "pipe"; + }); + if (hasNonControlSocket && (await this.state.storage.getAlarm()) === null) { await this.state.storage.setAlarm(Date.now() + SWEEP_INTERVAL_MS); } } @@ -355,10 +423,11 @@ export class TunnelDurableObject implements DurableObject { async alarm(): Promise { const now = Date.now(); const byId = new Map(); - let remaining = 0; + let remainingNonControl = 0; for (const ws of this.state.getWebSockets()) { - remaining += 1; const att = ws.deserializeAttachment() as SocketAttachment | null; + if (att?.role !== "client" && att?.role !== "pipe") continue; + remainingNonControl += 1; if (!att?.id) continue; const entry = byId.get(att.id) ?? { sockets: [], maxTs: 0 }; entry.sockets.push(ws); @@ -371,14 +440,14 @@ export class TunnelDurableObject implements DurableObject { for (const ws of entry.sockets) { try { ws.close(CLOSE_IDLE, "idle timeout"); - remaining -= 1; + remainingNonControl -= 1; } catch { // already closing } } } - if (remaining > 0) { + if (remainingNonControl > 0) { await this.state.storage.setAlarm(now + SWEEP_INTERVAL_MS); } } diff --git a/apps/tunnel-relay/test/relay.test.ts b/apps/tunnel-relay/test/relay.test.ts index f55bdc01a..57f5e67be 100644 --- a/apps/tunnel-relay/test/relay.test.ts +++ b/apps/tunnel-relay/test/relay.test.ts @@ -9,7 +9,13 @@ import { verifySignedQuery, type TunnelRelayEnv, } from "../src/relay"; -import { TunnelDurableObject } from "../src/tunnelDo"; +import { + CLOSE_BRIDGE_REJECTED, + CLOSE_HOST_OFFLINE, + CLOSE_PARTNER_CLOSED, + CLOSE_PRE_PIPE_BUFFER_OVERFLOW, + TunnelDurableObject, +} from "../src/tunnelDo"; const MACHINE_KEY = "a".repeat(48); const SECRET = "s".repeat(48); @@ -20,6 +26,7 @@ const SECRET = "s".repeat(48); class FakeStorage { private map = new Map(); private alarm: number | null = null; + setAlarmCalls = 0; async get(key: string): Promise { return this.map.get(key) as T | undefined; } @@ -30,18 +37,69 @@ class FakeStorage { return this.alarm; } async setAlarm(time: number): Promise { + this.setAlarmCalls += 1; this.alarm = time; } } +type TestSocketRole = "control" | "client" | "pipe"; + +class FakeSocket { + readonly sent: unknown[] = []; + readonly closes: Array<{ code?: number; reason?: string }> = []; + throwOnSend = false; + + constructor( + readonly tags: string[], + private attachment: { role: TestSocketRole; id?: string; ts: number }, + ) {} + + deserializeAttachment(): unknown { + return this.attachment; + } + + serializeAttachment(attachment: unknown): void { + this.attachment = attachment as typeof this.attachment; + } + + send(value: unknown): void { + if (this.throwOnSend) throw new Error("socket is dead"); + this.sent.push(value); + } + + close(code?: number, reason?: string): void { + this.closes.push({ code, reason }); + } +} + +class FakeState { + readonly storage = new FakeStorage(); + readonly sockets: FakeSocket[] = []; + + addSocket(role: TestSocketRole, id?: string, ts = Date.now()): FakeSocket { + const tags = [role, ...(id ? [`conn:${id}`] : [])]; + const socket = new FakeSocket(tags, { role, id, ts }); + this.sockets.push(socket); + return socket; + } + + getWebSockets(tag?: string): WebSocket[] { + const sockets = tag ? this.sockets.filter((socket) => socket.tags.includes(tag)) : this.sockets; + return sockets as unknown as WebSocket[]; + } + + acceptWebSocket(): void {} +} + +function makeDoHarness(): { durable: TunnelDurableObject; state: FakeState; storage: FakeStorage } { + const state = new FakeState(); + const durable = new TunnelDurableObject(state as unknown as DurableObjectState, {} as TunnelRelayEnv); + return { durable, state, storage: state.storage }; +} + function makeDo(): TunnelDurableObject { - const storage = new FakeStorage(); - const state = { - storage, - getWebSockets: () => [], - acceptWebSocket: () => undefined, - } as unknown as DurableObjectState; - return new TunnelDurableObject(state, {} as TunnelRelayEnv); + const { durable } = makeDoHarness(); + return durable; } function claimRequest(secret: unknown): Request { @@ -185,3 +243,105 @@ describe("signed upgrade auth rejections", () => { expect(res.status).toBe(401); }); }); + +describe("durable socket lifecycle", () => { + it("maps brain rejects to the waiting client and keeps unknown control types ignored", async () => { + const { durable, state } = makeDoHarness(); + const control = state.addSocket("control"); + const client = state.addSocket("client", "abcdef01"); + + await durable.webSocketMessage( + control as unknown as WebSocket, + JSON.stringify({ + t: "reject", + id: "abcdef01", + code: 4555, + reason: `bridge\u0000 refused ${"é".repeat(100)}`, + }), + ); + expect(client.closes).toHaveLength(1); + expect(client.closes[0]?.code).toBe(4555); + expect(client.closes[0]?.reason).not.toContain("\u0000"); + expect(Buffer.byteLength(client.closes[0]?.reason ?? "", "utf8")).toBeLessThanOrEqual(123); + + const secondClient = state.addSocket("client", "abcdef02"); + await durable.webSocketMessage( + control as unknown as WebSocket, + JSON.stringify({ t: "reject", id: "abcdef02", code: 1006, reason: "bad code" }), + ); + expect(secondClient.closes).toEqual([{ code: CLOSE_BRIDGE_REJECTED, reason: "bad code" }]); + + await durable.webSocketMessage(control as unknown as WebSocket, JSON.stringify({ t: "future-type" })); + expect(control.sent).toEqual([]); + expect(secondClient.closes).toHaveLength(1); + }); + + it("preserves application close details across a pair and falls back to 4000 otherwise", async () => { + const first = makeDoHarness(); + const client = first.state.addSocket("client", "abcdef01"); + const pipe = first.state.addSocket("pipe", "abcdef01"); + await first.durable.webSocketClose( + client as unknown as WebSocket, + 4666, + "phone\tclosed", + true, + ); + expect(client.closes).toEqual([]); + expect(pipe.closes).toEqual([{ code: 4666, reason: "phone closed" }]); + + const second = makeDoHarness(); + const normalClient = second.state.addSocket("client", "abcdef02"); + const normalPipe = second.state.addSocket("pipe", "abcdef02"); + await second.durable.webSocketClose(normalClient as unknown as WebSocket, 1000, "", true); + expect(normalPipe.closes).toEqual([{ code: CLOSE_PARTNER_CLOSED, reason: "partner closed" }]); + }); + + it("closes an early client when its buffered frames exceed 256 KiB", async () => { + const { durable, state } = makeDoHarness(); + const client = state.addSocket("client", "abcdef01"); + + await durable.webSocketMessage(client as unknown as WebSocket, new ArrayBuffer(128 * 1024)); + expect(client.closes).toEqual([]); + await durable.webSocketMessage(client as unknown as WebSocket, new ArrayBuffer(128 * 1024)); + expect(client.closes).toEqual([]); + await durable.webSocketMessage(client as unknown as WebSocket, new ArrayBuffer(1)); + expect(client.closes).toEqual([{ + code: CLOSE_PRE_PIPE_BUFFER_OVERFLOW, + reason: "pre-pipe buffer overflow", + }]); + }); + + it("reschedules alarms for data sockets but not for an idle control socket", async () => { + const { durable, state, storage } = makeDoHarness(); + state.addSocket("control"); + await durable.alarm(); + expect(storage.setAlarmCalls).toBe(0); + expect(await storage.getAlarm()).toBeNull(); + + state.addSocket("client", "abcdef01"); + await durable.alarm(); + expect(storage.setAlarmCalls).toBe(1); + expect(await storage.getAlarm()).not.toBeNull(); + }); + + it("closes the new client immediately when signaling the control socket throws", async () => { + const { durable, state, storage } = makeDoHarness(); + const control = state.addSocket("control"); + control.throwOnSend = true; + const testDurable = durable as unknown as { + acceptSocket: (attachment: { role: TestSocketRole; id?: string }) => Promise; + }; + testDurable.acceptSocket = async (attachment) => { + state.addSocket(attachment.role, attachment.id); + return new Response(null, { status: 200 }); + }; + + const response = await durable.fetch(new Request(`https://relay.test/connect/${MACHINE_KEY}`, { + headers: { Upgrade: "websocket" }, + })); + const client = state.sockets.find((socket) => socket.tags.includes("client")); + expect(response.status).toBe(200); + expect(client?.closes).toEqual([{ code: CLOSE_HOST_OFFLINE, reason: "host offline" }]); + expect(storage.setAlarmCalls).toBe(0); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d82d35d46..67e9abacb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -238,6 +238,14 @@ Static Cloudflare Pages controller built from the desktop renderer package. New connections start with ADE account sign-in and adopt a machine from the account directory over Relay; the resulting credential is DPoP-bound. The client keeps no local ADE DB and installs a sync-backed subset of `window.ade`. +Its static HTML paints the loading shell before React, while account bootstrap, +directory loading, and transactional IndexedDB privacy cleanup do not serialize +ordinary first paint. Vite preserves lazy feature boundaries, and the build +fails when the generated entry graph exceeds 1000 KB or eagerly references a +guarded heavy renderer chunk. Fingerprinted assets are immutable; the HTML +entry remains uncached. Connected visible tabs enforce separate transport-open +and authenticated-hello deadlines plus an inbound-traffic watchdog, then map +Relay application close codes into stable retry/offline UI. Its project routes use remote commands plus file/chat/terminal sub-protocols; `/chats` is also reachable from the project picker and uses runtime-scoped `personalChats.*` commands without @@ -254,13 +262,13 @@ Public-site product analytics is separately consented and uses the `ade_marketin The `/open` route is the HTTPS half of the ADE deeplink scheme (`https://ade-app.dev/open?type=...&...`). `apps/web/api/open.ts` is a Vercel serverless function that self-fetches `index.html`, rewrites OpenGraph + Twitter meta tags from the query params so chat-app unfurlers (Slack, Discord, iMessage, Gmail, Linear) show a rich card without executing JavaScript, then hands the SPA over to `OpenPage` which attempts the `ade://` upgrade in the browser and falls back to an install/marketing card if no handler is registered. Supported targets include lanes, Work sessions, repo branches, PRs, and Linear issues. See [features/deeplinks/README.md](./features/deeplinks/README.md). -### 2.7 Cloudflare relay workers (`apps/push-relay/`, `apps/tunnel-relay/`, `apps/webhook-relay/`) +### 2.7 Cloudflare relay workers (`apps/push-relay/`, `apps/tunnel-relay/`, `apps/account-directory/`, `apps/webhook-relay/`) Four independent Cloudflare Workers, each its own npm package / lockfile / `wrangler.jsonc` with its own trust model. None is a runtime dependency of the desktop app; the brain talks to them over HTTPS/WebSocket. - **`apps/push-relay/`** — fans ADE agent-state transitions out to iPhones as APNs alert pushes and Live Activity updates (Worker + a single D1 database; free-plan compatible, no Durable Objects). The brain is the only publisher: it claims an unguessable 32–64-hex `machineKey` with a relay secret (`POST /machines/:key/claim`, first-writer-wins) and HMAC-signs every later call (`x-ade-push-signature: sha256=HMAC(secret, "...")`). It stores only device tokens and in-flight notification payloads — no chat/PR content. APNs auth is an ES256 provider JWT from the `.p8` (wrangler secrets `APNS_KEY` / `APNS_KEY_ID` / `APNS_TEAM_ID`). Brain-side publisher lives at `apps/ade-cli/src/services/push/`. See [features/sync-and-multi-device/push-notifications.md](./features/sync-and-multi-device/push-notifications.md). -- **`apps/tunnel-relay/`** — pipes ADE **sync** WebSocket frames between a controller and a brain when there is no direct LAN/Tailscale path (Worker + Durable Object with SQLite storage, one instance per `machineKey`, WebSocket Hibernation API). The brain holds a persistent HMAC-signed outbound control socket while the machine has a valid ADE account session; a controller dials `/connect/:machineKey`; the DO pairs it with a dedicated brain-side pipe socket and passes bytes through 1:1 with no frame wrapping, so the normal ADE hello / pairing / DPoP handshake is unchanged. Brain-side client is `apps/ade-cli/src/services/sync/syncTunnelClientService.ts`. There is no user relay toggle: sign-in starts and advertises Relay, while sign-out closes it. It remains the lowest-priority `relay` address candidate after LAN and Tailscale. TLS terminates at the Worker, so this is a trusted-operator plaintext path rather than end-to-end encryption; relay payload E2E encryption is planned security work. -- **`apps/account-directory/`** — Clerk-authenticated machine directory and OAuth device-authorization bridge (Worker + D1). The machine brain publishes one health-filtered registration every 30 seconds through `accountMachinePublisherService.ts`; the Worker scopes rows by Clerk `sub` and treats a machine as online for 90 seconds. Authentication failures return only fixed classifications such as `token expired`, `invalid issuer`, and `invalid audience`; directory clients consume at most 512 response bytes before exposing the short reason in machine-list results and publisher health. Desktop, ADE Code, hosted web, and iOS use the compiled HTTPS Worker origin by default. Headless login binds each short-lived device code to a daemon secret, uses Clerk OAuth + PKCE in any browser, and atomically burns the approved token pair on redemption. +- **`apps/tunnel-relay/`** — pipes ADE **sync** WebSocket frames between a controller and a brain when there is no direct LAN/Tailscale path (Worker + Durable Object with SQLite storage, one instance per `machineKey`, WebSocket Hibernation API). The brain holds a persistent HMAC-signed outbound control socket while the machine has a valid ADE account session; a controller dials `/connect/:machineKey`; the DO pairs it with a dedicated brain-side pipe socket and passes bytes through 1:1 with no frame wrapping, so the normal ADE hello / pairing / DPoP handshake is unchanged. Native 30-second ping / 10-second pong liveness avoids waking a hibernated DO with JSON keepalives. Failed bridge opens are rejected explicitly; application close codes and bounded sanitized reasons survive the phone/pipe/local boundaries. Early controller frames are bounded by both 64 frames and 256 KiB, and idle-sweep alarms run only while a client or pipe exists. Brain-side client is `apps/ade-cli/src/services/sync/syncTunnelClientService.ts`. There is no user relay toggle: sign-in starts and advertises Relay, while sign-out closes it. It remains the lowest-priority `relay` address candidate after LAN and Tailscale. TLS terminates at the Worker, so this is a trusted-operator plaintext path rather than end-to-end encryption; relay payload E2E encryption is planned security work. +- **`apps/account-directory/`** — Clerk-authenticated machine directory and OAuth device-authorization bridge (Worker + D1). The machine brain publishes a health-filtered registration through `accountMachinePublisherService.ts`: a 30-second heartbeat keeps the row inside the Worker's 90-second online window, while sign-in and publish-relevant relay-route changes trigger coalesced immediate writes and reset the heartbeat deadline. The Worker scopes rows by Clerk `sub`, selects at most the 500 most recently seen machines, then returns online-first order. Machine-list responses expose separate auth and D1 durations through `Server-Timing`, including auth failures. Authentication failures return only fixed classifications such as `token expired`, `invalid issuer`, and `invalid audience`; directory clients consume at most 512 response bytes before exposing the short reason in machine-list results and publisher health. Desktop, ADE Code, hosted web, and iOS use the compiled HTTPS Worker origin by default. Headless login binds each short-lived device code to a daemon secret, uses Clerk OAuth + PKCE in any browser, and atomically burns the approved token pair on redemption. - **`apps/webhook-relay/`** — the pre-existing GitHub webhook relay (different trust model and lifecycle again). See its own docs. --- diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index ad78ef9c8..c294d1d30 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -220,13 +220,20 @@ Runtime support files outside `services/sync/`: gated on `routeHealth.relay.relayBridgeValidated`, which the tunnel client now sets proactively (see `syncTunnelClientService.ts`) so the relay route appears in the directory without waiting for an external client to open the first - tunnel. A 60-second heartbeat keeps the Worker row inside its 90-second online + tunnel. A 30-second heartbeat keeps the Worker row inside its 90-second online window without turning sync retries or status polling into product analytics. Successful account sign-in also requests an immediate publish; the brain observes both its local auth event and cross-process credential-file changes - from desktop sign-in. A confirmed Relay identity-conflict recovery requests + from desktop sign-in. Separately, a lightweight 2-second observer computes a + signature for the publish-relevant relay control/bridge state and reachable + endpoints. Its first valid snapshot and every later change trigger a + coalesced publish, so a + newly validated or lost relay route reaches the directory without waiting for + the heartbeat. A triggered write becomes the new heartbeat anchor rather than + causing a duplicate write at the old deadline. A confirmed Relay + identity-conflict recovery also requests another publish as soon as the replacement control route validates. Every - heartbeat re-reads the active sync snapshot and token so a brain started + publication re-reads the active sync snapshot and token so a brain started before sign-in still recovers. The last typed publisher outcome is exposed as `routeHealth.accountDirectory` in `sync.getStatus`, `ade sync status`, and the desktop This Mac card, including @@ -255,6 +262,11 @@ Runtime support files outside `services/sync/`: `ADE_ALLOW_DEVELOPMENT_CLERK=1` is the explicit controlled-testing escape hatch. Source-checkout runtimes and non-development custom issuers keep their existing override behavior. +- `apps/account-directory/src/directory.ts` — the Clerk-scoped machine + register/list/delete Worker routes. Machine listing selects the owner's 500 + most recently seen rows before computing online-first order and exposes + separate authentication and D1 durations through `Server-Timing`; the + trusted web-client CORS response exposes that header. - `apps/desktop/src/shared/accountDirectory.ts` — canonical account-directory origin, bounded success/error response decoding, route allowlisting, machine selection, and paired endpoint validation shared by desktop, the brain, ADE @@ -665,13 +677,23 @@ Canonical files (`apps/ade-cli/src/services/sync/`): loopback sync listener (matching port + identity nonce) whenever the control socket opens and whenever the shared listener reports a fresh loopback validation (`sharedSyncListener.onLoopbackValidated`, wired in - `bootstrap.ts`), coalescing overlapping probes through a single in-flight - promise. This flips `relayBridgeValidated` — and therefore directory relay + `bootstrap.ts`), serializing probes through the same validation queue used by + inbound opens. This flips `relayBridgeValidated` — and therefore directory relay publication — true as soon as the listener is confirmed, so the earlier "bridge not validated against the sync port" state self-heals instead of waiting for an inbound client to open the first tunnel. `openTunnel` still - re-validates on every inbound open as defense in depth and refuses the pipe - if the sync port changed mid-validation. Control observability preserves the + re-validates on every inbound open as defense in depth. Validation calls are + strictly serialized, and the port plus loopback identity + are checked again before pipe creation so a listener change cannot reuse a + stale result. The control socket uses native WebSocket ping frames every 30 + seconds with a 10-second pong deadline; a miss terminates it and enters the + guarded reconnect state machine without waking the hibernated Durable Object + with JSON traffic. Opens that fail validation, local-listener setup, or pipe + setup send a bounded `{t:"reject"}` signal so the waiting controller closes + immediately instead of hanging. Pipe/local application close codes and + sanitized reasons are preserved across the bridge; other closes normalize to + `4000`. Account loss clears validation and all sockets, while account switches + force a clean control reconnect. Control observability preserves the causal failure rather than replacing it with a generic WebSocket error: upgrade rejection captures the HTTP status and at most 512 sanitized response bytes; close telemetry records code, reason, and whether the socket opened. diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 20f65556c..2643026de 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -26,15 +26,27 @@ direct deployment-check fallback. The app is built from `apps/desktop` with Build and static host: -- `apps/desktop/package.json` - `dev:webclient` and `build:webclient`. +- `apps/desktop/package.json` - `dev:webclient` and `build:webclient`. The + production build runs `check-webclient-entry.mjs` after Vite succeeds. - `apps/desktop/vite.webclient.config.ts` - Vite build for the hosted SPA. Renames `webclient.html` to `index.html`, writes to `apps/desktop/dist/web-client`, and copies Cloudflare Pages files into the - output root. -- `apps/desktop/src/renderer/webclient.html` - browser entry HTML. + output root. It relies on Rollup's normal graph splitting instead of forcing + the desktop renderer's heavy vendor groups into eager module preloads; the + shared renderer's dynamic imports remain the feature-loading boundaries. +- `apps/desktop/scripts/check-webclient-entry.mjs` - post-build regression + guard for the first-load graph. It collects local entry scripts and + `modulepreload` links from the generated `index.html`, rejects eagerly linked + Monaco/graph/terminal/Markdown-style chunks, rejects external entry scripts, + and caps raw entry HTML plus referenced JavaScript at 1000 KB. +- `apps/desktop/src/renderer/webclient.html` - browser entry HTML. It paints a + dependency-free loading shell before React evaluates and preconnects to the + production Clerk and account-directory origins. - `apps/desktop/src/renderer/webclient/public/_headers` - Pages headers, including CSP. `connect-src` allows `wss:` and `https:`, not arbitrary - hosted-page `ws:` connections; `img-src` allowlists the Clerk image CDNs and + hosted-page `ws:` connections. Fingerprinted `/assets/*` responses are cached + for one year as immutable; `/` and `/index.html` remain `no-cache` so a new + deployment's entry graph is discovered immediately. `img-src` allowlists the Clerk image CDNs and Clerk's Google Storage profile-image path used by the account client. - `apps/desktop/src/renderer/webclient/public/_redirects` - SPA fallback: `/* /index.html 200`. @@ -66,7 +78,12 @@ Browser sync client: - `apps/desktop/src/renderer/webclient/sync/connection.ts` - WebSocket lifecycle, account adoption, paired hello, DPoP proof on reconnect, heartbeat, reconnect/backoff, project catalog chunks, and auth-failure - attribution. + attribution. DPoP/token preparation begins in parallel with the socket dial; + transport open and authenticated hello have separate 8-second and 12-second + deadlines. While the page is visible, a 15-second watchdog closes a socket + after 75 seconds without inbound traffic; returning to a visible stale tab + bypasses accumulated backoff. Relay application close codes are translated + into stable offline/capacity/retry messages instead of exposing raw reasons. - `apps/desktop/src/renderer/webclient/sync/relayPolicy.ts` - typed hosted Relay authorization policy. It keeps local pairing provenance separate from account ownership, filters Relay routes while signed out, and surfaces the @@ -80,6 +97,12 @@ Browser sync client: A versioned one-time trust migration clears legacy environments and selection while preserving unrelated browser and account state; environments paired after the marker persist normally. + Every operation runs through an explicit transaction; trust reset and + account-owner pruning update environments, selection, and metadata + atomically. Generic IndexedDB opens fail after 4 seconds; a blocked upgrade + gets a 5-second grace period for another tab to close and reports a distinct + typed failure. Rejected cached promises are cleared so Retry can reopen, and + `versionchange` closes/resets the database and the trust-reset state. - `apps/desktop/src/renderer/webclient/sync/dpop.ts` - WebCrypto P-256 ECDSA DPoP key generation and proof signing. The private key is non-extractable by default. @@ -175,7 +198,13 @@ Browser shell and routes: machine picker, 30-second active-account lease monitor, project picker, adapter load, pending `/open` target stash, project binding, and projectless `/chats` routing before - a project is selected. Project binding now passes the selected `project.id` + a project is selected. Ordinary visits paint the machine picker immediately; + account bootstrap/directory work and IndexedDB loading proceed independently + instead of blocking first paint. Saved environments stay hidden until the + current account owner's atomic privacy prune completes, and stale async loads + cannot publish another account's records. Storage failure leaves account + sign-in and the directory usable with a separate saved-browser Retry path. + Project binding passes the selected `project.id` (from the switch result when available) into `bindProject` so the adapter's `getProjectId()` cannot fall back to a stale `activeProjectId` mid-reconnect, and a failed `switchProject` surfaces its message instead of being swallowed. @@ -187,8 +216,9 @@ Browser shell and routes: - `apps/desktop/src/renderer/webclient/shell/MachinePicker.tsx`, `ProjectPicker.tsx`, and `ScreenShell.tsx` - the account/switch UI pieces the boot sequence composes (account machines, compatible saved-machine list, - project list, and the shared screen frame); `shellTokens.ts` holds the - standalone-shell design tokens. + independent directory/storage loading and retry states, project list, and the + shared screen frame); `shellTokens.ts` holds the standalone-shell design + tokens. - `apps/desktop/src/renderer/webclient/shell/AccountIdentity.tsx` - shared signed-in identity label and trusted profile-image/initials fallback for the machine picker and connected shell; opaque account IDs are not shown. @@ -252,6 +282,10 @@ Machine runtime and sync host: Account connection and entry points: +- `apps/account-directory/src/directory.ts` - Clerk-scoped machine + register/list/delete routes. The list query reads the 500 most recently seen + rows, computes online-first order, and exposes auth/D1 durations through the + CORS-visible `Server-Timing` header. - `apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx` - the focused **Connections > Phone** and **Connections > Web** tab bodies plus the shared **This Mac** card. The Web variant is account-sign-in only; the @@ -295,6 +329,7 @@ Tests: - `apps/desktop/src/renderer/components/app/TopBar.test.tsx`. - `apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsx`. - `apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts`. +- `apps/desktop/src/renderer/webclient/sync/envStore.test.ts`. - `apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts`. - `apps/desktop/src/renderer/webclient/shell/__tests__/MachinePicker.test.tsx` - signed-in identity, account-machine availability, and reauthentication UI. @@ -326,7 +361,17 @@ Tests: DPoP private keys. The browser must sign in and adopt the account machine again; a legacy direct environment cannot be recreated through the hosted UI. The private key cannot be exported for - backup or migration. + backup or migration. A blocked upgrade or unavailable storage is recoverable: + the machine directory continues loading, saved environments remain hidden, + and Retry performs a fresh open attempt. Multi-store privacy cleanup must + remain transactional so selection metadata cannot outlive a removed + account-owned environment. +- **Connection liveness is application-observed.** An open browser WebSocket is + not proof that the relay path is usable. The visible-page watchdog requires + inbound ADE traffic within 75 seconds and reconnects stale tabs; do not remove + it in favor of the browser's socket state alone. Transport-open and + authenticated-hello deadlines are intentionally separate so slow token/DPoP + preparation and a dead network route produce different failures. - **Analytics consent is browser-local and fail-closed.** The preference lives in local storage, while the host keeps an in-memory consent bit for that paired socket. Every adapter connection reasserts the local choice before @@ -459,6 +504,11 @@ machine picker loads the user's machines from the Clerk-verified account-directory Worker. Offline machines remain listed with their last-seen state and cannot be selected. Saved pre-release direct environments are shown separately as a local compatibility path; they are not a pairing fallback. +The Worker reads at most the owner's 500 most recently seen rows from D1 before +computing online-first display order. Machine-list responses expose +`Server-Timing` entries for Clerk authentication and the D1 query; CORS exposes +that header to the hosted browser. These timings are diagnostics only and do +not change the directory response contract. Hosted builds use ADE's production Clerk application and production directory by default. Vite development uses the isolated development Clerk application @@ -628,6 +678,10 @@ Ops checks after deploy: - `https://ade-web-client.pages.dev/pair#` scrubs the obsolete route and fragment, then shows account sign-in; no pairing UI is rendered. - Response headers include the CSP from `_headers`. +- Fingerprinted assets return immutable one-year caching while `/` and + `/index.html` return `no-cache`. +- The build reports the raw first-load entry graph and fails if it exceeds + 1000 KB or eagerly references a guarded heavy feature chunk. ## Known limitations