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