Ctrl K
ComunidadGuías…

src/dashboard/windsurf-login.js

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

Archivo original ↗Preguntar a KiBot
/**
 * Windsurf direct login — Auth1/Firebase auth + Codeium registration.
 * Supports proxy tunneling and fingerprint randomization.
 */

import http from 'http';
import https from 'https';
import { randomBytes } from 'crypto';
import { config, log } from '../config.js';
import { resolveProxyConnectHost } from '../net-safety.js';
import { safeEmailRef, safeKeyRef, logHash, sliceRedactedJson } from '../log-safety.js';
import { isSocks, createSocksTunnel } from '../socks.js';
import { getEmailLockThreshold, getEmailLockMs, getBackendSwitch } from '../runtime-config.js';
import { parseFields, getField, getAllFields } from '../proto.js';
// R-1 (RATE-RETRY audit): bounded response-body reads, shared with the
// devin-connect readers — one ceiling, one truncation contract.
import { createBoundedBodyAccumulator, MAX_ERROR_BODY_BYTES } from '../devin-connect.js';

const FIREBASE_API_KEY = 'AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY';
const FIREBASE_AUTH_URL = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${FIREBASE_API_KEY}`;
const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
const CODEIUM_REGISTER_URL = 'https://api.codeium.com/register_user/';
const AUTH1_CONNECTIONS_URL = 'https://windsurf.com/_devin-auth/connections';
const AUTH1_PASSWORD_LOGIN_URL = 'https://windsurf.com/_devin-auth/password/login';
const AUTH1_EMAIL_START_URL = 'https://windsurf.com/_devin-auth/email/start';
const AUTH1_EMAIL_COMPLETE_URL = 'https://windsurf.com/_devin-auth/email/complete';
// 2026-04-26: Windsurf added a Connect-RPC email-method probe under
// `_backend/...SeatManagementService/CheckUserLoginMethod` (`{userExists,
// hasPassword}`). It is NOT reliable: for a real, existing account it returns
// an EMPTY `{}` (live recon 2026-10-22), and treating that as "no password"
// or as "password-capable" both misclassify the account. `/_devin-auth/
// connections` is the authoritative probe — its `auth_method.has_password` is
// what windsurf.com's own login page reads. CheckUserLoginMethod is kept only
// as enrichment for the case where connections is unreachable.
const WINDSURF_CHECK_LOGIN_METHOD_URL = 'https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/CheckUserLoginMethod';
const WINDSURF_SEAT_SERVICE_BASE = 'https://server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService';
const WINDSURF_POST_AUTH_URL = `${WINDSURF_SEAT_SERVICE_BASE}/WindsurfPostAuth`;
const WINDSURF_ONE_TIME_TOKEN_URL = `${WINDSURF_SEAT_SERVICE_BASE}/GetOneTimeAuthToken`;
// v2.0.57 (Fix 2): Windsurf migrated PostAuth into the website _backend
// path. Wam-bundle and the official 2.0.67 IDE talk to the new host;
// keep the old self-serve endpoint as fallback so a regional outage on
// either side doesn't break login. Same for GetOneTimeAuthToken which
// shares the SeatManagementService surface.
const WINDSURF_BACKEND_SEAT_BASE = 'https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService';
const WINDSURF_POST_AUTH_URL_NEW = `${WINDSURF_BACKEND_SEAT_BASE}/WindsurfPostAuth`;
const WINDSURF_ONE_TIME_TOKEN_URL_NEW = `${WINDSURF_BACKEND_SEAT_BASE}/GetOneTimeAuthToken`;

// ─── Second-host fallback: app.devin.ai (opt-in, OFF by default) ──────────
// The OAuth feasibility study (docs-internal/workflow-results/oauth-relogin-study/
// FEASIBILITY-BLUEPRINT.md §6 + tool-github-oauth-totp.md §1.1) found the
// leaked Windsurf account-switcher drives app.devin.ai/api/auth1/{connections,
// password/login} as the SAME Auth1 mechanism we hit on windsurf.com/_devin-auth,
// differing only by host. windsurf.com runs on Vercel functions that 504/503
// intermittently and has migrated endpoints under us before; pointing a fallback
// at a second host (app.devin.ai) spreads that single-host availability risk
// without touching GitHub OAuth / TOTP / form-scraping.
//
// Gated behind DEVIN_CONNECT_LOGIN_HOST_FALLBACK=1. When the env var is unset
// (default), windsurfLogin behaves EXACTLY as before — only the windsurf.com
// primary path runs and this code is never reached.
const DEVIN_HOST_BASE = 'https://app.devin.ai';
const DEVIN_AUTH1_CONNECTIONS_URL = `${DEVIN_HOST_BASE}/api/auth1/connections`;
const DEVIN_AUTH1_PASSWORD_LOGIN_URL = `${DEVIN_HOST_BASE}/api/auth1/password/login`;
// Same Auth1 email-code flow, same two payloads — only the host and the
// optional `product` field differ (Devin's own bundle sends `{email, mode}`).
const DEVIN_AUTH1_EMAIL_START_URL = `${DEVIN_HOST_BASE}/api/auth1/email/start`;
const DEVIN_AUTH1_EMAIL_COMPLETE_URL = `${DEVIN_HOST_BASE}/api/auth1/email/complete`;

function isLoginHostFallbackEnabled() {
  return getBackendSwitch('loginHostFallback');
}

// Test-only transport seam: when set, every httpsRequest in this module is
// routed through the injected fn (same (url, opts, postData, proxy) signature,
// must resolve { status, data } — data is a Buffer when opts.raw is set). Lets
// the host-fallback tests drive the full login flow deterministically with zero
// real network. Mirrors the __set*ForTests convention used elsewhere (auth.js
// __setReloginDeps, _resetEmailLockoutForTests below).
let _transportOverride = null;
export function __setLoginTransportForTests(fn) { _transportOverride = fn; }

export function parsePostAuthResponseData(payload) {
  const raw = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload || '');
  try {
    const parsed = JSON.parse(raw);
    if (parsed && typeof parsed === 'object') return parsed;
  } catch {}
  // The wire response is application/proto — decode it directly (binary-safe)
  // instead of scanning the bytes as text: session_token = #1, orgs = #2,
  // auth1_token = #3 (optional, possibly ROTATED by this exchange),
  // account_id = #4, primary_org_id = #5.
  if (Buffer.isBuffer(payload)) {
    try {
      const fields = parseFields(payload);
      const sessionToken = getField(fields, 1, 2)?.value?.toString('utf8');
      if (sessionToken) {
        return {
          sessionToken,
          accountId: getField(fields, 4, 2)?.value?.toString('utf8'),
          primaryOrgId: getField(fields, 5, 2)?.value?.toString('utf8'),
          // Field #3 exists in WindsurfPostAuthResponse and the decoder used to
          // skip it. Absent → undefined (no behaviour change); present → the
          // token that can mint the NEXT session, so callers can re-store it.
          auth1Token: getField(fields, 3, 2)?.value?.toString('utf8'),
          orgs: getAllFields(fields, 2).filter(f => f.wireType === 2).map(f => decodePostAuthOrg(f.value)),
        };
      }
    } catch {}
  }
  // Regex fallback for non-protobuf responses (plain-text bodies etc.).
  const sessionToken = raw.match(/devin-session-token\$[a-zA-Z0-9._-]+/)?.[0];
  const accountId = raw.match(/account-[a-f0-9]+/)?.[0];
  const primaryOrgId = raw.match(/org-[a-f0-9]+/)?.[0];
  if (sessionToken) return { sessionToken, accountId, primaryOrgId };
  return { error: raw.slice(0, 200) || 'empty response' };
}

/** Decode one WindsurfPostAuthOrg sub-message (#2 of WindsurfPostAuthResponse). */
function decodePostAuthOrg(buf) {
  const f = parseFields(buf);
  const str = (n) => getField(f, n, 2)?.value?.toString('utf8');
  const bool = (n) => { const v = getField(f, n, 0); return v ? v.value !== 0 : undefined; };
  return {
    id: str(1), name: str(2), isAdmin: bool(3), canUseCli: bool(4),
    canUseCascade: bool(5), planSlug: str(6), subscriptionStatus: str(7),
    webappHost: str(8), ssoLoginRequired: bool(9),
  };
}

async function postAuthDualPath(auth1Token, fingerprint, proxy, preferredHost = null) {
  // v2.0.91 (#144 by @Await-d): upstream PostAuth now expects:
  // - empty application/proto body (not the old JSON bridge)
  // - X-Devin-Auth1-Token header (not in body)
  // - Referer from windsurf.com
  // - Raw response that may be binary proto or JSON
  const body = Buffer.alloc(0);
  const headers = {
    ...fingerprint,
    'Content-Type': 'application/proto',
    'Content-Length': 0,
    'Connect-Protocol-Version': '1',
    'X-Devin-Auth1-Token': auth1Token,
    'Referer': 'https://windsurf.com/account/login',
  };
  const orderedHosts = preferredHost === 'legacy'
    ? [[WINDSURF_POST_AUTH_URL, 'legacy'], [WINDSURF_POST_AUTH_URL_NEW, 'new']]
    : [[WINDSURF_POST_AUTH_URL_NEW, 'new'], [WINDSURF_POST_AUTH_URL, 'legacy']];
  let lastErr;
  for (const [url, label] of orderedHosts) {
    try {
      const rawRes = await httpsRequest(url, { method: 'POST', headers, raw: true }, body, proxy);
      const res = { ...rawRes, data: parsePostAuthResponseData(rawRes.data) };
      if (res.status >= 400 && res.status < 500) return { res, label };
      if (res.status >= 200 && res.status < 300 && res.data?.sessionToken) {
        return { res, label };
      }
      lastErr = new Error(`PostAuth ${label} HTTP ${res.status}: ${sliceRedactedJson(res.data)}`);
    } catch (e) {
      lastErr = new Error(`PostAuth ${label}: ${e.message}`);
    }
  }
  throw lastErr || new Error('PostAuth: both endpoints failed');
}

