Ctrl K
ComunidadGuías…

src/dashboard/api.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Dashboard API route handlers.
 * All routes are under /dashboard/api/*.
 */

import { config, log } from '../config.js';
import { accountRef, buildSubprocessEnv } from '../log-safety.js';
import {
  existsSync,
  mkdirSync,
  readFileSync,
  renameSync,
  rmdirSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from 'node:fs';
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import {
  getAccountList, getAccountCount, getAccountListStats, getAccountPublic, addAccountByKey, addAccountByPastedSecret,
  countAccountList, ACCOUNT_STATE_VALUES, ACCOUNT_SORT_VALUES,
  removeAccount, setAccountStatus, resetAccountErrors, updateAccountLabel,
  markAccountReauthenticated, requestReauthCheck, setAccountCredentialEmail,
  isAuthenticated, probeAccount, ensureLsForAccount,
  refreshCredits, searchWebForAccount,
  setAccountBlockedModels, setAccountTokens, setAccountTier, setAccountSpendPolicy,
  getAccountInternal, isLocalBindHost, maskApiKey, safeEqualString,
  checkLockout, failedAuthAttempt, successfulAuthAttempt,
  getDroughtSummary, getPoolHealthWindow, getRateLimitHistory,
  getAccountRequests,
  getConnectSelectorPricing, isConnectSelectorBlockedByDrought,
  sanitizeLabelText,
} from '../auth.js';
import { restartLsForProxy } from '../langserver.js';
import { getLsStatus, stopLanguageServerAndWait, startLanguageServer, isLanguageServerRunning, getLsAdmissionStatus } from '../langserver.js';
import { getStats, getRequestLog, resetStats, recordRequest, exportStats, importStats } from './stats.js';
import { getConnectMetrics, resetConnectMetrics } from '../devin-connect-metrics.js';
import { getCascadeImageMetrics, resetCascadeImageMetrics } from '../cascade-image-metrics.js';
import { detachedRegistry, cascadeDetachEnabled } from '../detached-registry.js';
import { getStickyStats, isStickyEnabled } from '../account/sticky-session.js';
import { getResponseStoreStats } from '../response-store.js';
import { cacheStats, cacheClear } from '../cache.js';
import {
  getExperimental, setExperimental, getTunables, setTunables, getPrefs, setPrefs, getSystemPrompts, setSystemPrompts, resetSystemPrompt,
  getCredentials, setRuntimeCredentials,
  verifyPassword, getEffectiveApiKey, getEffectiveDashboardPasswordStored,
  getBackendSwitch, getBackendSwitchOverrides, setBackendSwitches,
  getBreakerTunables, getBreakerOverrides, setBreakerTunables,
  getQuotaTunables, getQuotaOverrides, setQuotaTunables,
} from '../runtime-config.js';
import { poolStats as convPoolStats, poolClear as convPoolClear } from '../conversation-pool.js';
import { getClineCompatStats } from '../handlers/cline-compat.js';
import { getCcCompatStats } from '../handlers/cc-compat.js';
import { getLogs, subscribeToLogs, unsubscribeFromLogs } from './logger.js';
import { getProxyConfig, getProxyConfigMasked, setGlobalProxy, setAccountProxy, removeProxy, getEffectiveProxy } from './proxy-config.js';
import { MODELS, MODEL_TIER_ACCESS as _TIER_TABLE, getTierModels as _getTierModels, filterModelKeysByCloudCatalog, resolveModel } from '../models.js';
import { buildConnectReachability } from '../handlers/models.js';
import { FREE_REACHABLE_SELECTORS, getLiveCatalog, getSnapshotSelectors, resolveConnectSelector } from '../devin-connect-models.js';
import { buildCatalogDiff } from './catalog-diff.js';
import { accountState, cooldownState } from './pool-events.js';
import {
  probeOutcomeEntry,
  summarizeProbeResults,
  startFleetJob,
  awaitFleetJob,
  getFleetJob,
  cancelFleetJob,
  publicFleetJob,
} from './fleet-jobs.js';
import { peekBillingFacts } from './devin-read.js';
import { readQuotaBurnDown } from './quota-burn-down.js';
import { readAcuUsageByOrg } from './acu-usage.js';
import { readBillingHealth } from './billing-health.js';
import { readModelReconciliation } from './model-reconcile.js';
import { buildAccountCards, preflightCapacity } from './devin-cards.js';
import {
  buildDevinPanel,
  buildQuotaSection,
  buildRuntimeSection,
  buildSessionsSection,
  buildTurnsSection,
  MAX_SCAN_ACCOUNTS,
  RUNTIME_SWITCH_KEYS,
} from './devin-panel.js';
import { listRequests, requestRingStats } from './request-ring.js';
import { windsurfLogin, refreshFirebaseToken, reRegisterWithCodeium, probeLoginMethod, emailLoginStart, emailLoginComplete, checkEmailLocked, assertLoginIdentity } from './windsurf-login.js';
import { getModelAccessConfig, setModelAccessMode, setModelAccessList, addModelToList, removeModelFromList, setDefaultModel } from './model-access.js';
import { checkMessageRateLimit } from '../windsurf-api.js';
import { getNativeBridgeConfigStatus } from '../cascade-native-bridge.js';
import { getNativeBridgeStats } from '../native-bridge-stats.js';
import { assertPublicUrlHost } from '../image.js';
import { validateHostFormat, resolveProxyConnectHost, trustedClientIp } from '../net-safety.js';
import { discoverWindsurfCredentials, isLoopbackAddress } from './local-windsurf.js';
import { parseAccountText, classifyToken } from './account-text-parser.js';
import {
  DOCKER_DEPLOYER_WINDOW_MS,
  detectDockerSelfUpdate,
  reconcileDockerSelfUpdateState,
  runDockerSelfUpdate,
} from './docker-self-update.js';
import { BatchImportParseError, parseBatchImportInput } from './import-parser.js';
import {
  DEFAULT_ATTESTATION_REPOSITORY,
  normalizeAttestationMode,
  parseGitHubRepository,
  verifyReleaseAttestation,
} from '../release-attestation.js';
import { detectSupervisor, gracefulRestart } from '../restart.js';
import { getActiveServer } from '../server-registry.js';
import { abortActiveSse } from '../sse-registry.js';
import { renameSyncWithRetry, writeJsonAtomic } from '../fs-atomic.js';
import { getVersionInfo } from '../version.js';

// Statuses an operator may write through PATCH /accounts/:id. Deliberately NOT
// ACCOUNT_STATE_VALUES (the read-side vocabulary): that list also carries the
// derived/evidence-gated states, and only these three are operator intent.
const PATCHABLE_ACCOUNT_STATUSES = Object.freeze(['active', 'disabled', 'error']);

// Test-only seam for exercising the route-level LS integrity gate without a
// real installer. Never set from production code.
let _langserverInstallScriptOverride = null;
export function __setLangserverInstallScriptForTests(path) {
  _langserverInstallScriptOverride = path ? String(path) : null;
}

function shouldPrewarmLsOnAccountAdd() {
  return process.env.LS_PREWARM_ON_ACCOUNT_ADD === '1' || process.env.LS_PREWARM_PROXIES === '1';
}

async function runAccountWarmup(accountId, { probe = true, requireOptIn = true } = {}) {
  if (requireOptIn && !shouldPrewarmLsOnAccountAdd()) return { ok: true, skipped: true, reason: 'prewarm_disabled' };
  const ls = await ensureLsForAccount(accountId);
  if (!ls?.ok) return { ok: false, skipped: false, reason: ls?.errorType || 'ls_start_failed', ls };
  if (!probe) return { ok: true, skipped: false, ls };
  try {
    const probeResult = await probeAccount(accountId);
    return { ok: true, skipped: false, ls, probe: probeResult };
  } catch (e) {
    return { ok: false, skipped: false, reason: e?.message || 'probe_failed', ls };
  }
}

function scheduleAccountWarmup(accountId, { probe = true } = {}) {
  if (!shouldPrewarmLsOnAccountAdd()) return;
  runAccountWarmup(accountId, { probe, requireOptIn: false })
    .then(r => {
      if (r && !r.ok) log.warn(`LS warmup failed for account ${accountId}: ${r.reason || 'unknown_error'}`);
      return null;
    })
    .catch(e => log.warn(`LS ensure failed: ${e.message}`));
}

// One row per hot-swappable backend switch: effective value + whether it came
// from runtime-config.json or is following the deployment env. Shared by
// GET/PUT /runtime-env-status and the Devin panel so the two can never report
// a different value for the same switch.
function runtimeSwitchRows() {
  const overrides = getBackendSwitchOverrides();
  const rows = {};
  for (const key of RUNTIME_SWITCH_KEYS) {
    rows[key] = {
      value: getBackendSwitch(key),
      override: overrides[key] === undefined ? null : overrides[key],
      source: overrides[key] === null || overrides[key] === undefined ? 'env' : 'config',
    };
  }
  return rows;
}

export function parseProxyUrl(proxy) {
  // Normalize whitespace so "socks5 127.0.0.1   1089" and
  // "socks5://127.0.0.1:1089" both parse correctly.
  const s = String(proxy).replace(/\s+/g, ' ').trim();
  // Try canonical URL form first: protocol://[user:pass@]host:port
  // Host must not contain spaces — otherwise "http 1.2.3.4:8080" would
  // greedily capture "http 1.2.3.4" as the host.
  let m = s.match(/^(?:(\w+):\/\/)?(?:([^\s:]+):([^\s@]+)@)?([^\s:]+):(\d+)$/);
  // Fallback: "type host port" (space-separated, no :// and no colon)
  if (!m) m = s.match(/^(\w+)\s+([^\s:]+)\s+(\d+)$/);
  // Fallback: "type host:port" (type prefix, no ://)
  if (!m) m = s.match(/^(\w+)\s+([^\s:]+):(\d+)$/);
  if (!m) return null;
  if (m.length === 4) {
    // space-or-type-separated form: [type] host port
    return {
      type: m[1],
      host: m[2],
      port: parseInt(m[3]),
      username: '',
      password: '',
    };
  }
  return {
    type: m[1] || 'http',
    host: m[4],
    port: parseInt(m[5]),
    username: m[2] || '',
    password: m[3] || '',
  };
}

export async function validateProxyHost(proxy) {
  if (!proxy?.host) return;
  if (config.allowPrivateProxyHosts) {
    await validateHostFormat(proxy.host);
  } else {
    await assertPublicUrlHost(proxy.host);
  }
}

export function buildBatchProxyBinding(result, proxy) {
  const accountId = result?.account?.id || null;
  if (!result?.success || !proxy || !accountId) return null;
  const parsed = parseProxyUrl(proxy);
  if (!parsed) return null;
  return {
    accountId,
    proxy: parsed,
  };
}

// ─── Batch-import diagnostics (V-25 / audit A24) ──────────────────────────
//
// A batch-import response used to echo the submitted line (`raw`) and the
// proxy string including its userinfo (`proxyRaw`). A failed import therefore
// returned the password / token / API key the operator had just pasted, in a
// JSON body the browser may cache — and the dashboard copied it into
// localStorage login history. Responses now carry structured diagnostics
// derived from the PARSED item (kind, line number, error code) plus a
// credential-free proxy label. Nothing here is a substring of the input.

/** `type://host:port` — never the username/password the proxy URL carried. */
function redactedProxyLabel(proxy) {
  if (!proxy || typeof proxy !== 'object' || !proxy.host) return null;
  const type = typeof proxy.type === 'string' && proxy.type ? proxy.type : 'http';
  const port = Number.isInteger(proxy.port) && proxy.port > 0 ? proxy.port : null;
  return `${type}://${proxy.host}${port ? `:${port}` : ''}`;
}

const IMPORT_KIND_LABELS = Object.freeze({
  email_password: 'email+password',
  token: 'token',
  api_key: 'api key',
  issue: 'unrecognized',
});

/** One-line human diagnostic that names the shape, not the secret. */
function describeImportItem(item) {
  const kind = IMPORT_KIND_LABELS[item?.kind] || IMPORT_KIND_LABELS.issue;
  const proxy = redactedProxyLabel(item?.proxy);
  return proxy ? `${kind} · proxy ${proxy}` : kind;
}

// AUTH-1 CORS hardening: the dashboard used to answer every request (and
// every OPTIONS preflight) with `Access-Control-Allow-Origin: *` while also
// advertising the X-Dashboard-Password header, which invites any web origin
// to script authenticated dashboard calls from a victim's browser. We now
// reflect only origins the operator explicitly allowlists via
// DASHBOARD_CORS_ORIGINS (csv). Unset ⇒ no ACAO header at all (same-origin
// dashboard use needs none; cross-origin is denied by the browser). The
// literal `*` is still honoured but only when the operator opts in by
// listing `*` in DASHBOARD_CORS_ORIGINS.
function resolveCorsOrigin(req) {
  const origin = req?.headers?.origin || '';
  if (!origin) return '';
  const allow = String(process.env.DASHBOARD_CORS_ORIGINS || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean);
  if (!allow.length) return '';
  if (allow.includes('*')) return '*';
  return allow.includes(origin) ? origin : '';
}

// ─── A04: mutation request gate ─────────────────────────
//
// Open-local dashboards (DASHBOARD_ALLOW_NO_AUTH=1 on a loopback bind) have no
// secret for a CSRF attacker to steal, so the only thing that stood between the
// operator's browser and any other web page was the CORS *response* policy —
// which does not stop a cross-site "simple" POST (`Content-Type: text/plain`)
// from reaching the handler. The handler used to accept that body as JSON and
// run the mutation. Every state-changing dashboard request now has to prove it
// is an operator mutation before any side effect:
//
//   1. JSON content type — a cross-origin request with this type needs a CORS
//      preflight, which the dashboard never grants to an unlisted origin.
//   2. Same-origin Origin/Host agreement — catches simple form/fetch posts
//      that bypass preflight.
//   3. Fetch Metadata (`Sec-Fetch-Site`/`Mode`/`Dest`) — rejects cross-site and
//      navigation/form requests even when Origin is missing.
//   4. Strict Host validation — on a loopback bind only loopback Host names
//      are accepted (DNS-rebinding defence); non-loopback names require
//      DASHBOARD_ALLOWED_HOSTS (a trusted X-Forwarded-Host is honoured only
//      when TRUST_PROXY_X_FORWARDED_FOR=1, but still has to pass that list).
//
// Non-browser clients (curl, scripts, the CLI) send no Origin/Sec-Fetch and
// remain usable after auth, but they must still declare a JSON content type
// when they carry a body. `/auth` is a credential probe, not a mutation, and
// stays outside the gate.

const SAFE_DASHBOARD_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const JSON_CONTENT_TYPE_RE = /^application\/(?:[a-z0-9!#$&^_.+-]+\+)?json(?:\s*;|$)/i;

function dashboardHeader(req, name) {
  const value = req?.headers?.[name];
  if (Array.isArray(value)) return String(value[0] ?? '');
  return typeof value === 'string' ? value : '';
}

function isJsonContentType(value) {
  return JSON_CONTENT_TYPE_RE.test(String(value ?? '').trim());
}

function stripHostBrackets(hostname) {
  return String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}

// Parse a Host header (name[:port], [v6]:port) without accepting URL syntax.
function splitHostHeader(value) {
  const raw = String(value ?? '').trim();
  if (!raw || /[\s/\\@?#]/.test(raw)) return null;
  let hostname = '';
  let port = '';
  if (raw.startsWith('[')) {
    const end = raw.indexOf(']');
    if (end < 0) return null;
    hostname = raw.slice(1, end);
    const rest = raw.slice(end + 1);
    if (rest) {
      if (!rest.startsWith(':') || !/^\d{1,5}$/.test(rest.slice(1))) return null;
      port = rest.slice(1);
    }
  } else {
    const firstColon = raw.indexOf(':');
    const lastColon = raw.lastIndexOf(':');
    if (firstColon !== -1 && firstColon !== lastColon) return null; // bare IPv6 is not a valid Host
    if (lastColon !== -1) {
      hostname = raw.slice(0, lastColon);
      port = raw.slice(lastColon + 1);
      if (!/^\d{1,5}$/.test(port)) return null;
    } else {
      hostname = raw;
    }
  }
  hostname = stripHostBrackets(hostname);
  if (!hostname || hostname.length > 253) return null;
  if (!/^[a-z0-9._:-]+$/i.test(hostname)) return null;
  return { hostname, port };
}

function parseOriginHeader(value) {
  const raw = String(value ?? '').trim();
  if (!raw || raw === 'null') return null;
  try {
    const url = new URL(raw);
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
    if (!url.hostname) return null;
    return {
      protocol: url.protocol,
      hostname: stripHostBrackets(url.hostname),
      port: url.port,
    };
  } catch {
    return null;
  }
}

function originMatchesHost(origin, host) {
  if (!origin || !host) return false;
  if (origin.hostname !== host.hostname) return false;
  const defaultPort = origin.protocol === 'https:' ? '443' : '80';
  return (origin.port || defaultPort) === (host.port || defaultPort);
}

function isLoopbackHostname(hostname) {
  const host = stripHostBrackets(hostname);
  if (host === 'localhost' || host === '::1' || host === '::ffff:7f00:1') return true;
  if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
  if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
  return false;
}

function explicitDashboardHosts() {
  const hosts = new Set();
  const configured = stripHostBrackets(config.host);
  if (configured && configured !== '0.0.0.0' && configured !== '::') hosts.add(configured);
  for (const entry of String(process.env.DASHBOARD_ALLOWED_HOSTS || '').split(',')) {
    const host = stripHostBrackets(entry);
    if (host) hosts.add(host);
  }
  return hosts;
}

function dashboardHostAllowed(hostname) {
  const host = stripHostBrackets(hostname);
  if (!host) return false;
  if (isLoopbackHostname(host)) return true;
  if (explicitDashboardHosts().has(host)) return true;
  // Loopback bind + a non-loopback Host is the DNS-rebinding case: the
  // browser resolved attacker.example to 127.0.0.1 and now sends
  // `Host: attacker.example` / `Origin: http://attacker.example`. Reject it
  // unless an operator explicitly allowlisted the name (checked above) or a
  // trusted proxy supplied X-Forwarded-Host (checked by the caller).
  if (isLocalBindHost()) return false;
  // Public bind: IP literals are unambiguous. A DNS name must either match
  // HOST/DASHBOARD_ALLOWED_HOSTS or, for backwards compatibility, is accepted
  // only when no explicit host allowlist was configured.
  if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':')) return true;
  return explicitDashboardHosts().size === 0;
}

function explicitlyAllowedCorsOrigin(origin) {
  const allow = String(process.env.DASHBOARD_CORS_ORIGINS || '')
    .split(',')
    .map(s => s.trim())
    .filter(s => s && s !== '*');
  return allow.includes(origin);
}

// Validate the Host / X-Forwarded-Host of EVERY dashboard API request (not
// just mutations): DNS rebinding can otherwise read cleartext account keys
// through GET /accounts/import-local with Host: attacker.example. Returns
// { host, trustProxy } or null after writing 403.
//
// A trusted proxy's X-Forwarded-Host is honoured only when the operator opted
// into TRUST_PROXY_X_FORWARDED_FOR AND supplied X-Forwarded-For, but the
// forwarded name must still pass the SAME allowlist as the raw Host. It used
// to short-circuit DASHBOARD_ALLOWED_HOSTS for every non-loopback value, which
// let any client that could reach the app directly set the header itself and
// walk past the list. X-Forwarded-Host may be a comma list; the right-most
// entry is the one appended by the nearest trusted proxy (same right-counting
// rule as XFF).
export function validateDashboardRequestHost(req, res) {
  const hostHeader = dashboardHeader(req, 'host');
  const forwardedHostRaw = dashboardHeader(req, 'x-forwarded-host');
  const forwardedFor = dashboardHeader(req, 'x-forwarded-for');
  // A trusted proxy is only trusted when the operator opted in AND actually
  // supplied the header the proxy appends. A browser cannot set either header
  // on a simple cross-site request.
  const trustProxy = process.env.TRUST_PROXY_X_FORWARDED_FOR === '1' && !!forwardedFor;
  const trustForwardedHost = trustProxy && !!forwardedHostRaw;
  const forwardedHost = trustForwardedHost
    ? forwardedHostRaw.split(',').map(s => s.trim()).filter(Boolean).pop() || ''
    : '';
  const effectiveHostValue = forwardedHost || hostHeader;
  let host = null;
  if (effectiveHostValue) {
    host = splitHostHeader(effectiveHostValue);
    if (!host || !dashboardHostAllowed(host.hostname)) {
      json(res, 403, {
        error: 'ERR_DASHBOARD_HOST_REJECTED',
        message: 'Request Host is not allowed for the dashboard',
      });
      return null;
    }
  }
  return { host, trustProxy };
}

// Returns true when the request may proceed; otherwise writes the 403/415
// response itself and returns false. Host validation runs for every method;
// the remaining origin/content-type/fetch-metadata checks are mutation-only.
function enforceDashboardMutationGate(method, subpath, req, res) {
  const hostState = validateDashboardRequestHost(req, res);
  if (!hostState) return false;

  const verb = String(method || '').toUpperCase();
  if (SAFE_DASHBOARD_METHODS.has(verb)) return true;
  if (subpath === '/auth') return true; // credential probe, no state change

  const headers = req?.headers || {};
  const contentType = dashboardHeader(req, 'content-type');
  // A real IncomingMessage always carries method/httpVersion/Host. Bare
  // handler-level unit fixtures (no HTTP shape) predate the gate and stay
  // callable; the moment they declare a content type or HTTP fields, the
  // gate applies exactly as it does in production.
  const looksLikeHttp = !!(req && (req.method || req.httpVersion || headers.host));
  const bodyMethod = verb === 'POST' || verb === 'PUT' || verb === 'PATCH';
  const contentLength = Number(dashboardHeader(req, 'content-length') || 0);
  const hasDeclaredBody = (Number.isFinite(contentLength) && contentLength > 0)
    || !!dashboardHeader(req, 'transfer-encoding');
  // Audit wording is "for bodies": an empty-body POST (e.g. the dashboard's
  // /self-update button before it had a payload) has nothing to parse, and
  // Node/HTTP still gives it a Content-Length of 0. Cross-site empty POSTs
  // are already stopped by Origin/Sec-Fetch-Site below; a declared or
  // present content type must always be JSON.
  const needsJsonContentType = !!contentType || (bodyMethod && looksLikeHttp && hasDeclaredBody);
  if (needsJsonContentType && !isJsonContentType(contentType)) {
    json(res, 415, {
      error: 'ERR_UNSUPPORTED_MEDIA_TYPE',
      message: 'Dashboard mutations require Content-Type: application/json',
    });
    return false;
  }

  let host = hostState.host;
  const originRaw = dashboardHeader(req, 'origin');
  const trustedCorsOrigin = !!originRaw && explicitlyAllowedCorsOrigin(originRaw);
  let origin = null;
  if (originRaw) {
    origin = parseOriginHeader(originRaw);
    if (!origin) {
      json(res, 403, {
        error: 'ERR_DASHBOARD_ORIGIN_REJECTED',
        message: 'Request Origin is invalid for a dashboard mutation',
      });
      return false;
    }
    if (!trustedCorsOrigin) {
      // A missing Host on a real HTTP/1.1 request is rejected by Node before
      // we get here. For handler-level fixtures, let a loopback Origin stand
      // in for the Host so same-origin tests do not need to fake transport
      // metadata.
      if (!host && isLoopbackHostname(origin.hostname)) {
        host = { hostname: origin.hostname, port: origin.port };
      }
      if (!originMatchesHost(origin, host)) {
        // A trusted reverse proxy may rewrite Host to the loopback upstream
        // while preserving the browser's public Origin. Only allow that when
        // the proxy actually supplied X-Forwarded-For (browser simple
        // requests cannot set it) and the Host is the loopback upstream.
        const proxyRewroteHost = hostState.trustProxy && host && isLoopbackHostname(host.hostname);
        if (!proxyRewroteHost) {
          json(res, 403, {
            error: 'ERR_DASHBOARD_ORIGIN_REJECTED',
            message: 'Cross-origin dashboard mutations are not allowed',
          });
          return false;
        }
      }
    }
  }

  const fetchSite = dashboardHeader(req, 'sec-fetch-site').toLowerCase();
  // An operator-allowlisted cross-origin dashboard is trusted for mutations;
  // its browser still sends Sec-Fetch-Site: cross-site by definition.
  if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none' && !trustedCorsOrigin) {
    json(res, 403, {
      error: 'ERR_DASHBOARD_FETCH_METADATA_REJECTED',
      message: 'Cross-site dashboard mutations are not allowed',
    });
    return false;
  }
  const fetchMode = dashboardHeader(req, 'sec-fetch-mode').toLowerCase();
  if (fetchMode === 'no-cors' || fetchMode === 'navigate') {
    json(res, 403, {
      error: 'ERR_DASHBOARD_FETCH_METADATA_REJECTED',
      message: 'Simple-form dashboard mutations are not allowed',
    });
    return false;
  }
  const fetchDest = dashboardHeader(req, 'sec-fetch-dest').toLowerCase();
  if (fetchDest && fetchDest !== 'empty') {
    json(res, 403, {
      error: 'ERR_DASHBOARD_FETCH_METADATA_REJECTED',
      message: 'Navigation dashboard mutations are not allowed',
    });
    return false;
  }

  return true;
}

function json(res, status, body, extraHeaders = null) {
  // Error envelopes carry `success: false`, the same key 2xx bodies use for
  // `success: true`, so a client can tell "this request failed" apart from
  // "this payload happens to have no success key" (#257). Scoped to non-2xx
  // statuses so no successful response body changes shape.
  const payload = status >= 400
    && body && typeof body === 'object' && !Array.isArray(body)
    && !Object.prototype.hasOwnProperty.call(body, 'success')
    ? { ...body, success: false }
    : body;
  const data = JSON.stringify(payload);
  const headers = {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, X-Dashboard-Password',
  };
  // Authed card payloads (billing, models, pool) opt into `Cache-Control:
  // no-store`; the server-side TTL cache is independent of the browser cache.
  if (extraHeaders) Object.assign(headers, extraHeaders);
  // Origin resolved once per request at the top of handleDashboardApi and
  // stashed on res. Only emit ACAO when an origin is actually allowed; a
  // specific echoed origin also needs Vary: Origin so caches don't serve
  // one origin's CORS decision to another.
  const acao = res?._dashboardCorsOrigin || '';
  if (acao) {
    headers['Access-Control-Allow-Origin'] = acao;
    if (acao !== '*') headers['Vary'] = 'Origin';
  }
  res.writeHead(status, headers);
  res.end(data);
}

function dashboardUrl(req) {
  return new URL(req?.url || '/', 'http://localhost');
}

// Authed billing/model/pool JSON must never be stored by the browser.
const NO_STORE = Object.freeze({ 'Cache-Control': 'no-store' });

/**
 * Price annotation for the per-model usage table.
 *
 * The table is keyed by whatever model string the client sent, which is exactly
 * what a bill shows — but the PRICE belongs to the selector that string resolves
 * to, and only the live billing sync knows whether that selector is free right
 * now. Resolving server-side keeps the UI from re-implementing the alias ladder,
 * and keeps "0 credits" (a real free promo) distinct from "unknown" (no live
 * data): `freeNow: null` renders no badge at all rather than a paid one.
 */
export function buildModelBillingIndex(modelCounts = {}) {
  const out = {};
  for (const model of Object.keys(modelCounts || {})) {
    const key = resolveModel(model) || model;
    const resolution = resolveConnectSelector(key, { warnOnFallback: false });
    const selector = resolution?.mapped ? resolution.selector : null;
    const pricing = selector ? getConnectSelectorPricing(selector) : null;
    out[model] = {
      selector,
      freeNow: pricing ? pricing.freeNow : null,
      freeReason: pricing ? pricing.freeReason : null,
      costTier: pricing ? pricing.costTier : null,
      promoEndsAt: pricing ? pricing.promoEndsAt : null,
      promoLabel: pricing ? pricing.promoLabel : null,
      pricingFetchedAt: pricing ? pricing.pricingFetchedAt : null,
      pricingStale: pricing ? pricing.pricingStale : false,
    };
  }
  return out;
}

function parseBoundedInt(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
  const n = Number.parseInt(String(value ?? ''), 10);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(max, Math.max(min, n));
}

function getAccountsPayload(req) {
  const url = dashboardUrl(req);
  const qs = url.searchParams;
  const wantsPaged = qs.has('page') || qs.has('pageSize') || qs.has('offset') || qs.has('limit') || qs.has('view')
    || qs.has('filter') || qs.has('tier') || qs.has('q') || qs.has('status') || qs.has('sort') || qs.has('order');
  if (!wantsPaged) return { accounts: getAccountList() };

  const view = qs.get('view') === 'summary' ? 'summary' : 'full';
  const pageSize = parseBoundedInt(qs.get('pageSize') ?? qs.get('limit'), 50, { min: 1, max: 1000 });
  const page = parseBoundedInt(qs.get('page'), 1, { min: 1, max: 1000000 });
  const explicitOffset = qs.has('offset')
    ? parseBoundedInt(qs.get('offset'), 0, { min: 0, max: Number.MAX_SAFE_INTEGER })
    : null;
  const offset = explicitOffset ?? ((page - 1) * pageSize);
  const filter = qs.get('filter') === 'flagged' ? 'flagged' : '';
  const tier = ['pro', 'free', 'unknown', 'expired'].includes(qs.get('tier')) ? qs.get('tier') : '';
  // Unknown values are ignored, never a 400: a stale bookmark must still list
  // the pool instead of blanking the table. `q` is a literal substring (no SQL
  // LIKE), so % and _ are ordinary characters. 200 chars bounds the work.
  const q = String(qs.get('q') ?? '').trim().slice(0, 200);
  const status = ACCOUNT_STATE_VALUES.includes(qs.get('status')) ? qs.get('status') : '';
  const sort = ACCOUNT_SORT_VALUES.includes(qs.get('sort')) ? qs.get('sort') : '';
  const order = qs.get('order') === 'asc' || qs.get('order') === 'desc' ? qs.get('order') : '';
  const stats = getAccountListStats();
  // D10: total comes from the SAME predicate the rows use (filter + tier + q +
  // status), not from a single stat that silently drops the other filters.
  const total = countAccountList({ filter, tier, q, status });
  const accounts = getAccountList({ view, offset, limit: pageSize, filter, tier, q, status, sort, order });
  return {
    accounts,
    page,
    pageSize,
    offset,
    total,
    hasMore: offset + accounts.length < total,
    view,
    filter,
    tier,
    q,
    status,
    sort,
    order,
    stats,
  };
}

// Query params shared by GET /accounts and GET /accounts/ids. Kept in one place
// so "select all N results" fetches exactly the rows the table is showing.
function accountFiltersFromQuery(qs) {
  return {
    filter: qs.get('filter') === 'flagged' ? 'flagged' : '',
    tier: ['pro', 'free', 'unknown', 'expired'].includes(qs.get('tier')) ? qs.get('tier') : '',
    q: String(qs.get('q') ?? '').trim().slice(0, 200),
    status: ACCOUNT_STATE_VALUES.includes(qs.get('status')) ? qs.get('status') : '',
    sort: ACCOUNT_SORT_VALUES.includes(qs.get('sort')) ? qs.get('sort') : '',
    order: qs.get('order') === 'asc' || qs.get('order') === 'desc' ? qs.get('order') : '',
  };
}

// Client IP for the dashboard lockout bucket + audit logs. Delegates to
// net-safety.js:trustedClientIp — the single source of truth shared with
// src/caller-key.js so the dashboard lockout and the caller-key fingerprint
// can't drift apart (audit S4; previously two byte-identical private copies
// kept in sync only by a comment). See trustedClientIp for the audit-H2
// (default-ignore XFF) and XFF-1 (count from the right) rationale.
function dashboardClientIp(req) {
  return trustedClientIp(req);
}

function checkAuth(req) {
  // Header-only auth. logs/stream switched from EventSource to fetch +
  // ReadableStream months ago, so the EventSource exception is gone and
  // ?pwd= query passwords would only leak into URL access logs and
  // browser history without any callers needing them.
  //
  // v2.0.55 (audit H1): on non-local binds we no longer fall back to
  // `config.apiKey` as the dashboard password. That fallback turned
  // every chat-API caller into a service operator (list accounts,
  // reveal-key, change proxy, trigger LS / docker self-update). Public
  // bind WITHOUT DASHBOARD_PASSWORD now fails closed; operators must
  // set DASHBOARD_PASSWORD explicitly. Localhost-only deployments keep
  // the convenience fallback so single-user `docker-compose up` doesn't
  // suddenly require an extra env.
  //
  // v2.0.56: dashboardPassword now comes from runtime-config (settable
  // from the dashboard) before falling back to env. apiKey fallback
  // (localhost only) also honours the runtime override.
  const pw = req.headers['x-dashboard-password'] || '';
  const storedDashboardPw = getEffectiveDashboardPasswordStored();
  if (storedDashboardPw) {
    // SEC-01 (audit 2026-10-22): an empty/blank header cannot match any
    // configured password, yet it used to reach verifyPassword → scryptSync
    // (N=2^14, ~20 ms of blocked event loop). Unlike a wrong NON-empty guess it
    // also never advanced the brute-force lockout, so unauthenticated callers
    // had a free CPU DoS. Reject before hashing; real guesses keep the exact
    // same verifyPassword path.
    if (!String(pw ?? '').trim()) return false;
    return verifyPassword(pw, storedDashboardPw);
  }
  // audit #1: the localhost API-key fallback (and the no-auth opt-in) let a
  // caller present the shared chat API key as the dashboard password. Gating on
  // isLocalBindHost() alone is unsafe: behind a same-host reverse proxy
  // (openresty → 127.0.0.1) the bind IS local for everyone. Gating on the raw
  // socket peer is ALSO unsafe there — the proxy connects from 127.0.0.1, so a
  // remote user proxied in still looks loopback. Use the TRUSTED client IP
  // (dashboardClientIp: real client when TRUST_PROXY_X_FORWARDED_FOR + hops are
  // configured, else the socket peer) so the convenience is genuinely local:
  //  - XFF trust configured (the recommended proxy setup, see the #7 warning):
  //    a remote client's real IP is non-loopback → fallback denied.
  //  - No proxy, genuine localhost: client IP is loopback → fallback allowed.
  //  - Proxy present but XFF trust OFF: can't verify the client, so the operator
  //    is told (startup #7 warning) to set TRUST_PROXY_X_FORWARDED_FOR=1 or a
  //    real DASHBOARD_PASSWORD; until then this convenience should not be relied
  //    on for isolation.
  const clientIp = dashboardClientIp(req);
  if (isLocalBindHost() && isLoopbackAddress(clientIp)) {
    const effectiveApiKey = getEffectiveApiKey();
    // H2 (Grok audit): the "chat API key doubles as the dashboard password"
    // convenience is now OPT-IN (default OFF). Even from a verified-local
    // client it's a footgun — a shared chat key becomes full operator access,
    // and a reverse-proxy misconfig (XFF trust off) can make a remote client
    // look loopback. Operators who genuinely want the single-secret local
    // convenience must set DASHBOARD_ALLOW_API_KEY_AS_PASSWORD=1. Otherwise the
    // dashboard requires a real DASHBOARD_PASSWORD.
    if (effectiveApiKey && process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD === '1') {
      return safeEqualString(pw, effectiveApiKey);
    }
    // AUTH-1: localhost with NO secret configured used to be treated as
    // authenticated for every caller. Default is now fail-closed; operators who
    // relied on the old open-local convenience must opt in with
    // DASHBOARD_ALLOW_NO_AUTH=1 (and even then only from a verified-local client).
    if (!effectiveApiKey) return process.env.DASHBOARD_ALLOW_NO_AUTH === '1';
    return false;
  }
  return false;
}

// H1 (Grok audit): reusable "is this an authenticated dashboard operator?"
// check for the account-management endpoints in server.js (/auth/login,
// /auth/accounts, DELETE /auth/accounts/:id). Those used to sit behind only the
// chat API key, so any chat client could add/list/delete pool accounts. They
// now require the SAME auth the dashboard does (dashboard password, or the
// opt-in localhost apiKey fallback) — a shared chat key alone is no longer
// enough to act as operator. Thin wrapper so the resolution order stays single-
// sourced in checkAuth.
export function verifyAdminRequest(req) {
  return checkAuth(req);
}

// AUTH-1: explicit re-auth for the reveal-key endpoint. The caller must
// present the dashboard password AGAIN in this request even though ambient
// dashboard auth already passed — via body.password / body.dashboardPassword
// or the X-Dashboard-Password-Confirm header. Mirrors checkAuth()'s secret
// resolution order (runtime/env dashboard password, then localhost API-key
// fallback). When no secret is configured at all the re-auth is only accepted
// on an opt-in open-local dashboard (DASHBOARD_ALLOW_NO_AUTH=1) — otherwise
// checkAuth would already have failed closed before we get here.
function confirmReauth(req, body) {
  const proof = (body && typeof body === 'object'
    && (typeof body.password === 'string' ? body.password
      : typeof body.dashboardPassword === 'string' ? body.dashboardPassword : ''))
    || req?.headers?.['x-dashboard-password-confirm']
    || '';
  const storedDashboardPw = getEffectiveDashboardPasswordStored();
  if (storedDashboardPw) {
    // SEC-01: same empty-proof guard as checkAuth. A blank re-auth proof is
    // never valid and must not pay for scrypt before being refused.
    if (!String(proof ?? '').trim()) return false;
    return verifyPassword(proof, storedDashboardPw);
  }
  // audit #1: same gate as checkAuth — use the TRUSTED client IP, not the raw
  // socket peer (which is the proxy's 127.0.0.1 for everyone behind a same-host
  // reverse proxy), so a remote proxied caller can't reauth with the shared chat
  // key.
  const clientIp = dashboardClientIp(req);
  if (isLocalBindHost() && isLoopbackAddress(clientIp)) {
    const effectiveApiKey = getEffectiveApiKey();
    if (effectiveApiKey) return safeEqualString(proof, effectiveApiKey);
    return process.env.DASHBOARD_ALLOW_NO_AUTH === '1';
  }
  return false;
}

// Mask an email for logs: keep first char + domain, redact the rest of the
// local part (a@b.com -> a***@b.com). Never log the full address.
function maskEmail(email) {
  const s = String(email || '');
  const at = s.indexOf('@');
  if (at <= 0) return s ? s[0] + '***' : '';
  return s[0] + '***' + s.slice(at);
}

// Pull the auth token out of whatever the user pasted after finishing the
// Windsurf sign-in: a full redirect URL (token in query or #fragment), or the
// bare token string itself. Returns '' if nothing token-shaped is found. The
// caller must never log the raw input (it may contain a live token).
//
// Two diagnostic cases throw an ERR_* instead of returning '' so the user gets
// an actionable reason rather than a generic "no token found":
//   - ERR_INTERMEDIATE_CALLBACK: the pasted URL is a federated *intermediate*
//     callback (has ?code= on a /auth/<provider>/callback path). That code can
//     only be exchanged by windsurf.com's backend (holds the client_secret) —
//     the user pasted too early; they must wait for the show-auth-token page and
//     paste the token shown there.
//   - ERR_OAUTH_UPSTREAM:<error>[:<desc>]: the provider redirected back an
//     ?error= (e.g. access_denied) — surface the real reason.
// Exported for unit testing (also used by the /oauth/callback endpoint).
//
// ANY absolute URL scheme is accepted, not just http(s): the Windsurf desktop
// app's sign-in callback is `windsurf://codeium.windsurf?access_token=…` (F4).
// Testing only ^https?:// returned that whole 88-char URL as the "token", which
// then failed RegisterUser upstream.
const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.\-]*:\/\//i;

export function extractOAuthToken(input) {
  const raw = String(input || '').trim();
  if (!raw) return '';
  const pick = (params) => params.get('access_token') || params.get('token') || params.get('auth_token') || '';
  if (ABSOLUTE_URL_RE.test(raw)) {
    try {
      const u = new URL(raw);
      const fromQuery = pick(u.searchParams);
      if (fromQuery) return fromQuery.trim();
      // token may live in the #fragment (implicit flow): #access_token=...&...
      const frag = u.hash.startsWith('#') ? u.hash.slice(1) : u.hash;
      const fragParams = frag ? new URLSearchParams(frag) : null;
      if (fragParams) {
        const fromHash = pick(fragParams);
        if (fromHash) return fromHash.trim();
      }
      // No token present — before giving up, distinguish an explicit upstream
      // error or an intermediate callback so the caller can explain what to do.
      const upstreamErr = u.searchParams.get('error') || (fragParams && fragParams.get('error')) || '';
      if (upstreamErr) {
        const desc = u.searchParams.get('error_description') || (fragParams && fragParams.get('error_description')) || '';
        const e = new Error('ERR_OAUTH_UPSTREAM:' + upstreamErr + (desc ? ':' + desc : ''));
        e.code = 'ERR_OAUTH_UPSTREAM';
        throw e;
      }
      if (u.searchParams.get('code') && /\/auth\/[^/]+\/callback/i.test(u.pathname)) {
        const e = new Error('ERR_INTERMEDIATE_CALLBACK');
        e.code = 'ERR_INTERMEDIATE_CALLBACK';
        throw e;
      }
    } catch (e) {
      // Propagate our own diagnostic ERR_* codes; swallow URL-parse failures
      // (malformed input) and fall through to bare-token handling.
      if (e && typeof e.message === 'string' && e.message.startsWith('ERR_')) throw e;
    }
    return '';
  }
  // Bare paste: accept a raw token (session/auth token), reject anything with
  // whitespace (that's not a token).
  if (/\s/.test(raw)) return '';
  return raw;
}

// Decide whether to persist a login password into the encrypted cred store so a
// dead session can auto-relogin. Storing a user's password is privacy-sensitive,
// so on a PUBLIC bind it requires BOTH an operator opt-in env AND a per-request
// opt-in (the dashboard checkbox). The "local" fast-path requires the request to
// ALSO come from a trusted loopback client — not just a loopback BIND host. Behind a
// reverse proxy (openresty → app on 127.0.0.1) isLocalBindHost() is always true,
// so checking bind alone would let any authenticated REMOTE user silently
// persist their plaintext password, defeating the DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE
// gate. Mirror the /accounts/import-local double gate. Store is still a no-op
// unless DEVIN_CONNECT_CRED_KEY is set.
function credStoreGateOpen(wantStore, req) {
  if (wantStore !== true) return false;
  // Consistency with v2.0.55 AUTH-1 and checkAuth: the proxy is not the client.
  const remote = dashboardClientIp(req);
  if (isLocalBindHost() && isLoopbackAddress(remote)) return true;
  return process.env.DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE === '1';
}

async function processWindsurfLogin({ email, password, loginProxy, autoAdd, storeCredential: wantStore, req, accountId = null }) {
  if (!email || !password) {
    const err = new Error('ERR_EMAIL_PASSWORD_REQUIRED');
    err.statusCode = 400;
    err.code = 'ERR_EMAIL_PASSWORD_REQUIRED';
    throw err;
  }

  // Use provided proxy, or global proxy
  const proxy = loginProxy?.host ? loginProxy : getProxyConfig().global;
  const result = await windsurfLogin(email, password, proxy);
  return finalizeWindsurfLogin(result, { password, loginProxy, autoAdd, storeCredential: wantStore, req, accountId });
}

// Shared tail of every successful login (password, email code, pasted token):
// add the account to the pool — or, when `accountId` is set, REPLACE that
// account's token — persist the per-account proxy, optionally persist the
// password for auto-relogin, and shape the response. `password` is null for the
// email-code flow — there is nothing to store.
//
// `accountId` is the re-auth binding (SessionReauth): the operator proved
// control of an existing account by logging in, so the fresh token lands on
// that row. Adding it again would create a duplicate for the same person and
// leave the dead one in the list.
async function finalizeWindsurfLogin(result, { password, loginProxy, autoAdd, storeCredential: wantStore, req, accountId = null }) {
  const email = result.email || '';
  let account = null;
  let reauthenticated = false;
  // Whether the password the caller asked to store actually landed. null = not
  // asked / store unavailable, true = encrypted and persisted, false = the save
  // failed (busy store, missing key, record loss) and auto-relogin stays disarmed.
  let credentialStored = null;

  if (accountId) {
    const existing = getAccountInternal(accountId);
    if (!existing) throw Object.assign(new Error('ERR_ACCOUNT_NOT_FOUND'), { code: 'ERR_ACCOUNT_NOT_FOUND', statusCode: 404 });
    if (!result.apiKey) throw Object.assign(new Error('ERR_LOGIN_FAILED'), { code: 'ERR_LOGIN_FAILED', statusCode: 502 });
    // V-24 (audit A23): a bound login REPLACES this row's credential, so the
    // login must prove it belongs to this row's identity. This runs before
    // markAccountReauthenticated() — a token for someone else must not rotate
    // the key, clear error/ban state, or flip `reauth` back to `active`.
    assertLoginIdentity(result, existing.email);
    // A successful login IS the proof that the new key works, so a same-key
    // re-login is legitimate (`allowSameKey`). The refusal exists for the
    // operator asserting an UNCHANGED key without logging in.
    markAccountReauthenticated(accountId, {
      apiKey: result.apiKey,
      via: result.viaEmailCode ? 'email_code' : 'password',
      allowSameKey: true,
    });
    account = existing;
    reauthenticated = true;
  } else if (autoAdd !== false) {
    // `result.name` is upstream-supplied: sanitize (never assert) so a hostile
    // login response cannot fail the whole login via the caller-facing label
    // boundary inside addAccountByKey.
    account = addAccountByKey(result.apiKey, sanitizeLabelText(result.name) || email);
  }

  if (account) {
    // Persist refresh token via the setter so it survives restart and
    // the background Firebase-renewal loop can find it.
    if (result.refreshToken) {
      setAccountTokens(account.id, { refreshToken: result.refreshToken, idToken: result.idToken });
    }
    // Persist the per-account proxy we used for login so chat requests
    // also egress through the same IP, then warm up a matching LS.
    if (loginProxy?.host) setAccountProxy(account.id, loginProxy);
    scheduleAccountWarmup(account.id);

    // Gap A+B: persist the password (encrypted) so a dead session_id can
    // auto-relogin (reLoginAccount). Gated by credStoreGateOpen — on a public
    // bind this needs both DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE=1 and the
    // request's storeCredential:true. Best-effort: a store failure must never
    // break the login that already succeeded. Plaintext password is never
    // logged (only the masked email).
    if (password && credStoreGateOpen(wantStore, req)) {
      // True only after a credential was actually persisted. It used to start
      // true, so a store that is disabled (no DEVIN_CONNECT_CRED_KEY) reported
      // "stored" without ever calling the store (2026-09-17 review, F3).
      credentialStored = false;
      try {
        const { storeCredential, isCredStoreEnabled } = await import('../devin-connect-credentials.js');
        if (isCredStoreEnabled()) {
          credentialStored = storeCredential(email, password) === true;
          // Remember WHICH address the record is keyed by: the row can be
          // renamed, and re-login / removal must still find the secret.
          if (credentialStored) setAccountCredentialEmail(account.id, email);
          if (credentialStored) log.info(`Credential stored for auto-relogin: ${maskEmail(email)}`);
          else log.warn(`Credential NOT stored for ${maskEmail(email)} — auto-relogin stays disarmed for this account`);
        } else {
          log.warn(`Credential NOT stored for ${maskEmail(email)} — credential storage is disabled (no DEVIN_CONNECT_CRED_KEY); auto-relogin stays disarmed for this account`);
        }
      } catch (e) {
        // A failed save used to be invisible in the response: the user asked for
        // storage and got success back. Surface it instead of only warning.
        credentialStored = false;
        log.warn(`Could not persist credential for re-login: ${e.code || e.message}`);
      }
    }
  }

  return {
    success: true,
    // When autoAdd:false, the caller is doing a one-time login to retrieve the
    // upstream key without storing it (e.g. external tooling that wants the
    // raw key) — they need the full apiKey. When the key is in the pool the
    // response only echoes a masked form (the dashboard never needs the raw key
    // in the listing path; the explicit reveal-key endpoint covers the rare
    // per-account export case). A bound re-auth always masks: the row already
    // holds the key.
    ...(autoAdd === false && !reauthenticated
      ? { apiKey: result.apiKey }
      : { apiKey_masked: maskApiKey(result.apiKey) }),
    name: result.name,
    email: result.email,
    apiServerUrl: result.apiServerUrl,
    account: account ? { id: account.id, email: account.email, status: account.status } : null,
    // How the session was obtained, so the UI can show it instead of making
    // the operator guess which path ran.
    method: result.viaEmailCode ? 'email_code' : 'password',
    // The login replaced an existing account's token instead of adding a row.
    ...(reauthenticated ? { reauthenticated: true } : {}),
    ...(result.viaHost ? { viaHost: result.viaHost } : {}),
    ...(wantStore ? { credentialStored: credentialStored === true } : {}),
  };
}

/**
 * Install a pasted token on an EXISTING account (SessionReauth).
 *
 * The only accepted shape is a `devin-session-token$…` session token — that is
 * what the upstream login mints, and it is the only thing this pool routes with.
 * An `auth1_` value is a PostAuth intermediate and is rejected with its own
 * code; anything else is not a pool credential.
 *
 * Errors are thrown with `statusCode` so both routes answer with the same
 * contract: 404 unknown id, 409 wrong state, 400 unusable token.
 */
function applyReauthToken(id, rawToken, { via = 'token' } = {}) {
  const account = getAccountInternal(id);
  if (!account) throw Object.assign(new Error('ERR_ACCOUNT_NOT_FOUND'), { code: 'ERR_ACCOUNT_NOT_FOUND', statusCode: 404 });
  if (account.status === 'disabled' || account.status === 'banned') {
    throw Object.assign(new Error('ERR_REAUTH_NOT_APPLICABLE'), { code: 'ERR_REAUTH_NOT_APPLICABLE', statusCode: 409 });
  }
  const token = extractOAuthToken(String(rawToken ?? ''));
  if (!token) throw Object.assign(new Error('ERR_NO_TOKEN_IN_INPUT'), { code: 'ERR_NO_TOKEN_IN_INPUT', statusCode: 400 });
  const kind = classifyToken(token);
  if (kind === 'auth1') throw Object.assign(new Error('ERR_AUTH1_NOT_A_POOL_KEY'), { code: 'ERR_AUTH1_NOT_A_POOL_KEY', statusCode: 400 });
  if (kind !== 'session') throw Object.assign(new Error('ERR_REAUTH_TOKEN_INVALID'), { code: 'ERR_REAUTH_TOKEN_INVALID', statusCode: 400 });
  // A PASTED token is not proof of anything — unlike a completed login, the
  // operator can paste the very token upstream just retired. Installing the
  // same key on a reauth account is refused (409) so the state cannot be
  // "cleared" without a new credential; a different key is a real rotation.
  markAccountReauthenticated(id, { apiKey: token, via });
  return {
    success: true,
    reauthenticated: true,
    account: { id: account.id, email: account.email, method: account.method, status: account.status },
    apiKey_masked: maskApiKey(account.apiKey),
  };
}

// Marker set on the IncomingMessage by preflightDashboardRequest(). A Symbol
// (not a string key) so a handler-level fixture cannot forge it and an HTTP
// client can never set it: request headers live under req.headers.
const DASHBOARD_PREFLIGHT_PASSED = Symbol('dashboardPreflightPassed');

/**
 * Host/mutation/lockout/auth gate shared by `preflightDashboardRequest` and
 * `handleDashboardApi`. Returns true when the request may proceed; otherwise
 * writes the 403/415/429/401 response itself and returns false.
 *
 * Extracted from handleDashboardApi unchanged (S03/G05) so server.js can run
 * it BEFORE reading a /dashboard/api/* body: an unauthenticated mutation must
 * not be able to make the process buffer and JSON-parse up to the 10 MiB body
 * cap. Keeping one implementation is what keeps the two call sites from
 * drifting apart.
 */
function runDashboardRequestGate(method, subpath, req, res) {
  // A04: reject cross-site/simple-form mutations before auth counters, body
  // handling, or any route side effect. This is deliberately before the
  // lockout so a hostile page cannot spend a victim's auth attempts.
  if (!enforceDashboardMutationGate(method, subpath, req, res)) return false;

  // v2.0.56: brute-force lockout — apply BEFORE the auth check so the
  // password comparison itself can't be used as an oracle once the IP is
  // banned. /auth route is exempt (unauthenticated probe used by the UI
  // to learn whether auth is required) but still feeds the lockout when
  // it serves as a credential-verification endpoint.
  const clientIp = dashboardClientIp(req);
  const lock = checkLockout(clientIp);
  if (lock.blocked) {
    res.setHeader?.('Retry-After', String(Math.ceil(lock.retryAfterMs / 1000)));
    json(res, 429, {
      error: `Too many failed attempts. IP banned for ${Math.ceil(lock.retryAfterMs / 1000)}s.`,
      retryAfterMs: lock.retryAfterMs,
    });
    return false;
  }

  // Auth check (except for auth verification endpoint)
  if (subpath !== '/auth' && !checkAuth(req)) {
    // Only count this toward the brute-force lockout when a NON-EMPTY password
    // was actually submitted — a wrong guess. A missing/empty header is the
    // normal pre-login state: the dashboard fires ~a dozen authed API calls
    // (overview/stats/accounts/…) on page load before the user has typed
    // anything, and counting each empty-password 401 as a "failed attempt"
    // banned the IP the instant the page opened. Mirrors the /auth probe below.
    if (String(req.headers['x-dashboard-password'] || '')) failedAuthAttempt(clientIp);
    json(res, 401, { error: 'Unauthorized. Set X-Dashboard-Password header.' });
    return false;
  }
  if (subpath !== '/auth') successfulAuthAttempt(clientIp);
  return true;
}

/**
 * Pre-body gate for /dashboard/api/*.
 *
 * server.js calls this BEFORE readBody() so an unauthenticated mutation is
 * rejected at the Host/mutation/lockout/auth boundary without touching the
 * request stream (S03/G05: the body read used to run first, letting an
 * anonymous caller force up to MAX_BODY_SIZE of buffering + JSON parsing per
 * request). Reuses the same gate handleDashboardApi runs.
 *
 * Returns true when the caller may proceed (and, for a mutation, read the
 * body); false when the rejection response was already written. OPTIONS
 * carries no body and is answered by handleDashboardApi's early 204 exactly
 * as before, so it passes through untouched.
 */
export function preflightDashboardRequest(method, subpath, req, res) {
  if (res) res._dashboardCorsOrigin = resolveCorsOrigin(req);
  if (method === 'OPTIONS') return true;
  if (!runDashboardRequestGate(method, subpath, req, res)) return false;
  if (req && typeof req === 'object') req[DASHBOARD_PREFLIGHT_PASSED] = true;
  return true;
}

/**
 * Handle all /dashboard/api/* requests.
 */
export async function handleDashboardApi(method, subpath, body, req, res) {
  // Resolve the CORS origin once (allowlist-gated, default none) so every
  // json() response — including the OPTIONS preflight below — reflects the
  // same decision instead of a blanket `*`.
  if (res) res._dashboardCorsOrigin = resolveCorsOrigin(req);
  if (method === 'OPTIONS') return json(res, 204, '');

  // server.js already ran preflightDashboardRequest() before reading the body;
  // running the gate again would re-validate the Host and double-count the
  // successful-auth reset. Direct callers (unit tests, in-process handlers)
  // never set the marker and still get the full gate here, exactly as before.
  if (!(req && req[DASHBOARD_PREFLIGHT_PASSED] === true)) {
    if (!runDashboardRequestGate(method, subpath, req, res)) return;
  }

  // ─── Auth ─────────────────────────────────────────────
  if (subpath === '/auth') {
    // The gate that used to declare clientIp now lives in
    // runDashboardRequestGate (and may have already run as the server.js
    // preflight). Re-resolve the same pure value for the probe's counter
    // updates — no lockout/auth side effect is repeated.
    const clientIp = dashboardClientIp(req);
    const storedPw = getEffectiveDashboardPasswordStored();
    const effectiveApiKey = getEffectiveApiKey();
    const hasSecret = !!(storedPw || effectiveApiKey);
    if (hasSecret) {
      const ok = checkAuth(req);
      // /auth is the credential-probe endpoint dashboards call before
      // showing the rest of the UI — count failures here so a brute-force
      // script doesn't get unlimited attempts via /auth alone.
      if (ok) successfulAuthAttempt(clientIp);
      else if (req.headers['x-dashboard-password']) failedAuthAttempt(clientIp);
      return json(res, 200, { required: true, valid: ok });
    }
    // No secret configured. AUTH-1: the open-local convenience is now
    // opt-in — only report the dashboard as open (required:false) on a
    // localhost bind when the operator set DASHBOARD_ALLOW_NO_AUTH=1, which
    // mirrors checkAuth(). Otherwise (default, and on public binds) checkAuth
    // fails closed, so the UI must show it as required-but-unconfigurable and
    // prompt the operator to set DASHBOARD_PASSWORD or API_KEY.
    if (isLocalBindHost() && process.env.DASHBOARD_ALLOW_NO_AUTH === '1') {
      return json(res, 200, { required: false });
    }
    return json(res, 200, { required: true, valid: false, locked: true });
  }

  // ─── Overview ─────────────────────────────────────────
  if (subpath === '/overview' && method === 'GET') {
    const stats = getStats();
    return json(res, 200, {
      uptime: process.uptime(),
      startedAt: stats.startedAt,
      accounts: getAccountCount(),
      authenticated: isAuthenticated(),
      langServer: getLsStatus(),
      totalRequests: stats.totalRequests,
      successCount: stats.successCount,
      errorCount: stats.errorCount,
      successRate: stats.totalRequests > 0
        ? ((stats.successCount / stats.totalRequests) * 100).toFixed(1)
        : '0.0',
      cache: cacheStats(),
      nativeBridge: getNativeBridgeStats(),
      nativeBridgeConfig: getNativeBridgeConfigStatus(),
      // Gate 1 (design §7.4) reads `admit` and `serve` from this block for the
      // two-week measurement. `enabled` separates "switch off" from "switch on
      // with no traffic"; the counters are process-lifetime and reset on
      // restart, so a scrape must accumulate deltas (docs/DETACH-ROLLOUT.md).
      detach: {
        enabled: cascadeDetachEnabled(),
        ...detachedRegistry.stats(),
      },
      poolBalance: (() => {
        const s = getAccountListStats();
        return { total: s.balanceTotal, accounts: s.balanceAccounts };
      })(),
    });
  }

  // Lightweight request ring for the dashboard's request-level view. Rows are
  // metadata-only (no headers/prompt/upstream body); the caller is a short hash.
  if (subpath === '/requests' && method === 'GET') {
    const u = new URL(req?.url || '/', 'http://localhost');
    const filter = {};
    for (const key of ['model', 'status', 'class']) {
      const value = u.searchParams.get(key);
      if (value !== null && value !== '') filter[key] = value;
    }
    const page = listRequests({
      cursor: u.searchParams.get('cursor'),
      limit: u.searchParams.get('limit'),
      filter,
    });
    return json(res, 200, { ...page, ring: requestRingStats() });
  }

  // Lightweight pool snapshot for the dashboard's pool-event notifier and the
  // cooldown card. Reads the in-memory pool only (zero upstream cost) and returns
  // the per-account state flags the client diffs into transition toasts
  // (healthy ↔ cooling ↔ capacity ↔ quota ↔ disabled). `ref` is a hash — never
  // the token prefix — and `upstream` is a cache-only billing overlay, so the
  // 20 s poll never triggers an upstream call.
  if (subpath === '/pool-events' && method === 'GET') {
    const now = Date.now();
    const accounts = getAccountList({ view: 'summary' }).map((a) => {
      const cooldown = cooldownState({
        rateLimitedUntil: a.rateLimitedUntil,
        quotaResetAt: a.quotaResetAt,
        capacityThrottled: a.capacityThrottled,
        now,
      });
      const token = getAccountInternal(a.id)?.apiKey || '';
      return {
        id: a.id,
        ref: accountRef(a.id),
        status: a.status,
        state: accountState({
          status: a.status,
          rateLimited: cooldown.rateLimited,
          capacityThrottled: cooldown.capacityThrottled,
          quotaCooled: cooldown.quotaCooled,
        }),
        tier: a.tier || 'unknown',
        balance: a.balance ?? null,
        errorCount: a.errorCount || 0,
        rateLimited: cooldown.rateLimited,
        capacityThrottled: cooldown.capacityThrottled,
        quotaCooled: cooldown.quotaCooled,
        cooldownUntil: cooldown.cooldownUntil,
        cooldownReason: cooldown.cooldownReason,
        cooldownRemainingMs: cooldown.cooldownRemainingMs,
        upstream: peekBillingFacts({ accountId: a.id, token }),
      };
    });
    return json(res, 200, { accounts, ts: now }, { 'Cache-Control': 'no-store' });
  }

  // ─── Experimental features ────────────────────────────
  if (subpath === '/experimental' && method === 'GET') {
    return json(res, 200, { flags: getExperimental(), tunables: getTunables(), conversationPool: convPoolStats() });
  }
  if (subpath === '/experimental' && method === 'PUT') {
    // Numeric tunables (e.g. droughtThresholdPercent) ride the same endpoint
    // but must NOT go through setExperimental, which coerces every key to bool.
    const patch = { ...(body || {}) };
    let tunables;
    if (patch.tunables && typeof patch.tunables === 'object') {
      tunables = setTunables(patch.tunables);
    }
    delete patch.tunables;
    const flags = setExperimental(patch);
    // Dropping the toggle should also drop any live entries so nothing
    // resumes against a disabled feature on the next request.
    if (!flags.cascadeConversationReuse) convPoolClear();
    return json(res, 200, { success: true, flags, tunables: tunables ?? getTunables() });
  }
  if (subpath === '/experimental/conversation-pool' && method === 'DELETE') {
    const n = convPoolClear();
    return json(res, 200, { success: true, cleared: n });
  }

  // ─── Cline compatibility layer status ────────────────────
  // enabled = the master experimental toggle (detection path); stats.argRepairs =
  // how many tool-call arguments the compat layer has normalized since boot (both
  // the /v1/cline/* namespace and the detection path feed this counter). The
  // dedicated namespace works regardless of the toggle, so a nonzero counter with
  // enabled:false just means a partner is hitting /v1/cline/*.
  if (subpath === '/cline-compat' && method === 'GET') {
    return json(res, 200, { enabled: !!getExperimental().clineCompat, stats: getClineCompatStats() });
  }

  // ─── Claude Code compatibility layer status ──────────────
  // enabled = the master experimental toggle (detection path). stats counts what
  // the CC layer has done since boot: schemaNormalized (top-level combinators
  // stripped off tool schemas) and identityNeutralized (reserved). The dedicated
  // /v1/cc/* namespace works regardless of the toggle, so nonzero counts with
  // enabled:false just mean a Claude Code user is hitting /v1/cc/*.
  if (subpath === '/cc-compat' && method === 'GET') {
    return json(res, 200, { enabled: !!getExperimental().ccCompat, stats: getCcCompatStats() });
  }

  // ─── System prompts (tool reinforcement, communication) ──
  if (subpath === '/system-prompts' && method === 'GET') {
    return json(res, 200, { prompts: getSystemPrompts() });
  }
  if (subpath === '/system-prompts' && method === 'PUT') {
    const prompts = setSystemPrompts(body || {});
    return json(res, 200, { success: true, prompts });
  }
  if (subpath.match(/^\/system-prompts\/[^/]+$/) && method === 'DELETE') {
    const key = subpath.split('/').pop();
    const prompts = resetSystemPrompt(key);
    return json(res, 200, { success: true, prompts });
  }

  // ─── Runtime backend switches (hot-flippable) + security status (read-only) ──
  // The six routing switches are migrated to runtime-config: an operator can
  // flip them here without a redeploy. GET reports each switch's EFFECTIVE value
  // plus its SOURCE ('config' when an override is set, else 'env' — which also
  // covers the historical default). The two credential-store env switches stay
  // READ-ONLY: they are a security boundary (a writable remote-cred-store toggle
  // would let a compromised dashboard self-authorize password storage), so they
  // are surfaced as booleans but never accepted on PUT. Secrets are reported as
  // a presence flag only, never the value.
  if (subpath === '/runtime-env-status' && method === 'GET') {
    const on = (v) => String(v || '').trim() === '1';
    const switches = runtimeSwitchRows();
    return json(res, 200, {
      // Flat effective values (back-compat with the prior read-only card shape).
      devinConnect: switches.devinConnect.value,
      devinOnly: switches.devinOnly.value,
      devinCliMode: switches.devinCliMode.value,
      allowClientTools: switches.allowClientTools.value,
      loginHostFallback: switches.loginHostFallback.value,
      autoRelogin: switches.autoRelogin.value,
      // Per-switch detail (effective value + override + source) for the toggle UI.
      switches,
      // Security boundary — env-only, read-only, never writable from here.
      remoteCredStore: on(process.env.DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE),
      credStoreEnabled: !!(process.env.DEVIN_CONNECT_CRED_KEY || '').trim(),
    });
  }

  // PUT — flip one or more backend switches. Whitelisted keys only (the setter
  // rejects everything else, including remoteCredStore/credStoreEnabled). A key
  // set to null CLEARS the override (falls back to env). devinCliMode accepts
  // only 'acp'|'print'; the rest are booleans.
  if (subpath === '/runtime-env-status' && method === 'PUT') {
    setBackendSwitches(body || {});
    return json(res, 200, { success: true, switches: runtimeSwitchRows() });
  }

  // ─── Circuit-breaker / rate-limit tunables (hot-flippable) ──
  const breakerPayload = () => {
    const eff = getBreakerTunables(), ov = getBreakerOverrides(), knobs = {};
    for (const k of Object.keys(eff))
      knobs[k] = { value: eff[k], override: ov[k], source: ov[k] === null ? 'env' : 'config' };
    return { knobs };
  };
  if (subpath === '/breaker-config' && method === 'GET') return json(res, 200, breakerPayload());
  if (subpath === '/breaker-config' && method === 'PUT') {
    setBreakerTunables(body || {});
    return json(res, 200, { success: true, ...breakerPayload() });
  }

  // ─── Quota / on-demand spend tunables (global defaults, hot-flippable) ──
  // spendOnDemand + onDemandReserveUsd are the pool-wide defaults; each account
  // can still override them via PATCH /accounts/:id.
  const quotaPayload = () => {
    const eff = getQuotaTunables(), ov = getQuotaOverrides(), knobs = {};
    for (const k of Object.keys(eff))
      knobs[k] = { value: eff[k], override: ov[k], source: ov[k] === null ? 'env' : 'config' };
    return { knobs };
  };
  if (subpath === '/quota-config' && method === 'GET') return json(res, 200, quotaPayload());
  if (subpath === '/quota-config' && method === 'PUT') {
    setQuotaTunables(body || {});
    return json(res, 200, { success: true, ...quotaPayload() });
  }

  // ─── Proxy test — try an HTTP CONNECT through the given proxy ──
  if (subpath === '/test-proxy' && method === 'POST') {
    const { host, port, username, password, type = 'http' } = body || {};
    if (!host || !port) return json(res, 400, { ok: false, error: 'ERR_HOST_PORT_REQUIRED' });
    const startTime = Date.now();
    try {
      const result = await testProxy({ host, port: Number(port), username, password, type });
      return json(res, 200, { ok: true, ...result, latencyMs: Date.now() - startTime });
    } catch (err) {
      return json(res, 200, { ok: false, error: err.message, latencyMs: Date.now() - startTime });
    }
  }

  // ─── Self-update: pull latest code + request supervisor restart ──────
  if (subpath === '/self-update/check' && method === 'GET') {
    // D6: a pending journal is confirmed only when the RUNNING process reports
    // the expected commit — the same identity /health publishes. Confirmation
    // is what clears the rollback point; anything else keeps the retry
    // evidence and reports the pending/failed state.
    const selfUpdate = reconcileSelfUpdateJournal();
    // Docker updates record the previous image before the deployer starts and
    // reconcile the deployer's exit code after the restart. No journal means
    // no Docker call at all.
    let dockerSelfUpdate = null;
    try {
      dockerSelfUpdate = await reconcileDockerSelfUpdateState();
    } catch (err) {
      log.warn(`self-update: docker state reconcile failed: ${err.message}`);
    }
    const withJournal = (payload) => ({
      ...payload,
      ...(selfUpdate ? { selfUpdate } : {}),
      ...(dockerSelfUpdate ? { dockerSelfUpdate } : {}),
    });
    try {
      const info = await gitStatus();
      if (info.updateError) {
        return json(res, 200, withJournal({
          ok: false,
          mode: 'git',
          ...info,
          error: info.updateError,
        }));
      }
      return json(res, 200, withJournal({ ok: true, mode: 'git', ...info }));
    } catch (err) {
      if (isSelfUpdateUnavailableError(err)) {
        // Git path is unavailable (most often docker). Fall back to
        // docker self-update via /var/run/docker.sock if the user
        // mounted it into the container; otherwise report the same
        // "manual command" hint we did before.
        const docker = await detectDockerSelfUpdateForRequest();
        if (docker.available) {
          return json(res, 200, withJournal({
            ok: true,
            mode: 'docker',
            image: docker.image,
            project: docker.project,
            workingDir: docker.workingDir,
          }));
        }
        return json(res, 200, withJournal({
          ok: false,
          available: false,
          reason: err.reason,
          error: err.code,
          dockerReason: docker.reason,
          dockerDetail: docker.detail,
        }));
      }
      return json(res, 200, withJournal({ ok: false, error: err.message }));
    }
  }
  if (subpath === '/self-update/rollback' && method === 'POST') {
    let releaseMutation;
    try {
      releaseMutation = acquireSelfUpdateMutation();
    } catch (err) {
      return json(res, 200, { ok: false, error: err.code || 'ERR_UPDATE_LOCK_FAILED', message: err.message });
    }
    if (!releaseMutation) {
      return json(res, 200, {
        ok: false,
        error: SELF_UPDATE_IN_PROGRESS,
        message: 'Another update or rollback is already in progress.',
      });
    }
    let mutationLockHandedOff = false;
    // 失败回滚:reset 到最近一次 /self-update 记录的 before commit,重启。
    // 与 forceReset 同级保护(AUTH-1):本地领先或脏树时同样拒绝。
    try {
      try {
        // A previous rollback that is already confirmed by the running commit
        // has had its point cleared; reconcile first so this attempt reports
        // "no rollback point" instead of resetting to the same commit again.
        reconcileSelfUpdateJournal();
        const isCommitSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
        const { readFile } = await import('node:fs/promises');
        const paths = rollbackPointPaths();
        const readPoint = async (path) => {
          try { return JSON.parse(await readFile(path, 'utf8')); } catch { return {}; }
        };
        let beforeJson = await readPoint(paths.final);
        const pendingJson = await readPoint(paths.pending);
        let currentHead = '';
        if (isCommitSha(pendingJson?.commit) && isCommitSha(pendingJson?.after)) {
          currentHead = (await runGit(['rev-parse', 'HEAD'])).trim();
          if (currentHead === pendingJson.after) {
            // A crash can land after reset/merge but before pending→final rename.
            // The durable pending record is authoritative when its `after` SHA
            // matches current HEAD; promote best-effort and allow rollback now.
            beforeJson = pendingJson;
            try { promotePendingRollbackPoint(); } catch (err) {
              log.warn(err?.renamePublished
                ? `self-update: rollback point final file is visible, but its directory fsync failed during pending recovery: ${err.message}`
                : `self-update: pending rollback point is valid but promotion failed; pending remains available: ${err.message}`);
            }
          } else {
            // HEAD never reached the pending target, so this is an abandoned
            // pre-mutation record. Preserve the previous final rollback point.
            discardPendingRollbackPoint();
          }
        } else if (Object.keys(pendingJson || {}).length) {
          discardPendingRollbackPoint();
        }
        const beforeCommit = beforeJson?.commit;
        const afterCommit = beforeJson?.after;
        if (!beforeCommit) {
          return json(res, 200, { ok: false, error: 'ERR_NO_ROLLBACK_POINT', message: 'No previous update recorded.' });
        }
        if (!isCommitSha(beforeCommit) || !isCommitSha(afterCommit)) {
          return json(res, 200, {
            ok: false,
            error: 'ERR_INVALID_ROLLBACK_POINT',
            message: 'Rollback metadata must contain both the pre-update and post-update commit SHAs; refusing an unsafe legacy rollback point.',
          });
        }
        // M2: 校验当前 HEAD 仍是更新后的 commit(before.json 记录了 after)——
        // 若操作者更新后又提交了新代码,reset 会抹掉它,拒绝回滚。
        currentHead ||= (await runGit(['rev-parse', 'HEAD'])).trim();
        if (currentHead !== afterCommit) {
          return json(res, 200, { ok: false, error: 'ERR_HEAD_MOVED', message: 'HEAD moved since the update — refusing rollback that would discard newer commits.' });
        }
        // Supervisor detection is read-only and must happen before stash/reset.
        // A rejected rollback must leave the operator's working tree untouched.
        const sup = detectSelfUpdateSupervisor();
        if (!sup.supervised) {
          return json(res, 200, { ok: false, error: 'ERR_NO_SUPERVISOR', message: 'No process supervisor detected. Rollback would stop the gateway without relaunch.' });
        }
        const ignoredConflicts = await findIgnoredTargetPathConflicts(beforeCommit);
        if (ignoredConflicts.length) {
          return json(res, 200, {
            ok: false,
            error: 'ERR_IGNORED_PATH_CONFLICT',
            message: 'Rollback would overwrite ignored runtime data. Move or back up the listed paths manually before retrying.',
            conflictPaths: ignoredConflicts.slice(0, 20),
          });
        }
        // Rollback uses reset --hard, which can delete an untracked file or
        // directory that obstructs a path tracked by the target commit. Unlike
        // ordinary update checks, this destructive path must see untracked files
        // and require the same explicit force+stash protection.
        const dirty = (await runGit(['status', '--porcelain', '--untracked-files=all'])).trim();
        // D6: record the rollback intent durably BEFORE any mutation (stash or
        // reset). If the process dies between here and the restart, the journal
        // plus the untouched rollback point still describe what was attempted
        // and the rollback remains retryable. A journal write failure aborts
        // while the worktree is still unmodified.
        try {
          writeSelfUpdateJournal({
            version: 1,
            operation: 'rollback',
            state: 'pending-restart',
            phase: 'pre-reset',
            expectedCommit: beforeCommit,
            afterCommit,
            rollbackPointRetained: true,
            requestedAt: Date.now(),
          });
        } catch (journalErr) {
          return json(res, 200, {
            ok: false,
            error: 'ERR_UPDATE_JOURNAL_WRITE',
            message: `Could not durably record the rollback intent; nothing was changed: ${journalErr.message}`,
          });
        }
        const markJournalFailed = (reason, extra = {}) => {
          try {
            updateSelfUpdateJournal({ state: 'failed', reason, ...extra });
          } catch (journalErr) {
            log.warn(`self-update rollback: could not record ${reason}: ${journalErr.message}`);
          }
        };
        let protectiveStashOid = '';
        if (dirty) {
          const allowForce = !!(body && body.forceReset);
          if (!allowForce) {
            return json(res, 200, { ok: false, dirty: true, error: 'ERR_UNCOMMITTED_CHANGES', dirtyFiles: dirty.split('\n').slice(0, 20) });
          }
          try {
            protectiveStashOid = await createProtectiveStash('rollback-forceReset');
          } catch (err) {
            markJournalFailed('stash-failed', { error: err.message });
            return json(res, 200, {
              ok: false,
              error: 'ERR_STASH_FAILED',
              message: `Could not preserve local changes; rollback was not attempted: ${err.message}`,
            });
          }
        }
        let resetError = null;
        try {
          await runGit(['reset', '--hard', beforeCommit]);
        } catch (err) {
          resetError = err;
        }
        let rolledBackHead = '';
        let trackedAfter = '';
        try {
          rolledBackHead = (await runGit(['rev-parse', 'HEAD'])).trim();
          trackedAfter = (await runGit(['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none'])).trim();
        } catch (inspectErr) {
          markJournalFailed('state-unknown', { error: inspectErr.message });
          return json(res, 200, {
            ok: false,
            error: 'ERR_ROLLBACK_STATE_UNKNOWN',
            message: `Rollback state could not be verified; the rollback point and any protective stash were preserved: ${inspectErr.message}`,
            stashed: !!protectiveStashOid,
          });
        }
        if (rolledBackHead !== beforeCommit || trackedAfter) {
          let restored = false;
          const unchangedAndClean = rolledBackHead === currentHead && !trackedAfter;
          if (protectiveStashOid && unchangedAndClean) {
            try {
              await restoreProtectiveStash(protectiveStashOid);
              restored = true;
            } catch (restoreErr) {
              log.warn(`self-update rollback: reset failed and exact protective stash ${protectiveStashOid.slice(0, 12)} could not be applied: ${restoreErr.message}`);
            }
          }
          markJournalFailed('reset-not-verified', {
            actual: rolledBackHead,
            trackedDirty: !!trackedAfter,
          });
          return json(res, 200, {
            ok: false,
            error: trackedAfter
              ? 'ERR_ROLLBACK_PARTIAL'
              : resetError
                ? (unchangedAndClean ? 'ERR_GIT_ROLLBACK_FAILED' : 'ERR_ROLLBACK_PARTIAL')
                : 'ERR_ROLLBACK_TARGET_MISMATCH',
            message: resetError
              ? `Git could not reset cleanly to the recorded rollback commit: ${resetError.message}`
              : `Git reported success but rollback state is HEAD ${rolledBackHead.slice(0, 12)} with ${trackedAfter ? 'tracked changes' : 'a different commit'}.`,
            expected: beforeCommit,
            actual: rolledBackHead,
            dirtyFiles: trackedAfter ? trackedAfter.split('\n').slice(0, 20) : undefined,
            stashed: !!protectiveStashOid,
            stashRestored: restored || undefined,
          });
        }
        if (resetError) {
          log.warn(`self-update rollback: Git returned an error after reaching the verified rollback state: ${resetError.message}`);
        }
        // D6: the rollback point is NOT deleted here. It is retry evidence
        // until the restarted process proves it serves beforeCommit; the
        // journal reconcile step clears it then. Deleting it before the
        // restart made a scheduler failure indistinguishable from a verified
        // healthy rollback.
        try {
          updateSelfUpdateJournal({ phase: 'reset-verified' });
        } catch (err) {
          log.warn(`self-update rollback: could not record the verified reset: ${err.message}`);
        }
        const restartScheduled = scheduleSelfUpdateRestart(() => {
          return Promise.resolve(gracefulRestart({
            reason: 'self-update-rollback',
            drainMs: 10000,
            server: getActiveServer(),
            abortSse: abortActiveSse,
            stopLs: () => stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }),
            exitCode: selfUpdateRestartExitCode(),
          })).catch((err) => {
            // The process may survive a failed restart; leave durable evidence
            // instead of an unhandled rejection, and keep the rollback point.
            try {
              updateSelfUpdateJournal({
                state: 'restart-failed',
                restartScheduled: true,
                error: String(err?.message || err),
              });
            } catch (journalErr) {
              log.warn(`self-update rollback: could not record the restart failure: ${journalErr.message}`);
            }
            throw err;
          });
        }, releaseMutation);
        mutationLockHandedOff = restartScheduled === true;
        if (mutationLockHandedOff) {
          try {
            updateSelfUpdateJournal({ state: 'pending-restart', phase: 'restart-scheduled', restartScheduled: true });
          } catch (err) {
            log.warn(`self-update rollback: could not record the scheduled restart: ${err.message}`);
          }
          return json(res, 200, {
            ok: true,
            state: 'pending-restart',
            verified: false,
            restartScheduled: true,
            rollbackPointRetained: true,
            expectedCommit: beforeCommit,
            rolledBackTo: beforeCommit.slice(0, 7),
          });
        }
        // The scheduler declined: the reset is on disk but nothing will
        // relaunch the gateway. That is NOT a successful rollback — say so,
        // and leave the point plus journal so it can be retried.
        try {
          updateSelfUpdateJournal({ state: 'restart-not-scheduled', restartScheduled: false });
        } catch (err) {
          log.warn(`self-update rollback: could not record the unscheduled restart: ${err.message}`);
        }
        return json(res, 200, {
          ok: false,
          error: 'ERR_RESTART_NOT_SCHEDULED',
          message: 'The rollback reset was applied, but no restart could be scheduled; the running process still serves the previous code. The rollback point was kept so this rollback can be retried.',
          state: 'restart-not-scheduled',
          verified: false,
          restartScheduled: false,
          rollbackPointRetained: true,
          expectedCommit: beforeCommit,
          rolledBackTo: beforeCommit.slice(0, 7),
        });
      } catch (err) {
        if (isSelfUpdateUnavailableError(err)) return json(res, 200, { ok: false, error: err.code || 'ERR_GIT_UNAVAILABLE' });
        return json(res, 200, { ok: false, error: err.code || err.message, message: err.code ? err.message : undefined });
      }
    } finally {
      if (!mutationLockHandedOff) releaseMutation();
    }
  }
  if (subpath === '/self-update' && method === 'POST') {
    let releaseMutation;
    try {
      releaseMutation = acquireSelfUpdateMutation();
    } catch (err) {
      return json(res, 200, { ok: false, error: err.code || 'ERR_UPDATE_LOCK_FAILED', message: err.message });
    }
    if (!releaseMutation) {
      return json(res, 200, {
        ok: false,
        error: SELF_UPDATE_IN_PROGRESS,
        message: 'Another update or rollback is already in progress.',
      });
    }
    let mutationLockHandedOff = false;
    try {
      try {
        const before = await gitStatus();
        reconcilePendingRollbackPoint(before.commitFull);
      // M1: resolve the update target before any write (including forceReset).
      // Normal OTA follows the newest RELEASE TAG, not origin/<branch> HEAD:
      // release notes, generated assets, and next-release work commonly land
      // after a tag. Treating any such commit as ERR_UNRELEASED made the
      // published release itself impossible to install as soon as the first
      // post-tag commit landed. forceUpdate remains the explicit escape hatch
      // for operators who intentionally want the untagged branch head.
        const safeBranch = safeUpdateBranch(before.branch);
        // A failed fetch is not permission to operate on stale refs. The docker
        // fallback still activates when git itself is unavailable; every source
        // checkout otherwise fails closed until branch and tag refs are current.
        await runGit(['fetch', '--quiet', '--no-tags', 'origin', safeBranch]);
        const forceGate = !!(body && body.forceUpdate);
        const targetState = await releaseUpdateState(safeBranch, before.commitFull, {
          followUnreleased: forceGate,
        });
        if (targetState.diverged) {
          return json(res, 200, {
            ok: false,
            error: 'ERR_DIVERGED',
            message: `Current HEAD and update target ${targetState.targetCommit.slice(0, 7)} have diverged; refusing a non-fast-forward update.`,
          });
        }
        // Guard: working tree must be clean (ignoring untracked files like
        // accounts.json, stats.json, runtime-config.json which live in the
        // repo root but aren't checked in). If tracked files were edited,
        // surface a friendly error instead of a raw git failure.
        const dirty = (await runGit(['status', '--porcelain', '-uno'])).trim();
        let resetTarget = before.commitFull;
        if (dirty) {
          const allowForce = !!(body && body.forceReset);
          if (!allowForce) {
            return json(res, 200, {
              ok: false,
              dirty: true,
              error: 'ERR_UNCOMMITTED_CHANGES',
              dirtyFiles: dirty.split('\n').slice(0, 20),
            });
          }
          // AUTH-1: forceReset authorizes cleaning the working tree, not silently
          // deleting local commits. Failure to establish the ahead count is also
          // fail-closed: "unknown" must never be treated as zero before reset.
          let ahead;
          try {
            ahead = parseGitNonnegativeCount(
              await runGit(['rev-list', '--count', `origin/${safeBranch}..HEAD`]),
              `local commits ahead of origin/${safeBranch}`,
            );
          } catch (err) {
            return json(res, 200, {
              ok: false,
              dirty: true,
              error: 'ERR_LOCAL_COMMIT_CHECK_FAILED',
              message: `Could not verify whether local commits would be lost; reset was not attempted: ${err.message}`,
              dirtyFiles: dirty.split('\n').slice(0, 20),
            });
          }
          const allowHardReset = process.env.DASHBOARD_ALLOW_HARD_RESET === '1';
          if (ahead > 0 && !allowHardReset) {
            return json(res, 200, {
              ok: false,
              dirty: true,
              unpushedCommits: ahead,
              error: 'ERR_UNPUSHED_COMMITS',
              message: `Refusing hard-reset: ${ahead} local commit(s) not on origin/${safeBranch} would be lost. Set DASHBOARD_ALLOW_HARD_RESET=1 to override.`,
              dirtyFiles: dirty.split('\n').slice(0, 20),
            });
          }
          // A checkout can legitimately be past the latest release tag. Cleaning
          // its worktree must not downgrade it unless the separate hard-reset
          // opt-in explicitly authorizes dropping local commits.
          resetTarget = targetState.updateAvailable || (ahead > 0 && allowHardReset)
            ? targetState.targetCommit
            : before.commitFull;
        }

        const headTarget = dirty
          ? resetTarget
          : targetState.updateAvailable
            ? targetState.targetCommit
            : before.commitFull;
        const willChangeHead = headTarget !== before.commitFull;
        const willMutateWorktree = !!dirty || targetState.updateAvailable;
        if (willMutateWorktree) {
          const sup = detectSelfUpdateSupervisor();
          if (!sup.supervised) {
            return json(res, 200, {
              ok: false,
              error: 'ERR_NO_SUPERVISOR',
              message: 'No process supervisor detected. Update was not attempted because the gateway could not relaunch itself afterward.',
            });
          }
        }
        if (willMutateWorktree) {
          const ignoredConflicts = await findIgnoredTargetPathConflicts(headTarget);
          if (ignoredConflicts.length) {
            return json(res, 200, {
              ok: false,
              error: 'ERR_IGNORED_PATH_CONFLICT',
              message: 'The update would overwrite ignored runtime data. Move or back up the listed paths manually before retrying.',
              conflictPaths: ignoredConflicts.slice(0, 20),
            });
          }
        }
        if (willChangeHead) {
          try {
            // Write a durable PENDING point before stash/reset/merge while keeping
            // the previous successful final point intact. It is promoted only
            // after Git proves HEAD reached the exact expected commit.
            prepareRollbackPoint({ commit: before.commitFull, after: headTarget, ts: Date.now() });
          } catch (err) {
            return json(res, 200, {
              ok: false,
              error: 'ERR_ROLLBACK_POINT_WRITE',
              message: `Could not durably record the rollback point; update was not attempted: ${err.message}`,
              stashed: false,
            });
          }
        }

        let protectiveStashOid = '';
        if (dirty) {
          try {
            protectiveStashOid = await createProtectiveStash('self-update-forceReset');
          } catch (err) {
            if (willChangeHead) discardPendingRollbackPoint();
            return json(res, 200, {
              ok: false,
              error: 'ERR_STASH_FAILED',
              message: `Could not preserve local changes; reset was not attempted: ${err.message}`,
              stashed: false,
            });
          }
        }

        // execFile can't do `2>&1`; runGit includes stderr in thrown errors and
        // returns stdout for successful commands, which is what the UI displays.
        let pull;
        let mutationError = null;
        try {
          pull = dirty
            ? await runGit(['reset', '--hard', resetTarget]).then(() => 'hard-reset applied')
            : targetState.updateAvailable
              ? await runGit(['merge', '--ff-only', targetState.targetCommit])
              : `Already at latest published release ${targetState.latestTag || targetState.targetCommit.slice(0, 7)}`;
        } catch (err) {
          mutationError = err;
          pull = '';
        }

        let actualHead = before.commitFull;
        let trackedAfter = '';
        if (willMutateWorktree) {
          try {
            actualHead = (await runGit(['rev-parse', 'HEAD'])).trim();
            trackedAfter = (await runGit(['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none'])).trim();
          } catch (inspectErr) {
            return json(res, 200, {
              ok: false,
              error: 'ERR_UPDATE_STATE_UNKNOWN',
              message: `Update state could not be verified; the rollback point and any protective stash were preserved: ${inspectErr.message}`,
              expected: headTarget,
              stashed: !!protectiveStashOid,
            });
          }
        }

        const reachedTargetClean = actualHead === headTarget && !trackedAfter;
        if (!reachedTargetClean) {
          const unchangedAndClean = actualHead === before.commitFull && !trackedAfter;
          let restored = false;
          if (unchangedAndClean) {
            if (willChangeHead) discardPendingRollbackPoint();
            if (protectiveStashOid) {
              try {
                await restoreProtectiveStash(protectiveStashOid);
                restored = true;
              } catch (restoreErr) {
                log.warn(`self-update: failed mutation could not apply exact protective stash ${protectiveStashOid.slice(0, 12)}: ${restoreErr.message}`);
              }
            }
          }

          let rollbackPointError = '';
          if (willChangeHead && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(actualHead) && actualHead !== before.commitFull) {
            try {
              // Exit status alone cannot establish ref state. Preserve an honest
              // rollback point for the commit Git actually reached, and never
              // apply a pre-mutation stash onto that changed commit.
              prepareRollbackPoint({ commit: before.commitFull, after: actualHead, ts: Date.now(), expected: headTarget });
              promotePendingRollbackPoint();
            } catch (pointErr) {
              rollbackPointError = pointErr.message;
              log.warn(pointErr?.renamePublished
                ? `self-update: honest partial-update rollback point is visible, but its directory fsync failed: ${pointErr.message}`
                : `self-update: failed to publish the honest partial-update rollback point; pending remains available: ${pointErr.message}`);
            }
          }

          const partial = !!trackedAfter || !unchangedAndClean;
          return json(res, 200, {
            ok: false,
            error: partial
              ? 'ERR_UPDATE_PARTIAL'
              : mutationError ? 'ERR_GIT_UPDATE_FAILED' : 'ERR_UPDATE_TARGET_MISMATCH',
            message: mutationError
              ? `Git did not reach a verified clean update state: ${mutationError.message}`
              : `Git reported success but the verified update state does not match ${headTarget.slice(0, 12)}.`,
            expected: headTarget,
            actual: actualHead || undefined,
            dirtyFiles: trackedAfter ? trackedAfter.split('\n').slice(0, 20) : undefined,
            rollbackPointError: rollbackPointError || undefined,
            stashed: !!protectiveStashOid,
            stashRestored: restored || undefined,
          });
        }
        if (mutationError) {
          log.warn(`self-update: Git returned an error after reaching the verified update state: ${mutationError.message}`);
          pull = dirty ? 'hard-reset applied (verified after Git error)' : 'fast-forward applied (verified after Git error)';
        }

        if (willChangeHead) {
          try {
            promotePendingRollbackPoint();
          } catch (err) {
            // The pending point is already durable and rollback knows how to
            // recover it after restart. Do not hide a successful Git update.
            log.warn(err?.renamePublished
              ? `self-update: rollback point final file is visible, but its directory fsync failed after successful update: ${err.message}`
              : `self-update: rollback point remains pending after successful update: ${err.message}`);
          }
        }
        const changed = willMutateWorktree;
      // Schedule process exit so the service supervisor restarts us. This is
      // port/env-agnostic compared to spawning update.sh (which hardcodes
      // PORT=3003 default).
      //
      // v2.0.85 (#127 123cek): graceful-stop the LS pool before exit so
      // a supervisor hard-kill doesn't leave orphan language_server_linux_x64
      // processes holding ports. Startup-time cleanup also runs as a
      // backstop, but stopping cleanly here means the next process won't
      // even need cleanup most of the time.
      if (changed) {
        // D6: same durable journal as rollback. `{ok:true}` below now carries
        // state:'pending-restart' + verified:false until the restarted process
        // reports this commit on /health; a declined scheduler is reported as
        // restart-not-scheduled instead of a silent success.
        try {
          writeSelfUpdateJournal({
            version: 1,
            operation: 'update',
            state: 'pending-restart',
            phase: 'pre-restart',
            expectedCommit: actualHead,
            beforeCommit: before.commitFull,
            requestedAt: Date.now(),
          });
        } catch (err) {
          log.warn(`self-update: could not durably record the update journal: ${err.message}`);
        }
        const restartScheduled = scheduleSelfUpdateRestart(() => {
          // v3.4.x: drain in-flight requests before the LS stop + exit, via the
          // shared gracefulRestart path. The HTTP server handle is now reachable
          // from api.js through server-registry.js (registered in index.js), so
          // self-update no longer exits abruptly mid-request. Behaviour is
          // otherwise unchanged: same await-and-wait LS stop (audit H-4 — SIGTERM
          // lands before exit reparents surviving children to init) and the same
          // EX_TEMPFAIL exit code the supervisor relaunches on. If the server
          // handle is somehow unregistered, gracefulRestart simply skips the
          // drain step and still stops the LS and exits.
          log.info('self-update: exiting with restart-requested status for service supervisor');
          return Promise.resolve(gracefulRestart({
            reason: 'self-update',
            drainMs: 10000,
            server: getActiveServer(),
            abortSse: abortActiveSse,
            stopLs: () => stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }),
            exitCode: selfUpdateRestartExitCode(),
          })).catch((err) => {
            try {
              updateSelfUpdateJournal({
                state: 'restart-failed',
                restartScheduled: true,
                error: String(err?.message || err),
              });
            } catch (journalErr) {
              log.warn(`self-update: could not record the restart failure: ${journalErr.message}`);
            }
            throw err;
          });
        }, releaseMutation);
        mutationLockHandedOff = restartScheduled === true;
        try {
          updateSelfUpdateJournal({
            state: mutationLockHandedOff ? 'pending-restart' : 'restart-not-scheduled',
            phase: mutationLockHandedOff ? 'restart-scheduled' : 'pre-restart',
            restartScheduled: mutationLockHandedOff,
          });
        } catch (err) {
          log.warn(`self-update: could not record the restart scheduling result: ${err.message}`);
        }
      }
      return json(res, 200, {
        ok: true,
        changed,
        state: changed
          ? (mutationLockHandedOff ? 'pending-restart' : 'restart-not-scheduled')
          : 'unchanged',
        verified: !changed,
        restartScheduled: changed ? mutationLockHandedOff : false,
        before: before.commit,
        after: actualHead.slice(0, 7),
        pullOutput: pull.trim(),
        restarting: changed,
      });
      } catch (err) {
        if (isSelfUpdateUnavailableError(err)) {
        // Same fallback as /self-update/check: when git is unavailable
        // (docker), try the docker socket path. The dashboard may already
        // have called /self-update/check first and routed the user
        // straight to a docker-mode confirmation, but supporting fallback
        // here too keeps `POST /self-update` self-contained for scripts.
          const docker = await detectDockerSelfUpdateForRequest();
          if (docker.available) {
            const result = await runDockerSelfUpdateForRequest();
            if (result.ok) {
              mutationLockHandedOff = scheduleDockerSelfUpdateMutationRelease(releaseMutation) === true;
            }
            return json(res, 200, { mode: 'docker', ...result });
          }
          return json(res, 200, {
            ok: false,
            available: false,
            reason: err.reason,
            error: err.code,
            dockerReason: docker.reason,
            dockerDetail: docker.detail,
          });
        }
        return json(res, 200, {
          ok: false,
          error: err.code || err.message,
          message: err.code ? err.message : undefined,
        });
      }
    } finally {
      if (!mutationLockHandedOff) releaseMutation();
    }
  }

  // ─── One-click restart ────────────────────────────────
  // Gracefully drain in-flight requests then exit with the supervisor-relaunch
  // code (75, same as self-update) so systemd/PM2/docker restart us. This sits
  // under the same ambient dashboard-auth gate as /self-update (the checkAuth()
  // guard at the top of this handler covers every non-/auth mutation) — a
  // mutating operator action, so a chat API key alone is not enough.
  if (subpath === '/restart' && method === 'POST') {
    // Precheck: refuse to exit if no supervisor will relaunch us. Exiting into
    // nothing would take the gateway down permanently on one dashboard click.
    const sup = detectSupervisor();
    if (!sup.supervised) {
      return json(res, 200, {
        ok: false,
        error: 'ERR_NO_SUPERVISOR',
        message: 'No process supervisor (systemd/PM2/docker) detected. Restart would stop the gateway without relaunch. Run under a supervisor or set WINDSURFAPI_RESTART_SUPERVISED=1.',
      });
    }
    // Respond FIRST so the client sees the ack before we start tearing down,
    // THEN trigger the drain+exit on a short delay so the response flushes —
    // mirrors how /self-update schedules its exit via setTimeout.
    log.info(`restart: dashboard requested restart (supervisor=${sup.kind})`);
    setTimeout(() => {
      void gracefulRestart({
        reason: `dashboard /restart (supervisor=${sup.kind})`,
        drainMs: 10000,
        server: getActiveServer(),
        abortSse: abortActiveSse,
        // await-and-wait variant so SIGTERM lands before process.exit reparents
        // surviving LS children to init (the H-4 orphan race).
        stopLs: () => stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }),
        exitCode: selfUpdateRestartExitCode(),
      });
    }, 500);
    return json(res, 200, { ok: true, restarting: true, supervisor: sup.kind });
  }

  // ─── Cache ────────────────────────────────────────────
  if (subpath === '/cache' && method === 'GET') {
    return json(res, 200, cacheStats());
  }
  if (subpath === '/cache' && method === 'DELETE') {
    cacheClear();
    return json(res, 200, { success: true });
  }

  // ─── Accounts ─────────────────────────────────────────
  if (subpath === '/accounts' && method === 'GET') {
    return json(res, 200, getAccountsPayload(req));
  }

  // GET /accounts/ids — the id list for "select all N results". Same filters as
  // the list, no paging: the client must never guess that "all" means "page 1".
  if (subpath === '/accounts/ids' && method === 'GET') {
    const f = accountFiltersFromQuery(dashboardUrl(req).searchParams);
    const ids = getAccountList({ view: 'summary', ...f, limit: Infinity }).map((a) => a.id);
    return json(res, 200, { ids, total: ids.length });
  }

  // POST /accounts/fleet-jobs — start an observable probe/refresh run.
  // The run is serial server-side; what the browser gets is a job id it can
  // poll for `done/total`, cancel, and read failures from after the fact.
  if (subpath === '/accounts/fleet-jobs' && method === 'POST') {
    const op = String(body?.op || '');
    if (op !== 'probe' && op !== 'refresh') return json(res, 400, { error: 'ERR_FLEET_OP_INVALID' });
    const rawIds = Array.isArray(body?.ids) ? body.ids : null;
    if (rawIds && rawIds.length > 1000) return json(res, 400, { error: 'ERR_FLEET_TOO_LARGE', max: 1000 });
    const ids = rawIds ? [...new Set(rawIds.filter((id) => typeof id === 'string' && id))] : null;
    const force = body?.force === true || body?.allowLsStart === true;
    const job = startFleetJob({ op, ids, force });
    return json(res, 200, { success: true, job: publicFleetJob(job, { since: 0 }) });
  }

  // GET /accounts/fleet-jobs/:id?since=N — delta poll. `since` is the item
  // count the caller already has, so a long run does not resend every row.
  const fleetJobMatch = subpath.match(/^\/accounts\/fleet-jobs\/([^/]+)$/);
  if (fleetJobMatch && method === 'GET') {
    const job = getFleetJob(fleetJobMatch[1]);
    if (!job) return json(res, 404, { error: 'ERR_FLEET_JOB_NOT_FOUND' });
    const since = Number(dashboardUrl(req).searchParams.get('since') ?? 0);
    return json(res, 200, { success: true, job: publicFleetJob(job, { since }) });
  }

  // POST /accounts/fleet-jobs/:id/cancel — stop scheduling new accounts.
  const fleetCancelMatch = subpath.match(/^\/accounts\/fleet-jobs\/([^/]+)\/cancel$/);
  if (fleetCancelMatch && method === 'POST') {
    const job = cancelFleetJob(fleetCancelMatch[1]);
    if (!job) return json(res, 404, { error: 'ERR_FLEET_JOB_NOT_FOUND' });
    return json(res, 200, { success: true, job: publicFleetJob(job, { since: 0 }) });
  }

  // POST /accounts/batch — one request for a bulk operation, per-item results.
  // Serial by design: parallel writes to the same pool trip the upstream
  // brute-force guard. Partial failure is a 200 with `failed[]`, never a 500
  // that hides the items that did succeed.
  if (subpath === '/accounts/batch' && method === 'POST') {
    const op = String(body?.op || '');
    if (!['enable', 'disable', 'probe', 'refresh', 'reset', 'delete'].includes(op)) {
      return json(res, 400, { error: 'ERR_BATCH_OP_INVALID' });
    }
    const rawIds = Array.isArray(body?.ids) ? body.ids : [];
    const ids = [...new Set(rawIds.filter((id) => typeof id === 'string' && id))];
    if (ids.length === 0) return json(res, 400, { error: 'ERR_BATCH_IDS_EMPTY' });
    if (ids.length > 1000) return json(res, 400, { error: 'ERR_BATCH_TOO_LARGE', max: 1000 });
    const results = [];
    for (const id of ids) {
      try {
        if (op === 'probe') {
          const r = await probeAccount(id, { allowLsStart: false, canary: false });
          results.push(probeOutcomeEntry(id, r));
        } else if (op === 'refresh') {
          const r = await refreshCredits(id);
          results.push(r?.ok ? { id, ok: true } : { id, ok: false, error: r?.error || 'ERR_REFRESH_FAILED' });
        } else {
          const ok = op === 'enable' ? setAccountStatus(id, 'active')
            : op === 'disable' ? setAccountStatus(id, 'disabled')
              : op === 'reset' ? resetAccountErrors(id)
                : removeAccount(id);
          results.push(ok ? { id, ok: true } : { id, ok: false, error: 'ERR_ACCOUNT_NOT_FOUND' });
        }
      } catch (err) {
        results.push({ id, ok: false, error: err.message || 'ERR_BATCH_ITEM_FAILED' });
      }
    }
    const failed = results.filter((r) => !r.ok && !r.skipped);
    const skipped = results.filter((r) => r.skipped);
    if (op === 'delete') log.info(`dashboard accounts/batch delete: ${results.length - failed.length}/${ids.length} removed`);
    return json(res, 200, {
      success: true,
      op,
      results,
      failed,
      skipped,
      ...summarizeProbeResults(results),
    });
  }

  if (subpath === '/accounts' && method === 'POST') {
    try {
      const apiServerUrl = body.apiServerUrl || body.api_server_url || '';
      if (!body.api_key && !body.token) {
        return json(res, 400, { error: 'Provide api_key or token' });
      }

      // SessionReauth: a bound paste replaces an existing account's token. Same
      // helper as POST /accounts/:id/reauth {action:'token'} so the two entry
      // points cannot drift into different validation.
      if (body.accountId) {
        return json(res, 200, applyReauthToken(body.accountId, body.api_key || body.token, { via: 'token' }));
      }

      let parsedProxy = null;
      if (body.proxy) {
        parsedProxy = parseProxyUrl(body.proxy);
        if (!parsedProxy) {
          return json(res, 400, { error: 'ERR_PROXY_FORMAT_INVALID' });
        }
        try {
          await validateProxyHost(parsedProxy);
        } catch (e) {
          return json(res, 400, { error: e.message || 'ERR_PROXY_INVALID' });
        }
      }

      const account = body.api_key
        ? await addAccountByPastedSecret(body.api_key, body.label, apiServerUrl, { unknownAsKey: true })
        : await addAccountByPastedSecret(body.token, body.label, apiServerUrl);

      if (parsedProxy) {
        setAccountProxy(account.id, parsedProxy);
      }

      // Fire-and-forget LS warmup is opt-in. Current LS builds are heavy,
      // so adding many proxied accounts must not spawn many LSPs up front.
      scheduleAccountWarmup(account.id);
      return json(res, 200, {
        success: true,
        account: { id: account.id, email: account.email, method: account.method, status: account.status },
        ...getAccountCount(),
      });
    } catch (err) {
      // A bound re-auth can fail with 404/409/400; the unbound add path only
      // ever produced 400. Keep the code so the UI can react to it.
      return json(res, err.statusCode || 400, { error: err.code || err.message });
    }
  }

  // POST /accounts/import-text — smart bulk import. Paste mixed token formats
  // (session / show-auth-token URL / refresh-JWT / labeled pairs); the parser
  // classifies each and adds via addAccountByPastedSecret. auth1_ is skipped
  // (PostAuth intermediate, not a pool key). SECURITY: never logs the pasted
  // text or any token; results carry only id/email/kind, never raw token.
  if (subpath === '/accounts/import-text' && method === 'POST') {
    try {
      const text = String(body.text || '');
      if (!text.trim()) return json(res, 400, { error: 'ERR_IMPORT_TEXT_EMPTY' });
      const parsed = parseAccountText(text);
      const results = { added: [], skipped: [], failed: [] };
      // A devin-session-token$ is itself the apiKey (addAccountByKey); other
      // token shapes run RegisterUser (addAccountByToken). Route by classified
      // kind so a pasted session token doesn't fail against the Firebase-only
      // RegisterUser path.
      for (const raw of (parsed.tokens || [])) {
        const kind = classifyToken(raw);
        // auth1_ is a PostAuth intermediate, not a pool key.
        if (kind === 'auth1') { results.skipped.push({ kind, reason: 'auth1_not_a_pool_key' }); continue; }
        // F3: everything else is a candidate. A show-auth-token /
        // OneTimeAuthToken value is a 47-char opaque string that classifyToken
        // cannot name; the old `kind === 'unknown'` skip dropped it silently,
        // so the documented "paste the token" import added nothing. The adder
        // routes a session token to the pool key and everything else through
        // RegisterUser, and reports a rejection per token instead of hiding it.
        try {
          const acc = await addAccountByPastedSecret(raw, '');
          results.added.push({ id: acc.id, email: acc.email, kind });
        } catch (e) { results.failed.push({ kind, error: e.message }); }
      }
      for (const pair of (parsed.tokenPairs || [])) {
        const pairKind = classifyToken(pair.token);
        if (pairKind === 'auth1') {
          results.skipped.push({ kind: pairKind, email: pair.email, reason: 'auth1_not_a_pool_key' });
          continue;
        }
        try {
          const acc = await addByKind(pair.token, pair.email || '');
          results.added.push({ id: acc.id, email: acc.email || pair.email, kind: pairKind });
        } catch (e) { results.failed.push({ email: pair.email, error: e.message }); }
      }
      // Email/password accounts need the login flow (batch 2+); record as skipped.
      for (const a of (parsed.accounts || [])) {
        results.skipped.push({ kind: 'email_password', email: a.email, reason: 'use_login_flow' });
      }
      return json(res, 200, { success: true, ...results, ...getAccountCount() });
    } catch (err) {
      return json(res, 400, { error: err.message });
    }
  }

  // dashboard can hide / disable the "Import from local Windsurf" button on
  // public binds *before* the user clicks it. Returns the same gates the
  // import endpoint enforces, plus a friendly explanation.
  if (subpath === '/accounts/import-local-availability' && method === 'GET') {
    const remote = dashboardClientIp(req);
    const localBind = isLocalBindHost();
    const loopback = isLoopbackAddress(remote);
    let available = true;
    let reason = '';
    if (!localBind) {
      available = false;
      reason = 'public_bind';
    } else if (!loopback) {
      available = false;
      reason = 'non_loopback_caller';
    }
    return json(res, 200, {
      available,
      reason,
      bindHost: process.env.HOST || process.env.BIND_HOST || '0.0.0.0',
      remoteAddress: remote,
      hint: available
        ? ''
        : (reason === 'public_bind'
          ? '此实例绑定在公网/0.0.0.0 上 — "本地" Windsurf 是远端服务器上的,不是你电脑里的,所以这个功能被拒绝(设计如此)。要导入本机 Windsurf 凭证请用 localhost 部署。'
          : '只接受来自 127.0.0.1 的请求;当前调用来自 ' + (remote || '?') + '。'),
    });
  }

  // GET /accounts/import-local — discover Windsurf desktop client credentials
  // Local-only hardening: both the bind and trusted client must be loopback.
  // Use the same client identity as checkAuth, so same-host proxies cannot
  // expose local desktop credentials.
  if (subpath === '/accounts/import-local' && method === 'GET') {
    if (!isLocalBindHost()) {
      log.warn('local-windsurf import refused: dashboard not bound to loopback host');
      return json(res, 403, { error: 'ERR_LOCAL_IMPORT_NOT_AVAILABLE_PUBLIC_BIND' });
    }
    const remote = dashboardClientIp(req);
    if (!isLoopbackAddress(remote)) {
      log.warn(`local-windsurf import refused: non-loopback caller ${remote}`);
      return json(res, 403, { error: 'ERR_LOCAL_IMPORT_LOOPBACK_ONLY', message: 'Local Windsurf import only available from 127.0.0.1' });
    }
    try {
      const result = await discoverWindsurfCredentials();
      log.info(`local-windsurf import: found ${result.accounts.length} account(s) across ${result.sources.filter(s => s.ok).length} source(s)`);
      return json(res, 200, {
        success: true,
        accounts: result.accounts.map(a => ({
          method: a.method,
          apiKey: a.apiKey,
          apiKeyMasked: a.apiKeyMasked,
          email: a.email,
          name: a.name,
          apiServerUrl: a.apiServerUrl,
          label: a.label,
          source: a.source,
        })),
        sources: result.sources,
        sqliteSupport: result.sqliteSupport,
        platform: result.platform,
      });
    } catch (e) {
      log.warn(`local-windsurf import failed: ${e.message}`);
      return json(res, 500, { error: 'ERR_LOCAL_IMPORT_FAILED', message: e.message });
    }
  }

  // POST /accounts/probe-all — legacy blocking form. Kept for API clients and
  // the smoke matrix; the dashboard now uses /accounts/fleet-jobs so the run is
  // observable and cancellable. Same loop, one implementation.
  if (subpath === '/accounts/probe-all' && method === 'POST') {
    const force = body?.force === true || body?.allowLsStart === true;
    const started = startFleetJob({ op: 'probe', force });
    const job = await awaitFleetJob(started.id);
    const results = job?.items || [];
    return json(res, 200, { success: true, total: job?.total || 0, ...summarizeProbeResults(results), results });
  }

  // POST /accounts/:id/reauth — SessionReauth actions (never returns the key).
  //   {action:'probe'} → one free GetUserStatus probe. alive clears the state,
  //     UNAUTHORIZED keeps it, anything else (CAPACITY/UPSTREAM_INTERNAL) is
  //     transient and changes nothing. The operator-facing "Check token" button
  //     forces past the 60 s de-dupe.
  //   {action:'token'} → install a pasted session token on THIS account.
  const accountReauth = subpath.match(/^\/accounts\/([^/]+)\/reauth$/);
  if (accountReauth && method === 'POST') {
    const id = accountReauth[1];
    const action = String(body?.action || 'probe');
    try {
      if (action === 'probe') {
        if (!getAccountInternal(id)) return json(res, 404, { error: 'ERR_ACCOUNT_NOT_FOUND' });
        const result = await requestReauthCheck(id, { source: 'manual', force: body?.force !== false });
        if (result.reason === 'NOT_ELIGIBLE') return json(res, 409, { error: 'ERR_REAUTH_NOT_APPLICABLE' });
        return json(res, 200, result);
      }
      if (action === 'token') {
        return json(res, 200, applyReauthToken(id, body?.token ?? body?.api_key, { via: 'manual' }));
      }
      return json(res, 400, { error: 'ERR_REAUTH_ACTION_INVALID' });
    } catch (err) {
      return json(res, err.statusCode || 400, { error: err.code || err.message });
    }
  }

  // POST /accounts/:id/probe — manually trigger capability probe
  const accountProbe = subpath.match(/^\/accounts\/([^/]+)\/probe$/);
  if (accountProbe && method === 'POST') {
    try {
      const force = body?.force === true || body?.allowLsStart === true;
      // Billable canary sweep is opt-in: only when the caller explicitly asks
      // for a deep probe. A plain probe runs GetUserStatus only (free) and
      // never spends the account's prompt credits. (homecloud 2026-06-29:
      // a default canary sweep flipped a working free account to "expired".)
      const canary = body?.canary === true || body?.deep === true;
      const result = await probeAccount(accountProbe[1], { allowLsStart: force, canary });
      if (!result) return json(res, 404, { error: 'Account not found' });
      return json(res, 200, { success: true, ...result });
    } catch (err) {
      return json(res, 500, { error: err.message });
    }
  }

  // POST /accounts/refresh-credits — legacy blocking form of the refresh run.
  // The dashboard uses /accounts/fleet-jobs (progress + cancel); this stays for
  // API clients and the smoke matrix and shares the same loop.
  if (subpath === '/accounts/refresh-credits' && method === 'POST') {
    const started = startFleetJob({ op: 'refresh' });
    const job = await awaitFleetJob(started.id);
    const results = (job?.items || []).map((item) => ({
      id: item.id,
      email: item.email,
      ok: item.ok,
      ...(item.ok ? {} : { error: item.error || 'ERR_REFRESH_FAILED' }),
    }));
    return json(res, 200, { success: true, results });
  }

  // POST /accounts/:id/refresh-credits — single-account refresh
  const creditRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-credits$/);
  if (creditRefresh && method === 'POST') {
    const r = await refreshCredits(creditRefresh[1]);
    return json(res, r.ok ? 200 : 400, r);
  }

  // POST /accounts/:id/web-search — run one upstream web search on this account.
  // Exposes GetWebSearchResults, which the repo has implemented and tested since it
  // was reverse-engineered but never called from anywhere. Costs no model credits:
  // it rides the account's own session token.
  //
  // Authentication is inherited, not re-implemented: every /dashboard/api/* route
  // except /auth passes through the checkAuth gate above, so this endpoint is only
  // reachable by an authenticated dashboard session.
  const webSearch = subpath.match(/^\/accounts\/([^/]+)\/web-search$/);
  if (webSearch && method === 'POST') {
    const r = await searchWebForAccount(webSearch[1], {
      query: body?.query,
      limit: body?.limit,
      domain: body?.domain,
    });
    return json(res, r.ok ? 200 : 400, r);
  }

  const accountGet = subpath.match(/^\/accounts\/([^/]+)$/);
  if (accountGet && method === 'GET') {
    const account = getAccountPublic(accountGet[1], { view: 'full' });
    if (!account) return json(res, 404, { error: 'Account not found' });
    return json(res, 200, { account });
  }

  // PATCH /accounts/:id
  const accountPatch = subpath.match(/^\/accounts\/([^/]+)$/);
  if (accountPatch && method === 'PATCH') {
    const id = accountPatch[1];
    // The operator may only write the three statuses they can act on. `banned`
    // and `reauth` are evidence-gated states — the ban detector and the
    // UNAUTHORIZED probe put an account there, and letting a hand-written string
    // forge that evidence would bypass the two-signal rule (a `banned` written
    // this way also skips bannedReason/bannedAt). Any other value is a typo that
    // used to be persisted verbatim: selection filters on `status === 'active'`
    // while resolveAccountState maps unknown statuses to 'active', so the account
    // became invisible-but-unroutable and the value survived restart.
    if (body.status !== undefined && !PATCHABLE_ACCOUNT_STATUSES.includes(body.status)) {
      return json(res, 400, { error: 'ERR_ACCOUNT_STATUS_INVALID' });
    }
    try {
      // `force:true` is the operator's explicit override of the reauth guard
      // (they replaced the token out-of-band); without it the refusal is a 409
      // so the UI can point at the re-authenticate action instead of silently
      // putting a retired key back in rotation.
      if (body.status !== undefined) setAccountStatus(id, body.status, { force: body.force === true });
      if (body.label) updateAccountLabel(id, body.label);
      if (body.resetErrors) resetAccountErrors(id);
      if (Array.isArray(body.blockedModels)) setAccountBlockedModels(id, body.blockedModels);
      if (body.tier) setAccountTier(id, body.tier);
      // v3.0.3: per-account on-demand spend policy. Only the keys present are
      // touched (each can be set alone). spendOnDemand: true | false | null(inherit);
      // onDemandReserve: number | null/''(inherit).
      if ('spendOnDemand' in body || 'onDemandReserve' in body) {
        const patch = {};
        if ('spendOnDemand' in body) patch.spendOnDemand = body.spendOnDemand;
        if ('onDemandReserve' in body) patch.reserve = body.onDemandReserve;
        setAccountSpendPolicy(id, patch);
      }
    } catch (err) {
      return json(res, err.statusCode || 400, { error: err.code || err.message });
    }
    return json(res, 200, { success: true });
  }

  // GET /tier-access — hardcoded FREE/PRO model entitlement tables.
  // The dashboard uses this to render the full per-account model grid
  // (every row in the tier's list is shown, blocked models are dimmed).
  if (subpath === '/tier-access' && method === 'GET') {
    return json(res, 200, {
      free: _TIER_TABLE.free,
      pro: _TIER_TABLE.pro,
      unknown: _TIER_TABLE.unknown,
      expired: _TIER_TABLE.expired,
      allModels: filterModelKeysByCloudCatalog(),
    });
  }

  // DELETE /accounts/:id
  const accountDel = subpath.match(/^\/accounts\/([^/]+)$/);
  if (accountDel && method === 'DELETE') {
    const ok = removeAccount(accountDel[1]);
    return json(res, ok ? 200 : 404, { success: ok });
  }

  // ─── Stats ────────────────────────────────────────────
  if (subpath === '/stats' && method === 'GET') {
    const stats = getStats();
    return json(res, 200, { ...stats, modelBilling: buildModelBillingIndex(stats.modelCounts) });
  }

  // GET /stats/log — newest-first request-log page + aggregates over the whole
  // filtered window. Filters are server-side and every aggregate is computed
  // from the same rows the table pages through, so the numbers reconcile.
  // `from`/`to` are epoch milliseconds (the browser sends them); limit is
  // clamped to 200, offset >= 0. Retention is stated in the response.
  if (subpath === '/stats/log' && method === 'GET') {
    const u = dashboardUrl(req);
    const num = (key) => {
      const raw = u.searchParams.get(key);
      if (raw == null || raw === '') return null;
      const n = Number(raw);
      return Number.isFinite(n) ? n : null;
    };
    return json(res, 200, getRequestLog({
      from: num('from'),
      to: num('to'),
      model: u.searchParams.get('model') || null,
      account: u.searchParams.get('account') || null,
      status: u.searchParams.has('status') ? num('status') : null,
      limit: num('limit') ?? 50,
      offset: num('offset') ?? 0,
    }), NO_STORE);
  }

  // Account usage from app.devin.ai (quota burn-down card). GET-only reads
  // through the shared dashboard cache; `?account=<id>` selects a pool account;
  // with no id it uses the first active account, then the env-token deploy.
  // `?refresh=1` bypasses freshness but shares any in-flight request.
  if (subpath === '/usage' && method === 'GET') {
    const u = dashboardUrl(req);
    try {
      const result = await readQuotaBurnDown({
        id: u.searchParams.get('account') || undefined,
        force: u.searchParams.get('refresh') === '1',
      });
      const { httpStatus, ...payload } = result;
      // Preserve the upstream status marker for the browser. Without it a
      // forwarded upstream 401/429 is indistinguishable from the dashboard's
      // own password/IP-lock rejection, so the UI would clear a valid admin
      // session (or start a lockout) on a provider quota error.
      if (!result.ok) payload.httpStatus = httpStatus || 502;
      return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE);
    } catch (err) {
      log.warn(`Dashboard usage read failed: ${err?.kind || err?.name || 'unexpected'}`);
      return json(res, 502, { ok: false, error: 'upstream' }, NO_STORE);
    }
  }

  // GET /usage/orgs — org-scoped ACU & usage (Q3). Resolves every active
  // Devin session-token account to its org, reads the ledger ONCE per org, and
  // returns a hash-only org handle. Accounts that share an org are grouped, so
  // the same ACUs are never counted twice. Per-org failures stay per-org; the
  // panel renders the orgs that answered.
  if (subpath === '/usage/orgs' && method === 'GET') {
    const u = dashboardUrl(req);
    try {
      const result = await readAcuUsageByOrg({
        force: u.searchParams.get('refresh') === '1',
      });
      const { httpStatus, ...payload } = result;
      if (!result.ok) payload.httpStatus = httpStatus || 502;
      return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE);
    } catch (err) {
      log.warn(`Dashboard ACU usage read failed: ${err?.kind || err?.name || 'unexpected'}`);
      return json(res, 502, { ok: false, error: 'upstream', orgs: [] }, NO_STORE);
    }
  }

  // Billing health / pause banner (P0-2). Shares the `status` and `quota` cache
  // entries with /usage; a 403 is a gray "no permission", never a stale state.
  if (subpath === '/billing/health' && method === 'GET') {
    const u = dashboardUrl(req);
    try {
      const result = await readBillingHealth({
        id: u.searchParams.get('account') || undefined,
        force: u.searchParams.get('refresh') === '1',
      });
      const { httpStatus, ...payload } = result;
      // Preserve the upstream status marker for the browser. Without it a
      // forwarded upstream 401/429 is indistinguishable from the dashboard's
      // own password/IP-lock rejection, so the UI would clear a valid admin
      // session (or start a lockout) on a provider quota error.
      if (!result.ok) payload.httpStatus = httpStatus || 502;
      return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE);
    } catch (err) {
      log.warn(`Dashboard billing health read failed: ${err?.kind || err?.name || 'unexpected'}`);
      return json(res, 502, { ok: false, error: 'upstream' }, NO_STORE);
    }
  }

  if (subpath === '/stats' && method === 'DELETE') {
    resetStats();
    return json(res, 200, { success: true });
  }

  // v2.0.148 — export the full stats snapshot (credits + per-request detail) for
  // backup/migration. GET returns JSON the client can download as a file.
  if (subpath === '/stats/export' && method === 'GET') {
    return json(res, 200, exportStats());
  }

  // v2.0.148 — import a previously exported snapshot. Body: the snapshot object,
  // optional { mode: 'merge'|'replace' } (default merge). Merge sums counts.
  if (subpath === '/stats/import' && method === 'POST') {
    const mode = body && body.mode === 'replace' ? 'replace' : 'merge';
    const snap = body && body.snapshot ? body.snapshot : body;
    const result = importStats(snap, { mode });
    return json(res, result.ok ? 200 : 400, result);
  }

  // DEVIN_CONNECT recovery health: re-login / failover / dead-token / cooldown
  // counters + credential-store decrypt health, so an operator can watch the
  // self-heal machinery instead of grepping logs (#36, #37).
  if (subpath === '/connect-metrics' && method === 'GET') {
    // C5: fold in the persisted per-account rolling-hour health window so an
    // operator sees not just fleet-wide counters but which accounts are
    // currently throwing throttles/errors/dead-tokens over the last hour.
    // Sticky-session affinity counters ride along so an operator can verify
    // STICKY_SESSION_ENABLED is actually binding (hits/creates > 0) instead
    // of counting log lines — the only way to check before this.
    return json(res, 200, {
      ...getConnectMetrics(),
      poolHealth: getPoolHealthWindow(),
      sticky: { enabled: isStickyEnabled(), ...getStickyStats() },
      responseStore: getResponseStoreStats(),
      // Cascade replayed-history images: how many historical images rode field 6
      // vs how many were dropped (cap / bytes / truncated / no vision mode). The
      // drop path also logs `cascade_image_history_dropped`; this makes it
      // countable instead of grep-only.
      cascadeImages: getCascadeImageMetrics(),
    });
  }
  if (subpath === '/connect-metrics' && method === 'DELETE') {
    resetConnectMetrics();
    resetCascadeImageMetrics();
    return json(res, 200, { success: true });
  }

  // ─── Rate-limit history ───────────────────────────────
  // Bounded ring of the most recent markRateLimited events (upstream window vs
  // the clamped window that was actually applied), so an operator can see what
  // a lockout was really waiting for instead of grepping logs. `?limit=n`
  // returns the n most recent; the ring itself is capped at 500.
  if (subpath === '/rate-limit-history' && method === 'GET') {
    const url = dashboardUrl(req);
    const limit = parseBoundedInt(url.searchParams.get('limit'), 0, { min: 0, max: 500 });
    return json(res, 200, { events: getRateLimitHistory({ limit }) });
  }

  // ─── Logs ─────────────────────────────────────────────
  if (subpath === '/logs' && method === 'GET') {
    const url = new URL(req.url, 'http://localhost');
    const since = parseInt(url.searchParams.get('since') || '0', 10);
    const level = url.searchParams.get('level') || null;
    return json(res, 200, { logs: getLogs(since, level) });
  }

  // GET /logs/export — v2.0.60: download recent logs as JSONL or
  // pretty-text. Filterable by `type` (all / system / api), `level`
  // (debug/info/warn/error/all), `since` (unix ms). Designed for the
  // "give me logs to attach to a GitHub issue" flow so users don't have
  // to copy-paste from the streaming view. Returns Content-Disposition
  // so browsers download instead of preview.
  if (subpath === '/logs/export' && method === 'GET') {
    const url = new URL(req.url, 'http://localhost');
    const type = (url.searchParams.get('type') || 'all').toLowerCase();
    const level = url.searchParams.get('level') || null;
    const since = parseInt(url.searchParams.get('since') || '0', 10);
    const fmt = (url.searchParams.get('format') || 'jsonl').toLowerCase();

    let entries = getLogs(since, level);
    if (type === 'api') {
      // API path: any log emitted by request handlers — Probe[id] / Chat[id]
      // / Cascade / ToolGuard / drought etc., plus any entry with a ctx
      // requestId. This is the "what happened to my request" view.
      entries = entries.filter(e => {
        if (e.ctx && (e.ctx.requestId || e.ctx.reqId)) return true;
        const m = e.msg || '';
        return /^(?:Probe|Chat|Cascade|ToolGuard|ToolParser|drought|Workspace|Settings):|\[Probe |\[Chat /i.test(m);
      });
    } else if (type === 'system') {
      // System path: everything that's NOT obviously per-request — auth
      // pool, LS lifecycle, cron jobs, etc.
      entries = entries.filter(e => {
        if (e.ctx && (e.ctx.requestId || e.ctx.reqId)) return false;
        const m = e.msg || '';
        return !/^(?:Probe|Chat|Cascade|ToolGuard|ToolParser):|\[Probe |\[Chat /i.test(m);
      });
    }

    const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
    const filename = `windsurf-api-logs-${type}-${stamp}.${fmt === 'txt' ? 'log' : 'jsonl'}`;
    let body;
    if (fmt === 'txt' || fmt === 'log') {
      body = entries.map(e => {
        const ts = new Date(e.ts).toISOString();
        const ctx = e.ctx ? ' ' + Object.entries(e.ctx).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(' ') : '';
        return `${ts} [${e.level.toUpperCase()}] ${e.msg}${ctx}`;
      }).join('\n') + '\n';
    } else {
      body = entries.map(e => JSON.stringify(e)).join('\n') + '\n';
    }
    res.writeHead(200, {
      'Content-Type': fmt === 'txt' || fmt === 'log' ? 'text/plain; charset=utf-8' : 'application/x-ndjson; charset=utf-8',
      'Content-Disposition': `attachment; filename="${filename}"`,
      'Cache-Control': 'no-store',
    });
    res.end(body);
    return;
  }

  if (subpath === '/logs/stream' && method === 'GET') {
    req.socket.setKeepAlive(true);
    req.setTimeout(0);
    const streamHeaders = {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no',
    };
    // Same allowlist-gated CORS as json(): only emit ACAO for an allowed
    // origin, never a blanket `*` (see resolveCorsOrigin / AUTH-1).
    const streamAcao = res?._dashboardCorsOrigin || '';
    if (streamAcao) {
      streamHeaders['Access-Control-Allow-Origin'] = streamAcao;
      if (streamAcao !== '*') streamHeaders['Vary'] = 'Origin';
    }
    res.writeHead(200, streamHeaders);
    res.write('retry: 3000\n\n');

    // Send existing logs first
    const existing = getLogs();
    for (const entry of existing.slice(-50)) {
      res.write(`data: ${JSON.stringify(entry)}\n\n`);
    }

    const heartbeat = setInterval(() => {
      if (!res.writableEnded) res.write(': heartbeat\n\n');
    }, 15000);

    const cb = (entry) => {
      if (!res.writableEnded) res.write(`data: ${JSON.stringify(entry)}\n\n`);
    };
    subscribeToLogs(cb);

    req.on('close', () => {
      clearInterval(heartbeat);
      unsubscribeFromLogs(cb);
    });
    return;
  }

  // ─── Proxy ────────────────────────────────────────────
  // Always return the masked view over the API — plaintext passwords
  // would otherwise end up in dashboard network logs, HAR files, proxy
  // access logs, etc. The UI posts the sentinel back to preserve the
  // stored password when editing other fields (see mergePassword).
  // ─── Drought summary (v2.0.57 Fix 5) ──────────────────
  if (subpath === '/drought' && method === 'GET') {
    return json(res, 200, getDroughtSummary());
  }

  // ─── Upstream endpoints (v2.0.60 — show migration status) ──
  // Surfaces which Windsurf upstream paths the proxy is currently
  // wired to talk to. Lets the operator confirm at a glance that we're
  // on the new register.windsurf.com / windsurf.com/_backend hosts and
  // that the legacy fallbacks are still in place. Read-only — the
  // actual switching happens automatically per-request based on
  // network success / 5xx response.
  if (subpath === '/upstream-endpoints' && method === 'GET') {
    return json(res, 200, {
      registerUser: {
        primary: 'register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser',
        fallback: 'api.codeium.com/register_user/',
        protocol: 'Connect-RPC (primary) / REST (fallback)',
        migratedSince: 'v2.0.57',
      },
      postAuth: {
        primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth',
        fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth',
        protocol: 'Connect-RPC',
        migratedSince: 'v2.0.57',
      },
      oneTimeAuthToken: {
        primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken',
        fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken',
        protocol: 'Connect-RPC',
        migratedSince: 'v2.0.57',
      },
      checkUserLoginMethod: {
        primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/CheckUserLoginMethod',
        fallback: 'windsurf.com/_devin-auth/connections',
        protocol: 'Connect-RPC',
        migratedSince: 'v2.0.39',
      },
      getUserStatus: {
        primary: 'server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
        fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
        protocol: 'Connect-RPC',
        note: '内置 daily/weekly% 解析;wam-bundle 用的 GetPlanStatus 是同一 service 的另一个 RPC,返回字段被 GetUserStatus.planStatus 嵌套覆盖。',
      },
      getCascadeModelConfigs: {
        primary: 'server.codeium.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs',
        fallback: 'server.self-serve.windsurf.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs',
        protocol: 'Connect-RPC',
      },
      firebaseAuth: {
        primary: 'identitytoolkit.googleapis.com/v1/accounts:signInWithPassword',
        refreshUrl: 'securetoken.googleapis.com/v1/token',
        note: 'Windsurf project Firebase API key 直连 — 同 WindsurfSwitch / wam-bundle 路径。',
      },
    });
  }

  // ─── Credentials (v2.0.56 — runtime-rotatable API_KEY + DASHBOARD_PASSWORD) ─────
  // GET /settings/credentials — masked snapshot. The plaintext API key is
  // never returned (use revealApiKey if/when added), but we expose
  // - source: 'runtime' | 'env' | 'unset'
  // - masked: 'sk-12...3456' for the API key
  // - dashboardPasswordSet: bool
  // - dashboardPasswordSource: 'runtime' | 'env' | 'unset'
  if (subpath === '/settings/credentials' && method === 'GET') {
    const creds = getCredentials();
    const effectiveApiKey = getEffectiveApiKey();
    const apiKeySource = creds.apiKey ? 'runtime' : (config.apiKey ? 'env' : 'unset');
    const dashboardPasswordSource = creds.dashboardPasswordHash
      ? 'runtime'
      : (config.dashboardPassword ? 'env' : 'unset');
    return json(res, 200, {
      apiKey_masked: maskApiKey(effectiveApiKey),
      apiKeySource,
      dashboardPasswordSet: !!getEffectiveDashboardPasswordStored(),
      dashboardPasswordSource,
    });
  }

  // PUT /settings/credentials — rotate one or both credentials. Body:
  //   { apiKey?: string, dashboardPassword?: string }
  // Empty string clears the runtime override (env value takes over).
  // dashboardPassword is trimmed before validation and hashing; a non-empty
  // value that trims to nothing is rejected as blank (SEC-04).
  // Requires the caller to re-authenticate with the NEW dashboard
  // password on the next request — no session cookies, so the UI just
  // re-prompts. We don't accept the old-password proof here because the
  // caller already passed dashboard auth at the top of this function.
  //
  // V-21 (audit A22): validation AND commit are one transaction. The old
  // order validated+persisted `apiKey` and only then looked at
  // `dashboardPassword`, so a request answered with 400 had already rotated
  // the chat key. setRuntimeCredentials() validates every field first and
  // writes both in one atomic replace.
  if (subpath === '/settings/credentials' && method === 'PUT') {
    if (!body || typeof body !== 'object') {
      return json(res, 400, { error: 'Body must be a JSON object' });
    }
    const hasApiKey = Object.prototype.hasOwnProperty.call(body, 'apiKey');
    const hasPassword = Object.prototype.hasOwnProperty.call(body, 'dashboardPassword');
    let committed;
    try {
      committed = setRuntimeCredentials({
        ...(hasApiKey ? { apiKey: body.apiKey } : {}),
        ...(hasPassword ? { dashboardPassword: body.dashboardPassword } : {}),
      });
    } catch (err) {
      if (err?.code === 'ERR_CREDENTIAL_PERSIST_FAILED') {
        // The file write failed and both fields were rolled back; saying
        // "success" here would leave the operator with credentials that only
        // exist until the next restart.
        log.warn(`Settings: credential rotation was NOT persisted: ${err.cause?.message || err.message}`);
        return json(res, 500, { error: 'ERR_CREDENTIAL_PERSIST_FAILED' });
      }
      return json(res, 400, { error: err.message });
    }
    const out = {};
    if (hasApiKey) {
      out.apiKeyUpdated = true;
      out.apiKey_masked = maskApiKey(committed.apiKey || getEffectiveApiKey());
    }
    if (hasPassword) out.dashboardPasswordUpdated = true;
    log.info(`Settings: credentials rotated from ${dashboardClientIp(req) || 'unknown'} (apiKey=${!!out.apiKeyUpdated}, dashboardPassword=${!!out.dashboardPasswordUpdated})`);
    return json(res, 200, { success: true, ...out });
  }

  // GET /settings/prefs — dashboard UI preferences (booleans, shared across
  // browsers via runtime-config). PUT to change one or more.
  if (subpath === '/settings/prefs' && method === 'GET') {
    return json(res, 200, { prefs: getPrefs() });
  }
  if (subpath === '/settings/prefs' && method === 'PUT') {
    if (!body || typeof body !== 'object') {
      return json(res, 400, { error: 'Body must be a JSON object' });
    }
    return json(res, 200, { success: true, prefs: setPrefs(body) });
  }

  if (subpath === '/proxy' && method === 'GET') {
    return json(res, 200, getProxyConfigMasked());
  }

  if (subpath === '/proxy/global' && method === 'PUT') {
    // v2.0.55 (audit H3): wire this PUT through the same private-host
    // gate the add-account path uses, otherwise a dashboard-authenticated
    // caller can pin the global proxy at 127.0.0.1 / 169.254.169.254 /
    // any internal socket, then upstream egress flows through it. Skip
    // when the operator explicitly allows private hosts, only validate
    // host format. Empty body / no host = clearing the global proxy via
    // empty PUT.
    if (body && typeof body === 'object' && body.host) {
      try { await validateProxyHost(body); }
      catch (e) {
        return json(res, 400, { error: e?.message || 'ERR_PROXY_PRIVATE_HOST' });
      }
    }
    setGlobalProxy(body);
    return json(res, 200, { success: true, config: getProxyConfigMasked() });
  }

  if (subpath === '/proxy/global' && method === 'DELETE') {
    removeProxy('global');
    return json(res, 200, { success: true });
  }

  const proxyAccount = subpath.match(/^\/proxy\/accounts\/([^/]+)$/);
  if (proxyAccount && method === 'PUT') {
    // Same H3 gate as /proxy/global PUT — per-account proxies were the
    // other half of the bypass. Empty body / no host = clearing the
    // proxy, leave it unvalidated.
    if (body && typeof body === 'object' && body.host) {
      try { await validateProxyHost(body); }
      catch (e) {
        return json(res, 400, { error: e?.message || 'ERR_PROXY_PRIVATE_HOST' });
      }
    }
    setAccountProxy(proxyAccount[1], body);
    const admission = getLsAdmissionStatus(body?.host ? body : null);
    const warmupRequested = body?.warmup === true || body?.prewarm === true || shouldPrewarmLsOnAccountAdd();
    let warmup = { ok: true, skipped: true, reason: 'warmup_not_requested' };
    if (warmupRequested) {
      warmup = await runAccountWarmup(proxyAccount[1], { probe: false, requireOptIn: false });
    }
    return json(res, 200, { success: true, admission, warmup });
  }
  if (proxyAccount && method === 'DELETE') {
    removeProxy('account', proxyAccount[1]);
    return json(res, 200, { success: true });
  }

  // ─── Config ───────────────────────────────────────────
  if (subpath === '/config' && method === 'GET') {
    return json(res, 200, {
      port: config.port,
      defaultModel: config.defaultModel,
      maxTokens: config.maxTokens,
      logLevel: config.logLevel,
      lsBinaryPath: config.lsBinaryPath,
      lsPort: config.lsPort,
      codeiumApiUrl: config.codeiumApiUrl,
      hasApiKey: !!config.apiKey,
      hasDashboardPassword: !!config.dashboardPassword,
    });
  }

  // ─── Language Server binary inspect / update ─────────
  // GET → current LS binary stat (size, mtime, sha256 prefix). UI uses
  // this to show "binary installed at X, version sha:abcd1234, age 21d".
  if (subpath === '/langserver/binary' && method === 'GET') {
    const binPath = config.lsBinaryPath;
    try {
      const { statSync } = await import('node:fs');
      const { createReadStream } = await import('node:fs');
      const { createHash } = await import('node:crypto');
      const stat = statSync(binPath);
      const sha = await new Promise((resolve, reject) => {
        const h = createHash('sha256');
        createReadStream(binPath)
          .on('data', c => h.update(c))
          .on('end', () => resolve(h.digest('hex')))
          .on('error', reject);
      });
      return json(res, 200, {
        ok: true,
        path: binPath,
        sizeBytes: stat.size,
        mtime: stat.mtime.toISOString(),
        sha256: sha.slice(0, 16),
      });
    } catch (err) {
      return json(res, 200, {
        ok: false,
        path: binPath,
        error: err.code || err.message,
      });
    }
  }

  // POST → run install-ls.sh to download the latest binary, then restart
  // every LS pool entry so requests pick up the new binary on next call.
  // Body: { url?: string } — optional override (e.g. desktop-extracted
  // binary URL); falls back to `install-ls.sh` manifest mode (pinned
  // upstream release from ls-pins.json).
  if (subpath === '/langserver/update' && method === 'POST') {
    const { spawn } = await import('node:child_process');
    const { fileURLToPath } = await import('node:url');
    const { dirname, join: pjoin } = await import('node:path');
    // install-ls.sh ships at the repo root, two levels above this file
    // (src/dashboard/api.js). Resolving by import.meta.url avoids any
    // dependence on cwd, so the endpoint works whether started from /app
    // (Docker), the repo root, or a deeper subdir.
    const here = dirname(fileURLToPath(import.meta.url));
    const scriptPath = _langserverInstallScriptOverride
      || pjoin(here, '..', '..', 'install-ls.sh');
    if (!existsSync(scriptPath)) {
      return json(res, 500, {
        ok: false,
        error: `install-ls.sh not found at ${scriptPath}`,
      });
    }
    const url = body && typeof body.url === 'string' ? body.url.trim() : '';
    const expectedSha256 = body && typeof body.sha256 === 'string'
      ? body.sha256.trim().toLowerCase()
      : '';
    // Defence-in-depth: only allow https URLs from a small allowlist of
    // hosts. Without this, an attacker who got past dashboard auth could
    // pipe an arbitrary URL into the install script and have curl write
    // bytes to LS_BINARY_PATH which is then chmod +x and exec'd as the
    // node user. Still gated by checkAuth above; this is a second layer.
    const allowedHosts = [
      'github.com',
      'objects.githubusercontent.com',
      'release-assets.githubusercontent.com',
      'api.github.com',
    ];
    if (url) {
      let parsed;
      try { parsed = new URL(url); } catch {
        return json(res, 400, { ok: false, error: 'invalid url' });
      }
      if (parsed.protocol !== 'https:') {
        return json(res, 400, { ok: false, error: 'url must be https' });
      }
      if (!allowedHosts.includes(parsed.hostname)) {
        return json(res, 400, {
          ok: false,
          error: `url host not allowed; permitted: ${allowedHosts.join(', ')}`,
        });
      }
      // A05: a custom URL is an unpinned download. Without an operator-
      // supplied hash there is no trust anchor at all (the old route simply
      // omitted --sha256 and install-ls.sh warned then installed whatever
      // arrived). Require the 64-hex digest before spawning anything.
      if (!/^[0-9a-f]{64}$/.test(expectedSha256)) {
        return json(res, 400, {
          ok: false,
          error: 'ERR_LS_SHA256_REQUIRED',
          message: 'Custom URL updates require the expected SHA-256 (64 hex characters) in body.sha256',
        });
      }
    } else if (expectedSha256) {
      return json(res, 400, {
        ok: false,
        error: 'ERR_LS_SHA256_WITHOUT_URL',
        message: 'body.sha256 is only valid together with body.url',
      });
    }
    // --allowed-host is repeated so install-ls.sh revalidates EVERY redirect
    // hop against the same allowlist, not just the first URL.
    const args = url
      ? ['--url', url, '--sha256', expectedSha256, ...allowedHosts.flatMap(host => ['--allowed-host', host])]
      : [];
    // A05: a custom URL installs into a sibling staging path first. The route
    // re-hashes those bytes and only then renames them onto config.lsBinaryPath
    // and restarts LS. A buggy/swapped installer that exits 0 with wrong bytes
    // therefore cannot publish or execute them.
    const staged = !!url;
    const installTarget = staged
      ? `${config.lsBinaryPath}.staged.${process.pid}.${randomUUID()}`
      : config.lsBinaryPath;
    const cleanupStaged = () => {
      if (!staged) return;
      try { unlinkSync(installTarget); } catch { /* already gone */ }
    };
    const env = {
      ...buildSubprocessEnv(),
      LS_INSTALL_PATH: config.lsBinaryPath,
    };
    if (staged) env.LS_INSTALL_STAGE_PATH = installTarget;
    // Snapshot sha256 BEFORE the install. install-ls.sh will atomic-
    // rename the binary into place even if the download is byte-
    // identical to what's already there (no upstream change), so
    // without comparing before/after the dashboard can't tell whether
    // "Update LS" actually replaced anything — leading to user reports
    // like "LS update has no effect". Returning both lets the toast
    // distinguish "binary changed" from "binary already up to date".
    let beforeSha = null;
    try {
      const { createReadStream } = await import('node:fs');
      const { createHash } = await import('node:crypto');
      beforeSha = await new Promise((resolve, reject) => {
        const h = createHash('sha256');
        createReadStream(config.lsBinaryPath)
          .on('data', c => h.update(c))
          .on('end', () => resolve(h.digest('hex')))
          .on('error', () => resolve(null));
      });
    } catch { /* missing or unreadable — that's fine, treat as null */ }

    const child = spawn('bash', [scriptPath, ...args], { env });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', c => { stdout += c.toString(); });
    child.stderr.on('data', c => { stderr += c.toString(); });
    const exitCode = await new Promise(resolve => {
      child.on('close', resolve);
      child.on('error', () => resolve(-1));
    });
    if (exitCode !== 0) {
      cleanupStaged();
      return json(res, 200, {
        ok: false,
        exitCode,
        stdout: stdout.slice(-4000),
        stderr: stderr.slice(-4000),
      });
    }
    let afterSha = null;
    try {
      const { createReadStream } = await import('node:fs');
      const { createHash } = await import('node:crypto');
      afterSha = await new Promise((resolve) => {
        const h = createHash('sha256');
        createReadStream(installTarget)
          .on('data', c => h.update(c))
          .on('end', () => resolve(h.digest('hex')))
          .on('error', () => resolve(null));
      });
    } catch { /* keep null */ }
    // A05: never publish/restart on an unverified binary. install-ls.sh already
    // checks the digest before its atomic rename, so this is a second,
    // independent gate at the process boundary: a bug or a swapped script
    // must not turn into an LS restart that executes attacker bytes.
    if (staged && afterSha !== expectedSha256) {
      cleanupStaged();
      return json(res, 409, {
        ok: false,
        error: 'ERR_LS_INTEGRITY_FAILED',
        message: 'Installed binary does not match the expected SHA-256; refusing to restart the language server',
        expectedSha256,
        actualSha256: afterSha,
        beforeSha: beforeSha ? beforeSha.slice(0, 16) : null,
      });
    }
    if (staged) {
      try {
        renameSyncWithRetry(installTarget, config.lsBinaryPath);
      } catch (err) {
        cleanupStaged();
        return json(res, 500, {
          ok: false,
          error: 'ERR_LS_INSTALL_COMMIT_FAILED',
          message: `Could not publish the verified language server: ${err.message}`,
        });
      }
      // A custom binary has no manifest metadata; drop the previous install's
      // sidecar so it cannot describe the new binary with stale hashes.
      try { unlinkSync(`${config.lsBinaryPath}.version.json`); } catch { /* none */ }
    }
    const binaryChanged = !!(beforeSha && afterSha && beforeSha !== afterSha);
    // Restart every LS pool entry. The pool is keyed by proxy; iterating
    // through the live list catches per-account proxies as well as the
    // default no-proxy LS. Errors during restart get surfaced so the user
    // knows whether they need to bounce the container.
    const { _poolKeys, restartLsForProxy: doRestart, getProxyByKey } =
      await import('../langserver.js');
    let restarted = 0;
    let restartErrors = [];
    try {
      const keys = typeof _poolKeys === 'function' ? _poolKeys() : ['default'];
      for (const key of keys) {
        try {
          const proxy = typeof getProxyByKey === 'function' ? getProxyByKey(key) : null;
          await doRestart(proxy);
          restarted++;
        } catch (e) {
          restartErrors.push(`${key}: ${e.message}`);
        }
      }
    } catch (e) {
      restartErrors.push(e.message);
    }
    return json(res, 200, {
      ok: true,
      stdout: stdout.slice(-4000),
      restarted,
      restartErrors,
      // 16-char prefix matches what /langserver/binary returns so the
      // dashboard can string-compare against its current shown stat.
      beforeSha: beforeSha ? beforeSha.slice(0, 16) : null,
      afterSha: afterSha ? afterSha.slice(0, 16) : null,
      binaryChanged,
      // poolEmpty distinguishes "no live LS to restart" (cold proxy,
      // restart will happen on next request) from "all LS restart
      // attempts failed" (real problem). Without this the toast counts
      // both as "restarted 0".
      poolEmpty: restarted === 0 && restartErrors.length === 0,
    });
  }

  // ─── Language Server ──────────────────────────────────
  if (subpath === '/langserver/restart' && method === 'POST') {
    if (!body.confirm) {
      return json(res, 400, { error: 'Send { confirm: true } to restart language server' });
    }
    Promise.resolve().then(async () => {
      try {
        await stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 });
        await startLanguageServer({
          binaryPath: config.lsBinaryPath,
          port: config.lsPort,
          apiServerUrl: config.codeiumApiUrl,
        });
      } catch (e) {
        log.error(`Language server restart failed: ${e.message}`);
      }
    });
    return json(res, 200, { success: true, message: 'Restarting language server...' });
  }

  // ─── Models list ──────────────────────────────────────
  //
  // Each row carries `reachable`: can this deployment actually serve it right now. On a
  // DEVIN_CONNECT deployment that answer comes from the SAME predicate /v1/models uses
  // (buildConnectReachability), because the two used to disagree completely — on a
  // free-only pool this panel listed 163 models the account could not call and omitted
  // `swe-1-6-slow`, the only one it could. Measured zero overlap between the two views
  // (#234's last acceptance criterion).
  //
  // Rows are ANNOTATED, never dropped. The panel renders the allow/deny chips from this
  // list alone, so narrowing it would make an existing allow-list entry invisible AND
  // unremovable — the operator could not click a chip that is no longer rendered.
  // Annotating also answers #235 ("which models actually cost me quota"), which narrowing
  // would not: the reporter needs to SEE the paid ones, greyed, not have them vanish.
  if (subpath === '/models' && method === 'GET') {
    const isReachable = buildConnectReachability();
    // Pool-wide quota state, computed once per request from the same inputs the
    // drought banner uses. `dailyDry` is what turns a bare "QUOTA" chip into
    // "daily quota exhausted" — the state a Pro account actually sat in while the
    // panel called its only working models paid and a phantom selector free.
    const drought = getDroughtSummary();
    const dailyDry = typeof drought.lowestDailyPercent === 'number' && drought.lowestDailyPercent <= 0;
    // One resolver for every row producer below, so a synthesized row and a MODELS
    // row can never disagree about price or catalog membership.
    //
    // quotaState answers a DIFFERENT question from freeNow and that split is the
    // fix: freeNow is "would this debit quota", quotaState is "would the upstream
    // accept it right now". The old single `currentlyFree` boolean collapsed them,
    // which is why a model the catalog omits (swe-1-6-slow) could read as FREE.
    const pricingFields = (selector) => {
      const pricing = getConnectSelectorPricing(selector);
      let quotaState = null;
      if (!selector) quotaState = null; // not a Connect-namespace row
      else if (pricing.inLiveCatalog === false) quotaState = 'not_in_catalog';
      else if (pricing.freeNow === true) quotaState = 'free';
      else if (dailyDry) quotaState = 'blocked_daily';
      else if (isConnectSelectorBlockedByDrought(selector)) quotaState = 'blocked_weekly';
      else quotaState = 'unknown';
      return {
        // Upstream cost tier ("MODEL_COST_TIER_FREE" | LOW | MEDIUM | HIGH), null
        // when the account's Cascade catalog has not reported it.
        costTier: pricing.costTier,
        // ISO end of the active promotion, when the upstream sent one.
        promoEndsAt: pricing.promoEndsAt,
        // Tri-state price: true free, false billable, null no data. Never a guess.
        freeNow: pricing.freeNow,
        // Why freeNow is true: cost_tier_free | rate_zero | fallback_whitelist.
        freeReason: pricing.freeReason,
        // Upstream's own promo text ("83% off"), null when it sent none.
        promoLabel: pricing.promoLabel,
        // When the live pricing behind these fields was last synced, and whether
        // that is older than the staleness budget. The panel shows it next to a
        // FREE badge so "free" from a stopped sync is visibly old.
        pricingFetchedAt: pricing.pricingFetchedAt,
        pricingStale: pricing.pricingStale,
        // free | blocked_daily | blocked_weekly | not_in_catalog | unknown | null.
        quotaState,
      };
    };
    const models = filterModelKeysByCloudCatalog().map((id) => {
      const info = MODELS[id];
      const { reachable, selector } = isReachable(id);
      return {
        id,
        name: info.name,
        provider: info.provider,
        credit: typeof info.credit === 'number' ? info.credit : null,
        reachable,
        // The resolved upstream selector, or null when the name maps to nothing on this
        // transport. Surfaced for diagnosis: "unreachable" and "does not resolve at all"
        // look identical in the UI otherwise.
        connectSelector: selector,
        ...pricingFields(selector),
      };
    });
    // Selectors that ARE serveable but have no MODELS row (`swe-1-6-slow` is in neither the
    // frozen snapshot nor the live catalog, so it appears in no Cascade-derived list). They
    // are the whole point of the panel on a free deployment, so synthesize them here the
    // way /v1/models' third producer does. Name/provider fall back to the selector because
    // there is no MODELS entry to read them from.
    //
    // Gated on the transport: these are CONNECT-namespace selectors. Injecting one into a
    // Cascade deployment's panel advertises a model that transport cannot route, which is
    // the same category of error as the mismatch this whole route fixes — just pointing the
    // other way. Caught by the "backend off" case in
    // test/dashboard-models-connect-parity.test.js.
    if (getBackendSwitch('devinConnect')) {
      const seen = new Set(models.map((m) => m.id));
      const representedSelectors = new Set(models
        .map((m) => m.connectSelector)
        .filter(Boolean));

      // /v1/models has a second producer for selectors present in the live Connect
      // catalog but absent from the shared MODELS table. Mirror it here; otherwise
      // the public model API can advertise a live-only selector that the Dashboard
      // cannot display, price, or manage. Keep the row annotated rather than
      // filtering it out: a restricted account still needs to see that the selector
      // exists but is currently unreachable for its pool.
      for (const row of getLiveCatalog()) {
        const id = (typeof row === 'string' ? row : row?.selector)?.trim();
        if (!id || seen.has(id) || representedSelectors.has(id)) continue;
        seen.add(id);
        const { reachable, selector } = isReachable(id);
        if (selector) representedSelectors.add(selector);
        models.push({
          id,
          name: row?.label || id,
          provider: row?.provider || 'windsurf',
          credit: null,
          reachable,
          connectSelector: selector,
          ...pricingFields(selector),
        });
      }

      for (const selector of FREE_REACHABLE_SELECTORS) {
        if (seen.has(selector)) continue;
        seen.add(selector);
        // Reachability is resolved, not asserted. The static floor is unpaid-tier
        // ENTITLEMENT; whether this account's live catalog actually contains the
        // selector is a separate fact, and `swe-1-6-slow` fails it on the measured
        // Pro deployment (absent from the 240-selector catalog, 502 quota on call).
        // The row stays visible — an operator must be able to see and remove an
        // allow-list entry — but it is annotated as not-in-catalog instead of
        // being advertised as reachable+free, which is how the panel ended up
        // pointing clients at the one selector that could not run.
        const { reachable } = isReachable(selector);
        models.push({
          id: selector,
          name: selector,
          provider: 'windsurf',
          credit: null,
          reachable,
          connectSelector: selector,
          ...pricingFields(selector),
        });
      }
    }
    return json(res, 200, { models });
  }

  // Live-vs-snapshot catalog diff for the dashboard. Read-only; it reports set
  // differences and alias resolution, never entitlement.
  if (subpath === '/models/catalog' && method === 'GET') {
    return json(res, 200, buildCatalogDiff({
      live: getLiveCatalog(),
      snapshot: getSnapshotSelectors(),
      resolve: resolveConnectSelector,
    }));
  }

  // Live model reconciliation (P0-3): local /v1/models rows vs the live catalog
  // vs the committed snapshot, plus counts-only views of the account-scoped
  // model sources. Upstream failures degrade to `partial:true`; only an
  // in-process handleModels() failure is a 502.
  if (subpath === '/models/reconcile' && method === 'GET') {
    const u = dashboardUrl(req);
    try {
      const result = await readModelReconciliation({
        id: u.searchParams.get('account') || undefined,
        force: u.searchParams.get('refresh') === '1',
      });
      const { httpStatus, ...payload } = result;
      // Preserve the upstream status marker for the browser. Without it a
      // forwarded upstream 401/429 is indistinguishable from the dashboard's
      // own password/IP-lock rejection, so the UI would clear a valid admin
      // session (or start a lockout) on a provider quota error.
      if (!result.ok) payload.httpStatus = httpStatus || 502;
      return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE);
    } catch (err) {
      log.warn(`Dashboard model reconciliation failed: ${err?.kind || err?.name || 'unexpected'}`);
      return json(res, 502, { ok: false, error: 'upstream' }, NO_STORE);
    }
  }

  // ─── Model Access Control ──────────────────────────────
  if (subpath === '/model-access' && method === 'GET') {
    return json(res, 200, getModelAccessConfig());
  }

  if (subpath === '/model-access' && method === 'PUT') {
    if (body.mode) setModelAccessMode(body.mode);
    if (body.list) setModelAccessList(body.list);
    if (body.defaultModel !== undefined) setDefaultModel(body.defaultModel);
    return json(res, 200, { success: true, config: getModelAccessConfig() });
  }

  if (subpath === '/model-access/add' && method === 'POST') {
    if (!body.model) return json(res, 400, { error: 'model is required' });
    addModelToList(body.model);
    return json(res, 200, { success: true, config: getModelAccessConfig() });
  }

  if (subpath === '/model-access/remove' && method === 'POST') {
    if (!body.model) return json(res, 400, { error: 'model is required' });
    removeModelFromList(body.model);
    return json(res, 200, { success: true, config: getModelAccessConfig() });
  }

  // ─── Windsurf Login ────────────────────────────────────
  if (subpath === '/windsurf-login' && method === 'POST') {
    try {
      const { email, password, proxy: loginProxy, autoAdd, storeCredential } = body || {};
      return json(res, 200, await processWindsurfLogin({ email, password, loginProxy, autoAdd, storeCredential, req, accountId: body?.accountId || null }));
    } catch (err) {
      // The UI needs `method` to switch the form (email code vs OAuth-only)
      // without a second probe, and `probeUnknown` to offer the alternatives
      // when the password was rejected but the login method was never
      // confirmed — the exact shape that made a passwordless account read as
      // "wrong password" on 3.9.42.
      return json(res, err.statusCode || 400, {
        error: err.code || err.message,
        isAuthFail: !!err.isAuthFail,
        firebaseCode: err.firebaseCode,
        ...(err.method ? { method: err.method } : {}),
        ...(err.oauthProviders?.length ? { oauthProviders: err.oauthProviders } : {}),
        ...(err.detail ? { detail: err.detail } : {}),
        ...(err.probeUnknown ? { probeUnknown: true } : {}),
        ...(err.alternatives ? { alternatives: err.alternatives } : {}),
      });
    }
  }

  // Login-method probe. The UI calls this BEFORE showing a password field:
  // `hasPassword:false` means the account can only log in with an emailed code
  // or OAuth, so a password attempt would be a guaranteed, misleading 401.
  // Dashboard auth is enforced above; this route never sees a credential.
  if (subpath === '/windsurf-login/probe' && method === 'POST') {
    const email = String(body?.email || '').trim();
    if (!email) return json(res, 400, { error: 'ERR_EMAIL_REQUIRED' });
    // Per-email rate limit, shared with the login path. 400 (not 429): the
    // dashboard renders 429 as an IP-wide ban overlay, and this lock is
    // per-email.
    const lockMs = checkEmailLocked(email);
    if (lockMs != null) return json(res, 400, { error: 'ERR_EMAIL_LOCKED', retryAfterMs: lockMs });
    try {
      const loginProxy = body?.proxy?.host ? body.proxy : getProxyConfig().global;
      const probe = await probeLoginMethod(email, loginProxy);
      return json(res, 200, {
        ok: true,
        email,
        userExists: probe.userExists,
        hasPassword: probe.hasPassword,
        method: probe.method,
        emailCode: probe.emailCode,
        oauth: probe.oauth,
        source: probe.source,
        // Which login host answered (`windsurf` | `devin` | null). The email
        // flow starts on the same host instead of guessing.
        host: probe.host || null,
      }, NO_STORE);
    } catch (err) {
      // A malformed address is the caller's error (400); anything else is an
      // upstream availability failure (502).
      return json(res, err.statusCode || 502, {
        ok: false,
        error: err.code || 'ERR_LOGIN_PROBE_FAILED',
        ...(err.detail ? { detail: err.detail } : {}),
      }, NO_STORE);
    }
  }

  // Ask Windsurf to email a login code. The upstream verification token stays
  // server-side, keyed by the returned opaque flowId — only {flowId, sent} is
  // returned, and neither the token nor the code is ever logged.
  //
  // `host:'devin'` is an explicit operator choice ("the windsurf.com mail never
  // arrived — retry on app.devin.ai"). It is allowlisted, never free text, and
  // it is not the automatic cross-host fallback (that stays behind
  // DEVIN_CONNECT_LOGIN_HOST_FALLBACK).
  if (subpath === '/windsurf-login/email/start' && method === 'POST') {
    const email = String(body?.email || '').trim();
    if (!email) return json(res, 400, { error: 'ERR_EMAIL_REQUIRED' });
    const host = body?.host === 'devin' ? 'devin' : (body?.host === 'windsurf' ? 'windsurf' : undefined);
    try {
      const loginProxy = body?.proxy?.host ? body.proxy : getProxyConfig().global;
      return json(res, 200, await emailLoginStart(email, loginProxy, { host }), NO_STORE);
    } catch (err) {
      // Stable code + machine-readable detail (F8). A transport failure used to
      // escape as a raw English message with no code; the UI then printed the
      // upstream text verbatim and the operator could not tell whether the mail
      // had been sent.
      return json(res, err.statusCode || 400, {
        error: err.code || err.message,
        ...(err.detail ? { detail: err.detail } : {}),
        ...(err.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
        ...(err.retryable ? { retryable: true } : {}),
      }, NO_STORE);
    }
  }

  // Verify the emailed code and finish the login. Same response shape as
  // /windsurf-login, including the auto-add + credential-store tail.
  if (subpath === '/windsurf-login/email/complete' && method === 'POST') {
    const { flowId, code, proxy: loginProxy, autoAdd } = body || {};
    if (!flowId || !code) return json(res, 400, { error: 'ERR_EMAIL_FLOW_CODE_REQUIRED' });
    try {
      // V-24: when the caller binds the exchange to an account, the flow's own
      // identity must match it BEFORE the single-use code is spent. finalize
      // re-checks before any mutation; this early check keeps a mismatch from
      // costing the operator a second email.
      const boundAccount = body?.accountId ? getAccountInternal(body.accountId) : null;
      const result = await emailLoginComplete(
        flowId,
        code,
        loginProxy?.host ? loginProxy : getProxyConfig().global,
        { expectedIdentityEmail: boundAccount?.email },
      );
      // No password exists in this flow, so there is nothing for the credential
      // store to persist — pass storeCredential:false regardless of the request.
      return json(res, 200, await finalizeWindsurfLogin(result, {
        password: null, loginProxy, autoAdd, storeCredential: false, req,
        accountId: body?.accountId || null,
      }));
    } catch (err) {
      return json(res, err.statusCode || 400, {
        error: err.code || err.message,
        ...(err.detail ? { detail: err.detail } : {}),
        ...(err.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
        ...(err.attemptsRemaining != null ? { attemptsRemaining: err.attemptsRemaining } : {}),
        ...(err.retryable ? { retryable: true } : {}),
        isAuthFail: !!err.isAuthFail,
      });
    }
  }

  // Import a token captured from the official sign-in page
  // (windsurf.com/editor/show-auth-token) or from any OAuth / deep-link
  // redirect, in ONE step.
  //
  // Stateless on purpose. `/oauth/callback` is the state-bound flow for CLI and
  // remote-onboarding clients; requiring a pending session here meant a valid
  // token failed with ERR_INVALID_STATE whenever the page had been opened by a
  // different button (or the session had expired). The token itself is the
  // credential — the state adds nothing to this path.
  //
  // `devin-session-token$…` is stored as the pool key; a Firebase idToken /
  // show-auth-token value goes through RegisterUser. `auth1_` is an
  // intermediate and is rejected. The raw input is NEVER echoed or logged.
  if (subpath === '/windsurf-login/token' && method === 'POST') {
    const raw = body?.token ?? body?.redirect_url ?? '';
    const label = String(body?.label || '').trim();
    if (!raw) return json(res, 400, { error: 'ERR_NO_TOKEN_IN_INPUT' });
    try {
      // extractOAuthToken keeps the actionable diagnostics (ERR_OAUTH_UPSTREAM,
      // ERR_INTERMEDIATE_CALLBACK) and returns a bare token unchanged.
      const token = extractOAuthToken(raw);
      if (!token) return json(res, 400, { error: 'ERR_NO_TOKEN_IN_INPUT' });
      // SessionReauth: a bound paste replaces the account's token instead of
      // adding a second row for the same person.
      if (body?.accountId) {
        const applied = applyReauthToken(body.accountId, token, { via: 'token' });
        return json(res, 200, { ...applied, method: 'token' }, NO_STORE);
      }
      const account = await addAccountByPastedSecret(token, label);
      scheduleAccountWarmup(account.id);
      return json(res, 200, {
        success: true,
        method: 'token',
        account: { id: account.id, email: account.email, status: account.status },
      }, NO_STORE);
    } catch (err) {
      return json(res, err.statusCode || 400, { error: err.code || err.message });
    }
  }

  if (subpath === '/windsurf-login/batch' && method === 'POST') {
    try {
      const { accounts, proxy: loginProxy, autoAdd, storeCredential } = body || {};
      if (!Array.isArray(accounts) || !accounts.length) {
        return json(res, 400, { error: 'ERR_ACCOUNTS_REQUIRED' });
      }

      const results = [];
      for (const acct of accounts) {
        const email = String(acct?.email || '').trim();
        const password = String(acct?.password || '').trim();
        try {
          const result = await processWindsurfLogin({ email, password, loginProxy, autoAdd, storeCredential, req });
          // V-25: /windsurf-login returns the raw key for a one-time export
          // when autoAdd:false. A BATCH response has no such contract — every
          // row is keyed by an address whose password the caller just sent, so
          // a raw key here is only a leak. Mask it and keep the same shape.
          if (result && result.apiKey) {
            const { apiKey, ...rest } = result;
            results.push({ ...rest, apiKey_masked: maskApiKey(apiKey) });
          } else {
            results.push(result);
          }
        } catch (err) {
          results.push({
            success: false,
            email,
            error: err.code || String(err.message || 'ERR_LOGIN_FAILED').slice(0, 200),
            isAuthFail: !!err.isAuthFail,
            firebaseCode: err.firebaseCode,
          });
        }
      }

      const successCount = results.filter(r => r.success).length;
      const failCount = results.length - successCount;
      return json(res, 200, {
        success: true,
        total: results.length,
        successCount,
        failCount,
        results,
      }, NO_STORE);
    } catch (err) {
      return json(res, 400, { error: err.message });
    }
  }

  // ─── Batch proxy + account import ─────────────────────
  // POST /batch-import — each line: "proxy email password" or "email password"
  if (subpath === '/batch-import' && method === 'POST') {
    try {
      const { text, autoAdd = true, storeCredential } = body || {};
      if (!text || typeof text !== 'string') return json(res, 400, { error: 'ERR_TEXT_REQUIRED' });
      const parsed = parseBatchImportInput(text, parseProxyUrl);
      if (!parsed.items.length) return json(res, 400, { error: 'ERR_NO_VALID_LINES' });
      const results = [];
      let skippedCount = 0;
      for (const item of parsed.items) {
        if (item.kind === 'issue') {
          skippedCount++;
          results.push({
            success: false,
            skipped: true,
            email: item.label,
            error: item.error,
            lineNumber: item.lineNumber,
            // V-25: never `item.raw` — that string is the operator's pasted
            // line (password/token included). The kind label is derived from
            // the parsed shape, so it cannot contain any of the input.
            detail: describeImportItem(item),
          });
          continue;
        }
        try {
          if (item.proxy) await validateProxyHost(item.proxy);
          let result;
          if (item.kind === 'email_password') {
            const loginProxy = item.proxy || getProxyConfig().global;
            result = await processWindsurfLogin({
              email: item.email,
              password: item.password,
              loginProxy,
              autoAdd,
              storeCredential,
              req,
            });
            const binding = buildBatchProxyBinding(result, item.proxyRaw);
            if (binding) {
              setAccountProxy(binding.accountId, binding.proxy);
              scheduleAccountWarmup(binding.accountId);
            }
          } else if (item.kind === 'token') {
            const account = await addAccountByPastedSecret(item.token, item.label);
            if (item.proxy) {
              setAccountProxy(account.id, item.proxy);
              scheduleAccountWarmup(account.id);
            }
            result = {
              success: true,
              email: account.email,
              apiKey_masked: maskApiKey(account.apiKey),
              account: { id: account.id, email: account.email, status: account.status },
            };
          } else if (item.kind === 'api_key') {
            const account = await addAccountByPastedSecret(item.apiKey, item.label, item.apiServerUrl || '', { unknownAsKey: true });
            if (item.proxy) {
              setAccountProxy(account.id, item.proxy);
              scheduleAccountWarmup(account.id);
            }
            result = {
              success: true,
              email: account.email,
              apiKey_masked: maskApiKey(account.apiKey),
              account: { id: account.id, email: account.email, status: account.status },
            };
          } else {
            throw new Error('ERR_UNSUPPORTED_IMPORT_ITEM');
          }
          // V-25: `proxyRaw` carried user:pass@; the label drops the userinfo
          // and is what the dashboard shows / stores in login history.
          result.proxy = redactedProxyLabel(item.proxy);
          result.importKind = item.kind;
          results.push(result);
        } catch (err) {
          results.push({
            success: false,
            skipped: false,
            email: item.email || item.label || '',
            // Prefer the stable code; a bare Error message can carry upstream
            // prose, so it is bounded as a last resort.
            error: err.code || String(err.message || 'ERR_IMPORT_ITEM_FAILED').slice(0, 200),
            lineNumber: item.lineNumber,
            proxy: redactedProxyLabel(item.proxy),
            importKind: item.kind,
            detail: describeImportItem(item),
          });
        }
      }
      const successCount = results.filter(r => r.success).length;
      const failCount = results.filter(r => !r.success && !r.skipped).length;
      return json(res, 200, {
        success: true,
        mode: parsed.mode,
        total: results.length,
        successCount,
        failCount,
        skippedCount,
        results,
      }, NO_STORE);
    } catch (err) {
      if (err instanceof BatchImportParseError) {
        return json(res, 400, { error: err.code });
      }
      return json(res, 400, { error: err.message });
    }
  }

  // ─── OAuth login (Google / GitHub via Firebase) ────────
  // POST /oauth-login — accepts Firebase idToken from client-side OAuth
  if (subpath === '/oauth-login' && method === 'POST') {
    try {
      const { idToken, refreshToken, email, provider, autoAdd } = body;
      if (!idToken) return json(res, 400, { error: 'ERR_IDTOKEN_REQUIRED' });

      const proxy = getProxyConfig().global;
      const { apiKey, name } = await reRegisterWithCodeium(idToken, proxy);

      let account = null;
      if (autoAdd !== false) {
        account = addAccountByKey(apiKey, sanitizeLabelText(name) || email || provider || 'OAuth');
        if (refreshToken) {
          setAccountTokens(account.id, { refreshToken, idToken });
        }
        scheduleAccountWarmup(account.id);
      }

      return json(res, 200, {
        success: true,
        // Same one-time-export contract as /windsurf-login: raw key returned
        // only when autoAdd:false (caller takes the key themselves and we do
        // not persist it). Otherwise mask for listings.
        ...(autoAdd === false
          ? { apiKey }
          : { apiKey_masked: maskApiKey(apiKey) }),
        name,
        email: email || '',
        account: account ? { id: account.id, email: account.email, status: account.status } : null,
      });
    } catch (err) {
      return json(res, 400, { error: err.message });
    }
  }

  // ─── OAuth remote-onboarding state machine ──────────────────────
  // Lets a PUBLIC deploy onboard via the official Windsurf sign-in page without
  // the server ever receiving a localhost callback: start -> user logs in in
  // their own browser -> they paste the whole redirect URL back -> server pulls
  // the token out of it -> poll for the result. Ported (in-memory) from
  // CLIProxyAPI's oauth_sessions/oauth_callback. All three inherit checkAuth.
  if (subpath === '/oauth/start' && method === 'POST') {
    const { registerSession } = await import('./oauth-sessions.js');
    const state = registerSession('windsurf');
    // Windsurf editor backup-token signin URL (response_type=token). state rides
    // the OAuth state param so we can correlate the pasted callback.
    const url = 'https://windsurf.com/windsurf/signin?response_type=token'
      + '&client_id=3GUryQ7ldAeKEuD2obYnppsnmj58eP5u&redirect_uri=show-auth-token'
      + '&state=' + encodeURIComponent(state)
      + '&prompt=login&redirect_parameters_type=query&workflow=';
    return json(res, 200, { success: true, url, state });
  }
  if (subpath === '/oauth/callback' && method === 'POST') {
    const { validateState, getSession, completeSession, failSession } = await import('./oauth-sessions.js');
    const { state, redirect_url } = body || {};
    if (!validateState(state)) return json(res, 400, { error: 'ERR_INVALID_STATE' });
    const session = getSession(state);
    if (!session || session.status !== 'pending') return json(res, 409, { error: 'ERR_SESSION_NOT_PENDING' });
    try {
      // Extract the token from whatever the user pasted: a full redirect URL
      // (query or #hash), or the bare token itself. NEVER log redirect_url.
      const token = extractOAuthToken(redirect_url);
      if (!token) throw new Error('ERR_NO_TOKEN_IN_INPUT');
      // A devin-session-token$ IS the apiKey — it must go through addAccountByKey,
      // NOT addAccountByToken (which runs RegisterUser and only accepts a Firebase
      // idToken). Route by prefix so both token shapes onboard correctly.
      const account = await addAccountByPastedSecret(token, '');
      scheduleAccountWarmup(account.id);
      completeSession(state);
      return json(res, 200, { success: true, account: { id: account.id, email: account.email, status: account.status } });
    } catch (err) {
      failSession(state, err.message);
      return json(res, 400, { error: err.message });
    }
  }
  if (subpath === '/oauth/status' && method === 'GET') {
    const { getStatus } = await import('./oauth-sessions.js');
    const st = new URL(req.url, 'http://localhost').searchParams.get('state') || '';
    return json(res, 200, getStatus(st));
  }

  // ─── Email OTP Login — SEALED 2026-07-06 ────────────────────────
  // These endpoints are intact and the Connect-RPC wiring is verified, but the
  // flow is dormant: it needs a Cloudflare Turnstile token whose sitekey is
  // domain-bound to windsurf.com, so a self-hosted dashboard can't produce one.
  // The UI panel is hidden (index.html data-sealed). Endpoints remain reachable
  // for a future upstream change / a manually-supplied turnstile token.
  // ─── Email OTP Login (Step 1: Send verification code) ────
  if (subpath === '/email-otp/send' && method === 'POST') {
    try {
      const { email, firstName, turnstileToken } = body || {};
      if (!email || !firstName || !turnstileToken) {
        return json(res, 400, { error: 'ERR_EMAIL_FIRSTNAME_TURNSTILE_REQUIRED' });
      }
      const { sendEmailVerification } = await import('./email-otp-login.js');
      const proxy = getProxyConfig().global;
      await sendEmailVerification(email, firstName, turnstileToken, proxy);
      return json(res, 200, {
        success: true,
        email,
        message: 'Verification code sent. Check your email.',
      });
    } catch (err) {
      return json(res, err.statusCode || 400, { error: err.message });
    }
  }

  // ─── Email OTP Login (Step 2: Complete registration) ────
  if (subpath === '/email-otp/complete' && method === 'POST') {
    try {
      const { email, otpCode, turnstileToken, firstName, lastName, autoAdd } = body || {};
      if (!email || !otpCode || !turnstileToken) {
        return json(res, 400, { error: 'ERR_EMAIL_OTP_TURNSTILE_REQUIRED' });
      }
      const { registerUserWithOtp } = await import('./email-otp-login.js');
      const proxy = getProxyConfig().global;
      const { apiKey, name, apiServerUrl } = await registerUserWithOtp(
        email,
        otpCode,
        turnstileToken,
        firstName || '',
        lastName || '',
        proxy
      );

      let account = null;
      if (autoAdd !== false) {
        account = addAccountByKey(apiKey, sanitizeLabelText(name) || email || 'OTP');
        scheduleAccountWarmup(account.id);
      }

      return json(res, 200, {
        success: true,
        ...(autoAdd === false
          ? { apiKey }
          : { apiKey_masked: maskApiKey(apiKey) }),
        name,
        email,
        apiServerUrl,
        account: account ? { id: account.id, email: account.email, status: account.status } : null,
      });
    } catch (err) {
      return json(res, err.statusCode || 400, { error: err.message });
    }
  }

  // ─── Rate Limit Check ──────────────────────────────────
  // POST /accounts/:id/rate-limit — check capacity for a single account
  const rateLimitCheck = subpath.match(/^\/accounts\/([^/]+)\/rate-limit$/);
  if (rateLimitCheck && method === 'POST') {
    const acct = getAccountPublic(rateLimitCheck[1], { view: 'summary' });
    if (!acct) return json(res, 404, { error: 'Account not found' });
    const secret = getAccountInternal(acct.id);
    try {
      const proxy = getEffectiveProxy(acct.id) || null;
      const result = await checkMessageRateLimit(secret.apiKey, proxy);
      return json(res, 200, { success: true, account: acct.email, ...result });
    } catch (err) {
      return json(res, 500, { error: err.message });
    }
  }

  // ─── DevIn panel (page-level snapshot) ─────────────────
  // GET /devin/panel — one in-process snapshot for the Devin panel page:
  // runtime switches + LS/detach state, the merged per-account billed-turn
  // rings, the drought decision, and the quota/spend knobs. No upstream call
  // and no key material, so opening the page costs no quota. Sources the proxy
  // cannot read (Cascade trajectories, ACP sessions) are declared unavailable
  // with a machine-readable reason by the builder — never returned as empty
  // lists, which would read as "no sessions".
  if (subpath === '/devin/panel' && method === 'GET') {
    const now = Date.now();
    // Newest-used first, so MAX_SCAN_ACCOUNTS only drops accounts that never
    // served a turn. `lastUsed` is an ISO string (or null) on the summary view.
    const allAccounts = getAccountList({ view: 'summary' });
    const accounts = allAccounts
      .map((a) => ({ id: a.id, ref: accountRef(a.id), lastUsed: a.lastUsed ? Date.parse(a.lastUsed) : 0 }))
      .sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0))
      .slice(0, MAX_SCAN_ACCOUNTS);
    const runtime = buildRuntimeSection({
      uptimeSec: Math.round(process.uptime()),
      startedAt: getStats().startedAt,
      switches: runtimeSwitchRows(),
      credStoreEnabled: !!(process.env.DEVIN_CONNECT_CRED_KEY || '').trim(),
      remoteCredStore: String(process.env.DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE || '').trim() === '1',
      langServer: getLsStatus(),
      detach: { enabled: cascadeDetachEnabled(), ...detachedRegistry.stats() },
    });
    const sessions = buildSessionsSection({
      turns: buildTurnsSection({ accounts, totalAccounts: allAccounts.length, readRequests: getAccountRequests, now }),
    });
    const quota = buildQuotaSection({
      drought: getDroughtSummary(),
      tunables: getQuotaTunables(),
      overrides: getQuotaOverrides(),
    });
    return json(res, 200, buildDevinPanel({ now, runtime, sessions, quota }), NO_STORE);
  }

  // ─── DevinMap cards (account drawer) ──────────────────
  // GET /accounts/:id/devin-cards — the three account-local cards (ACU ledger,
  // model entitlement, key rotation). No network: every field is read from the
  // stored account record plus the in-process credit ledger, so opening the
  // drawer never costs an upstream call. Auth is inherited from the gate above.
  const devinCards = subpath.match(/^\/accounts\/([^/]+)\/devin-cards$/);
  if (devinCards && method === 'GET') {
    const payload = buildAccountCards(devinCards[1]);
    return json(res, payload.ok ? 200 : (payload.httpStatus || 404), payload);
  }

  // GET /accounts/:id/requests — the account's own request history: billed
  // requests plus failed attempts attributed to it. Read-only, process-local,
  // no upstream call and no key material: rows carry model + token counts +
  // cost, or model + outcome + status for a failure. `total` is the ring size
  // (billed + failed), and `billed`/`failed` split it, so the drawer can print
  // "50 of 100" instead of implying the page is the whole history.
  const accountRequests = subpath.match(/^\/accounts\/([^/]+)\/requests$/);
  if (accountRequests && method === 'GET') {
    const acct = getAccountPublic(accountRequests[1], { view: 'summary' });
    if (!acct) return json(res, 404, { error: 'Account not found' });
    const u = new URL(req?.url || '/', 'http://localhost');
    const payload = getAccountRequests(acct.id, { limit: u.searchParams.get('limit') });
    return json(res, 200, { ok: true, accountRef: accountRef(acct.id), ...payload });
  }

  // POST /accounts/:id/capacity-preflight — on-demand selector capacity check
  // before a run. One upstream CheckUserMessageRateLimit call, memoized 60 s per
  // account+selector (DevinMap §6.4: never on page load). The local half is
  // always computed, so an upstream failure still explains the pool-side state.
  const capacityPreflight = subpath.match(/^\/accounts\/([^/]+)\/capacity-preflight$/);
  if (capacityPreflight && method === 'POST') {
    const result = await preflightCapacity({
      id: capacityPreflight[1],
      model: typeof body?.model === 'string' ? body.model : '',
      force: body?.force === true,
    });
    return json(res, result.ok ? 200 : (result.httpStatus || 400), result);
  }

  const revealKey = subpath.match(/^\/account\/([^/]+)\/reveal-key$/);
  if (revealKey && method === 'POST') {
    const acct = getAccountInternal(revealKey[1]);
    if (!acct) return json(res, 404, { error: 'Account not found' });
    // AUTH-1: revealing an upstream key in cleartext is the single most
    // sensitive read the dashboard offers. Passing ambient dashboard auth is
    // no longer enough — require the operator to re-prove the dashboard
    // password in THIS request (body.password / body.dashboardPassword or the
    // X-Dashboard-Password-Confirm header), and audit-log the reveal. This
    // blocks a drive-by (CSRF / an already-open localhost dashboard) from
    // silently exfiltrating keys, and forces a deliberate confirmation step.
    if (!confirmReauth(req, body)) {
      log.warn(`reveal-key REFUSED for account ${revealKey[1]} from ${dashboardClientIp(req) || 'unknown'}: missing/invalid re-auth confirmation`);
      return json(res, 401, { error: 'ERR_REVEAL_REAUTH_REQUIRED', message: 'Re-enter the dashboard password to reveal this key.' });
    }
    log.info(`reveal-key: account ${revealKey[1]} (${maskEmail(acct.email) || 'no-email'}) revealed from ${dashboardClientIp(req) || 'unknown'}`);
    return json(res, 200, { success: true, apiKey: acct.apiKey });
  }

  // ─── Firebase Token Refresh ───────────────────────────────
  // POST /accounts/:id/refresh-token — manually refresh Firebase token
  const tokenRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-token$/);
  if (tokenRefresh && method === 'POST') {
    const acct = getAccountInternal(tokenRefresh[1]);
    if (!acct) return json(res, 404, { error: 'Account not found' });
    if (!acct.refreshToken) return json(res, 400, { error: 'Account has no refresh token' });
    try {
      const proxy = getEffectiveProxy(acct.id) || null;
      const { idToken, refreshToken: newRefresh } = await refreshFirebaseToken(acct.refreshToken, proxy);
      const { apiKey } = await reRegisterWithCodeium(idToken, proxy);
      const keyChanged = apiKey && apiKey !== acct.apiKey;
      // Persist the fresh credentials back onto the account. Without this, the
      // in-memory apiKey stays on the now-stale value until the next server
      // restart — every subsequent request from this account will fail auth.
      setAccountTokens(acct.id, { apiKey: apiKey || acct.apiKey, refreshToken: newRefresh || acct.refreshToken, idToken });
      return json(res, 200, { success: true, keyChanged, email: acct.email });
    } catch (err) {
      return json(res, 400, { error: err.message });
    }
  }

  json(res, 404, { error: `Dashboard API: ${method} ${subpath} not found` });
}

