diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 8086e562..c4783f4e 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1635,11 +1635,19 @@ async function main(options = {}) { // Relay demand = any paired device or pending pairing session that uses the // relay transport. Drives the auto on/off lifecycle. hasRelayDemand: async () => { - const [pendingRelay, deviceRelay] = await Promise.all([ - clientPairingRuntime.hasActiveRelaySession().catch(() => false), - remoteClientAuthRuntime.hasActiveRelayClients().catch(() => false), + // A store read failure must NOT masquerade as "no demand": reconcile + // persists enabled=false and severs paired devices. Any affirmative + // answer wins; otherwise a failed check aborts reconcile (throw) so the + // relay keeps its current state until a trustworthy read succeeds. + const [pendingRelay, deviceRelay] = await Promise.allSettled([ + clientPairingRuntime.hasActiveRelaySession(), + remoteClientAuthRuntime.hasActiveRelayClients(), ]); - return pendingRelay || deviceRelay; + if (pendingRelay.status === 'fulfilled' && pendingRelay.value) return true; + if (deviceRelay.status === 'fulfilled' && deviceRelay.value) return true; + if (pendingRelay.status === 'rejected') throw pendingRelay.reason; + if (deviceRelay.status === 'rejected') throw deviceRelay.reason; + return false; }, }); relayServiceInstance = relayService; diff --git a/packages/web/server/lib/client-auth/remote-clients.js b/packages/web/server/lib/client-auth/remote-clients.js index dcb26751..171dca56 100644 --- a/packages/web/server/lib/client-auth/remote-clients.js +++ b/packages/web/server/lib/client-auth/remote-clients.js @@ -138,13 +138,16 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP }; // Relay-transport demand from paired devices: any non-revoked, non-expired - // client that was paired over the relay. + // client that was paired over the relay OR was actually observed connecting + // through the relay tunnel (lastTransport). The observed transport is the + // authoritative signal — it covers records written before usesRelay existed + // and devices re-paired via a QR that carried no relay candidate. const hasActiveRelayClients = async () => { return withStoreMutation(async () => { const store = await readStore(); const now = Date.now(); return store.clients.some((client) => { - if (client.usesRelay !== true) return false; + if (client.usesRelay !== true && client.lastTransport !== 'relay') return false; if (client.revokedAt) return false; const expires = Date.parse(client.expiresAt || ''); return !Number.isFinite(expires) || expires > now; @@ -237,7 +240,9 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP } // Which transport carried this request: the relay tunnel proxy stamps every // forwarded request with x-openchamber-relay-connection; anything else is a - // direct (local/LAN/tunnel-URL) request. Display-only device metadata. + // direct (local/LAN/tunnel-URL) request. Feeds device display AND relay + // demand (hasActiveRelayClients), so a relay request must never be + // misclassified as direct. const transport = req?.headers?.['x-openchamber-relay-connection'] ? 'relay' : 'direct'; return withStoreMutation(async () => { const tokenHash = hashToken(token); @@ -247,9 +252,15 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null; const now = Date.now(); const lastUsedAt = Date.parse(client.lastUsedAt || ''); + // Self-heal the paired-over-relay flag from the authoritative signal: a + // request that arrived through the tunnel proves this device uses the + // relay, regardless of what the pairing-time snapshot recorded. Sticky on + // purpose — a later LAN request must not turn the relay host off again. + const healUsesRelay = transport === 'relay' && client.usesRelay !== true; + if (healUsesRelay) client.usesRelay = true; // Write on the throttle interval — or immediately when the transport // changed, so a LAN⇄relay switch is visible right away, not a minute late. - if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS || client.lastTransport !== transport) { + if (healUsesRelay || !Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS || client.lastTransport !== transport) { client.lastUsedAt = new Date(now).toISOString(); client.lastTransport = transport; await writeStore(store); diff --git a/packages/web/server/lib/client-auth/remote-clients.test.js b/packages/web/server/lib/client-auth/remote-clients.test.js index c6207fe8..68b220d1 100644 --- a/packages/web/server/lib/client-auth/remote-clients.test.js +++ b/packages/web/server/lib/client-auth/remote-clients.test.js @@ -94,6 +94,50 @@ describe('remote client auth runtime', () => { } }); + it('self-heals usesRelay when a request arrives through the relay tunnel', async () => { + const { dir, runtime } = await createRuntime(); + try { + // Pairing-time snapshot said "no relay" (pre-pairing-v2 record, or a QR + // without a relay candidate). + const created = await runtime.createClient({ label: 'Phone' }); + expect(created.client.usesRelay).toBe(false); + expect(await runtime.hasActiveRelayClients()).toBe(false); + + // A tunneled request is the authoritative proof the device uses the relay. + const relayReq = { headers: { 'x-openchamber-relay-connection': 'conn-1' } }; + const authenticated = await runtime.authenticateBearerToken(created.token, relayReq); + expect(authenticated?.ok).toBe(true); + expect(authenticated?.client.usesRelay).toBe(true); + expect(await runtime.hasActiveRelayClients()).toBe(true); + + // Sticky: a later direct request must not clear relay demand. + await runtime.authenticateBearerToken(created.token, { headers: {} }); + const listed = await runtime.listClients(); + expect(listed[0].usesRelay).toBe(true); + expect(listed[0].lastTransport).toBe('direct'); + expect(await runtime.hasActiveRelayClients()).toBe(true); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('counts an observed relay transport as relay demand even without the pairing flag', async () => { + const { dir, runtime } = await createRuntime(); + try { + const created = await runtime.createClient({ label: 'Tablet' }); + // Simulate a store written by a build that tracked lastTransport but not + // the healed usesRelay flag. + const storePath = path.join(dir, 'remote-clients.json'); + const store = JSON.parse(await fs.readFile(storePath, 'utf8')); + store.clients[0].lastTransport = 'relay'; + await fs.writeFile(storePath, JSON.stringify(store)); + expect(created.client.usesRelay).toBe(false); + expect(await runtime.hasActiveRelayClients()).toBe(true); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it('does not resurrect revoked clients after concurrent auth traffic', async () => { const { dir, runtime } = await createRuntime(); try {