Ctrl K
ComunidadGuías…

src/windsurf-api.js

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

Archivo original ↗Preguntar a KiBot
/**
 * REST/Connect-RPC client for Windsurf/Codeium cloud services.
 *
 * Unlike client.js (which talks to the local language server binary over gRPC),
 * this module hits public Connect-RPC endpoints that accept JSON, so we don't
 * need proto builders/parsers to fetch account metadata.
 *
 *   POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus
 *   Content-Type: application/json
 *   Connect-Protocol-Version: 1
 *
 * Currently exposes:
 *   - getUserStatus(apiKey, proxy)        — plan info, quotas, credit balance
 *   - getCascadeModelConfigs(apiKey, proxy) — live model catalog (82+ models)
 *   - checkMessageRateLimit(apiKey, proxy)  — pre-flight rate limit check
 *   - getUserJwt(apiKey, host, proxy)     — short-lived user JWT (env-gated,
 *     rides in Metadata.user_jwt on chat RPCs via src/windsurf.js)
 */

import http from 'http';
import https from 'https';
import { config, log } from './config.js';
import { redactCredentialFragments, safeKeyRef } from './log-safety.js';
import { resolveProxyConnectHost } from './net-safety.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 SERVER_HOSTS = [
  'server.codeium.com',
  'server.self-serve.windsurf.com',
];
const USER_STATUS_PATH = '/exa.seat_management_pb.SeatManagementService/GetUserStatus';
const MODEL_CONFIGS_PATH = '/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs';
const RATE_LIMIT_PATH = '/exa.api_server_pb.ApiServerService/CheckUserMessageRateLimit';
const WEB_SEARCH_PATH = '/exa.api_server_pb.ApiServerService/GetWebSearchResults';
// exa.auth_pb.AuthService/GetUserJwt → GetUserJwtResponse { user_jwt = 1,
// custom_api_server_url = 2 }. Cross-validated across four independent .protos
// (windsurf-grpc, widevin, Antigravity-Tools-LS, OmniRoute); rsvedant/
// opencode-windsurf-auth treats the JWT as REQUIRED on chat RPCs and rides it
// in Metadata.user_jwt = 21. ~24 min TTL, HS256-signed. Gate: env-only, default
// OFF — attaching user_jwt changes the wire every chat RPC sends, and we have no
// live capture proving the Windsurf backend needs or tolerates it.
const USER_JWT_PATH = '/exa.auth_pb.AuthService/GetUserJwt';
const USER_JWT_TTL_MS = 24 * 60 * 1000;
// TTL floor lets tests (which fake time via cacheEpoch) and clock jitter force
// a re-mint without waiting out a full window; the JWT is not persisted.
const USER_JWT_MIN_TTL_MS = 5 * 60 * 1000;

// Env switch: enable minting + attaching the short-lived user JWT. Default OFF
// (byte-identical wire until an operator opts in). Read through a function so
// tests can flip it and callers share one source of truth.
export function isUserJwtEnabled(env = process.env) {
  return String(env.WINDSURFAPI_USER_JWT || '') === '1';
}

// Monotonic epoch: bumped on every sign-out / key rotation. A mint that races a
// logout can't re-populate a stale JWT — the epoch guard below rejects the
// result of any mint that started before the bump. Mirrors rsvedant's cache
// design (per-(apiKey,host) in-flight dedup + cacheEpoch).
let _cacheEpoch = 0;
const _jwtCache = new Map();   // `${apiKey}@${host}` → { jwt, expiresAt, epoch }
const _jwtInflight = new Map(); // `${apiKey}@${host}` → Promise

/** Test seam: advance the epoch (logout/rotation path in auth.js calls this). */
export function __bumpUserJwtCacheEpoch() { _cacheEpoch++; }

import { isSocks, createSocksTunnel } from './socks.js';