async function oneTimeTokenDualPath(body, fingerprint, proxy, preferredHost = null) {
  // v2.0.61 (#114): pin OneTimeAuthToken to whichever host PostAuth used.
  // The session token gateways aren't symmetric — a token minted by
  // windsurf.com/_backend may be rejected as "invalid token" on
  // server.self-serve.windsurf.com (and vice versa). When we know which
  // host PostAuth just talked to, retry order is forced to put it first
  // so we don't accidentally replay the token across host boundaries.
  const headers = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1' });
  const orderedHosts = preferredHost === 'legacy'
    ? [[WINDSURF_ONE_TIME_TOKEN_URL, 'legacy'], [WINDSURF_ONE_TIME_TOKEN_URL_NEW, 'new']]
    : [[WINDSURF_ONE_TIME_TOKEN_URL_NEW, 'new'], [WINDSURF_ONE_TIME_TOKEN_URL, 'legacy']];
  let lastErr;
  let firstRes = null;
  let firstLabel = null;
  for (const [url, label] of orderedHosts) {
    try {
      const res = await httpsRequest(url, { method: 'POST', headers }, body, proxy);
      if (res.status >= 200 && res.status < 300 && res.data?.authToken) {
        return { res, label };
      }
      // v2.0.61: 4xx from the preferred host is meaningful — used to
      // return immediately so caller saw the real auth error.
      //
      // v2.0.79 (audit M-3): widened to keep trying the other host
      // ONLY when the preferred host returned an "invalid token"
      // 401 — that signal is exactly the cross-host symmetry failure
      // we want to fall through. Other 4xx codes (400 bad request,
      // 403 forbidden, 410 gone) still short-circuit because they're
      // genuine permanent errors and trying the other host won't help.
      if (res.status >= 400 && res.status < 500) {
        const blob = JSON.stringify(res.data || '').toLowerCase();
        const isInvalidToken = res.status === 401 && /invalid\s*token|unauthenticated/i.test(blob);
        if (label === orderedHosts[0][1] && !isInvalidToken) {
          return { res, label };
        }
        // Either non-preferred 4xx OR preferred-but-invalid-token: keep
        // the response around in case the other host also fails — we
        // surface the FIRST 4xx (preferred host) so the caller sees the
        // primary auth error not whatever the fallback produced.
        if (firstRes === null) {
          firstRes = res;
          firstLabel = label;
        }
        lastErr = new Error(`OneTimeToken ${label} HTTP ${res.status}: ${sliceRedactedJson(res.data)}`);
        continue;
      }
      lastErr = new Error(`OneTimeToken ${label} HTTP ${res.status}: ${sliceRedactedJson(res.data)}`);
    } catch (e) {
      lastErr = new Error(`OneTimeToken ${label}: ${e.message}`);
    }
  }
  // Both hosts failed — return the preferred-host 4xx if we have one
  // (more useful to the caller than the fallback's error).
  if (firstRes) return { res: firstRes, label: firstLabel };
  throw lastErr || new Error('OneTimeToken: both endpoints failed');
}

// ─── Fingerprint randomization ────────────────────────────

const OS_VERSIONS = [
  'Windows NT 10.0; Win64; x64',
  'Windows NT 10.0; WOW64',
  'Macintosh; Intel Mac OS X 10_15_7',
  'Macintosh; Intel Mac OS X 11_6_0',
  'Macintosh; Intel Mac OS X 12_3_1',
  'Macintosh; Intel Mac OS X 13_4_1',
  'Macintosh; Intel Mac OS X 14_2_1',
  'X11; Linux x86_64',
  'X11; Ubuntu; Linux x86_64',
];

const CHROME_VERSIONS = [
  '120.0.0.0', '121.0.0.0', '122.0.0.0', '123.0.0.0', '124.0.0.0',
  '125.0.0.0', '126.0.0.0', '127.0.0.0', '128.0.0.0', '129.0.0.0',
  '130.0.0.0', '131.0.0.0', '132.0.0.0', '133.0.0.0', '134.0.0.0',
];

// Same version the official IDE reports (src/windsurf.js DEFAULT_CLIENT_VERSION),
// sent as x-client-version on the Auth1/check-login-method hops. Mirror the
// upstream browser/desktop client header set so referrer-based firewall rules
// see the same fingerprint as a real Windsurf client.
const CLIENT_VERSION = process.env.WINDSURF_CLIENT_VERSION || '2.0.67';

const ACCEPT_LANGUAGES = [
  'en-US,en;q=0.9', 'en-GB,en;q=0.9', 'zh-TW,zh;q=0.9,en;q=0.8',
  'zh-CN,zh;q=0.9,en;q=0.8', 'ja,en-US;q=0.9,en;q=0.8',
  'ko,en-US;q=0.9,en;q=0.8', 'de,en-US;q=0.9,en;q=0.8',
  'fr,en-US;q=0.9,en;q=0.8', 'es,en-US;q=0.9,en;q=0.8',
  'pt-BR,pt;q=0.9,en;q=0.8',
];

function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }

function generateFingerprint() {
  const os = pick(OS_VERSIONS);
  const chromeVer = pick(CHROME_VERSIONS);
  const major = chromeVer.split('.')[0];
  const ua = `Mozilla/5.0 (${os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVer} Safari/537.36`;

  // Auth1/PostAuth routes are gated on a desktop-browser referrer; a desktop
  // User-Agent + Origin/Referer pair keeps this proxy indistinguishable from
  // the real Windsurf website client (mirrors cubezhao/ai-tools-mng auth.rs).
  return {
    'User-Agent': ua,
    'Accept-Language': pick(ACCEPT_LANGUAGES),
    'Accept': 'application/json, text/plain, */*',
    'Accept-Encoding': 'identity',
    'sec-ch-ua': `"Chromium";v="${major}", "Google Chrome";v="${major}", "Not-A.Brand";v="99"`,
    'sec-ch-ua-mobile': '?0',
    'sec-ch-ua-platform': os.includes('Windows') ? '"Windows"' : os.includes('Mac') ? '"macOS"' : '"Linux"',
    'Sec-Fetch-Dest': 'empty',
    'Sec-Fetch-Mode': 'cors',
    'Sec-Fetch-Site': 'cross-site',
    'Origin': 'https://windsurf.com',
    'Referer': 'https://windsurf.com/',
  };
}

function buildJsonHeaders(fingerprint, body, extra = {}) {
  return {
    ...fingerprint,
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body),
    'x-client-version': CLIENT_VERSION,
    ...extra,
  };
}

// ─── Proxy tunnel (HTTP CONNECT or SOCKS5) ───────────────

async function createProxyTunnel(proxy, targetHost, targetPort) {
  if (isSocks(proxy)) return createSocksTunnel(proxy, targetHost, targetPort);
  // #11: pin the proxy host to a vetted IP literal (no second rebindable lookup).
  const rawHost = proxy.host.replace(/:\d+$/, '');
  const proxyHost = await resolveProxyConnectHost(rawHost, { allowPrivate: config.allowPrivateProxyHosts });
  return new Promise((resolve, reject) => {
    const proxyPort = proxy.port || 8080;

    const connectReq = http.request({
      host: proxyHost,
      port: proxyPort,
      method: 'CONNECT',
      path: `${targetHost}:${targetPort}`,
      headers: {
        Host: `${targetHost}:${targetPort}`,
        ...(proxy.username ? { 'Proxy-Authorization': `Basic ${Buffer.from(`${proxy.username}:${proxy.password || ''}`).toString('base64')}` } : {}),
      },
    });

    connectReq.on('connect', (res, socket) => {
      if (res.statusCode === 200) {
        resolve(socket);
      } else {
        socket.destroy();
        reject(new Error(`Proxy CONNECT failed: ${res.statusCode}`));
      }
    });

    connectReq.on('error', (err) => reject(new Error(`Proxy connection error: ${err.message}`)));
    connectReq.setTimeout(15000, () => { connectReq.destroy(); reject(new Error('Proxy connection timeout')); });
    connectReq.end();
  });
}

// ─── HTTPS request with optional proxy ────────────────────

function httpsRequest(url, opts, postData, proxy) {
  if (_transportOverride) return _transportOverride(url, opts, postData, proxy);
  return new Promise(async (resolve, reject) => {
    const parsed = new URL(url);
    const requestOpts = {
      hostname: parsed.hostname,
      port: 443,
      path: parsed.pathname + parsed.search,
      method: opts.method || 'POST',
      headers: opts.headers || {},
    };

    const handleResponse = (res) => {
      // R-1 (RATE-RETRY audit): bounded read — see createBoundedBodyAccumulator
      // (shared ceiling). Over the cap → reject; never parse a truncated prefix.
      const buffer = createBoundedBodyAccumulator();
      res.on('data', buffer.onData);
      res.on('end', () => {
        const { raw: rawBuffer, truncated } = buffer.finish();
        if (truncated) {
          reject(new Error(`Response body exceeded ${MAX_ERROR_BODY_BYTES} bytes; truncated`));
          return;
        }
        if (opts.raw) {
          resolve({ status: res.statusCode, data: rawBuffer });
          return;
        }
        const raw = rawBuffer.toString('utf8');
        try {
          resolve({ status: res.statusCode, data: JSON.parse(raw) });
        } catch {
          reject(new Error(`Parse error (status ${res.statusCode}, encoding ${res.headers['content-encoding'] || 'identity'}): ${raw.slice(0, 200)}`));
        }
      });
      res.on('error', reject);
    };

    try {
      let req;
      if (proxy && proxy.host) {
        const socket = await createProxyTunnel(proxy, parsed.hostname, 443);
        requestOpts.socket = socket;
        requestOpts.agent = false;
        req = https.request(requestOpts, handleResponse);
      } else {
        req = https.request(requestOpts, handleResponse);
      }

      req.on('error', (err) => reject(new Error(`Request error: ${err.message}`)));
      req.setTimeout(30000, () => { req.destroy(); reject(new Error('Request timeout')); });
      if (postData) req.write(postData);
      req.end();
    } catch (err) {
      reject(err);
    }
  });
}

// ─── Login flow ───────────────────────────────────────────

function createFriendlyAuthError(prefix, detail, fallback = 'ERR_LOGIN_FAILED') {
  const normalized = String(detail || '').trim();
  // Map Firebase/Auth1 error codes to our error codes
  const errorCodeMap = {
    'EMAIL_NOT_FOUND': 'ERR_EMAIL_NOT_FOUND',
    'INVALID_PASSWORD': 'ERR_INVALID_PASSWORD',
    'INVALID_LOGIN_CREDENTIALS': 'ERR_INVALID_CREDENTIALS',
    'Invalid email or password': 'ERR_INVALID_CREDENTIALS',
    'No password set. Please log in with Google or GitHub.': 'ERR_NO_PASSWORD_SET',
    'No password set': 'ERR_NO_PASSWORD_SET',
    'USER_DISABLED': 'ERR_USER_DISABLED',
    'TOO_MANY_ATTEMPTS_TRY_LATER': 'ERR_TOO_MANY_ATTEMPTS',
    'INVALID_EMAIL': 'ERR_INVALID_EMAIL',
    // Firebase API-key referrer restriction (403). Not a credential failure —
    // our egress is what upstream rejected, so it must read as a deployment
    // problem, not "wrong password", and must never feed the email lockout.
    'API_KEY_HTTP_REFERRER_BLOCKED': 'ERR_HTTP_REFERRER_BLOCKED',
  };
  const errorCode = errorCodeMap[normalized] || normalized || fallback;
  const err = new Error(errorCode);
  err.isAuthFail = [
    'EMAIL_NOT_FOUND',
    'INVALID_PASSWORD',
    'INVALID_LOGIN_CREDENTIALS',
    'Invalid email or password',
    'No password set. Please log in with Google or GitHub.',
    'No password set',
  ].includes(normalized);
  err.firebaseCode = normalized || undefined;
  err.code = errorCode;
  return err;
}

