import { buildExternalManualRestartResponse } from './config-mutation-response.js'; const parseLoopbackUrl = (rawUrl) => { if (typeof rawUrl !== 'string') { return null; } let url; try { url = new URL(rawUrl); } catch { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { return null; } const host = url.hostname; if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '0.0.0.0') { return null; } return url; }; export const registerServerStatusRoutes = (app, dependencies) => { const { express, process, openchamberVersion, runtimeName, serverStartedAt, gracefulShutdown, getHealthSnapshot, // Port this OpenChamber instance serves on and the tunnel public URL (if // a tunnel is active). Exposed on /api/system/info so the UI can surface // the active instance's service URLs. Optional: older wiring omits them // and the endpoint reports null. getServerPort = () => null, getTunnelUrl = () => null, // Stable server identity (hash of the public signing key — not a secret). // Exposed on /health and /api/version so a client can verify that a // learned/probed address belongs to the expected server BEFORE sending its // bearer token there. Optional: older wiring omits it. getServerId = async () => null, tunnelAuthController = null, uiAuthController = null, } = dependencies; // The identity is immutable for the process lifetime; resolve once, and never // let an identity failure break health reporting. let cachedServerId = null; const resolveServerId = async () => { if (cachedServerId) return cachedServerId; try { const value = await getServerId(); cachedServerId = typeof value === 'string' && value.trim() ? value.trim() : null; } catch { cachedServerId = null; } return cachedServerId; }; const allocateLoopbackPort = async () => { const net = await import('node:net'); return await new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { try { const address = server.address(); const port = address && typeof address === 'object' ? address.port : 0; server.close(() => { resolve(port); }); } catch (error) { try { server.close(); } catch { } reject(error); } }); }); }; const compatibility = { apiVersion: 1, minClientApiVersion: 1, capabilities: [ 'api.health.v1', 'api.runtime-url.v1', 'api.raw-file.v1', 'realtime.sse.v1', 'realtime.websocket.global-events.v1', 'terminal.websocket.v1', ], }; const isDevShutdownAllowed = () => { // Dev-only escape hatch: allow terminating the whole dev process group. // This should never be enabled in production runtimes. return process.env.OPENCHAMBER_DEV_SHUTDOWN === 'true'; }; const isSameOriginRequest = (req) => { const rawOrigin = typeof req.get === 'function' ? req.get('origin') : ''; const rawHost = typeof req.get === 'function' ? req.get('host') : ''; if (!rawOrigin || !rawHost) { return false; } try { const origin = new URL(rawOrigin); return origin.host === rawHost; } catch { return false; } }; const resolveProcessGroupId = async (pid) => { if (!pid || typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) { return null; } if (process.platform === 'win32') { return null; } try { const { execFile } = await import('node:child_process'); const { promisify } = await import('node:util'); const execFileAsync = promisify(execFile); const result = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)]); const raw = String(result.stdout || '').trim(); const pgid = Number.parseInt(raw, 10); return Number.isFinite(pgid) && pgid > 0 ? pgid : null; } catch { return null; } }; const parseLoopbackPort = (rawUrl) => { if (typeof rawUrl !== 'string') { return null; } let url; try { url = new URL(rawUrl); } catch { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { return null; } const host = url.hostname; if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '0.0.0.0') { return null; } const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80); if (!Number.isFinite(port) || port <= 0 || port > 65535) { return null; } return port; }; const killListenPort = async (port) => { if (!Number.isFinite(port) || port <= 0) { return; } if (process.platform === 'win32') { return; } try { const { execFile } = await import('node:child_process'); const { promisify } = await import('node:util'); const execFileAsync = promisify(execFile); const result = await execFileAsync('lsof', ['-nP', '-t', `-iTCP:${Math.trunc(port)}`, '-sTCP:LISTEN'], { timeout: 2500, }); const pids = String(result.stdout || '') .split(/\s+/) .map((value) => Number.parseInt(value, 10)) .filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid); for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { } } if (pids.length > 0) { setTimeout(() => { for (const pid of pids) { try { process.kill(pid, 'SIGKILL'); } catch { } } }, 1200).unref?.(); } } catch { // ignore (no lsof, no permission, etc.) } }; app.get('/health', async (_req, res) => { const serverId = await resolveServerId(); res.json({ status: 'ok', timestamp: new Date().toISOString(), openchamberVersion, runtime: runtimeName, compatibility, ...(serverId ? { serverId } : {}), ...getHealthSnapshot(), }); }); app.get('/api/version', async (_req, res) => { const serverId = await resolveServerId(); res.json({ status: 'ok', openchamberVersion, runtime: runtimeName, startedAt: serverStartedAt, compatibility, ...(serverId ? { serverId } : {}), }); }); const requireShutdownAuth = async (req, res, next) => { if (!uiAuthController || typeof uiAuthController.requireAuth !== 'function') { return next(); } const requestScope = typeof tunnelAuthController?.classifyRequestScope === 'function' ? tunnelAuthController.classifyRequestScope(req) : 'local'; if ( (requestScope === 'tunnel' || requestScope === 'unknown-public') && typeof tunnelAuthController?.requireTunnelSession === 'function' ) { return tunnelAuthController.requireTunnelSession(req, res, next); } return uiAuthController.requireAuth(req, res, next); }; app.post('/api/system/shutdown', async (req, res, next) => { try { await requireShutdownAuth(req, res, () => { res.json({ ok: true }); gracefulShutdown({ exitProcess: true }).catch((error) => { console.error('Shutdown request failed:', error?.message || error); }); }); } catch (error) { next(error); } }); app.post('/api/system/dev-shutdown', express.json({ limit: '64kb' }), async (req, res) => { if (!isDevShutdownAllowed()) { return res.status(403).json({ ok: false, error: 'Dev shutdown is disabled' }); } if (!isSameOriginRequest(req)) { return res.status(403).json({ ok: false, error: 'Invalid origin' }); } res.json({ ok: true }); // Terminate the entire dev process group so `bun run dev` leaves no orphans. // We still run graceful shutdown to clean up OpenCode, terminals, websockets. try { const rawPreviewUrls = Array.isArray(req.body?.previewUrls) ? req.body.previewUrls : []; const previewPorts = Array.from(new Set( rawPreviewUrls .map((value) => parseLoopbackPort(value)) .filter((port) => typeof port === 'number') )); // Attempt to stop preview servers that may have daemonized away from the PTY. // This is dev-only and limited to loopback ports supplied by the UI. await Promise.all(previewPorts.map((port) => killListenPort(port))); const pgid = await resolveProcessGroupId(process.pid); const ppid = typeof process.ppid === 'number' ? process.ppid : null; const parentPgid = ppid ? await resolveProcessGroupId(ppid) : null; // Kick off shutdown cleanup first. void gracefulShutdown({ exitProcess: false }); const pgidsToKill = Array.from(new Set([pgid, parentPgid].filter(Boolean))); for (const id of pgidsToKill) { try { process.kill(-id, 'SIGTERM'); } catch { } } setTimeout(() => { for (const id of pgidsToKill) { try { process.kill(-id, 'SIGKILL'); } catch { } } }, 1500).unref?.(); // Ensure the server process itself exits even if the group kill fails. setTimeout(() => { try { process.exit(0); } catch { } }, 2500).unref?.(); } catch (error) { console.error('Dev shutdown request failed:', error?.message || error); // As a last resort, exit. try { process.exit(0); } catch { } } }); app.get('/api/system/info', (_req, res) => { res.json({ openchamberVersion, runtime: runtimeName, pid: process.pid, startedAt: serverStartedAt, port: getServerPort(), tunnelUrl: getTunnelUrl(), }); }); // Allocates a best-effort free TCP port hint on 127.0.0.1. // Another process can still claim it before the preview server binds. app.get('/api/system/free-port', async (_req, res) => { try { const port = await allocateLoopbackPort(); if (!Number.isFinite(port) || port <= 0) { return res.status(500).json({ error: 'Failed to allocate port' }); } return res.json({ port }); } catch (error) { return res.status(500).json({ error: (error && error.message) || 'Failed to allocate port' }); } }); }; export const registerAuthAndAccessRoutes = (app, dependencies) => { const { express, tunnelAuthController, uiAuthController, remoteClientAuthRuntime, clientPairingRuntime, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, // Returns the relay pairing candidate ({ type:'relay', relayUrl, serverId, // hostEncPubJwk, priority }) when the host relay is enabled, else null. // Injected lazily because the relay service is constructed after these routes. getRelayPairingCandidate = async () => null, // Re-evaluate the relay lifecycle after pairing/device changes. reconcileRelay = async () => {}, // Returns { local, lan, relayAvailable } — the direct transport URLs the // server can actually be reached on (LAN derived from the server bind, not // the UI origin), for the create-device dialog. getPairingTransports = () => ({ local: null, lan: null, relayAvailable: true }), // Returns ALL direct LAN URLs the server is currently reachable on (client- // reached address first, then interface scan) for the candidates-refresh // endpoint. Empty when the server is loopback-only. getDirectCandidateUrls = () => [], // Stable server identity for client-side verification of learned addresses. getServerId = async () => null, // Display name a paired device shows for THIS server (issuing machine's // hostname), distinct from the per-device pairing label typed by the operator. getServerLabel = () => 'OpenChamber', } = dependencies; const PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; const PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS = 10; const pairingRedeemAttempts = new Map(); const runWithUiAuth = async (req, res, next, handler, options = {}) => { try { const requireAuth = options.sessionOnly === true && typeof uiAuthController.requireSessionAuth === 'function' ? uiAuthController.requireSessionAuth : uiAuthController.requireAuth; await requireAuth(req, res, async () => { await handler(); }); } catch (error) { next(error); } }; const runWithClientManagementAuth = async (req, res, next, handler) => { try { if (typeof uiAuthController.resolveAuthContext === 'function') { const context = await uiAuthController.resolveAuthContext(req, res, { allowClientAuth: true, allowUrlToken: false, }); if (context?.type === 'session' || context?.type === 'client') { await handler(context); return; } } await runWithUiAuth(req, res, next, async () => { await handler({ type: 'session' }); }, { sessionOnly: true }); } catch (error) { next(error); } }; const runWithClientCreateAuth = async (req, res, next, handler) => { try { if (typeof uiAuthController.resolveAuthContext === 'function') { const context = await uiAuthController.resolveAuthContext(req, res, { allowClientAuth: true, allowUrlToken: false, }); if (context?.type === 'session') { await handler(context); return; } if (context?.type === 'client') { const client = await clientRecordFromAuthContext(context); if (client?.clientKind === 'desktop-local') { await handler({ ...context, client }); return; } return res.status(403).json({ error: 'Client tokens cannot create remote clients' }); } } await runWithUiAuth(req, res, next, async () => { await handler({ type: 'session' }); }, { sessionOnly: true }); } catch (error) { next(error); } }; const clientIdFromAuthContext = (context) => { const raw = context?.client?.id || context?.clientId; return typeof raw === 'string' && raw.length > 0 ? raw : null; }; const clientRecordFromAuthContext = async (context) => { if (context?.client && typeof context.client === 'object') { return context.client; } const clientId = clientIdFromAuthContext(context); if (!clientId) return null; const clients = await remoteClientAuthRuntime.listClients(); return clients.find((client) => client.id === clientId) || null; }; const requestOrigin = (req) => { const forwardedProto = typeof req.headers?.['x-forwarded-proto'] === 'string' ? req.headers['x-forwarded-proto'].split(',')[0].trim() : ''; const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http'); const host = typeof req.headers?.host === 'string' ? req.headers.host.trim() : ''; if (!host) return null; return `${protocol}://${host}`; }; const requestIp = (req) => { // Do not use req.ip here: Express rewrites it from X-Forwarded-For when // trust proxy is enabled, and redeem is unauthenticated before this limit. return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown'; }; const pairingIdFromRequest = (req) => { const raw = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : ''; return raw || 'missing'; }; const checkPairingRedeemRateLimit = (req) => { const now = Date.now(); const key = `${requestIp(req)}:${pairingIdFromRequest(req)}`; for (const [entryKey, entry] of pairingRedeemAttempts.entries()) { if (!entry || now - entry.firstAttemptAt >= PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) { pairingRedeemAttempts.delete(entryKey); } } const entry = pairingRedeemAttempts.get(key); if (!entry) { pairingRedeemAttempts.set(key, { count: 1, firstAttemptAt: now }); return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - 1, reset: Math.ceil((now + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000) }; } const reset = Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000); if (entry.count >= PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS) { return { allowed: false, remaining: 0, reset, retryAfter: Math.max(1, Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS - now) / 1000)), }; } entry.count += 1; return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - entry.count, reset }; }; const clearPairingRedeemRateLimit = (req) => { pairingRedeemAttempts.delete(`${requestIp(req)}:${pairingIdFromRequest(req)}`); }; const normalizeCandidateUrl = (value) => { if (typeof value !== 'string' || !value.trim()) return null; try { const parsed = new URL(value.trim()); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; parsed.hash = ''; parsed.search = ''; return parsed.toString().replace(/\/+$/, ''); } catch { return null; } }; const candidateUrlType = (url) => { try { return new URL(url).protocol === 'https:' ? 'tunnel' : 'lan'; } catch { return 'lan'; } }; const isLoopbackCandidateUrl = (url) => { try { const hostname = new URL(url).hostname.toLowerCase(); return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; } catch { return true; } }; // `preferredServerUrl` is the caller-supplied externally reachable URL (the // desktop UI reaches its own server over loopback, so the request origin is not // scannable — it passes the LAN URL instead). Falls back to the request origin // for remote callers where the Host header IS the reachable address. // // `includeRelay` is the per-link transport choice from the create-link dialog: // true → add the relay candidate, enabling the relay host on demand; // false → direct only, never relay; // undefined → legacy: advertise relay only if it is already enabled. // `includeDirect === false` produces a relay-only link (no direct candidate). const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => { const candidates = []; if (includeDirect) { const preferred = normalizeCandidateUrl(preferredServerUrl); const origin = normalizeCandidateUrl(requestOrigin(req)); const direct = preferred || origin; if (direct) { candidates.push({ type: candidateUrlType(direct), url: direct, priority: 10 }); } // The origin the creator is browsing over (e.g. a public https domain in // front of a reverse proxy) is a reachable address the server cannot // discover from its own interfaces. Carry it as an additional direct // candidate so the paired device can keep using that same domain instead // of depending on LAN hairpin behavior or relay availability. Loopback // origins (desktop shell, localhost dev) are unreachable from another // device and are skipped. if (origin && direct && origin !== direct && !isLoopbackCandidateUrl(origin)) { candidates.push({ type: candidateUrlType(origin), url: origin, priority: 20 }); } } // The client races candidates and falls back to relay only if the direct URL // is unreachable (relay carries a higher priority number). if (includeRelay !== false) { try { const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: includeRelay === true }); if (relayCandidate) candidates.push(relayCandidate); } catch { // A relay enable/status failure must not break direct pairing. } } return candidates; }; const sendPairingRedeemError = (res, error) => { const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400; res.status(statusCode).json({ error: 'Invalid or expired pairing session' }); }; const requireApiAuth = async (req, res, next) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return tunnelAuthController.requireTunnelSession(req, res, next); } return uiAuthController.requireAuth(req, res, next); }; app.get('/auth/session', async (req, res) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req); if (tunnelSession) { return res.json({ authenticated: true, scope: 'tunnel' }); } tunnelAuthController.clearTunnelSessionCookie(req, res); return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true }); } try { await uiAuthController.handleSessionStatus(req, res); } catch { res.status(500).json({ error: 'Internal server error' }); } }); app.post('/auth/session', (req, res) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true }); } return uiAuthController.handleSessionCreate(req, res); }); app.post('/auth/url-token', async (req, res, next) => { try { await uiAuthController.handleUrlAuthToken(req, res); } catch (error) { next(error); } }); app.get('/auth/passkey/status', (req, res) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null, tunnelLocked: true }); } return uiAuthController.handlePasskeyStatus(req, res); }); app.post('/auth/passkey/authenticate/options', (req, res) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true }); } return uiAuthController.handlePasskeyAuthenticationOptions(req, res); }); app.post('/auth/passkey/authenticate/verify', (req, res) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true }); } return uiAuthController.handlePasskeyAuthenticationVerify(req, res); }); app.post('/auth/passkey/register/options', async (req, res, next) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true }); } try { await uiAuthController.requireSessionAuth(req, res, async () => { await uiAuthController.handlePasskeyRegistrationOptions(req, res); }); } catch (error) { next(error); } }); app.post('/auth/passkey/register/verify', async (req, res, next) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true }); } try { await uiAuthController.requireSessionAuth(req, res, async () => { await uiAuthController.handlePasskeyRegistrationVerify(req, res); }); } catch (error) { next(error); } }); app.get('/api/passkeys', async (req, res, next) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true }); } try { await uiAuthController.requireSessionAuth(req, res, async () => { await uiAuthController.handlePasskeyList(req, res); }); } catch (error) { next(error); } }); app.delete('/api/passkeys/:id', async (req, res, next) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true }); } try { await uiAuthController.requireSessionAuth(req, res, async () => { await uiAuthController.handlePasskeyRevoke(req, res); }); } catch (error) { next(error); } }); app.post('/api/auth/reset', async (req, res, next) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true }); } try { await uiAuthController.requireSessionAuth(req, res, async () => { await uiAuthController.handleResetAuth(req, res); }); } catch (error) { next(error); } }); app.get('/api/client-auth/clients', async (req, res, next) => { await runWithClientManagementAuth(req, res, next, async (authContext) => { if (authContext.type === 'client') { const client = await clientRecordFromAuthContext(authContext); // The desktop shell's local client is the trusted operator of this // server; it manages devices just like a browser UI session. Every // other client token is scoped to its own record. if (client?.clientKind !== 'desktop-local') { return res.json({ clients: client ? [client] : [] }); } } const clients = await remoteClientAuthRuntime.listClients(); res.json({ clients }); }); }); app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => { await runWithClientCreateAuth(req, res, next, async () => { const result = await remoteClientAuthRuntime.createClient({ label: req.body?.label, clientKind: req.body?.clientKind, dedupeKey: req.body?.dedupeKey, }); res.setHeader('Cache-Control', 'no-store'); res.status(201).json(result); }); }); app.delete('/api/client-auth/clients/:id', async (req, res, next) => { await runWithClientManagementAuth(req, res, next, async (authContext) => { if (authContext.type === 'client') { const actingClient = await clientRecordFromAuthContext(authContext); // The desktop shell's local client manages every device; other client // tokens may only revoke themselves. if (actingClient?.clientKind !== 'desktop-local') { const clientId = clientIdFromAuthContext(authContext); if (!clientId || clientId !== req.params?.id) { return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' }); } } } const result = await remoteClientAuthRuntime.revokeClient(req.params?.id); if (!result.revoked) { return res.status(404).json({ revoked: false, error: 'Client not found' }); } void reconcileRelay(); res.json(result); }); }); app.delete('/api/client-auth/clients', async (req, res, next) => { await runWithClientManagementAuth(req, res, next, async (authContext) => { if (authContext.type === 'client') { const actingClient = await clientRecordFromAuthContext(authContext); // Purging revoked devices is a whole-server management action; only the // trusted desktop shell client (or a UI session) may do it. if (actingClient?.clientKind !== 'desktop-local') { return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' }); } } const result = await remoteClientAuthRuntime.purgeRevokedClients(); void reconcileRelay(); res.json(result); }); }); app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => { await runWithClientCreateAuth(req, res, next, async (authContext) => { const candidates = await pairingServerCandidates(req, { preferredServerUrl: req.body?.serverUrl, includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined, includeDirect: req.body?.includeDirect !== false, }); const usesRelay = candidates.some((candidate) => candidate.type === 'relay'); const result = await clientPairingRuntime.createPairingSession({ label: req.body?.label, allowedClientKinds: req.body?.allowedClientKinds, createdByClientId: clientIdFromAuthContext(authContext), usesRelay, }); void reconcileRelay(); res.setHeader('Cache-Control', 'no-store'); res.status(201).json({ ...result, server: { label: getServerLabel(), candidates }, }); }); }); // Current reachable transports for an ALREADY-PAIRED device. Pairing-payload // candidates are a snapshot: when DHCP hands this machine a new address, the // device's saved LAN candidate goes stale and it is stuck on the relay forever. // A client that connected over any live transport calls this to learn the // server's present LAN URLs (plus the relay candidate when enabled) and update // its saved candidate set. `serverId` lets the client bind the response — and // later /health probes of the learned addresses — to this server's identity // before trusting them with its bearer token. // Auth: UI session or client bearer; never the short-lived URL token. app.get('/api/client-auth/connection/candidates', async (req, res, next) => { await runWithClientManagementAuth(req, res, next, async () => { const candidates = []; const directUrls = (() => { try { const urls = getDirectCandidateUrls(req); return Array.isArray(urls) ? urls : []; } catch { return []; } })(); for (const url of directUrls) { const normalized = normalizeCandidateUrl(url); if (normalized) candidates.push({ type: 'lan', url: normalized, priority: 10 }); } try { const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: false }); if (relayCandidate) candidates.push(relayCandidate); } catch { // Relay status failure must not break the direct-candidate refresh. } let serverId = null; try { const value = await getServerId(); serverId = typeof value === 'string' && value.trim() ? value.trim() : null; } catch { serverId = null; } res.setHeader('Cache-Control', 'no-store'); res.json({ label: getServerLabel(), ...(serverId ? { serverId } : {}), candidates }); }); }); // Direct transports the server can be reached on (for the create-device dialog). app.get('/api/client-auth/pairing/transports', async (req, res, next) => { await runWithClientCreateAuth(req, res, next, async () => { res.setHeader('Cache-Control', 'no-store'); res.json(getPairingTransports(req)); }); }); // Pending pairing sessions (link created, device not yet connected) for the // "pending devices" list. Secrets are never included. app.get('/api/client-auth/pairing/sessions', async (req, res, next) => { await runWithClientCreateAuth(req, res, next, async () => { const pending = await clientPairingRuntime.listPendingSessions(); res.setHeader('Cache-Control', 'no-store'); res.json({ pending }); }); }); app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => { await runWithClientCreateAuth(req, res, next, async () => { const result = await clientPairingRuntime.cancelPairingSession(req.params?.id); if (!result.cancelled) { return res.status(404).json({ cancelled: false, error: 'Pairing session not found' }); } void reconcileRelay(); res.json(result); }); }); app.post('/api/client-auth/pairing/redeem', express.json({ limit: '64kb' }), async (req, res, next) => { try { const rateLimit = checkPairingRedeemRateLimit(req); res.setHeader('X-RateLimit-Limit', PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS); res.setHeader('X-RateLimit-Remaining', rateLimit.remaining); res.setHeader('X-RateLimit-Reset', rateLimit.reset); if (!rateLimit.allowed) { res.setHeader('Retry-After', rateLimit.retryAfter); return res.status(429).json({ error: 'Invalid or expired pairing session' }); } const result = await clientPairingRuntime.redeemPairingSession({ pairingId: req.body?.pairingId, secret: req.body?.secret, clientLabel: req.body?.clientLabel, clientKind: req.body?.clientKind, deviceName: req.body?.deviceName, devicePlatform: req.body?.devicePlatform, deviceModel: req.body?.deviceModel, appVersion: req.body?.appVersion, dedupeKey: req.body?.dedupeKey, }); clearPairingRedeemRateLimit(req); // The session became a device: relay demand may have moved from the pending // session to the paired device (or a non-relay redeem may drop it). void reconcileRelay(); res.setHeader('Cache-Control', 'no-store'); res.json({ ok: true, server: { label: getServerLabel(), url: requestOrigin(req), fingerprint: result.pairing?.fingerprint || null, }, client: result.client, clientToken: result.token, }); } catch (error) { if (error?.message === 'Invalid or expired pairing session') { sendPairingRedeemError(res, error); return; } next(error); } }); app.get('/connect', async (req, res) => { try { const token = typeof req.query?.t === 'string' ? req.query.t : ''; const settings = await readSettingsFromDiskMigrated(); const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); const exchange = tunnelAuthController.exchangeBootstrapToken({ req, res, token, sessionTtlMs: tunnelSessionTtlMs, }); res.setHeader('Cache-Control', 'no-store'); if (!exchange.ok) { if (exchange.reason === 'rate-limited') { res.setHeader('Retry-After', String(exchange.retryAfter || 60)); return res.status(429).type('text/plain').send('Too many attempts. Please try again later.'); } return res.status(401).type('text/plain').send('Connection link is invalid or expired.'); } return res.redirect(302, '/'); } catch { return res.status(500).type('text/plain').send('Failed to process connect request.'); } }); app.post('/api/system/probe-url', express.json({ limit: '16kb' }), async (req, res, next) => { try { await requireApiAuth(req, res, async () => { const url = parseLoopbackUrl(req.body?.url); if (!url) { return res.status(400).json({ ok: false, error: 'Invalid loopback URL' }); } try { const response = await fetch(url.toString(), { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(1500), }); return res.json({ ok: response.status >= 200 && response.status < 600, status: response.status }); } catch (error) { return res.json({ ok: false, error: error?.message || 'Probe failed' }); } }); } catch (error) { next(error); } }); app.use('/api', async (req, res, next) => { try { await requireApiAuth(req, res, next); } catch (err) { next(err); } }); }; export const registerSettingsUtilityRoutes = (app, dependencies) => { const { readCustomThemesFromDisk, refreshOpenCodeAfterConfigChange, clientReloadDelayMs, } = dependencies; app.get('/api/config/themes', async (_req, res) => { try { const customThemes = await readCustomThemesFromDisk(); res.json({ themes: customThemes }); } catch (error) { console.error('Failed to load custom themes:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' }); } }); app.post('/api/config/reload', async (_req, res) => { try { console.log('[Server] Manual configuration reload requested'); const refreshResult = await refreshOpenCodeAfterConfigChange('manual configuration reload'); if (refreshResult?.external) { return res.json(buildExternalManualRestartResponse( 'Configuration is saved on disk. Restart your connected OpenCode server to apply the changes.', )); } res.json({ success: true, requiresReload: true, message: 'Configuration reloaded successfully. Refreshing interface…', reloadDelayMs: clientReloadDelayMs, }); } catch (error) { console.error('[Server] Failed to reload configuration:', error); res.status(500).json({ error: error.message || 'Failed to reload configuration', success: false, }); } }); }; export const registerCommonRequestMiddleware = (app, dependencies) => { const { express, verboseRequestLogs = false } = dependencies; app.use((req, res, next) => { if (req.path.startsWith('/api/behavior')) { const contentLength = parseInt(req.headers['content-length'] || '0', 10); if (contentLength > 1024 * 1024) { return res.status(413).json({ error: 'Content exceeds maximum size of 1048576 bytes' }); } express.json({ limit: '1mb' })(req, res, next); } else if ( req.path.startsWith('/api/config/agents') || req.path.startsWith('/api/config/commands') || req.path.startsWith('/api/config/mcp') || req.path.startsWith('/api/config/snippets') || req.path.startsWith('/api/config/settings') || req.path.startsWith('/api/config/skills') || req.path.startsWith('/api/config/plugins') || req.path.startsWith('/api/projects') || req.path.startsWith('/api/fs') || req.path.startsWith('/api/git') || req.path.startsWith('/api/magic-prompts') || req.path.startsWith('/api/prompts') || req.path.startsWith('/api/terminal') || req.path.startsWith('/api/opencode') || req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || req.path.startsWith('/api/permission-auto-accept') || req.path.startsWith('/api/message-queue') || req.path.startsWith('/api/provider') || req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/small-model') || req.path.startsWith('/api/walkthrough') || req.path.startsWith('/api/goals') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || req.path.startsWith('/api/tts') || req.path.startsWith('/api/openchamber/tunnel') ) { express.json({ limit: '50mb' })(req, res, next); } else if (req.path.startsWith('/api')) { next(); } else { express.json({ limit: '50mb' })(req, res, next); } }); app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use((req, _res, next) => { if (verboseRequestLogs) { console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); } next(); }); };