// Tunnel HTTPS through an HTTP CONNECT proxy or SOCKS5 proxy.
async function createProxyTunnel(proxy, targetHost, targetPort) {
  if (isSocks(proxy)) return createSocksTunnel(proxy, targetHost, targetPort);
  // #11: pin the proxy host to a vetted IP literal so the CONNECT socket does no
  // second (rebindable) DNS lookup. Honors ALLOW_PRIVATE_PROXY_HOSTS.
  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 req = 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')}`,
        } : {}),
      },
    });
    req.on('connect', (res, socket) => {
      if (res.statusCode === 200) resolve(socket);
      else { socket.destroy(); reject(new Error(`Proxy CONNECT failed: ${res.statusCode}`)); }
    });
    req.on('error', (err) => reject(new Error(`Proxy tunnel: ${err.message}`)));
    req.setTimeout(15000, () => { req.destroy(); reject(new Error('Proxy tunnel timeout')); });
    req.end();
  });
}

/** Detect errors caused by the proxy itself (not the upstream API). */
function isProxyError(err) {
  const m = err?.message || '';
  return /Proxy CONNECT failed|Proxy tunnel|Proxy connection/i.test(m);
}

let postJsonOverride = null;

export function __setWindsurfApiPostJsonForTest(fn) {
  postJsonOverride = typeof fn === 'function' ? fn : null;
}

function postJson(host, path, body, proxy) {
  if (postJsonOverride) return postJsonOverride(host, path, body, proxy);
  return new Promise(async (resolve, reject) => {
    const postData = JSON.stringify(body);
    const opts = {
      hostname: host,
      port: 443,
      path,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(postData),
        'Connect-Protocol-Version': '1',
        'Accept': 'application/json',
        'User-Agent': 'windsurf/1.9600.41',
      },
    };
    const onRes = (res) => {
      // R-1 (RATE-RETRY audit): bound the read. Concatenating unbounded is an
      // availability bug — a misbehaving upstream can stream past V8's string
      // limit and the ERR_STRING_TOO_LONG throw escapes this 'end' listener as
      // an uncaughtException (see createBoundedBodyAccumulator). Over the cap
      // the response is rejected rather than parsed from 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;
        }
        const raw = rawBuffer.toString('utf8');
        try {
          const parsed = raw ? JSON.parse(raw) : {};
          // `headers` feeds the rate-limit pre-flight's Retry-After fallback (R-2).
          resolve({ status: res.statusCode, data: parsed, raw, headers: res.headers });
        } catch {
          reject(new Error(`Non-JSON response (${res.statusCode}): ${raw.slice(0, 200)}`));
        }
      });
      res.on('error', reject);
    };
    try {
      let req;
      if (proxy && proxy.host) {
        const socket = await createProxyTunnel(proxy, host, 443);
        opts.socket = socket;
        opts.agent = false;
        req = https.request(opts, onRes);
      } else {
        req = https.request(opts, onRes);
      }
      req.on('error', (err) => reject(new Error(`Request: ${err.message}`)));
      req.setTimeout(20000, () => { req.destroy(); reject(new Error('Request timeout')); });
      req.write(postData);
      req.end();
    } catch (err) { reject(err); }
  });
}

/**
 * Mint (and cache) the short-lived user JWT for `apiKey` from the given host.
 *
 * Returns the JWT string. Callers that will send it MUST first check
 * isUserJwtEnabled() — this function does the network round-trip regardless, so
 * the hot path must not call it when the feature is off.
 *
 * Cache shape (mirrors rsvedant/opencode-windsurf-auth):
 *   - per-(apiKey,host) entries, so a multi-host pool doesn't share one JWT
 *     minted against the wrong host;
 *   - in-flight dedup: concurrent callers for the same (apiKey,host) share one
 *     upstream RPC instead of stampeding GetUserJwt;
 *   - monotonic cacheEpoch: auth.js bumps the epoch on every logout / key
 *     rotation, so a mint racing a logout cannot repopulate a stale JWT. The
 *     epoch guard lives INSIDE the shared mint promise, so every awaiter (the
 *     owner and any concurrent joiner) sees the same rejection rather than the
 *     owner alone — a joiner that returned a stale token would defeat the guard.
 *
 * The TTL is shortened to USER_JWT_MIN_TTL_MS when it would expire sooner, so
 * a process that observes clock skew re-mints rather than trusting a token that
 * may already be expired upstream.
 *
 * @param {string} apiKey
 * @param {string} host
 * @param {object} [proxy]
 * @returns {Promise<string>}
 */