// 5xx retry helper: Windsurf 的 _devin-auth/* 端点跑在 Vercel functions
// 上,时不时 504 / 503 (FUNCTION_INVOCATION_TIMEOUT)。一次 dispatch 的
// 暂时失败不该让用户看到 "登录失败",所以对 5xx 加退避重试 (max 3 次)。
// 4xx 和 200 直接返回不重试。
async function httpsRequestRetrying(url, opts, postData, proxy, label = 'request') {
  let lastErr = null;
  const delays = [0, 2000, 5000];
  for (let i = 0; i < delays.length; i++) {
    if (delays[i]) await new Promise(r => setTimeout(r, delays[i]));
    try {
      const res = await httpsRequest(url, opts, postData, proxy);
      if (res.status >= 500 && res.status < 600) {
        log.warn(`${label} upstream ${res.status} (attempt ${i + 1}/${delays.length})`);
        lastErr = new Error(`Windsurf upstream ${res.status}: ${sliceRedactedJson(res.data || '')}`);
        continue;
      }
      return res;
    } catch (e) {
      log.warn(`${label} threw: ${e.message} (attempt ${i + 1}/${delays.length})`);
      lastErr = e;
    }
  }
  throw lastErr || new Error(`${label} failed after retries`);
}

// Windsurf serves two shapes on /_devin-auth/connections:
//   { auth_method: { method, has_password, enterprise_sso_domain }, connections: [...] }
//   { auth_method: { method: 'auth1', has_password: bool } }            (older)
//
// `auth_method.has_password` is the ONLY authoritative password signal — it is
// what windsurf.com's own login page branches on (`if (has_password) password
// step; else send email code`).
//
// `connections[type=email].enabled` does NOT mean "has a password": the live
// OAuth-only account returns `email.enabled:true` alongside
// `has_password:false`, because the email connection is what carries the
// email CODE. Reading `enabled` as "password-capable" (what this function used
// to do) sent that account to password/login, and the resulting 401 was
// reported as ERR_INVALID_CREDENTIALS instead of "no password". Never infer a
// password from the connection list; when `auth_method` is absent the answer
// is `null` (unknown), not `true`.
function interpretConnections(data) {
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
    return { method: null, hasPassword: null, userExists: null, emailCode: false, oauth: [], raw: {} };
  }
  const authMethod = data.auth_method && typeof data.auth_method === 'object' ? data.auth_method : null;
  const conns = Array.isArray(data.connections)
    ? data.connections.filter(c => c && typeof c === 'object')
    : null;
  const emailConn = conns ? conns.find(c => c.type === 'email') : null;
  const emailOffered = !!(emailConn && emailConn.enabled !== false);
  const oauth = conns
    ? conns.filter(c => c.enabled !== false && (c.type === 'google' || c.type === 'github')).map(c => c.type)
    : [];
  const hasPassword = authMethod && typeof authMethod.has_password === 'boolean'
    ? authMethod.has_password
    : null;
  // `method:null` on a present auth_method is upstream's "no such account", and
  // so is the LITERAL string "not_found": measured live 2026-10-22, an unknown
  // address returns
  //   auth_method: { method: "not_found", has_password: false, enterprise_sso_domain: false }
  // and windsurf.com's own login bundle branches on exactly that value
  // (`method === 'not_found'` → "No account found. Sign up?"). Reading it as a
  // method made `userExists` true and offered an email code for a typo (F1).
  // It is a sentinel, never a login method — do not return it to callers.
  const rawMethod = authMethod && typeof authMethod.method === 'string'
    ? authMethod.method.trim()
    : '';
  const method = rawMethod && rawMethod !== 'not_found' ? rawMethod : null;
  // With `auth_method` present, `method:null`/`not_found` is upstream's "no
  // such account". WITHOUT it, an empty `connections: []` is not evidence of
  // anything — the body may simply be partial — so the answer is "unknown"
  // (null), never "no account". Returning false there made a probe reply of
  // `{"connections":[]}` (a real shape from app.devin.ai) abort the login as
  // ERR_EMAIL_NOT_FOUND.
  const userExists = authMethod
    ? method !== null
    : (conns && conns.length > 0 ? true : null);
  // Email-code login is the alternative whenever there is no password. With
  // `hasPassword:true` the email connection is the password form, not a code.
  // A missing account gets neither: upstream's has_password:false describes a
  // method with no account behind it, so it must not become an offer to send
  // mail (F1).
  const emailCode = userExists === false
    ? false
    : hasPassword === false
      ? (conns ? emailOffered : true)
      : (hasPassword === null ? emailOffered : false);
  // The connection list is global, not account-scoped: upstream returns the
  // same email/google/github entries for an address that does not exist. Do
  // not hand a caller a provider list for a missing account.
  return { method, hasPassword, userExists, emailCode, oauth: userExists === false ? [] : oauth, raw: data };
}

// Which host answered the method probe for an address. The email-code flow
// starts on the SAME host: a probe that only app.devin.ai could answer is
// evidence that windsurf.com is not reachable from this deployment, and
// guessing again would send the operator's code request into the outage.
const PROBE_HOST_TTL_MS = 5 * 60 * 1000;
const _probeHost = new Map();
export function _resetProbeHostForTests() { _probeHost.clear(); }

function rememberProbeHost(email, host) {
  const key = String(email || '').toLowerCase();
  if (!key || (host !== 'windsurf' && host !== 'devin')) return;
  _probeHost.set(key, { host, at: Date.now() });
  if (_probeHost.size <= 200) return;
  const oldest = [..._probeHost.entries()].sort((a, b) => a[1].at - b[1].at)
    .slice(0, _probeHost.size - 200);
  for (const [k] of oldest) _probeHost.delete(k);
}

function preferredEmailHost(email) {
  const key = String(email || '').toLowerCase();
  const entry = _probeHost.get(key);
  if (!entry) return null;
  if (Date.now() - entry.at > PROBE_HOST_TTL_MS) {
    _probeHost.delete(key);
    return null;
  }
  return entry.host;
}

async function fetchAuth1Connections(email, fingerprint, proxy, host = 'windsurf') {
  const body = JSON.stringify({ product: 'windsurf', email });
  const headers = buildJsonHeaders(fingerprint, body);
  const url = host === 'devin' ? DEVIN_AUTH1_CONNECTIONS_URL : AUTH1_CONNECTIONS_URL;
  const res = await httpsRequestRetrying(
    url, { method: 'POST', headers }, body, proxy, `Auth1 connections (${host})`
  );
  // 400/422 is upstream answering about the REQUEST (measured: a malformed
  // address returns `value is not a valid email address`). Retrying it on the
  // other host or on the secondary probe cannot change that answer, so it is
  // terminal instead of "unknown".
  if (res.status === 400 || res.status === 422) {
    const detail = upstreamDetail(res.data);
    const invalidEmail = /email/i.test(detail);
    const err = new Error(invalidEmail ? 'ERR_INVALID_EMAIL' : 'ERR_LOGIN_PROBE_FAILED');
    err.code = err.message;
    err.statusCode = 400;
    err.detail = detail || `upstream ${res.status}`;
    throw err;
  }
  if (res.status >= 400) {
    const err = new Error(`connections ${res.status}`);
    err.statusCode = res.status;
    throw err;
  }
  return res.data || {};
}

// Secondary probe (Windsurf 2026-04-26 Connect-RPC surface). Only consulted
// when /_devin-auth/connections is unreachable — it returns `{}` for real
// accounts, so an empty body is "unknown" (null), never "no password" and
// never "password-capable".
// Returns the uniform { method, hasPassword, userExists, emailCode, oauth, raw }
// shape, or null when it learned nothing.
async function fetchCheckUserLoginMethod(email, fingerprint, proxy) {
  const body = JSON.stringify({ email });
  const headers = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1' });
  try {
    const res = await httpsRequest(
      WINDSURF_CHECK_LOGIN_METHOD_URL, { method: 'POST', headers }, body, proxy
    );
    if (res.status !== 200 || !res.data || typeof res.data !== 'object') {
      log.warn(`CheckUserLoginMethod non-200 (${res.status}): ${sliceRedactedJson(res.data || '')}`);
      return null;
    }
    // Empirically (2026-04-29) the Vercel function will sometimes serve
    // an empty `{}` for valid emails — likely a cache miss / cold-start
    // edge or geo-routing fallback. Treating `userExists`/`hasPassword`
    // as false in that case wrongly funnels every account into the
    // "no password set" branch and aborts before any login attempt.
    // When neither field is present, the answer is "unknown".
    const hasUserField = Object.prototype.hasOwnProperty.call(res.data, 'userExists');
    const hasPwField = Object.prototype.hasOwnProperty.call(res.data, 'hasPassword');
    if (!hasUserField && !hasPwField) {
      log.warn(`CheckUserLoginMethod empty body for ${safeEmailRef(email)}; login method unknown`);
      return null;
    }
    if (res.data.userExists === false) {
      return { method: null, hasPassword: null, userExists: false, emailCode: false, oauth: [], raw: res.data };
    }
    const hasPassword = hasPwField ? !!res.data.hasPassword : null;
    return {
      method: 'auth1',
      hasPassword,
      userExists: true,
      // This endpoint says nothing about connections, so email-code availability
      // is only implied when it positively reports "no password".
      emailCode: hasPassword === false,
      oauth: [],
      raw: res.data,
    };
  } catch (e) {
    log.warn(`CheckUserLoginMethod unreachable: ${e.message}`);
    return null;
  }
}