// ─── Proxy connectivity test ──────────────────────────────
// HTTP CONNECT tunnel to api.ipify.org:443 → GET / → the returned IP is the
// proxy's egress IP. Confirms the proxy works AND that auth is accepted.
// ─── Self-update helpers ───────────────────────────────
//
// execFile (not exec) for every invocation: no shell is spawned, so
// metacharacters in any future argument source are data, not commands.
// Belt-and-braces with the branch-name regex in /self-update — if a
// future refactor drops the regex, execFile still denies injection.
const SELF_UPDATE_UNAVAILABLE = 'ERR_SELF_UPDATE_UNAVAILABLE';
const SELF_UPDATE_IN_PROGRESS = 'ERR_UPDATE_IN_PROGRESS';
const NO_RELEASE_TAG = 'ERR_NO_RELEASE_TAG';
const RELEASE_TAG_LOOKUP_FAILED = 'ERR_RELEASE_TAG_LOOKUP';
let gitExecFileForTest = null;
let rollbackPointWriterForTest = null;
let selfUpdateSupervisorDetectorForTest = null;
let selfUpdateRestartSchedulerForTest = null;
let dockerSelfUpdateOverridesForTest = null;
let releaseAttestationVerifierForTest = null;
let selfUpdateMutationInFlight = false;
// Hold the mutation lock for the whole out-of-band deployer window (flush
// delay + recreate + health gate + possible rollback + second gate), so a
// retry cannot start while the deployer is still mutating the stack.
const DOCKER_SELF_UPDATE_MUTATION_HOLD_MS = DOCKER_DEPLOYER_WINDOW_MS;

