feat: connection candidates refresh + relay identity hardening

Candidates refresh (server + mobile + desktop clients):
- GET /api/client-auth/connection/candidates returns the server's current
  LAN URLs, relay candidate, and serverId for already-paired devices
- /health and /api/version expose serverId so clients can verify a learned
  address belongs to the expected server before sending their bearer token
- mobile: refresh saved candidates over the live transport after every
  connect/wake, hot-switch relay->LAN when a fresh address is reachable;
  serverId gate on direct probes; token no longer sent to /health
- desktop: refresh stored host apiUrl after a relay connect and hot-switch
  back to direct; electron probe verifies serverId before authenticated fetch

Fixes found while debugging a dead pairing:
- settings: strict reader that throws on corrupt/unreadable file instead of
  returning {}; relay signing/encryption key generation is now gated on it,
  so a swallowed read failure can no longer mint a new server identity and
  orphan every paired device (loud log when a keypair IS generated)
- SessionAuthGate: bounded auto-retry for transient session-check failures
  (initial request racing the relay tunnel's first WS attempt, startup 5xx)
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 18:09:54 +03:00
parent 22d5ad3814
commit afb368e11b
16 changed files with 610 additions and 27 deletions
@@ -42,6 +42,8 @@ export const createApnsRuntime = (deps) => {
APNS_TOKENS_FILE_PATH,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
// Strict settings reader gating identity regeneration (see signing-key.js).
readSettingsStrict,
} = deps;
let persistLock = Promise.resolve();
@@ -60,7 +62,7 @@ export const createApnsRuntime = (deps) => {
// relay identity — same keypair, same storage, same serverId derivation).
const getOrCreateRelayKeypair = async () => {
if (cachedRelayKey) return cachedRelayKey;
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
return cachedRelayKey;
};