// Authoritative login-method probe.
//
// Order matters: /_devin-auth/connections FIRST (its auth_method.has_password
// is what the website itself branches on), CheckUserLoginMethod only as
// enrichment when connections cannot answer. A probe that learns nothing
// yields hasPassword:null — callers must treat that as "unknown", not as
// either "password-capable" or "no password".
//
// When the login-host fallback is enabled, the SAME read-only probe is asked
// on app.devin.ai before giving up: both hosts were measured to return the
// identical shape (2026-10-22), and a probe outage on the Vercel host is the
// one input that used to turn "this account has no password" into
// "wrong password" (see the recheck in windsurfLoginPrimaryHost). With the
// fallback off, only windsurf.com is contacted — the default contract.
export async function probeLoginMethod(email, proxy = null, fingerprint = null) {
  const fp = fingerprint || generateFingerprint();
  const hosts = isLoginHostFallbackEnabled() ? ['windsurf', 'devin'] : ['windsurf'];
  let lastRaw = null;
  for (const host of hosts) {
    try {
      const connections = await fetchAuth1Connections(email, fp, proxy, host);
      lastRaw = connections;
      const interpreted = interpretConnections(connections);
      // A known non-auth1 method (firebase/oauth) is a routing answer, not noise:
      // returning it lets windsurfLoginPrimaryHost reach the legacy Firebase path
      // instead of guessing Auth1. Only a bare/empty body stays "unknown".
      if (interpreted.hasPassword !== null || interpreted.userExists === false
          || (interpreted.method && interpreted.method !== 'auth1')) {
        rememberProbeHost(email, host);
        return { ...interpreted, source: 'connections', host };
      }
    } catch (err) {
      // A definitive answer about the address is not an availability failure.
      if (err?.code === 'ERR_INVALID_EMAIL') throw err;
      log.warn(`Auth1 connections probe failed on ${host} for ${safeEmailRef(email)}: ${err.message}`);
    }
  }
  const check = await fetchCheckUserLoginMethod(email, fp, proxy);
  if (check) return { ...check, source: 'check_user_login_method', host: null };
  return {
    method: null,
    hasPassword: null,
    userExists: null,
    emailCode: false,
    oauth: [],
    raw: lastRaw || {},
    source: 'unknown',
    host: null,
  };
}

async function registerWithCodeium(token, fingerprint, proxy) {
  // v2.0.57 (Fix 1): try register.windsurf.com first, fall back to
  // api.codeium.com. Both go through our fingerprint+proxy-aware
  // httpsRequest so the egress IP / UA stays consistent with the rest of
  // this login flow.
  const { registerWithFirebaseToken } = await import('../windsurf-api.js');
  const requestFn = async (url, opts, body) => {
    // buildJsonHeaders adds fingerprint headers; preserve any explicit
    // Connect-Protocol-Version / Accept the helper provided.
    const merged = buildJsonHeaders(fingerprint, body, {
      'Connect-Protocol-Version': '1',
      'Accept': 'application/json',
    });
    const r = await httpsRequest(url, { method: opts.method || 'POST', headers: merged }, body, proxy);
    return { status: r.status, data: r.data, raw: typeof r.data === 'string' ? r.data : JSON.stringify(r.data || {}) };
  };
  try {
    const r = await registerWithFirebaseToken(token, { requestFn, proxy });
    // Preserve the snake_case shape downstream callers consume.
    return {
      api_key: r.apiKey,
      name: r.name,
      api_server_url: r.apiServerUrl,
    };
  } catch (e) {
    throw new Error(`ERR_CODEIUM_REGISTER_FAILED:${e.message}`);
  }
}

async function windsurfLoginViaAuth1(email, password, fingerprint, proxy) {
  const loginBody = JSON.stringify({ email, password });
  const loginHeaders = buildJsonHeaders(fingerprint, loginBody);
  const loginRes = await httpsRequestRetrying(
    AUTH1_PASSWORD_LOGIN_URL, { method: 'POST', headers: loginHeaders }, loginBody, proxy, 'Auth1 password/login'
  );

  // Pydantic v2 returns `detail: [...]` for validation errors; the older
  // shape was `detail: 'message'`. Normalize both for the friendly-error
  // mapper so we don't blow up trying to .toLowerCase an array.
  const rawDetail = loginRes.data?.detail;
  const detailMsg = Array.isArray(rawDetail)
    ? rawDetail.map(d => d?.msg || d?.type || JSON.stringify(d)).join('; ')
    : (typeof rawDetail === 'string' ? rawDetail : '');

  if (loginRes.status >= 400 || detailMsg) {
    throw createFriendlyAuthError('Auth1', detailMsg, 'ERR_LOGIN_FAILED');
  }

  const auth1Token = loginRes.data?.token;
  if (!auth1Token) {
    throw new Error(`ERR_AUTH1_TOKEN_MISSING:${sliceRedactedJson(loginRes.data, 200)}`);
  }

  log.info(`Auth1 login OK: ${safeEmailRef(email)}`);

  // v2.0.90 (#114 lnqdev / CharwinYAO): drop OneTimeAuthToken + Codeium
  // register_user step entirely. Upstream GetOneTimeAuthToken started
  // returning 401 invalid_token for ALL sessionTokens — matrix probe
  // (scripts/probes/v2089-ott-host-matrix.mjs) confirmed 12/12 fail
  // across 3 accounts × {sToken_new, sToken_legacy} × {OTT_new,
  // OTT_legacy}. Cross-host retry can't save it; the OTT endpoint is
  // gone for good (Cognition migrated to the Devin auth flow).
  //
  // Reverse-engineering windsurf-assistant v17.42.20 (2026-04-27, the
  // upstream-tracked Windsurf account-switcher) confirms its production
  // path is Devin-only:
  //     Auth1 password/login → WindsurfPostAuth → sessionToken
  // and uses sessionToken directly as the IDE auth credential. No OTT,
  // no RegisterUser, no codeium register_user.
  //
  // Probe scripts/probes/v2089-sessiontoken-as-apikey.mjs verified the
  // Cascade gRPC backend (server.codeium.com /
  // server.self-serve.windsurf.com) accepts the raw sessionToken
  // (devin-session-token$xxx) as metadata.apiKey on GetUserStatus
  // → 4/4 200 OK with valid planName. The downstream protocol treats
  // it identically to the codeium register_user-issued sk-ws-01-... key.
  //
  // So the chain collapses from
  //     Auth1 → PostAuth → OTT → registerWithCodeium → apiKey
  // to
  //     Auth1 → PostAuth → apiKey = sessionToken.
  // RegisterUser only accepts firebase_id_token (won't take sessionToken)
  // and Firebase signInWithPassword now demands App Check tokens that
  // server-side callers can't produce — so the firebase path is dead
  // too. Devin path is the only one that works post-2026-05-04.
  const { res: br, label: bl } = await postAuthDualPath(auth1Token, fingerprint, proxy);
  if (br.status >= 400 || !br.data?.sessionToken) {
    throw new Error(`ERR_POSTAUTH_FAILED:${sliceRedactedJson(br.data, 200)}`);
  }
  const sessionToken = br.data.sessionToken;
  const accountId = br.data.accountId || 'unknown';
  log.info(`Windsurf PostAuth OK (${bl}): ${safeEmailRef(email)} accountHash=${logHash(accountId)} → using sessionToken as apiKey`);

  return {
    apiKey: sessionToken,
    name: email,
    email,
    // V-24: the address upstream just authenticated. A bound re-auth compares
    // this with the account row before the token may replace anything.
    identityEmail: normalizeLoginIdentity(email),
    apiServerUrl: '',
    sessionToken,
    auth1Token,
  };
}

// ─── Second-host fallback chain: app.devin.ai ─────────────────────────────
//
// A COMPLETE, INDEPENDENT login attempt against app.devin.ai instead of
// windsurf.com. Only reached when DEVIN_CONNECT_LOGIN_HOST_FALLBACK=1 AND the
// windsurf.com primary path already failed. Same email+password, no GitHub,
// no TOTP, no form-scraping.
//
// Chain: app.devin.ai/api/auth1/connections (probe) →
//        app.devin.ai/api/auth1/password/login ({email,password}) → {token} →
//        postAuthDualPath(token) [REUSED from the primary path] → sessionToken.
//
// TODO(unverified): the final hop — feeding an Auth1 token minted on
// app.devin.ai into OUR WindsurfPostAuth (windsurf.com/_backend or
// server.self-serve.windsurf.com) and getting a usable `devin-session-token$`
// back — has NOT been exercised against a live account. The study
// (docs-internal/workflow-results/oauth-relogin-study/FEASIBILITY-BLUEPRINT.md §1.2/§5.3,
// tool-github-oauth-totp.md §1.1) establishes only that both hosts share the
// SAME Auth1 family statically; "same family" does NOT prove our PostAuth
// exchanger accepts a token sourced from the other host. This must be
// confirmed with a real account before relying on the fallback as live-effective.
async function windsurfLoginViaDevinHost(email, password, fingerprint, proxy) {
  // Step 1 — connections probe (app.devin.ai uses product:"devin").
  // Best-effort: a probe FAILURE must NOT abort — we still try the login,
  // exactly like the primary path tolerates a flaky connections endpoint.
  // A probe that SUCCEEDS and reports `has_password:false` is different: that
  // is the same terminal wrong-method answer as on windsurf.com, so the
  // password login is skipped instead of burning a guaranteed 401.
  try {
    const connBody = JSON.stringify({ product: 'devin', email });
    const connHeaders = buildJsonHeaders(fingerprint, connBody);
    const connRes = await httpsRequestRetrying(
      DEVIN_AUTH1_CONNECTIONS_URL, { method: 'POST', headers: connHeaders }, connBody, proxy,
      'Devin-host Auth1 connections'
    );
    const probed = interpretConnections(connRes.data);
    if (probed.hasPassword === false) {
      const err = new Error('ERR_NO_PASSWORD_OAUTH_ACCOUNT');
      err.code = 'ERR_NO_PASSWORD_OAUTH_ACCOUNT';
      err.method = probed.emailCode ? 'email_code' : 'oauth';
      err.oauthProviders = probed.oauth || [];
      err.detail = 'app.devin.ai reports this account has no password.';
      err.isAuthFail = false;
      throw err;
    }
  } catch (err) {
    if (err?.code === 'ERR_NO_PASSWORD_OAUTH_ACCOUNT') throw err;
    log.warn(`Devin-host connections probe failed for ${safeEmailRef(email)}: ${err.message}`);
  }

  // Step 2 — Auth1 password/login on app.devin.ai → auth1 bearer token.
  const loginBody = JSON.stringify({ email, password });
  const loginHeaders = buildJsonHeaders(fingerprint, loginBody);
  const loginRes = await httpsRequestRetrying(
    DEVIN_AUTH1_PASSWORD_LOGIN_URL, { method: 'POST', headers: loginHeaders }, loginBody, proxy,
    'Devin-host Auth1 password/login'
  );

  const rawDetail = loginRes.data?.detail;
  const detailMsg = Array.isArray(rawDetail)
    ? rawDetail.map(d => d?.msg || d?.type || JSON.stringify(d)).join('; ')
    : (typeof rawDetail === 'string' ? rawDetail : '');
  if (loginRes.status >= 400 || detailMsg) {
    throw createFriendlyAuthError('DevinHost', detailMsg, 'ERR_LOGIN_FAILED');
  }

  const auth1Token = loginRes.data?.token;
  if (!auth1Token) {
    throw new Error(`ERR_AUTH1_TOKEN_MISSING:${sliceRedactedJson(loginRes.data, 200)}`);
  }
  log.info(`Devin-host Auth1 login OK: ${safeEmailRef(email)}`);

  // Step 3 — REUSE the existing PostAuth dual-path exchanger. See the
  // TODO(unverified) above: this is the hop whose cross-host acceptance is
  // not yet live-confirmed.
  const { res: br, label: bl } = await postAuthDualPath(auth1Token, fingerprint, proxy);
  if (br.status >= 400 || !br.data?.sessionToken) {
    throw new Error(`ERR_POSTAUTH_FAILED:${sliceRedactedJson(br.data, 200)}`);
  }
  const sessionToken = br.data.sessionToken;
  const accountId = br.data.accountId || 'unknown';
  log.info(`Devin-host PostAuth OK (${bl}): ${safeEmailRef(email)} accountHash=${logHash(accountId)} → using sessionToken as apiKey`);

  return {
    apiKey: sessionToken,
    name: email,
    email,
    identityEmail: normalizeLoginIdentity(email),
    apiServerUrl: '',
    sessionToken,
    auth1Token,
    viaHost: 'app.devin.ai',
  };
}

