fix(relay): keep relay host alive for devices that actually use it

Relay demand now counts the authoritative transport signal: a request
arriving through the tunnel permanently marks the client usesRelay, and
hasActiveRelayClients also accepts lastTransport === 'relay'. Store read
failures no longer masquerade as no demand, so reconcile can't persist
enabled=false and sever paired devices on a transient error.
This commit is contained in:
Bohdan Triapitsyn
2026-08-10 15:10:37 +03:00
parent 5e26390ad2
commit 1738707f22
3 changed files with 71 additions and 8 deletions
+12 -4
View File
@@ -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;
@@ -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);
@@ -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 {