import type { IncomingMessage, ServerResponse } from "node:http"; import { hostname } from "node:os"; import { ensureIdentityMaterial, identityConfigForDataDir } from "../security.js"; import type { RegisterDeviceTokenRequest } from "../types.js"; import type { RouteContext, RouteDispatcher, RouteHelpers } from "./types.js"; import { aggregateDailyDetail, aggregateStats, getActiveSessions, getMemoryStats, parseRange, parseTzOffset, } from "./server-stats.js"; const PAIRING_MAX_FAILURES = 5; const PAIRING_WINDOW_MS = 60_000; const PAIRING_COOLDOWN_MS = 120_000; export function createIdentityRoutes(ctx: RouteContext, helpers: RouteHelpers): RouteDispatcher { const pairingFailuresBySource = new Map(); const pairingBlockedUntilBySource = new Map(); function pairingSourceKey(req: IncomingMessage): string { return req.socket.remoteAddress || "unknown"; } function isPairingRateLimited(source: string, now: number): boolean { const blockedUntil = pairingBlockedUntilBySource.get(source) || 0; if (blockedUntil > now) { return true; } if (blockedUntil > 0 && blockedUntil <= now) { pairingBlockedUntilBySource.delete(source); pairingFailuresBySource.delete(source); } return false; } function recordPairingFailure(source: string, now: number): void { const windowStart = now - PAIRING_WINDOW_MS; const failures = (pairingFailuresBySource.get(source) || []).filter((ts) => ts >= windowStart); failures.push(now); pairingFailuresBySource.set(source, failures); if (failures.length >= PAIRING_MAX_FAILURES) { pairingBlockedUntilBySource.set(source, now + PAIRING_COOLDOWN_MS); } } function clearPairingFailures(source: string): void { pairingFailuresBySource.delete(source); pairingBlockedUntilBySource.delete(source); } async function handlePair(req: IncomingMessage, res: ServerResponse): Promise { const source = pairingSourceKey(req); const now = Date.now(); if (isPairingRateLimited(source, now)) { helpers.error(res, 429, "Too many invalid pairing attempts. Try again later."); return; } const body = await helpers.parseBody<{ pairingToken?: string }>(req); const pairingToken = typeof body.pairingToken === "string" ? body.pairingToken.trim() : ""; if (!pairingToken) { helpers.error(res, 400, "pairingToken required"); return; } const deviceToken = ctx.storage.consumePairingToken(pairingToken); if (!deviceToken) { recordPairingFailure(source, now); helpers.error(res, 401, "Invalid or expired pairing token"); return; } clearPairingFailures(source); helpers.json(res, { deviceToken }); } function handleGetMe(res: ServerResponse): void { // Keep a stable single-user identifier for iOS decoding. helpers.json(res, { user: "owner", name: ctx.storage.getOwnerName(), }); } async function handleGetServerInfo(res: ServerResponse): Promise { const config = ctx.storage.getConfig(); const workspaces = ctx.storage.listWorkspaces(); const sessions = ctx.storage.listSessions(); const activeIds = ctx.sessions.getActiveSessionIds(); const activeSessions = sessions.filter( (s) => s.status !== "stopped" && s.status !== "error" && activeIds.has(s.id), ); const runtimeUpdate = await ctx.getRuntimeUpdateStatus(); const uptimeSeconds = Math.floor((Date.now() - ctx.serverStartedAt) / 1000); let identity: { fingerprint: string; keyId: string; algorithm: "ed25519" } | null = null; try { const material = ensureIdentityMaterial(identityConfigForDataDir(ctx.storage.getDataDir())); identity = { fingerprint: material.fingerprint, keyId: material.keyId, algorithm: material.algorithm, }; } catch { identity = null; } helpers.json(res, { name: hostname(), version: ctx.serverVersion, uptime: uptimeSeconds, os: process.platform, arch: process.arch, hostname: hostname(), nodeVersion: process.version, piVersion: ctx.piVersion, configVersion: config.configVersion ?? 1, identity, runtimeUpdate, stats: { workspaceCount: workspaces.length, activeSessionCount: activeSessions.length, totalSessionCount: sessions.length, skillCount: ctx.skillRegistry.list().length, modelCount: ctx.getModelCatalog().length, }, }); } async function handleListModels(res: ServerResponse): Promise { await ctx.refreshModelCatalog(); helpers.json(res, { models: ctx.getModelCatalog() }); } async function handleRuntimeUpdate(res: ServerResponse): Promise { const result = await ctx.runRuntimeUpdate(); const status = await ctx.getRuntimeUpdateStatus({ force: true }); helpers.json(res, { ok: result.ok, result, status }); // Auto-restart after successful update so the new runtime loads. // ServerProcessManager detects the exit and auto-restarts. if (result.ok) { setTimeout(() => { console.log("[runtime-update] Update installed, exiting for restart..."); process.exit(0); }, 1_000); } } async function handleRegisterDeviceToken( req: IncomingMessage, res: ServerResponse, ): Promise { const body = await helpers.parseBody(req); if (!body.deviceToken) { helpers.error(res, 400, "deviceToken required"); return; } const tokenType = body.tokenType || "apns"; if (tokenType === "liveactivity") { ctx.storage.setLiveActivityToken(body.deviceToken); console.log("[push] Live Activity token registered", { owner: ctx.storage.getOwnerName(), }); } else { ctx.storage.addPushDeviceToken(body.deviceToken); console.log("[push] Device token registered", { owner: ctx.storage.getOwnerName(), }); } helpers.json(res, { ok: true }); } async function handleDeleteDeviceToken(req: IncomingMessage, res: ServerResponse): Promise { const body = await helpers.parseBody<{ deviceToken: string }>(req); if (body.deviceToken) { ctx.storage.removePushDeviceToken(body.deviceToken); console.log("[push] Device token removed", { owner: ctx.storage.getOwnerName(), }); } helpers.json(res, { ok: true }); } function handleGetDailyDetail(date: string, url: URL, res: ServerResponse): void { const parsed = new Date(date + "T00:00:00Z"); if (isNaN(parsed.getTime())) { helpers.json(res, { error: "Invalid date format. Use YYYY-MM-DD." }, 400); return; } const tzOffsetMin = parseTzOffset(url.searchParams.get("tz")); const sessions = ctx.storage.listSessions(); const result = aggregateDailyDetail(sessions, date, tzOffsetMin); helpers.json(res, result); } function handleGetServerStats(url: URL, res: ServerResponse): void { const rangeDays = parseRange(url.searchParams.get("range")); const tzOffsetMin = parseTzOffset(url.searchParams.get("tz")); const sessions = ctx.storage.listSessions(); const workspaces = ctx.storage.listWorkspaces(); const memory = getMemoryStats(); const activeSessions = getActiveSessions(sessions, ctx.sessions.getActiveSessionIds()); const { daily, modelBreakdown, workspaceBreakdown, totals } = aggregateStats({ sessions, workspaces, rangeDays, tzOffsetMin, }); helpers.json(res, { memory, activeSessions, daily, modelBreakdown, workspaceBreakdown, totals, }); } return async ({ method, path, url, req, res }) => { if (path === "/pair" && method === "POST") { await handlePair(req, res); return true; } if (path === "/me" && method === "GET") { handleGetMe(res); return true; } if (path === "/server/info" && method === "GET") { await handleGetServerInfo(res); return true; } // Match /server/stats/daily/YYYY-MM-DD (must come before /server/stats) const dailyDetailMatch = path.match(/^\/server\/stats\/daily\/(\d{4}-\d{2}-\d{2})$/); if (dailyDetailMatch && method === "GET") { handleGetDailyDetail(dailyDetailMatch[1], url, res); return true; } if (path === "/server/stats" && method === "GET") { handleGetServerStats(url, res); return true; } if (path === "/server/runtime/status" && method === "GET") { const force = url.searchParams.get("force") === "true"; const status = await ctx.getRuntimeUpdateStatus({ force }); helpers.json(res, status); return true; } if (path === "/server/runtime/update" && method === "POST") { await handleRuntimeUpdate(res); return true; } if (path === "/models" && method === "GET") { await handleListModels(res); return true; } if (path === "/me/device-token" && method === "POST") { await handleRegisterDeviceToken(req, res); return true; } if (path === "/me/device-token" && method === "DELETE") { await handleDeleteDeviceToken(req, res); return true; } if (path === "/server/auto-title" && method === "GET") { const config = ctx.storage.getConfig(); helpers.json(res, config.autoTitle ?? { enabled: false }); return true; } if (path === "/server/auto-title" && method === "PUT") { const body = await helpers.parseBody<{ enabled?: boolean; model?: string }>(req); const current = ctx.storage.getConfig().autoTitle ?? { enabled: false }; const updated = { enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, model: typeof body.model === "string" && body.model.trim().length > 0 ? body.model.trim() : body.model === null ? undefined : current.model, }; ctx.storage.updateConfig({ autoTitle: updated }); helpers.json(res, updated); return true; } return false; }; }