// EX_TEMPFAIL: non-zero is intentional. systemd Restart=on-failure and PM2
// autorestart both relaunch after this self-update-only exit, while normal
// SIGINT/SIGTERM shutdown keeps its separate successful exit path in index.js.
export function selfUpdateRestartExitCode() {
  return 75;
}

export function setGitExecFileForTest(execFile) {
  gitExecFileForTest = execFile;
}

export function setRollbackPointWriterForTest(writer) {
  rollbackPointWriterForTest = typeof writer === 'function' ? writer : null;
}

export function setSelfUpdateSupervisorDetectorForTest(detector) {
  selfUpdateSupervisorDetectorForTest = typeof detector === 'function' ? detector : null;
}

export function setSelfUpdateRestartSchedulerForTest(scheduler) {
  selfUpdateRestartSchedulerForTest = typeof scheduler === 'function' ? scheduler : null;
}

export function setDockerSelfUpdateForTest(overrides) {
  dockerSelfUpdateOverridesForTest = overrides && typeof overrides === 'object' ? overrides : null;
}

export function setReleaseAttestationVerifierForTest(verifier) {
  releaseAttestationVerifierForTest = typeof verifier === 'function' ? verifier : null;
}

function detectSelfUpdateSupervisor() {
  return (selfUpdateSupervisorDetectorForTest || detectSupervisor)();
}

