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 demand = any paired device or pending pairing session that uses the
|
||||||
// relay transport. Drives the auto on/off lifecycle.
|
// relay transport. Drives the auto on/off lifecycle.
|
||||||
hasRelayDemand: async () => {
|
hasRelayDemand: async () => {
|
||||||
const [pendingRelay, deviceRelay] = await Promise.all([
|
// A store read failure must NOT masquerade as "no demand": reconcile
|
||||||
clientPairingRuntime.hasActiveRelaySession().catch(() => false),
|
// persists enabled=false and severs paired devices. Any affirmative
|
||||||
remoteClientAuthRuntime.hasActiveRelayClients().catch(() => false),
|
// 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;
|
relayServiceInstance = relayService;
|
||||||
|
|||||||
@@ -138,13 +138,16 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Relay-transport demand from paired devices: any non-revoked, non-expired
|
// 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 () => {
|
const hasActiveRelayClients = async () => {
|
||||||
return withStoreMutation(async () => {
|
return withStoreMutation(async () => {
|
||||||
const store = await readStore();
|
const store = await readStore();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return store.clients.some((client) => {
|
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;
|
if (client.revokedAt) return false;
|
||||||
const expires = Date.parse(client.expiresAt || '');
|
const expires = Date.parse(client.expiresAt || '');
|
||||||
return !Number.isFinite(expires) || expires > now;
|
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
|
// Which transport carried this request: the relay tunnel proxy stamps every
|
||||||
// forwarded request with x-openchamber-relay-connection; anything else is a
|
// 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';
|
const transport = req?.headers?.['x-openchamber-relay-connection'] ? 'relay' : 'direct';
|
||||||
return withStoreMutation(async () => {
|
return withStoreMutation(async () => {
|
||||||
const tokenHash = hashToken(token);
|
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;
|
if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastUsedAt = Date.parse(client.lastUsedAt || '');
|
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
|
// Write on the throttle interval — or immediately when the transport
|
||||||
// changed, so a LAN⇄relay switch is visible right away, not a minute late.
|
// 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.lastUsedAt = new Date(now).toISOString();
|
||||||
client.lastTransport = transport;
|
client.lastTransport = transport;
|
||||||
await writeStore(store);
|
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 () => {
|
it('does not resurrect revoked clients after concurrent auth traffic', async () => {
|
||||||
const { dir, runtime } = await createRuntime();
|
const { dir, runtime } = await createRuntime();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user