async function windsurfLoginViaFirebase(email, password, fingerprint, proxy) {
  const firebaseBody = JSON.stringify({
    email,
    password,
    returnSecureToken: true,
  });

  const fbHeaders = buildJsonHeaders(fingerprint, firebaseBody);
  const fbRes = await httpsRequest(FIREBASE_AUTH_URL, { method: 'POST', headers: fbHeaders }, firebaseBody, proxy);

  if (fbRes.data.error) {
    // 403s from identitytoolkit carry just `{error:{message}}`; a Referer/
    // API-key restriction arrives as API_KEY_HTTP_REFERRER_BLOCKED, which the
    // friendly-error mapper (createFriendlyAuthError) resolves to a clear
    // ERR_HTTP_REFERRER_BLOCKED instead of the raw code.
    // The referrer restriction does NOT arrive as `error.message`. Google's real
    // envelope is `{error:{code:403, status:'PERMISSION_DENIED', message:'Requests
    // from referer <x> are blocked.', details:[{reason:'API_KEY_HTTP_REFERRER_BLOCKED'}]}}`
    // — a numeric code and a prose message, with the machine-readable reason buried in
    // details[]. Reading only message/code (as this did) means the mapping fires only
    // for a shape Google never sends, and the operator still gets the prose string as
    // their error "code". So prefer the structured reason, then fall back.
    const err = fbRes.data.error;
    const reason = Array.isArray(err.details)
      ? err.details.find((d) => d && typeof d.reason === 'string')?.reason
      : null;
    const msg = reason || err.message || err.code || 'Unknown Firebase error';
    // Keep the prose as the human detail when the reason carried the code, so the log
    // still says WHICH referer was rejected.
    throw createFriendlyAuthError('Firebase', msg, err.message || msg);
  }

  const idToken = fbRes.data.idToken;
  if (!idToken) throw new Error('ERR_FIREBASE_TOKEN_MISSING');

  log.info(`Firebase login OK: ${safeEmailRef(email)}, uidHash=${logHash(fbRes.data.localId)}`);

  const reg = await registerWithCodeium(idToken, fingerprint, proxy);
  log.info(`Codeium register OK: ${safeEmailRef(email)} → ${safeKeyRef(reg.api_key, 'apiKey')}`);

  return {
    apiKey: reg.api_key,
    name: reg.name || email,
    email,
    identityEmail: normalizeLoginIdentity(email),
    idToken,
    refreshToken: fbRes.data.refreshToken || '',
    apiServerUrl: reg.api_server_url || '',
  };
}

/**
 * Full Windsurf login:
 *  - Auth1 password login → bridge session → one-time auth token → Codeium register
 *  - or legacy Firebase auth → Codeium register
 * @param {string} email
 * @param {string} password
 * @param {object} [proxy] - { host, port, username, password }
 * @returns {{ apiKey, name, email, idToken }}
 */
// v2.0.57 Fix 6 — per-email brute-force lockout. Inspiration:
// windsurf-assistant-pub `_bumpFailure` (3 strikes / 15 min ban). Without
// this, a dashboard-authenticated operator hammering bad credentials
// against /auth/login burns through Firebase/Windsurf upstream rate
// limits per account and risks getting the *real* email flagged. Lock
// the email locally so we never forward more than 3 fresh attempts in
// any 15-minute window.
const EMAIL_LOCK_IDLE_TTL_MS = 2 * 60 * 60 * 1000;
const _emailFailures = new Map();

export function _resetEmailLockoutForTests() { _emailFailures.clear(); }

export function checkEmailLocked(email) {
  if (!email || typeof email !== 'string') return null;
  const k = email.toLowerCase();
  // Operator disabled the email lockout (threshold 0): release any existing
  // lock immediately so "0 = off" takes effect now, not at natural expiry.
  if (getEmailLockThreshold() <= 0) {
    _emailFailures.delete(k);
    return null;
  }
  const e = _emailFailures.get(k);
  if (!e) return null;
  const now = Date.now();
  if (e.lockedUntil > now) return e.lockedUntil - now;
  if (e.lockedUntil > 0 && e.lockedUntil <= now) {
    e.count = 0;
    e.lockedUntil = 0;
  }
  return null;
}

function recordEmailFailure(email, reason) {
  if (!email) return;
  // Operator can disable the email lockout entirely (threshold 0) from Settings.
  const threshold = getEmailLockThreshold();
  if (threshold <= 0) return;
  const k = email.toLowerCase();
  const now = Date.now();
  let e = _emailFailures.get(k);
  if (!e) { e = { count: 0, lockedUntil: 0, lastActivity: now }; _emailFailures.set(k, e); }
  e.count += 1;
  e.lastActivity = now;
  e.lastReason = reason ? String(reason).slice(0, 80) : '';
  if (e.count >= threshold) {
    const durMs = getEmailLockMs();
    e.lockedUntil = now + durMs;
    e.count = 0;
    log.warn(`Email lockout: ${safeEmailRef(k)} banned for ${Math.round(durMs / 60000)}min after ${threshold} failed Windsurf logins (last="${e.lastReason}")`);
  }
}

function recordEmailSuccess(email) {
  if (!email) return;
  _emailFailures.delete(email.toLowerCase());
}

setInterval(() => {
  const now = Date.now();
  for (const [k, e] of _emailFailures) {
    if (e.lockedUntil > now) continue;
    if (now - (e.lastActivity || 0) > EMAIL_LOCK_IDLE_TTL_MS) _emailFailures.delete(k);
  }
}, 60 * 60 * 1000).unref?.();

export async function windsurfLogin(email, password, proxy = null) {
  const lockMs = checkEmailLocked(email);
  if (lockMs != null) {
    const minutes = Math.ceil(lockMs / 60000);
    const err = new Error(`Email ${email} 因连续 ${getEmailLockThreshold()} 次登录失败被本地锁定,请 ${minutes} 分钟后再试。`);
    err.code = 'ERR_EMAIL_LOCKED';
    err.retryAfterMs = lockMs;
    err.isAuthFail = false;
    throw err;
  }
  const fingerprint = generateFingerprint();
  log.info(`Windsurf login: ${safeEmailRef(email)} fpHash=${logHash(fingerprint['User-Agent'])} proxy=${proxy?.host || 'none'}`);

  try {
    return await windsurfLoginPrimaryHost(email, password, fingerprint, proxy);
  } catch (primaryErr) {
    // A terminal login-method answer is not an availability failure: the
    // account has no password, so the second host would be asked to run the
    // exact same impossible password login. Never fall back for these.
    if (primaryErr?.code === 'ERR_NO_PASSWORD_OAUTH_ACCOUNT' || primaryErr?.code === 'ERR_EMAIL_NOT_FOUND') {
      throw primaryErr;
    }
    // Default (flag unset): preserve the exact pre-existing behavior — only
    // windsurf.com runs, so re-throw and never touch app.devin.ai.
    if (!isLoginHostFallbackEnabled()) throw primaryErr;

    // Second-host fallback (app.devin.ai), opt-in. Same email+password, no
    // GitHub/TOTP. Spreads windsurf.com (Vercel) availability / endpoint-
    // migration risk across a second host.
    log.warn(`Primary windsurf.com login failed for ${safeEmailRef(email)} (${primaryErr.message}); attempting app.devin.ai host fallback`);
    try {
      const result = await windsurfLoginViaDevinHost(email, password, fingerprint, proxy);
      // Genuine success on the second host clears any failure the primary
      // path recorded — the credential is provably valid.
      recordEmailSuccess(email);
      log.info(`Host fallback OK via app.devin.ai for ${safeEmailRef(email)}`);
      return result;
    } catch (fallbackErr) {
      // Both hosts failed. Surface the ORIGINAL primary error so the caller
      // (reLoginAccount) sees the real primary signal and runs its existing
      // failure path → failover. We NEVER fake success, and we NEVER
      // reclassify the account as dead from here — a failed re-login just
      // means "re-login failed → false → failover", per the transient-first
      // error model. The upstream error classifier and the re-login flow in
      // auth.js stay untouched.
      log.warn(`Host fallback via app.devin.ai also failed for ${safeEmailRef(email)}: ${fallbackErr.message}`);
      throw primaryErr;
    }
  }
}

// One terminal "no password" answer, used both by the probe's first pass and
// by the post-rejection recheck. `method` lets the dashboard switch the form
// (email code vs OAuth-only) without a second probe, and `isAuthFail:false`
// keeps a wrong-method answer out of the per-email lockout.
function noPasswordError(probe) {
  const err = new Error('ERR_NO_PASSWORD_OAUTH_ACCOUNT');
  err.code = 'ERR_NO_PASSWORD_OAUTH_ACCOUNT';
  err.method = probe.emailCode ? 'email_code' : 'oauth';
  err.oauthProviders = probe.oauth || [];
  err.detail = probe.emailCode
    ? 'This account has no password. Log in with the emailed verification code, or with Google/GitHub, or paste an Auth Token from windsurf.com/show-auth-token.'
    : 'This account has no password and email-code login is disabled. Log in with Google/GitHub, or paste an Auth Token from windsurf.com/show-auth-token.';
  err.isAuthFail = false;
  err.statusCode = 400;
  return err;
}

