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
@@ -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);