function scheduleSelfUpdateRestart(callback, releaseMutation) {
  if (selfUpdateRestartSchedulerForTest) {
    return selfUpdateRestartSchedulerForTest(callback, releaseMutation);
  }
  const releaseOnExit = () => releaseMutation();
  process.once('exit', releaseOnExit);
  setTimeout(() => {
    Promise.resolve()
      .then(callback)
      .catch((err) => {
        // A rejecting restart must not become an unhandled rejection that
        // crashes the process after the HTTP response already went out. The
        // durable journal (written by the caller) is the retry evidence.
        log.warn(`self-update: restart callback failed: ${err?.message || err}`);
      })
      .finally(() => {
        process.off('exit', releaseOnExit);
        releaseMutation();
      });
  }, 800);
  return true;
}

function detectDockerSelfUpdateForRequest() {
  return (dockerSelfUpdateOverridesForTest?.detect || detectDockerSelfUpdate)();
}

function runDockerSelfUpdateForRequest() {
  return (dockerSelfUpdateOverridesForTest?.run || runDockerSelfUpdate)();
}

function scheduleDockerSelfUpdateMutationRelease(releaseMutation) {
  if (typeof dockerSelfUpdateOverridesForTest?.scheduleRelease === 'function') {
    return dockerSelfUpdateOverridesForTest.scheduleRelease(releaseMutation);
  }
  // The deployer sleeps for eight seconds and then runs compose out-of-band.
  // Keep both the in-process guard and repository lock owned across that
  // window. If compose fails and this process survives, a bounded timeout
  // eventually permits an operator retry; a successful recreate exits this
  // process first and releases through the exit hook.
  let released = false;
  const releaseOnce = () => {
    if (released) return;
    released = true;
    clearTimeout(timer);
    process.off('exit', releaseOnce);
    releaseMutation();
  };
  const timer = setTimeout(releaseOnce, DOCKER_SELF_UPDATE_MUTATION_HOLD_MS);
  timer.unref?.();
  process.once('exit', releaseOnce);
  return true;
}