// The original windsurf.com login path (login-method probe →
// Auth1 password/login → PostAuth, or legacy Firebase). Extracted from
// windsurfLogin so the public entry can wrap it with the opt-in
// app.devin.ai host fallback without altering this logic.
async function windsurfLoginPrimaryHost(email, password, fingerprint, proxy) {
  // Probe: /_devin-auth/connections is authoritative; CheckUserLoginMethod
  // only enriches when connections is unreachable. `hasPassword:null` means
  // both probes were silent — NOT "password-capable".
  const probe = await probeLoginMethod(email, proxy, fingerprint);

  if (probe.userExists === false) {
    const err = createFriendlyAuthError('Auth1', 'EMAIL_NOT_FOUND');
    // A nonexistent address is not a wrong credential; locking it would let a
    // typo (or an enumeration probe) ban a real operator for 15 minutes.
    throw err;
  }

  if (probe.hasPassword === false) {
    // Terminal wrong-method signal: this account has no password (registered
    // via Google/GitHub OAuth, or email-code only). It is NOT a brute-force
    // guess, so it must not count toward the email lockout, and it must never
    // reach password/login — the upstream 401 for such an account is not
    // evidence of a wrong password, only of a wrong method.
    throw noPasswordError(probe);
  }

  if (probe.hasPassword === true) {
    try {
      const result = await windsurfLoginViaAuth1(email, password, fingerprint, proxy);
      recordEmailSuccess(email);
      return result;
    } catch (e) {
      // Auth-shaped failures count toward the lockout. Network / 5xx
      // upstream errors don't (those aren't the operator's fault).
      if (e?.isAuthFail || /ERR_LOGIN_FAILED|ERR_AUTH1|EMAIL|PASSWORD/i.test(e?.message || '')) {
        recordEmailFailure(email, e?.message);
      }
      // The method WAS confirmed here, so the caller must not offer the
      // "this account may have no password" alternatives.
      if (e && typeof e === 'object') e.probeUnknown = false;
      throw e;
    }
  }

  // A known non-auth1 method (legacy firebase/oauth) is an explicit routing
  // answer: go straight to the legacy path instead of guessing Auth1.
  if (probe.method && probe.method !== 'auth1') {
    const result = await windsurfLoginViaFirebase(email, password, fingerprint, proxy);
    recordEmailSuccess(email);
    return result;
  }

  // Probe unknown (both hosts and the secondary probe silent): we have no
  // evidence the account lacks a password, so the operator-supplied password
  // still gets one attempt. Auth1 FIRST — the legacy Firebase
  // signInWithPassword endpoint is App-Check-gated upstream (measured
  // 2026-10-22: 401 "Firebase App Check token is invalid" for every
  // server-side caller), so leading with it only delays the real attempt.
  // Firebase stays as the last resort when Auth1 itself is unavailable.
  try {
    const result = await windsurfLoginViaAuth1(email, password, fingerprint, proxy);
    recordEmailSuccess(email);
    return result;
  } catch (auth1Err) {
    if (!auth1Err?.isAuthFail) {
      // Auth1 never judged the credential (network / 5xx / PostAuth). Try the
      // legacy path once; if it also fails, surface the Auth1 error, which is
      // the meaningful one.
      try {
        const result = await windsurfLoginViaFirebase(email, password, fingerprint, proxy);
        recordEmailSuccess(email);
        return result;
      } catch {
        throw auth1Err;
      }
    }

    // Auth1 positively rejected the credential. Before reporting "wrong
    // password", re-ask the method ONCE: a probe outage on the first call is
    // exactly how an email-code-only account was reported as
    // ERR_INVALID_CREDENTIALS (live 2026-10-21, 3.9.42). This recheck only
    // runs after a rejected password, so it costs nothing on the happy path.
    let recheck = null;
    try {
      recheck = await probeLoginMethod(email, proxy, fingerprint);
    } catch { /* stays unknown */ }
    if (recheck && recheck.hasPassword === false) {
      throw noPasswordError(recheck);
    }

    // One credential guess, counted once (the old Firebase→Auth1 chain could
    // have counted two).
    recordEmailFailure(email, auth1Err?.message);
    // Tell the caller the method was never confirmed: the password really was
    // rejected, but if the address is OAuth/email-code-only the operator
    // needs the alternative offered, not "wrong password" alone.
    auth1Err.probeUnknown = recheck ? recheck.hasPassword === null : true;
    auth1Err.alternatives = ['email_code', 'oauth', 'token'];
    throw auth1Err;
  }
}

// ─── Email-code login (accounts without a password) ───────────────────────
//
// For accounts whose probe says `has_password:false`, the only non-OAuth path
// is the emailed 6-digit code (recon .agent/loginrecon-20261022/LoginRecon.md §4.3):
//
//   POST /_devin-auth/email/start    {email, mode:'login', product:'Windsurf'}
//     → {email_verification_token}
//   POST /_devin-auth/email/complete {email_verification_token, code, mode:'login'}
//     → {token}                      (an Auth1 token, same kind password/login mints)
//   → postAuthDualPath(token) → session_token, used as the pool API key.
//
// The verification token is a bearer secret for one account. It stays in this
// module's memory keyed by an opaque flowId, never travels to the browser, and
// never appears in a log line. The code is equally secret: it is forwarded
// upstream and dropped.
//
// DESIGN GAP (Q2 F2, deliberately NOT implemented here): the resulting
// `devin-session-token$…` has no refresh path. refreshAllFirebaseTokens skips
// accounts without a refreshToken, reLoginAccount needs a stored password this
// flow never has (storeCredential:false), and the Auth1 token's TTL is
// unmeasured — so a retired session is only recoverable by re-adding the
// account. The design work that remains is an explicit "needs reauth" account
// state (or encrypted Auth1 storage once its TTL is known), not a retry here.
const EMAIL_FLOW_TTL_MS = 10 * 60 * 1000;
const EMAIL_FLOW_MAX_ATTEMPTS = 5;
const EMAIL_FLOW_MAX_ENTRIES = 200;
// Devin's own UI enforces a 30s resend cooldown; mirror it so a double-click
// loop cannot burn the upstream send quota (and the account's sender
// reputation) without an operator decision.
const EMAIL_CODE_RESEND_COOLDOWN_MS = 30 * 1000;
// A successful email/complete mints an Auth1 token that is single-use upstream,
// so it is the only thing that can be replayed when the PostAuth exchange
// fails. Keep it briefly: long enough to ride out a PostAuth blip, short enough
// that a forgotten flow cannot be used as a session minter (F6).
const EMAIL_FLOW_POSTAUTH_RETRY_MS = 2 * 60 * 1000;
const EMAIL_FLOW_POSTAUTH_MAX_ATTEMPTS = 3;

// Upstream `email/complete` failures are NOT all credential guesses (F5).
// Only a positive "the code is wrong" detail may feed the per-email lockout:
//   - an expired/consumed verification token (measured: 401
//     {"detail":"Invalid verification token"}, 6/6) says nothing about the
//     digits the user typed, and the token cannot be retried anyway;
//   - a 5xx / transport failure says nothing at all.
// Counting either as a wrong code locked PASSWORD login for 15 minutes after a
// code simply expired.
const EMAIL_CODE_MISMATCH_RE = /invalid verification code|invalid code|incorrect code|wrong code/i;
const EMAIL_FLOW_DEAD_RE = /invalid verification token|verification token|expired|already used|already consumed/i;

// 'expired'   → the verification token is dead upstream: drop the flow, retry
//               is impossible, no lockout.
// 'code'      → upstream positively says the digits are wrong: count the flow
//               attempt AND the email lockout.
// 'rejected'  → upstream 400/422 without a reason: count the flow attempt
//               (one flow must not be an unlimited oracle) but not the lockout.
// 'unavailable' → 403/404/405/429/5xx: availability, retry the same code.
function classifyEmailCompleteFailure(res, detail) {
  const text = String(detail || '');
  if (res.status === 401 || res.status === 410 || EMAIL_FLOW_DEAD_RE.test(text)) return 'expired';
  if (EMAIL_CODE_MISMATCH_RE.test(text)) return 'code';
  if (res.status === 400 || res.status === 422) return 'rejected';
  return 'unavailable';
}

const _emailFlows = new Map();
const _emailCodeSentAt = new Map();

export function _resetEmailFlowsForTests() {
  _emailFlows.clear();
  _emailCodeSentAt.clear();
}

function emailFlowError(code, detail, statusCode, extra = null) {
  const err = new Error(code);
  err.code = code;
  err.detail = detail;
  err.statusCode = statusCode;
  err.isAuthFail = false;
  if (extra) Object.assign(err, extra);
  return err;
}

// Same per-email lockout the password path uses, so a code-guessing loop is
// bounded by the operator's configured strike count. 400, not 429: the
// dashboard UI renders a 429 as an IP-wide ban overlay, and this lock is
// per-email.
function emailLockedError(lockMs) {
  return emailFlowError(
    'ERR_EMAIL_LOCKED',
    `Email temporarily locked after repeated failed logins; retry in ${Math.ceil(lockMs / 60000)} min.`,
    400,
    { retryAfterMs: lockMs },
  );
}

// ─── Login identity (V-24 / audit A23) ────────────────────────────────────
//
// A login result proves ONE thing about identity: the address that upstream
// authenticated. A bound re-auth (`accountId`) replaces the token on an
// existing row, so a result for a different address would install person B's
// session on person A's row and clear A's error/ban state on the way. The
// identity is therefore part of the login result and is compared before any
// token/status mutation; case and surrounding whitespace are not identity.
//
// Upstream reports `accountId` too, but the pool rows do not carry it, so it
// is not a comparable key here — the authenticated address is.

/** Case/whitespace-insensitive identity key. '' means "no identity proof". */
function normalizeLoginIdentity(email) {
  return String(email ?? '').trim().toLowerCase();
}

/** The identity a login result proves, or '' when it proves none. */
function loginResultIdentity(result) {
  return normalizeLoginIdentity(result?.identityEmail || result?.email);
}

/** Operator-facing masked address; never echoes the full identity. */
function maskIdentityEmail(email) {
  const s = String(email ?? '').trim();
  const at = s.indexOf('@');
  if (at <= 0) return s ? `${s[0]}***` : '';
  return `${s[0]}***${s.slice(at)}`;
}

/**
 * Throw unless `result` proves it belongs to `expectedEmail`.
 *
 * Called with the bound account's address BEFORE markAccountReauthenticated()
 * (api.js) and, for the email-code flow, before the single-use code is spent.
 * A result that carries no identity at all is refused on a bound path: the
 * token would replace a live credential on nothing but hope.
 */