export async function getUserJwt(apiKey, host, proxy = null) {
  const key = `${apiKey}@${host}`;
  const now = Date.now();
  const cached = _jwtCache.get(key);
  if (cached && cached.epoch === _cacheEpoch && cached.expiresAt > now) {
    return cached.jwt;
  }
  if (_jwtInflight.has(key)) {
    // A mint is already running for this (apiKey,host) — share its guarded
    // result instead of stampeding GetUserJwt. On failure, fall through and
    // start a fresh mint below.
    try { return await _jwtInflight.get(key); } catch { /* retry fresh */ }
  }
  const epochAtStart = _cacheEpoch;
  const mint = (async () => {
    const body = { metadata: buildMetadata(apiKey) };
    let lastErr = null;
    for (const px of proxy ? [proxy, null] : [null]) {
      try {
        const res = await postJson(host, USER_JWT_PATH, body, px);
        if (res.status >= 400) {
          lastErr = new Error(`GetUserJwt ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
          continue;
        }
        const jwt = res.data?.userJwt || res.data?.user_jwt;
        if (typeof jwt !== 'string' || !jwt) {
          lastErr = new Error(`GetUserJwt ${host}: response missing user_jwt`);
          continue;
        }
        // A logout/rotation that raced this mint must not re-populate the cache
        // with a stale JWT nor hand one to any awaiter — reject outright.
        if (epochAtStart !== _cacheEpoch) {
          throw new Error('GetUserJwt: cache epoch changed during mint (logout raced)');
        }
        const mintedAt = Date.now();
        const ttl = Math.max(USER_JWT_MIN_TTL_MS, Math.min(USER_JWT_TTL_MS, Number(res.data?.ttlMs) || USER_JWT_TTL_MS));
        _jwtCache.set(key, { jwt, expiresAt: mintedAt + ttl, epoch: _cacheEpoch });
        return jwt;
      } catch (e) {
        lastErr = e;
        if (px && isProxyError(e)) break; // bad tunnel — go straight to direct
      }
    }
    throw lastErr || new Error('GetUserJwt: all hosts failed');
  })();
  _jwtInflight.set(key, mint);
  try {
    return await mint;
  } finally {
    // Only the owner reaches here (joiners early-returned above); remove the
    // in-flight marker so a later caller can mint a fresh one.
    if (_jwtInflight.get(key) === mint) _jwtInflight.delete(key);
  }
}

/** Test seam: reset the cache + epoch so tests can start clean. */
export function __resetUserJwtCache() {
  _jwtCache.clear();
  _jwtInflight.clear();
  _cacheEpoch = 0;
}

/** Test seam: current cache size (dedup/invalidation assertions). */
export function __userJwtCacheStats() {
  return { entries: _jwtCache.size, inflight: _jwtInflight.size, epoch: _cacheEpoch };
}

function normalizeWebSearchResults(data) {
  const results = Array.isArray(data?.results) ? data.results : [];
  return {
    results,
    webSearchUrl: data?.webSearchUrl || data?.web_search_url || '',
    summary: data?.summary || '',
    raw: data,
    fetchedAt: Date.now(),
  };
}

/**
 * Fetch account status: plan, quotas, credit balance, and model catalog.
 * Tries both known Connect-RPC hostnames before giving up.
 *
 * Returns a normalized shape that covers both the legacy credit contract
 * (availablePromptCredits / usedPromptCredits) and the newer quota contract
 * (dailyQuotaRemainingPercent / weeklyQuotaRemainingPercent).
 *
 * @param {string} apiKey
 * @param {object} [proxy] optional HTTP CONNECT proxy
 * @returns {Promise<{planName, dailyPercent, weeklyPercent, dailyResetAt, weeklyResetAt, prompt:{used,limit}, flex:{used,limit}, raw}>}
 */
export async function getUserStatus(apiKey, proxy = null) {
  const body = {
    metadata: {
      apiKey,
      ideName: 'windsurf',
      ideVersion: '1.9600.41',
      extensionName: 'windsurf',
      extensionVersion: '1.9600.41',
      locale: 'en',
    },
  };

  // Try with proxy first, then retry direct if proxy itself fails (407 etc.).
  const proxyModes = proxy ? [proxy, null] : [null];
  let lastErr = null;
  for (const px of proxyModes) {
    for (const host of SERVER_HOSTS) {
      try {
        const res = await postJson(host, USER_STATUS_PATH, body, px);
        if (res.status >= 400) {
          lastErr = new Error(`GetUserStatus ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
          continue;
        }
        return normalizeUserStatus(res.data);
      } catch (e) {
        lastErr = e;
        log.debug(`getCreditUsage ${host} failed: ${e.message}`);
        if (px && isProxyError(e)) break; // skip second host, go straight to direct
      }
    }
  }
  throw lastErr || new Error('GetUserStatus: all hosts failed');
}

function normalizeUserStatus(data) {
  const ps = data?.userStatus?.planStatus || {};
  const plan = ps.planInfo || {};

  // Legacy values come in hundredths; divide by 100 for display.
  const legacyDiv = (n) => (typeof n === 'number' ? n / 100 : null);

  // Unix timestamps may be numeric or string depending on server version.
  const asUnix = (v) => {
    if (v == null) return null;
    if (typeof v === 'number') return v;
    const n = parseInt(v, 10);
    return Number.isFinite(n) ? n : null;
  };

  // proto3 JSON encodes int64 as a DECIMAL STRING ("80000000"), so the old bare
  // `typeof === 'number'` check silently dropped the on-demand balance whenever
  // the server emitted the spec-compliant form. Accept both encodings and keep
  // the protobuf path's rules: -1 (the "unlimited/absent" sentinel) and anything
  // outside 0..1e12 micro-USD is unknown, not a balance.
  const overageRaw = ps.overageBalanceMicros;
  const overageMicros = typeof overageRaw === 'string'
    ? (overageRaw.trim() === '' ? NaN : Number(overageRaw))
    : overageRaw;
  const overageBalance = typeof overageMicros === 'number'
    && Number.isFinite(overageMicros) && overageMicros >= 0 && overageMicros <= 1e12
    ? overageMicros / 1_000_000
    : null;

  const out = {
    planName: plan.planName || 'Unknown',
    // proto3-JSON omits zero values, so a fully-used quota (0% remaining) comes
    // back as an ABSENT field, not `0` — which we'd otherwise render as N/A even
    // though the real answer is "0% left / 100% used". When the quota dimension
    // is clearly active (its reset timestamp is present, or the sibling daily
    // field exists), treat a missing percentage as 0 rather than null/N/A.
    dailyPercent: typeof ps.dailyQuotaRemainingPercent === 'number'
      ? ps.dailyQuotaRemainingPercent
      : (ps.dailyQuotaResetAtUnix != null ? 0 : null),
    weeklyPercent: typeof ps.weeklyQuotaRemainingPercent === 'number'
      ? ps.weeklyQuotaRemainingPercent
      : ((ps.weeklyQuotaResetAtUnix != null || typeof ps.dailyQuotaRemainingPercent === 'number') ? 0 : null),
    dailyResetAt: asUnix(ps.dailyQuotaResetAtUnix),
    weeklyResetAt: asUnix(ps.weeklyQuotaResetAtUnix),
    overageBalance,
    prompt: {
      limit: legacyDiv(plan.monthlyPromptCredits),
      used: legacyDiv(ps.usedPromptCredits),
      remaining: legacyDiv(ps.availablePromptCredits),
    },
    flex: {
      limit: legacyDiv(plan.monthlyFlexCreditPurchaseAmount),
      used: legacyDiv(ps.usedFlexCredits),
      remaining: legacyDiv(ps.availableFlexCredits),
    },
    planStart: ps.planStart || null,
    planEnd: ps.planEnd || null,
    // Preserve the untouched response so downstream caching (model catalog)
    // can inspect fields we haven't normalized yet.
    raw: data,
    fetchedAt: Date.now(),
  };

  // Derive a single display-friendly percent: prefer daily remaining; otherwise
  // compute from prompt credits; otherwise null.
  if (out.dailyPercent != null) {
    out.percent = out.dailyPercent;
  } else if (out.prompt.limit && out.prompt.remaining != null) {
    out.percent = (out.prompt.remaining / out.prompt.limit) * 100;
  } else {
    out.percent = null;
  }

  return out;
}

// ─── Dynamic model catalog ────────────────────────────────

function buildMetadata(apiKey) {
  return {
    apiKey,
    ideName: 'windsurf',
    ideVersion: '1.9600.41',
    extensionName: 'windsurf',
    extensionVersion: '1.9600.41',
    locale: 'en',
  };
}

/**
 * Fetch the live model catalog from Codeium's cloud.
 * Returns an array of ClientModelConfig objects with modelUid, label,
 * creditMultiplier, provider, maxTokens, supportsImages, etc.
 *
 * @param {string} apiKey
 * @param {object} [proxy]
 * @returns {Promise<{configs: object[], sorts: object[], defaultOverride: object|null}>}
 */
export async function getCascadeModelConfigs(apiKey, proxy = null) {
  const body = { metadata: buildMetadata(apiKey) };

  const proxyModes = proxy ? [proxy, null] : [null];
  let lastErr = null;
  for (const px of proxyModes) {
    for (const host of SERVER_HOSTS) {
      try {
        const res = await postJson(host, MODEL_CONFIGS_PATH, body, px);
        if (res.status >= 400) {
          lastErr = new Error(`GetCascadeModelConfigs ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
          continue;
        }
        return {
          configs: res.data.clientModelConfigs || [],
          sorts: res.data.clientModelSorts || [],
          defaultOverride: res.data.defaultOverrideModelConfig || null,
        };
      } catch (e) {
        lastErr = e;
        log.debug(`GetCascadeModelConfigs host ${host} failed: ${e.message}`);
        if (px && isProxyError(e)) break;
      }
    }
  }
  throw lastErr || new Error('GetCascadeModelConfigs: all hosts failed');
}

/**
 * Direct Windsurf web search API.
 *
 * Confirmed from the LS descriptor dump and VPS canary:
 *   GetWebSearchResultsRequest {
 *     metadata = 1;
 *     query = 2;
 *     limit = 3;
 *     domain = 4;
 *     third_party_config = 5;
 *     mode = 6;
 *   }
 *   GetWebSearchResultsResponse { results = 1; web_search_url = 2; summary = 3 }
 *
 * This helper is intentionally separate from the native bridge. LS-native
 * WebSearch/WebFetch still returns a Cascade permission_denied error in live
 * canaries even when this direct API succeeds.
 */
export async function getWebSearchResults(apiKey, {
  query,
  limit = 5,
  domain = '',
  thirdPartyConfig = null,
  mode = undefined,
} = {}, proxy = null) {
  const q = String(query || '').trim();
  if (!q) throw new Error('getWebSearchResults: query required');
  const body = {
    metadata: buildMetadata(apiKey),
    query: q,
    limit: Math.max(1, Math.min(10, Number(limit) || 5)),
  };
  if (domain) body.domain = String(domain);
  if (thirdPartyConfig && typeof thirdPartyConfig === 'object') body.thirdPartyConfig = thirdPartyConfig;
  if (mode !== undefined && mode !== null && mode !== '') body.mode = mode;

  const proxyModes = proxy ? [proxy, null] : [null];
  let lastErr = null;
  for (const px of proxyModes) {
    for (const host of SERVER_HOSTS) {
      try {
        const res = await postJson(host, WEB_SEARCH_PATH, body, px);
        if (res.status >= 400) {
          lastErr = new Error(`GetWebSearchResults ${host} -> ${res.status}: ${res.raw.slice(0, 160)}`);
          continue;
        }
        return normalizeWebSearchResults(res.data);
      } catch (e) {
        lastErr = e;
        log.debug(`GetWebSearchResults host ${host} failed: ${e.message}`);
        if (px && isProxyError(e)) break;
      }
    }
  }
  throw lastErr || new Error('GetWebSearchResults: all hosts failed');
}

/**
 * Register a Codeium/Windsurf account from a Firebase ID token. v2.0.57:
 * tries the new `register.windsurf.com/.../SeatManagementService/RegisterUser`
 * Connect-RPC path first, then falls back to the legacy
 * `api.codeium.com/register_user/` REST path. Windsurf migrated the seat-
 * management surface in 2026 — the new path is the one wam-bundle and
 * WindsurfSwitch use, and is what the official Windsurf 2.0.67 IDE talks
 * to. The fallback keeps existing /auth/login flows alive even if the
 * new host has a regional outage or a temporary 5xx.
 *
 * Optional `customRequest(url, opts, body)` lets callers (windsurf-login.js)
 * inject fingerprint headers + proxy tunneling. When omitted we use the
 * built-in postJson with no fingerprint and direct egress.
 *
 * @param {string} firebaseToken
 * @param {object} [opts]
 * @param {(url:string, opts:object, body:string) => Promise<{status:number,data:any,raw:string}>} [opts.requestFn]
 *   Custom HTTP function. Receives full URL, fetch-like opts, and stringified body.
 * @param {object} [opts.proxy]  Used by the default postJson path.
 * @returns {Promise<{apiKey, name, apiServerUrl, source: 'new'|'legacy'}>}
 */
export async function registerWithFirebaseToken(firebaseToken, opts = {}) {
  if (!firebaseToken || typeof firebaseToken !== 'string') {
    throw new Error('registerWithFirebaseToken: firebase token required');
  }
  const body = { firebase_id_token: firebaseToken };
  const bodyStr = JSON.stringify(body);
  const proxy = opts.proxy || null;

  // Connect-RPC compliant request to register.windsurf.com.
  const newUrl = 'https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser';
  // Legacy REST endpoint at api.codeium.com.
  const legacyUrl = 'https://api.codeium.com/register_user/';

  const tryUrl = async (url, source) => {
    if (typeof opts.requestFn === 'function') {
      const headers = {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(bodyStr),
        'Connect-Protocol-Version': '1',
        'Accept': 'application/json',
        'User-Agent': 'windsurf/1.9600.41',
      };
      const r = await opts.requestFn(url, { method: 'POST', headers }, bodyStr);
      return { status: r.status, data: r.data, raw: r.raw, source };
    }
    // Default path: built-in postJson on the host of the URL.
    const u = new URL(url);
    const r = await postJson(u.hostname, u.pathname, body, proxy);
    return { status: r.status, data: r.data, raw: r.raw, source };
  };

  const errors = [];
  for (const [url, source] of [[newUrl, 'new'], [legacyUrl, 'legacy']]) {
    try {
      const r = await tryUrl(url, source);
      // Both paths return either snake_case (api_key/name/api_server_url) or
      // camelCase (apiKey/name/apiServerUrl) depending on the gateway.
      const apiKey = r.data?.api_key || r.data?.apiKey;
      const name = r.data?.name || '';
      const apiServerUrl = r.data?.api_server_url || r.data?.apiServerUrl || '';
      if (r.status < 400 && apiKey) {
        if (source === 'legacy') {
          log.warn(`RegisterUser fell back to legacy api.codeium.com (new endpoint failed)`);
        } else {
          log.info(`RegisterUser via register.windsurf.com OK (${safeKeyRef(apiKey, 'apiKey')})`);
        }
        return { apiKey, name, apiServerUrl, source };
      }
      // Redact first. A real Firebase JWT echoed in JSON is longer than
      // 120 chars and slice-then-redact leaves `eyJ…` with only one dot,
      // which misses the JWT regex and leaks into the dashboard toast.
      const raw = redactCredentialFragments(r.raw || '').slice(0, 120);
      errors.push(`${source}=HTTP ${r.status} ${raw || '(empty)'}`);
    } catch (e) {
      errors.push(`${source}=${e.message}`);
    }
  }
  throw new Error(`RegisterUser failed both endpoints: ${redactCredentialFragments(errors.join(' | '))}`);
}

// R-2: Retry-After is delta-seconds or an HTTP-date (RFC 9110) → ms, or null.
function parseRetryAfterHeaderMs(value) {
  if (value == null) return null;
  const s = String(value).trim();
  if (!s) return null;
  const seconds = Number(s);
  if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
  const when = Date.parse(s);
  if (Number.isNaN(when)) return null;
  return Math.max(0, when - Date.now());
}

/**
 * Pre-flight check: does this account still have message capacity?
 * Returns { hasCapacity, messagesRemaining, maxMessages, retryAfterMs }.
 * -1 means unlimited; retryAfterMs is the server cooldown in ms (null when
 * absent).
 *
 * @param {string} apiKey
 * @param {object} [proxy]
 * @returns {Promise<{hasCapacity: boolean, messagesRemaining: number, maxMessages: number}>}
 */
export async function checkMessageRateLimit(apiKey, proxy = null) {
  const body = { metadata: buildMetadata(apiKey) };

  const proxyModes = proxy ? [proxy, null] : [null];
  let lastErr = null;
  for (const px of proxyModes) {
    for (const host of SERVER_HOSTS) {
      try {
        const res = await postJson(host, RATE_LIMIT_PATH, body, px);
        if (res.status === 429 || res.status === 503) {
          // R-2 (RATE-RETRY audit): a throttle on the pre-flight itself must
          // not read as "capacity fine" — swallowing it burns an upstream
          // request right after. Report no-capacity with the reset window from
          // resets_in_seconds (#5) or the Retry-After header. The limit is
          // account-scoped, so there is no point trying the other host;
          // network errors below still fail open.
          const resetSeconds = res.data?.resetsInSeconds == null ? NaN : Number(res.data.resetsInSeconds);
          const bodyMs = Number.isFinite(resetSeconds) ? Math.round(resetSeconds * 1000) : null;
          const retryAfterMs = bodyMs ?? parseRetryAfterHeaderMs(res.headers?.['retry-after']) ?? null;
          log.warn(`CheckRateLimit ${host} → ${res.status}: no capacity${retryAfterMs != null ? ` (retry after ${retryAfterMs}ms)` : ''}`);
          return {
            hasCapacity: false,
            messagesRemaining: res.data?.messagesRemaining ?? -1,
            maxMessages: res.data?.maxMessages ?? -1,
            retryAfterMs,
          };
        }
        if (res.status >= 400) {
          lastErr = new Error(`CheckRateLimit ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
          continue;
        }
        // The cooldown rides resets_in_seconds (#5) — int64, which may arrive as
        // a number or a proto3-JSON string; retryAfterMs is the ×1000 ms form.
        const resetSeconds = res.data.resetsInSeconds == null ? NaN : Number(res.data.resetsInSeconds);
        return {
          hasCapacity: res.data.hasCapacity !== false,
          messagesRemaining: res.data.messagesRemaining ?? -1,
          maxMessages: res.data.maxMessages ?? -1,
          retryAfterMs: Number.isFinite(resetSeconds) ? resetSeconds * 1000 : null,
        };
      } catch (e) {
        lastErr = e;
        log.debug(`CheckRateLimit host ${host} failed: ${e.message}`);
        if (px && isProxyError(e)) break;
      }
    }
  }
  // On failure, assume capacity so we don't block requests.
  //
  // `error` distinguishes a real "yes" from "we could not ask": the dashboard's
  // capacity preflight must show `unknown` for this path, not a green light.
  // Callers that only read `hasCapacity` are unaffected.
  log.warn(`CheckRateLimit failed: ${lastErr?.message}`);
  return {
    hasCapacity: true,
    messagesRemaining: -1,
    maxMessages: -1,
    retryAfterMs: null,
    error: 'upstream_unreachable',
  };
}