function makeSelfUpdateError(code, message = code) {
  const err = new Error(message);
  err.code = code;
  return err;
}

const SELF_UPDATE_LOCK_DIRNAME = 'windsurfapi-update.lock';
const ROLLBACK_POINT_FILENAME = 'self-update-before.json';
const ROLLBACK_POINT_PENDING_FILENAME = 'self-update-before.pending.json';

function repositoryGitDir(cwd = process.cwd()) {
  const dotGit = join(cwd, '.git');
  if (!existsSync(dotGit)) return '';
  try {
    if (statSync(dotGit).isDirectory()) return dotGit;
    const match = readFileSync(dotGit, 'utf8').trim().match(/^gitdir:\s*(.+)$/i);
    return match ? resolve(dirname(dotGit), match[1]) : '';
  } catch {
    return '';
  }
}

function processIsAlive(pid) {
  try {
    process.kill(pid, 0);
    return true;
  } catch (err) {
    // EPERM still means the process exists; only ESRCH proves a stale owner.
    return err?.code !== 'ESRCH';
  }
}

function readUpdateLockOwner(lockDir) {
  try {
    const raw = readFileSync(join(lockDir, 'owner'), 'utf8').trim();
    const match = raw.match(/^(\d+)\s+([A-Za-z0-9._-]+)$/);
    if (!match) return null;
    const pid = Number(match[1]);
    return Number.isSafeInteger(pid) && pid > 0 ? { pid, token: match[2], legacy: false } : null;
  } catch {}
  // Backward compatibility for locks created by v3.9.23/update.sh. A live
  // legacy PID still blocks; a dead one can be recovered through the same
  // exclusive recovery-claim protocol below.
  try {
    const raw = readFileSync(join(lockDir, 'pid'), 'utf8').trim();
    if (!/^\d+$/.test(raw)) return null;
    const pid = Number(raw);
    return Number.isSafeInteger(pid) && pid > 0 ? { pid, token: '', legacy: true } : null;
  } catch {
    return null;
  }
}