export function assertLoginIdentity(result, expectedEmail) {
  const expected = normalizeLoginIdentity(expectedEmail);
  if (!expected) return;
  const actual = loginResultIdentity(result);
  if (actual && actual === expected) return;
  const err = new Error('ERR_LOGIN_IDENTITY_MISMATCH');
  err.code = 'ERR_LOGIN_IDENTITY_MISMATCH';
  err.statusCode = 409;
  err.detail = actual
    ? `This login belongs to ${maskIdentityEmail(actual)}, not to the selected account (${maskIdentityEmail(expected)}).`
    : `The login did not report which account it belongs to, so it cannot be bound to ${maskIdentityEmail(expected)}.`;
  throw err;
}

function pruneEmailFlows(now) {
  for (const [id, flow] of _emailFlows) {
    if (now > flow.expiresAt) _emailFlows.delete(id);
  }
  // The resend-cooldown map is keyed by email and would otherwise keep one
  // entry per address forever on a long-lived process.
  for (const [email, sentAt] of _emailCodeSentAt) {
    if (now - sentAt > EMAIL_FLOW_TTL_MS) _emailCodeSentAt.delete(email);
  }
  if (_emailFlows.size < EMAIL_FLOW_MAX_ENTRIES) return;
  const oldest = [..._emailFlows.entries()]
    .sort((a, b) => a[1].createdAt - b[1].createdAt)
    .slice(0, _emailFlows.size - EMAIL_FLOW_MAX_ENTRIES + 1);
  for (const [id] of oldest) _emailFlows.delete(id);
}

setInterval(() => pruneEmailFlows(Date.now()), 10 * 60 * 1000).unref?.();

function emailEndpointUrl(kind, host) {
  if (kind === 'start') return host === 'devin' ? DEVIN_AUTH1_EMAIL_START_URL : AUTH1_EMAIL_START_URL;
  return host === 'devin' ? DEVIN_AUTH1_EMAIL_COMPLETE_URL : AUTH1_EMAIL_COMPLETE_URL;
}

function emailEndpointBody(kind, host, { email, token, code }) {
  if (kind === 'start') {
    // Devin's bundle sends `{email, mode}`; windsurf.com adds `product`.
    return host === 'devin' ? { email, mode: 'login' } : { email, mode: 'login', product: 'Windsurf' };
  }
  return { email_verification_token: token, code, mode: 'login' };
}

function upstreamDetail(data) {
  const raw = data?.detail ?? data?.error ?? data?.message;
  if (Array.isArray(raw)) return raw.map(d => d?.msg || d?.type || JSON.stringify(d)).join('; ');
  if (typeof raw === 'string') return raw;
  if (raw && typeof raw === 'object') return String(raw.message || raw.code || '');
  return '';
}

// POST one email endpoint, moving to the other login host only on an
// AVAILABILITY failure (network / 5xx / endpoint gone) and only when the
// existing DEVIN_CONNECT_LOGIN_HOST_FALLBACK switch is on. A 4xx is upstream
// answering (wrong code, expired token, rate limit) — retrying it on a second
// host would just double-spend the attempt, so it is returned as-is.
//
// `kind === 'start'` sends mail, so it is EXACTLY ONCE (F8): no 5xx backoff and
// no second host after a transport error. A 504 can arrive after the message
// was queued and a transport error leaves delivery unknown, so either retry
// sends a second code (measured: 3 sends / 7.0 s for one request). Only an
// explicit 404/405 — proof that host has no such endpoint — falls through.
async function requestEmailEndpoint(kind, preferredHost, payload, fingerprint, proxy) {
  const first = preferredHost || 'windsurf';
  const second = first === 'windsurf' ? 'devin' : 'windsurf';
  const hosts = isLoginHostFallbackEnabled() ? [first, second] : [first];
  const exactlyOnce = kind === 'start';
  let lastErr = null;
  for (let i = 0; i < hosts.length; i++) {
    const host = hosts[i];
    const url = emailEndpointUrl(kind, host);
    const body = JSON.stringify(emailEndpointBody(kind, host, payload));
    const headers = buildJsonHeaders(fingerprint, body);
    const label = `Auth1 email/${kind} (${host})`;
    try {
      const res = exactlyOnce
        ? await httpsRequest(url, { method: 'POST', headers }, body, proxy)
        : await httpsRequestRetrying(url, { method: 'POST', headers }, body, proxy, label);
      const endpointGone = res.status === 404 || res.status === 405;
      if (endpointGone && i < hosts.length - 1) {
        log.warn(`${label} unavailable (${res.status}); trying the other login host`);
        lastErr = new Error(`${label} upstream ${res.status}`);
        continue;
      }
      return { res, host };
    } catch (err) {
      lastErr = err;
      if (exactlyOnce || i === hosts.length - 1) throw err;
      log.warn(`${label} failed (${err.message}); trying the other login host`);
      continue;
    }
  }
  throw lastErr || new Error('ERR_EMAIL_ENDPOINT_UNREACHABLE');
}

/**
 * Ask Windsurf to email a login code.
 *
 * The upstream `email_verification_token` is stored server-side under a fresh
 * opaque flowId (TTL 10 min, single successful use) and is NOT part of the
 * returned object — only `{ flowId, sent: true }` reaches the browser.
 *
 * @param {string} email
 * @param {object} [proxy]
 * @returns {Promise<{flowId: string, sent: true, expiresInMs: number}>}
 */
export async function emailLoginStart(email, proxy = null, opts = {}) {
  const normalizedEmail = String(email || '').trim();
  if (!normalizedEmail) throw emailFlowError('ERR_EMAIL_REQUIRED', 'email is required', 400);

  const lockMs = checkEmailLocked(normalizedEmail);
  if (lockMs != null) throw emailLockedError(lockMs);

  // Send the code on the host that answered the method probe (or the one the
  // operator explicitly picked). A probe that only app.devin.ai could answer
  // is evidence windsurf.com is unreachable from this deployment; re-guessing
  // would push the request straight back into the outage. `opts.host` is an
  // explicit operator choice from the dashboard, not a blind fallback.
  const explicitHost = opts?.host === 'devin' ? 'devin' : (opts?.host === 'windsurf' ? 'windsurf' : null);
  const preferredHost = explicitHost || preferredEmailHost(normalizedEmail) || 'windsurf';

  const now = Date.now();
  const sendKey = normalizedEmail.toLowerCase();
  const sentAt = _emailCodeSentAt.get(sendKey);
  if (sentAt != null && now - sentAt < EMAIL_CODE_RESEND_COOLDOWN_MS) {
    throw emailFlowError(
      'ERR_EMAIL_CODE_COOLDOWN',
      'A verification code was just sent; wait before requesting another.',
      400,
      { retryAfterMs: EMAIL_CODE_RESEND_COOLDOWN_MS - (now - sentAt) },
    );
  }

  // Stamp the ATTEMPT, before the upstream call. A send that fails upstream
  // (throw / 4xx / 5xx) must not reset the window, otherwise a failing sender
  // can be hammered in a loop — every retry would reach an upstream that is
  // already refusing. The success path re-stamps on completion so the 30 s
  // window still measures from the send that actually went out.
  _emailCodeSentAt.set(sendKey, now);

  const fingerprint = generateFingerprint();
  let started;
  try {
    started = await requestEmailEndpoint('start', preferredHost, { email: normalizedEmail }, fingerprint, proxy);
  } catch (err) {
    // The transport layer rejects with a bare Error (no code), which used to
    // reach the route as HTTP 400 + a raw English message, breaking the
    // stable-code contract (F8). One send was attempted; whether the mail went
    // out is unknowable, so say so instead of inviting an immediate resend.
    if (err && typeof err.code === 'string' && err.code.startsWith('ERR_EMAIL_') && err.statusCode) throw err;
    log.warn(`Auth1 email/start transport failure: ${err?.message || err}`);
    throw emailFlowError(
      'ERR_EMAIL_START_FAILED',
      'The email endpoint failed before confirming the send — check the inbox before retrying.',
      502,
    );
  }
  const { res, host } = started;
  const detail = upstreamDetail(res.data);
  if (res.status >= 400 || detail) {
    throw emailFlowError(
      'ERR_EMAIL_START_FAILED',
      detail || `upstream ${res.status}`,
      res.status >= 500 ? 502 : 400,
    );
  }
  const token = res.data?.email_verification_token;
  if (typeof token !== 'string' || !token) {
    throw emailFlowError('ERR_EMAIL_START_NO_TOKEN', 'upstream did not return a verification token', 502);
  }

  pruneEmailFlows(now);
  const flowId = randomBytes(16).toString('hex');
  _emailFlows.set(flowId, {
    email: normalizedEmail,
    // V-24: the identity the code was sent to, bound at start so the exchange
    // can be refused before the single-use code is spent.
    identityEmail: normalizeLoginIdentity(normalizedEmail),
    token,
    host,
    createdAt: now,
    expiresAt: now + EMAIL_FLOW_TTL_MS,
    attempts: 0,
    used: false,
    inFlight: false,
    // Set only after upstream accepts the code; the sole retryable state when
    // the PostAuth exchange fails (F6). Never the verification token.
    auth1Token: null,
    verifiedAt: 0,
    postAuthAttempts: 0,
  });
  _emailCodeSentAt.set(sendKey, Date.now());
  log.info(`Email login code requested: ${safeEmailRef(normalizedEmail)} host=${host} flowHash=${logHash(flowId)}`);
  return { flowId, sent: true, expiresInMs: EMAIL_FLOW_TTL_MS, host };
}

/**
 * Verify the emailed code and finish the login.
 *
 * The code is single-use upstream, but the *exchange* is not the code: a
 * successful email/complete mints an Auth1 token that is kept on the flow for
 * EMAIL_FLOW_POSTAUTH_RETRY_MS, so a PostAuth failure can be retried without
 * asking the user for another email (F6). The flow is marked used only once the
 * session token exists.
 *
 * @param {string} flowId - opaque id returned by emailLoginStart
 * @param {string} code - the 6-digit code from the inbox
 * @param {object} [proxy]
 * @param {object} [opts]
 * @param {string} [opts.expectedIdentityEmail] - address of the account the
 *   caller is about to install this session on. A mismatch is refused HERE,
 *   before the upstream exchange, because the code cannot be replayed.
 */
export async function emailLoginComplete(flowId, code, proxy = null, opts = {}) {
  const id = String(flowId || '');
  const flow = _emailFlows.get(id);
  if (!flow) {
    throw emailFlowError('ERR_EMAIL_FLOW_NOT_FOUND', 'Verification flow not found — request a new code.', 404);
  }
  if (flow.used) {
    throw emailFlowError('ERR_EMAIL_FLOW_USED', 'This verification code was already used — request a new one.', 409);
  }
  const now = Date.now();
  if (now > flow.expiresAt) {
    _emailFlows.delete(id);
    throw emailFlowError('ERR_EMAIL_FLOW_EXPIRED', 'The verification code expired — request a new one.', 410);
  }
  if (flow.inFlight) {
    throw emailFlowError('ERR_EMAIL_FLOW_IN_USE', 'This verification code is already being checked.', 409);
  }
  // V-24: the flow's identity was bound at start. A caller binding the
  // exchange to a different account is refused before the code is spent —
  // after the exchange the code is gone, so a late check would cost the
  // operator another email for nothing.
  const expectedIdentity = normalizeLoginIdentity(opts?.expectedIdentityEmail);
  if (expectedIdentity && expectedIdentity !== (flow.identityEmail || normalizeLoginIdentity(flow.email))) {
    throw emailFlowError(
      'ERR_LOGIN_IDENTITY_MISMATCH',
      `This verification code was sent to ${maskIdentityEmail(flow.email)}, not to the selected account (${maskIdentityEmail(expectedIdentity)}). Request a code for the account you are re-authenticating.`,
      409,
    );
  }
  const normalizedCode = String(code ?? '').replace(/\s+/g, '');
  // 6 digits upstream; the bounds are wide on purpose so an upstream policy
  // change does not reject a valid code here.
  if (!/^\d{4,10}$/.test(normalizedCode)) {
    throw emailFlowError('ERR_EMAIL_CODE_INVALID', 'Enter the numeric verification code from the email.', 400);
  }
  const lockMs = checkEmailLocked(flow.email);
  if (lockMs != null) throw emailLockedError(lockMs);

  const fingerprint = generateFingerprint();
  let auth1Token = null;
  let completedHost = flow.host;
  flow.inFlight = true;
  try {
    // A code that upstream already accepted is spent: replaying its verification
    // token would only earn a 401. Retry the exchange with the stored Auth1
    // token instead, and only inside the short window.
    const reusable = typeof flow.auth1Token === 'string' && flow.auth1Token
      && flow.verifiedAt && now - flow.verifiedAt <= EMAIL_FLOW_POSTAUTH_RETRY_MS;
    if (reusable) {
      auth1Token = flow.auth1Token;
      log.info(`Email code already verified; retrying the session exchange: ${safeEmailRef(flow.email)} flowHash=${logHash(id)}`);
    } else {
      if (!flow.token) {
        // Spent verification token, retry window closed: nothing left to send.
        _emailFlows.delete(id);
        throw emailFlowError('ERR_EMAIL_FLOW_EXPIRED', 'The verification code was already used — request a new one.', 410);
      }
      let exchange;
      try {
        exchange = await requestEmailEndpoint(
          'complete', flow.host, { email: flow.email, token: flow.token, code: normalizedCode }, fingerprint, proxy,
        );
      } catch (err) {
        // Transport layer gave up (plain Error, no code). Upstream never judged
        // the digits: keep the flow and its attempt count, and say so with a
        // stable code (F5) instead of leaking a raw message as HTTP 400.
        if (err && typeof err.code === 'string' && err.code.startsWith('ERR_EMAIL_') && err.statusCode) throw err;
        log.warn(`Auth1 email/complete transport failure for ${safeEmailRef(flow.email)}: ${err?.message || err}`);
        throw emailFlowError(
          'ERR_EMAIL_VERIFY_UNAVAILABLE',
          'The verification service did not answer — the code was not checked. Try again.',
          502,
          { retryable: true },
        );
      }
      const { res, host } = exchange;
      completedHost = host;
      const detail = upstreamDetail(res.data);
      if (res.status >= 400 || detail) {
        const kind = classifyEmailCompleteFailure(res, detail);
        if (kind === 'expired') {
          // The verification token is dead upstream — a retry can only 401
          // again. Drop the flow and count NOTHING: this is not a code guess
          // and must never feed the 900 s lockout (F5).
          _emailFlows.delete(id);
          throw emailFlowError(
            'ERR_EMAIL_FLOW_EXPIRED',
            'The verification code expired or was already used — request a new one.',
            410,
          );
        }
        if (kind === 'unavailable') {
          // 5xx / transport / 429 / 403: upstream never judged the digits.
          // Keep the flow and its attempt count exactly as they were.
          log.warn(`Auth1 email/complete unavailable (${res.status}) for ${safeEmailRef(flow.email)}`);
          throw emailFlowError(
            'ERR_EMAIL_VERIFY_UNAVAILABLE',
            'The verification service did not answer — the code was not checked. Try again.',
            502,
            { retryable: true },
          );
        }
        flow.attempts += 1;
        // Only a positive "wrong code" answer is a credential guess and feeds
        // the per-email lockout the password path uses. An ambiguous 400/422
        // still burns a flow attempt so one flow cannot be an unlimited oracle.
        if (kind === 'code') recordEmailFailure(flow.email, 'email_code_rejected');
        if (flow.attempts >= EMAIL_FLOW_MAX_ATTEMPTS) {
          flow.used = true;
          flow.token = null;
        }
        throw emailFlowError(
          'ERR_EMAIL_CODE_REJECTED',
          detail || `upstream ${res.status}`,
          400,
          { isAuthFail: kind === 'code', attemptsRemaining: Math.max(0, EMAIL_FLOW_MAX_ATTEMPTS - flow.attempts) },
        );
      }
      auth1Token = res.data?.token;
      if (typeof auth1Token !== 'string' || !auth1Token) {
        throw emailFlowError(
          'ERR_EMAIL_VERIFY_UNAVAILABLE',
          'The verification service accepted the code but returned no session token. Try again.',
          502,
          { retryable: true },
        );
      }
      // The verification token is spent; the Auth1 token is the only thing a
      // retry may replay, and only for the exchange window.
      flow.token = null;
      flow.auth1Token = auth1Token;
      flow.verifiedAt = Date.now();
      flow.host = host;
      log.info(`Email code accepted: ${safeEmailRef(flow.email)} host=${host} flowHash=${logHash(id)}`);
    }

    flow.postAuthAttempts += 1;
    const postAuthAttempt = flow.postAuthAttempts;
    const remaining = Math.max(0, EMAIL_FLOW_POSTAUTH_MAX_ATTEMPTS - postAuthAttempt);
    const giveUp = () => {
      flow.used = true;
      flow.auth1Token = null;
    };
    let br;
    let label;
    try {
      ({ res: br, label } = await postAuthDualPath(auth1Token, fingerprint, proxy));
    } catch (e) {
      // Both PostAuth hosts failed at the transport/5xx layer. The code stays
      // valid for the retry window (F6).
      log.warn(`PostAuth unavailable after an accepted code (${safeEmailRef(flow.email)}): ${e.message}`);
      if (remaining === 0) giveUp();
      throw emailFlowError(
        'ERR_POSTAUTH_FAILED',
        'Windsurf accepted the code but the session exchange did not answer — retry the same code.',
        502,
        { retryable: remaining > 0, attemptsRemaining: remaining },
      );
    }
    if (br.status >= 400 || !br.data?.sessionToken) {
      log.warn(`PostAuth refused after an accepted code (${safeEmailRef(flow.email)}): HTTP ${br.status}`);
      if (remaining === 0) giveUp();
      throw emailFlowError(
        'ERR_POSTAUTH_FAILED',
        'Windsurf accepted the code but the session exchange was refused — retry the same code.',
        502,
        { retryable: remaining > 0, attemptsRemaining: remaining },
      );
    }
    // Only a real session token consumes the flow.
    flow.used = true;
    flow.auth1Token = null;
    recordEmailSuccess(flow.email);
    const accountId = br.data.accountId || 'unknown';
    log.info(`Email-code login OK (${label}): ${safeEmailRef(flow.email)} accountHash=${logHash(accountId)} → using sessionToken as apiKey`);
    return {
      apiKey: br.data.sessionToken,
      name: flow.email,
      email: flow.email,
      // V-24: the identity bound to this flow at start — the address the code
      // was mailed to, not whatever the caller claims at completion.
      identityEmail: flow.identityEmail || normalizeLoginIdentity(flow.email),
      apiServerUrl: '',
      sessionToken: br.data.sessionToken,
      auth1Token,
      viaEmailCode: true,
      ...(completedHost === 'devin' ? { viaHost: 'app.devin.ai' } : {}),
    };
  } finally {
    flow.inFlight = false;
  }
}

/**
 * Refresh a Firebase ID token using a stored refresh token.
 * Returns a new { idToken, refreshToken, expiresIn } or throws.
 *
 * @param {string} refreshToken
 * @param {object} [proxy]
 * @returns {Promise<{idToken: string, refreshToken: string, expiresIn: number}>}
 */
export async function refreshFirebaseToken(refreshToken, proxy = null) {
  if (!refreshToken) throw new Error('No refresh token available');

  const postBody = `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`;
  const headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': Buffer.byteLength(postBody),
    'Referer': 'https://windsurf.com/',
    'Origin': 'https://windsurf.com',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36',
  };

  const res = await httpsRequest(FIREBASE_REFRESH_URL, { method: 'POST', headers }, postBody, proxy);

  if (res.data?.error) {
    const msg = res.data.error.message || res.data.error.code || 'Unknown error';
    throw new Error(`Firebase token refresh failed: ${msg}`);
  }

  const newIdToken = res.data?.id_token || res.data?.idToken;
  const newRefreshToken = res.data?.refresh_token || res.data?.refreshToken || refreshToken;
  const expiresIn = parseInt(res.data?.expires_in || res.data?.expiresIn || '3600', 10);

  if (!newIdToken) {
    throw new Error(`Firebase token refresh: no idToken in response: ${sliceRedactedJson(res.data, 200)}`);
  }

  log.info(`Firebase token refreshed, expires in ${expiresIn}s`);
  return { idToken: newIdToken, refreshToken: newRefreshToken, expiresIn };
}

/**
 * Re-register with Codeium using a refreshed Firebase token.
 * Returns a fresh API key (may be the same key if unchanged).
 *
 * @param {string} idToken - fresh Firebase ID token
 * @param {object} [proxy]
 * @returns {Promise<{apiKey: string, name: string}>}
 */
export async function reRegisterWithCodeium(idToken, proxy = null) {
  const fingerprint = generateFingerprint();
  const regRes = await registerWithCodeium(idToken, fingerprint, proxy);

  return {
    apiKey: regRes.api_key,
    name: regRes.name || '',
  };
}