function sameUpdateLockOwner(left, right) {
  return !!left && !!right && left.pid === right.pid && left.token === right.token && left.legacy === right.legacy;
}

function removeUpdateLockDirectory(lockDir, ownerToken = null) {
  if (ownerToken !== null) {
    const owner = readUpdateLockOwner(lockDir);
    if (!owner || owner.legacy || owner.pid !== process.pid || owner.token !== ownerToken) return false;
  }
  try { unlinkSync(join(lockDir, 'recovery')); } catch {}
  try { unlinkSync(join(lockDir, 'owner')); } catch {}
  try { unlinkSync(join(lockDir, 'pid')); } catch {}
  try {
    rmdirSync(lockDir);
    return true;
  } catch {
    return false;
  }
}

function recoverStaleUpdateLock(lockDir) {
  const observed = readUpdateLockOwner(lockDir);
  if (!observed || processIsAlive(observed.pid)) return false;

  // Claim recovery *inside the still-existing stale directory*. O_EXCL makes
  // exactly one contender the recovery owner; losers cannot unlink/recreate a
  // lock that another contender has already acquired (the stale-lock ABA).
  const claimToken = `${process.pid}-${randomUUID()}`;
  const claimPath = join(lockDir, 'recovery');
  try {
    writeFileSync(claimPath, `${process.pid} ${claimToken}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
  } catch {
    return false;
  }

  const quarantine = `${lockDir}.stale-${claimToken}`;
  try {
    if (!sameUpdateLockOwner(readUpdateLockOwner(lockDir), observed)) return false;
    renameSync(lockDir, quarantine);
  } catch {
    return false;
  } finally {
    // Before rename this removes only our own claim. After rename the old path
    // no longer exists, so cleanup continues against the quarantine below.
    try { unlinkSync(claimPath); } catch {}
  }
  removeUpdateLockDirectory(quarantine);
  return true;
}

function acquireRepositoryUpdateLock() {
  const gitDir = repositoryGitDir();
  if (!gitDir) return () => {};
  const lockDir = join(gitDir, SELF_UPDATE_LOCK_DIRNAME);
  const create = () => {
    try {
      mkdirSync(lockDir, { mode: 0o700 });
      return true;
    } catch (err) {
      if (err?.code === 'EEXIST') return false;
      throw makeSelfUpdateError('ERR_UPDATE_LOCK_FAILED', `Could not create update lock: ${err.message}`);
    }
  };
  if (!create()) {
    if (!recoverStaleUpdateLock(lockDir) || !create()) return null;
  }
  const ownerToken = randomUUID();
  try {
    writeFileSync(join(lockDir, 'owner'), `${process.pid} ${ownerToken}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
    // Keep the numeric pid file for one-release interoperability with older
    // update.sh versions; ownership and release authority come from `owner`.
    writeFileSync(join(lockDir, 'pid'), `${process.pid}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
  } catch (err) {
    removeUpdateLockDirectory(lockDir, ownerToken) || removeUpdateLockDirectory(lockDir);
    throw makeSelfUpdateError('ERR_UPDATE_LOCK_FAILED', `Could not initialize update lock: ${err.message}`);
  }
  let released = false;
  return () => {
    if (released) return;
    released = true;
    removeUpdateLockDirectory(lockDir, ownerToken);
  };
}

function acquireSelfUpdateMutation() {
  if (selfUpdateMutationInFlight) return null;
  const releaseRepositoryLock = acquireRepositoryUpdateLock();
  if (!releaseRepositoryLock) return null;
  selfUpdateMutationInFlight = true;
  let released = false;
  return () => {
    if (released) return;
    released = true;
    selfUpdateMutationInFlight = false;
    releaseRepositoryLock();
  };
}

function rollbackPointPaths({ ensureDir = false } = {}) {
  const dataDir = join(process.cwd(), 'data');
  if (ensureDir) mkdirSync(dataDir, { recursive: true });
  return {
    final: join(dataDir, ROLLBACK_POINT_FILENAME),
    pending: join(dataDir, ROLLBACK_POINT_PENDING_FILENAME),
  };
}

// ─── Durable self-update journal (D6) ─────────────────────
//
// `{ok:true}` used to mean only "git reset applied / deployer sidecar
// started". The restart (source) or recreate (docker) happens afterwards, and
// a scheduler/deployer failure was indistinguishable from a verified healthy
// rollback. This journal is written BEFORE the mutating restart is requested,
// survives a crash, and is confirmed only when the running process reports the
// commit `/health` would publish. The rollback point is retained until then,
// so a failed or never-scheduled restart stays retryable.
const SELF_UPDATE_JOURNAL_FILENAME = 'self-update-journal.json';
const SELF_UPDATE_PENDING_STATES = new Set([
  'pending-restart',
  'restart-not-scheduled',
  'restart-failed',
]);

function selfUpdateJournalPath() {
  return join(process.cwd(), 'data', SELF_UPDATE_JOURNAL_FILENAME);
}

function readSelfUpdateJournal() {
  try {
    const value = JSON.parse(readFileSync(selfUpdateJournalPath(), 'utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

function writeSelfUpdateJournal(record) {
  const path = selfUpdateJournalPath();
  mkdirSync(dirname(path), { recursive: true });
  writeJsonAtomic(path, record);
}

function updateSelfUpdateJournal(patch) {
  const next = { ...(readSelfUpdateJournal() || {}), ...patch, updatedAt: Date.now() };
  writeSelfUpdateJournal(next);
  return next;
}

let selfUpdateRunningCommitForTest = null;
export function setSelfUpdateRunningCommitForTest(fn) {
  selfUpdateRunningCommitForTest = typeof fn === 'function' ? fn : null;
}

// The commit the restarted process would serve on /health. Defaults to the
// build identity of the RUNNING process, which is exactly what /health
// publishes (server.js VERSION_INFO.commit).
function selfUpdateRunningCommit() {
  if (selfUpdateRunningCommitForTest) {
    try { return String(selfUpdateRunningCommitForTest() || ''); } catch { return ''; }
  }
  try { return String(getVersionInfo().commit || ''); } catch { return ''; }
}

function commitsCorrespond(left, right) {
  const a = String(left || '').trim().toLowerCase();
  const b = String(right || '').trim().toLowerCase();
  if (!a || !b) return false;
  const shortest = Math.min(a.length, b.length);
  if (shortest < 7) return false;
  return (a.startsWith(b) || b.startsWith(a));
}

/**
 * Confirm a pending self-update journal against the running commit.
 *
 * Called on /self-update/check (and before a rollback attempt), i.e. after a
 * restart. A rollback is only "confirmed" when the process that answers now is
 * the commit the rollback reset to; that confirmation is what deletes the
 * rollback point. Anything else keeps both the journal and the point intact so
 * the operation remains retryable and its failure stays visible.
 */
export function reconcileSelfUpdateJournal() {
  const journal = readSelfUpdateJournal();
  if (!journal || !SELF_UPDATE_PENDING_STATES.has(journal.state)) return journal;
  const expected = journal.expectedCommit || journal.beforeCommit;
  const running = selfUpdateRunningCommit();
  if (!commitsCorrespond(running, expected)) return journal;
  const confirmed = {
    ...journal,
    state: 'confirmed',
    confirmedCommit: running,
    confirmedAt: Date.now(),
    updatedAt: Date.now(),
  };
  try {
    writeSelfUpdateJournal(confirmed);
  } catch (err) {
    log.warn(`self-update: could not record journal confirmation: ${err.message}`);
    return journal;
  }
  if (journal.operation === 'rollback') {
    const paths = rollbackPointPaths();
    try { unlinkSync(paths.final); } catch {}
    try { unlinkSync(paths.pending); } catch {}
  }
  return confirmed;
}

function nulSeparatedGitPaths(output) {
  return String(output || '').split('\0').filter(Boolean);
}

function gitPathsConflict(left, right) {
  // Be conservative on case-insensitive worktrees: a differently-cased local
  // ignored path can still be the same filesystem object reset --hard replaces.
  const a = left.replace(/\/$/, '').toLowerCase();
  const b = right.replace(/\/$/, '').toLowerCase();
  return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
}

async function findIgnoredTargetPathConflicts(targetCommit) {
  const treeOutput = await runGit(['ls-tree', '-r', '-z', '--name-only', targetCommit]);
  const targetPaths = nulSeparatedGitPaths(treeOutput);
  if (!targetPaths.length) return [];

  // First ask Git for a collapsed inventory of ignored roots. `--directory`
  // avoids descending into wholly ignored trees such as node_modules, while
  // still exposing partially ignored paths (for example data/accounts.json).
  // Matching roots case-insensitively here is deliberately independent of
  // core.ignorecase: the filesystem may be case-insensitive even when that
  // config is absent or stale.
  const targetRoots = [...new Set(targetPaths.map(path => path.split('/')[0]).filter(Boolean))];
  const targetRootsFolded = new Set(targetRoots.map(root => root.toLowerCase()));
  const inventoryOutput = await runGit([
    'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z',
  ]);
  const localRoots = [...new Set(nulSeparatedGitPaths(inventoryOutput)
    .map(path => path.replace(/\/$/, '').split('/')[0])
    .filter(root => root && targetRootsFolded.has(root.toLowerCase())))];
  if (!localRoots.length) return [];

  // Now enumerate only the actual local root spellings that can collide. This
  // keeps the precise ancestor/descendant test without scanning unrelated
  // ignored runtime data.
  const ignoredOutput = await runGit([
    'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--',
    ...localRoots.map(root => `:(top,literal)${root}`),
  ]);
  const ignoredPaths = nulSeparatedGitPaths(ignoredOutput);
  return [...new Set(ignoredPaths.filter(ignored => targetPaths.some(target => gitPathsConflict(ignored, target))))];
}

function validGitObjectId(value) {
  return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(String(value || ''));
}

function parseGitNonnegativeCount(output, label) {
  const value = String(output ?? '').trim();
  if (!/^(?:0|[1-9]\d*)$/.test(value)) {
    throw makeSelfUpdateError(
      'ERR_INVALID_GIT_COUNT',
      `Git returned an invalid count while checking ${label}; refusing to infer zero.`,
    );
  }
  const count = Number(value);
  if (!Number.isSafeInteger(count)) {
    throw makeSelfUpdateError(
      'ERR_INVALID_GIT_COUNT',
      `Git returned an unsafe count while checking ${label}; refusing to continue.`,
    );
  }
  return count;
}

async function currentStashTip() {
  const output = (await runGit(['for-each-ref', '--format=%(objectname)', 'refs/stash'])).trim();
  if (!output) return '';
  if (!validGitObjectId(output)) {
    throw new Error('refs/stash did not resolve to one valid object ID');
  }
  return output;
}

function ownedStashOid(listing, marker) {
  const matches = String(listing || '').split(/\r?\n/).filter(Boolean).flatMap((line) => {
    const tab = line.indexOf('\t');
    if (tab < 0 || !line.slice(tab + 1).includes(marker)) return [];
    const oid = line.slice(0, tab);
    if (!validGitObjectId(oid)) throw new Error('protective stash has an invalid object ID');
    return [oid];
  });
  if (matches.length > 1) throw new Error('protective stash ownership is ambiguous');
  return matches[0] || '';
}

async function createProtectiveStash(label) {
  const beforeTip = await currentStashTip();
  const marker = `windsurfapi-${label}-${randomUUID()}`;
  await runGit(['stash', 'push', '--include-untracked', '-m', marker]);
  const listing = await runGit(['stash', 'list', '--format=%H%x09%gs']);
  const oid = ownedStashOid(listing, marker);
  if (oid) return oid;

  // `git stash push` legitimately exits zero with "No local changes to save"
  // when another actor cleans the tree between the dirty probe and this call.
  // In that case no stash belongs to us. If refs/stash changed anyway, a
  // concurrent/user stash won the race; abort rather than ever treating it as
  // our recovery object.
  const afterTip = await currentStashTip();
  if (afterTip !== beforeTip) {
    throw new Error('refs/stash changed, but the updater could not identify its own protective stash');
  }
  return '';
}

async function restoreProtectiveStash(oid) {
  if (!validGitObjectId(oid)) throw new Error('protective stash object ID is invalid');
  // Apply the exact object. Never `stash pop` the current top: normal Git
  // commands are not serialized by the updater lock, so a user may have added
  // another stash after ours. Keep the owned stash as a recoverable backup.
  await runGit(['stash', 'apply', oid]);
}

function prepareRollbackPoint(value) {
  const { pending } = rollbackPointPaths({ ensureDir: true });
  (rollbackPointWriterForTest || writeJsonAtomic)(pending, value);
}

function discardPendingRollbackPoint() {
  const { pending } = rollbackPointPaths();
  try { unlinkSync(pending); } catch {}
}

function promotePendingRollbackPoint() {
  const { pending, final } = rollbackPointPaths();
  renameSyncWithRetry(pending, final);
}

function reconcilePendingRollbackPoint(currentCommit) {
  const { pending } = rollbackPointPaths();
  let point;
  try { point = JSON.parse(readFileSync(pending, 'utf8')); } catch { return; }
  const validSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
  if (!validSha(point?.commit) || !validSha(point?.after) || point.after !== currentCommit) {
    discardPendingRollbackPoint();
    return;
  }
  try {
    promotePendingRollbackPoint();
  } catch (err) {
    if (err?.renamePublished) {
      log.warn(`self-update: recovered rollback point final file is visible, but its directory fsync failed: ${err.message}`);
      return;
    }
    throw makeSelfUpdateError(
      'ERR_ROLLBACK_POINT_RECOVERY_FAILED',
      `A pending rollback point matches current HEAD but could not be recovered: ${err.message}`,
    );
  }
}

function makeSelfUpdateUnavailableError() {
  const err = new Error(SELF_UPDATE_UNAVAILABLE);
  err.code = SELF_UPDATE_UNAVAILABLE;
  err.reason = 'docker';
  return err;
}

function isSelfUpdateUnavailableError(err) {
  return err?.code === SELF_UPDATE_UNAVAILABLE || err?.message === SELF_UPDATE_UNAVAILABLE;
}

function hasGitMetadata(cwd = process.cwd()) {
  return existsSync(join(cwd, '.git'));
}

async function getGitExecFile() {
  if (gitExecFileForTest) return gitExecFileForTest;
  const { execFile } = await import('node:child_process');
  return execFile;
}

export function runGit(args, opts = {}) {
  return new Promise((resolve, reject) => {
    if (!hasGitMetadata(opts.cwd)) return reject(makeSelfUpdateUnavailableError());
    getGitExecFile().then((execFile) => {
      execFile('git', args, { timeout: 30_000, maxBuffer: 1024 * 1024, ...opts }, (err, stdout, stderr) => {
        if (err?.code === 'ENOENT') return reject(makeSelfUpdateUnavailableError());
        if (err) {
          const failure = new Error((stderr || err.message).toString().slice(0, 500));
          // `git merge-base --is-ancestor` uses exit 1 for the ordinary
          // "not an ancestor" answer and other exit codes for indeterminate
          // failures. Preserve the numeric status so release discovery can skip
          // an off-branch tag without turning a real Git/protocol failure into a
          // false negative.
          if (Number.isInteger(err.code)) failure.gitExitCode = err.code;
          return reject(failure);
        }
        resolve(stdout.toString());
      });
    }).catch(reject);
  });
}

function stableReleaseVersion(tag) {
  const match = String(tag || '').match(/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
  return match ? match.slice(1) : null;
}

function compareDecimalComponents(left, right) {
  if (left.length !== right.length) return left.length > right.length ? 1 : -1;
  if (left === right) return 0;
  return left > right ? 1 : -1;
}

function compareStableReleaseTags(left, right) {
  const a = stableReleaseVersion(left);
  const b = stableReleaseVersion(right);
  for (let i = 0; i < 3; i++) {
    const compared = compareDecimalComponents(a[i], b[i]);
    if (compared) return compared;
  }
  return 0;
}

function validRemoteTagName(tag) {
  return !!tag
    && !tag.startsWith('.')
    && !tag.endsWith('.')
    && !tag.startsWith('/')
    && !tag.endsWith('/')
    && !tag.includes('..')
    && !tag.includes('//')
    && !tag.includes('@{')
    && !/[\x00-\x20\x7f~^:?*[\\]/.test(tag);
}

function parseRemoteStableReleaseTags(output) {
  const byName = new Map();
  for (const line of String(output || '').split(/\r?\n/).filter(Boolean)) {
    const match = line.match(/^([0-9a-f]{40}|[0-9a-f]{64})\trefs\/tags\/(.+)$/i);
    if (!match) throw new Error('remote tag enumeration returned a malformed record');
    const oid = match[1].toLowerCase();
    const peeled = match[2].endsWith('^{}');
    const name = peeled ? match[2].slice(0, -3) : match[2];
    if (!validRemoteTagName(name)) throw new Error('remote tag enumeration returned an invalid ref name');
    if (!stableReleaseVersion(name)) continue;

    const entry = byName.get(name) || { name, object: '', peeled: '' };
    const field = peeled ? 'peeled' : 'object';
    if (entry[field]) throw new Error(`remote tag enumeration duplicated ${name}${peeled ? '^{}' : ''}`);
    entry[field] = oid;
    byName.set(name, entry);
  }

  const candidates = [...byName.values()];
  for (const candidate of candidates) {
    if (!candidate.object) throw new Error(`remote tag ${candidate.name} has a peeled record without its tag ref`);
  }
  candidates.sort((left, right) => compareStableReleaseTags(right.name, left.name));
  return candidates;
}

async function materializeRemoteReleaseTag(candidate) {
  // Fetch the exact remote ref only into FETCH_HEAD. This retrieves annotated
  // tag objects when needed but never creates, overwrites, or prunes a local tag:
  // operator-owned and previously fetched tags remain untouched.
  await runGit(['fetch', '--quiet', '--no-tags', 'origin', `refs/tags/${candidate.name}`]);
  const fetchedObject = (await runGit(['rev-parse', 'FETCH_HEAD'])).trim().toLowerCase();
  if (!validGitObjectId(fetchedObject) || fetchedObject !== candidate.object) {
    throw new Error(`remote tag ${candidate.name} changed during verification`);
  }

  const fetchedCommit = (await runGit(['rev-parse', 'FETCH_HEAD^{commit}'])).trim().toLowerCase();
  if (!validGitObjectId(fetchedCommit)) throw new Error(`remote tag ${candidate.name} did not peel to a commit`);
  const objectType = (await runGit(['cat-file', '-t', fetchedObject])).trim();
  if (candidate.peeled) {
    if (objectType !== 'tag' || fetchedCommit !== candidate.peeled) {
      throw new Error(`annotated remote tag ${candidate.name} failed object/peeled-commit correspondence`);
    }
  } else if (objectType !== 'commit' || fetchedCommit !== fetchedObject) {
    // A standards-compliant ls-remote includes ^{} for an annotated tag. Treat
    // an omitted/mismatched peeled record as a protocol anomaly, not as a
    // lightweight release.
    throw new Error(`lightweight remote tag ${candidate.name} did not name one commit object`);
  }
  return { ...candidate, commit: fetchedCommit };
}

async function gitCommitIsAncestor(commit, ref) {
  try {
    await runGit(['merge-base', '--is-ancestor', commit, ref]);
    return true;
  } catch (err) {
    if (err?.gitExitCode === 1) return false;
    throw err;
  }
}

async function latestReleaseTag(branch) {
  // Query the remote tag namespace directly. `git fetch --tags` is additive:
  // when a mistaken/revoked release tag is deleted upstream, its local ref
  // survives and `git tag --list` keeps selecting it indefinitely. Remote refs
  // are the publication authority; local tags are neither read nor mutated.
  try {
    const output = await runGit(['ls-remote', '--tags', 'origin', 'refs/tags/v[0-9]*']);
    const remoteRef = `origin/${branch}`;
    for (const advertised of parseRemoteStableReleaseTags(output)) {
      const candidate = await materializeRemoteReleaseTag(advertised);
      if (await gitCommitIsAncestor(candidate.commit, remoteRef)) return candidate;
    }
    return { name: '', commit: '' };
  } catch (cause) {
    const err = makeSelfUpdateError(
      RELEASE_TAG_LOOKUP_FAILED,
      'Unable to verify published release tags; refusing an unverified update target.',
    );
    err.cause = cause;
    throw err;
  }
}

/**
 * D9 source-OTA attestation: verify the tag `releaseUpdateState` selected.
 * The candidate has already been materialized and proven reachable; this binds
 * its peeled commit to the signed release-attestation.json before any mutation
 * target is returned. `warn` is the default rollout so existing installs keep
 * updating; `required` makes missing material fatal.
 */
async function verifySelectedReleaseAttestation(release) {
  const mode = normalizeAttestationMode(process.env.WINDSURFAPI_UPDATE_ATTESTATION);
  let repository = '';
  try {
    const originUrl = (await runGit(['remote', 'get-url', 'origin'])).trim();
    const parsed = parseGitHubRepository(originUrl);
    if (parsed && parsed.toLowerCase() === DEFAULT_ATTESTATION_REPOSITORY.toLowerCase()) {
      repository = DEFAULT_ATTESTATION_REPOSITORY;
    }
  } catch {
    // A missing/unreadable origin is not a policy failure by itself: without a
    // canonical repository there is no attestation to download, and the mode
    // decides whether that absence is fatal (required) or a warning (warn).
    repository = '';
  }

  const options = {
    tag: release.name,
    commit: release.commit,
    mode,
    dir: process.env.WINDSURFAPI_ATTESTATION_DIR || '',
    repository,
  };
  const result = releaseAttestationVerifierForTest
    ? await releaseAttestationVerifierForTest(options)
    : await verifyReleaseAttestation(options);
  if (result?.warning) log.warn(`self-update attestation: ${result.warning}`);
  return result;
}

function safeUpdateBranch(branch) {
  const candidate = String(branch || '').trim();
  // `git rev-parse --abbrev-ref HEAD` returns the literal "HEAD" for a
  // detached checkout, which is normal for tag-based deployments. origin/HEAD
  // is not guaranteed to exist locally, so use this repository's canonical
  // release branch instead.
  return candidate !== 'HEAD' && /^[\w.\-\/]+$/.test(candidate) ? candidate : 'master';
}

async function releaseUpdateState(branch, currentCommit, { followUnreleased = false } = {}) {
  const remoteRef = `origin/${branch}`;
  const remoteHead = (await runGit(['rev-parse', remoteRef])).trim();
  const release = await latestReleaseTag(branch);
  const latestTag = release.name;
  if (!latestTag && !followUnreleased) {
    throw makeSelfUpdateError(
      NO_RELEASE_TAG,
      'No stable release tag is reachable from the update branch; use forceUpdate only if you intentionally want the branch head.',
    );
  }
  // D9: bind the selected release tag's peeled commit to the signed release
  // attestation before `releasedCommit` can become a mutation target.
  if (latestTag) await verifySelectedReleaseAttestation(release);
  const releasedCommit = release.commit;
  const targetCommit = followUnreleased ? remoteHead : releasedCommit;

  let unreleasedCount = 0;
  if (latestTag) {
    unreleasedCount = parseGitNonnegativeCount(await runGit([
      'rev-list', '--count', `${releasedCommit}..${remoteHead}`,
    ]), 'commits after the latest release');
  }

  const commitsToTarget = parseGitNonnegativeCount(await runGit([
    'rev-list', '--count', `${currentCommit}..${targetCommit}`,
  ]), 'commits from the current checkout to the update target');
  const commitsPastTarget = parseGitNonnegativeCount(await runGit([
    'rev-list', '--count', `${targetCommit}..${currentCommit}`,
  ]), 'commits past the update target');

  return {
    remoteHead,
    latestTag,
    releasedCommit,
    targetCommit,
    unreleasedCount,
    commitsToTarget,
    commitsPastTarget,
    updateAvailable: commitsToTarget > 0 && commitsPastTarget === 0,
    diverged: commitsToTarget > 0 && commitsPastTarget > 0,
  };
}

async function gitStatus() {
  const commit = (await runGit(['rev-parse', 'HEAD'])).trim();
  const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
  let update = null;
  let updateError = '';
  try {
    const safeBranch = safeUpdateBranch(branch);
    await runGit(['fetch', '--quiet', '--no-tags', 'origin', safeBranch]);
    update = await releaseUpdateState(safeBranch, commit);
  } catch (err) {
    if (isSelfUpdateUnavailableError(err)) throw err;
    updateError = err?.code || err?.message || 'ERR_UPDATE_CHECK_FAILED';
  }
  const localMsg = (await runGit(['log', '-1', '--pretty=format:%s'])).trim();
  const target = update?.targetCommit || '';
  const remoteMsg = update?.updateAvailable
    ? (await runGit(['log', '-1', '--pretty=format:%s', target]).catch(() => '')).trim()
    : '';
  return {
    commit: commit.slice(0, 7),
    commitFull: commit,
    branch,
    localMessage: localMsg,
    // "remote" in the dashboard means the safe OTA target: latest release
    // tag by default. remoteHeadCommit is diagnostic only and may contain
    // release notes/generated assets/next-release work after that tag.
    remoteCommit: target ? target.slice(0, 7) : '',
    remoteHeadCommit: update?.remoteHead ? update.remoteHead.slice(0, 7) : '',
    remoteMessage: remoteMsg,
    behind: !!update?.updateAvailable,
    diverged: !!update?.diverged,
    latestTag: update?.latestTag || '',
    published: !!update?.latestTag,
    updateError,
    unreleasedCount: update?.unreleasedCount || 0,
  };
}

// Exported (and timeout-parameterised) so the RATE-RISK red→green test can pin
// the TLS-stage deadline in ms instead of waiting out the 10s default; the
// dashboard route below calls it with the default.
export async function testProxy({ host, port, username, password, type }, tlsTimeoutMs = 10_000) {
  if (config.allowPrivateProxyHosts) {
    await validateHostFormat(host);
  } else {
    await assertPublicUrlHost(host);
  }
  const { isSocks, createSocksTunnel } = await import('../socks.js');
  const tls = await import('node:tls');
  const targetHost = 'api.ipify.org';
  const targetPort = 443;
  const proxy = { host, port, username, password, type };

  // Get a raw TCP socket — either via SOCKS5 or HTTP CONNECT
  let socket;
  if (isSocks(proxy)) {
    socket = await createSocksTunnel(proxy, targetHost, targetPort, 10000);
  } else {
    const http = await import('node:http');
    // #11: pin the proxy host to a vetted IP literal so this CONNECT does not
    // re-resolve the (already validated) hostname via a second, rebindable lookup.
    const dialHost = await resolveProxyConnectHost(host, { allowPrivate: config.allowPrivateProxyHosts });
    socket = await new Promise((resolve, reject) => {
      const authHeader = username
        ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${username}:${password || ''}`).toString('base64') }
        : {};
      const req = http.request({
        host: dialHost, port, method: 'CONNECT',
        path: `${targetHost}:${targetPort}`,
        headers: { Host: `${targetHost}:${targetPort}`, ...authHeader },
        timeout: 10000,
      });
      req.on('connect', (res, sock) => {
        if (res.statusCode !== 200) { sock.destroy(); return reject(new Error(`ERR_PROXY_HTTP_ERROR:${res.statusCode}`)); }
        resolve(sock);
      });
      req.on('error', (err) => reject(new Error(`ERR_CONNECTION_FAILED:${err.message}`)));
      req.on('timeout', () => { req.destroy(); reject(new Error('ERR_TIMEOUT')); });
      req.end();
    });
  }

  // TLS handshake + GET to verify the tunnel works. SEC-W5: verify the cert —
  // targetHost is a fixed, well-known host (api.ipify.org) with a valid public
  // certificate, so there is no reason to accept an unverified cert. Leaving
  // rejectUnauthorized:false would let a MITM (or a malicious proxy itself)
  // present any cert and still pass the "tunnel works" check.
  return new Promise((resolve, reject) => {
      const tlsSock = tls.connect({ socket, servername: targetHost, rejectUnauthorized: true }, () => {
        tlsSock.write(`GET / HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nUser-Agent: WindsurfAPI/ProxyTest\r\n\r\n`);
      });
      // R-5 (RATE-RETRY audit): the CONNECT stage has a 10s timer, but the TLS
      // stage had none — a target that completes the tunnel and then goes
      // silent left this promise pending forever (the dashboard button spun).
      // destroy() alone emits no 'error' on a raw socket, so settle explicitly.
      tlsSock.setTimeout(tlsTimeoutMs, () => {
        tlsSock.destroy();
        reject(new Error('ERR_TIMEOUT'));
      });
      const chunks = [];
      tlsSock.on('data', c => chunks.push(c));
      tlsSock.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf-8');
        const match = body.match(/\r\n\r\n([^\r\n]+)/);
        const ip = match ? match[1].trim() : '';
        tlsSock.destroy();
        if (!ip || !/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
          return reject(new Error('ERR_TLS_TUNNEL_ERROR'));
        }
        resolve({ egressIp: ip, type });
      });
      tlsSock.on('error', (err) => reject(new Error(`ERR_TLS_FAILED:${err.message}`)));
  });
}