// ada backend — the Cursor-style routing layer. // Client → here (auth → route → dispatch to an adapter) → upstream providers. // Provider keys live ONLY here; the client never sees them. import { createServer, type Server } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http"; import { timingSafeEqual } from "node:crypto"; import type { ProviderName } from "../shared/types.ts"; import { PORT, PROVIDERS, clientKeys, configuredProviders, isConfigured, providerKey, providerStatus } from "./config.ts"; import { type ExchangeRequest, exchangeClients, exchangeHosts, exchangeMisconfigured, handleMcpOauthExchange } from "./mcp-oauth-exchange.ts"; import { CorruptStore, type Identity, appendAudit, appendUsage, auditTail, createSeat, disableSeat, disableSeatByExternalId, enterpriseMode, envDefaults, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, upsertSeatForSSO, usageSummary, validatePolicy } from "./enterprise.ts"; import { adminUsers, verifyIdentity } from "./identity.ts"; import { addAllowed, listAllowed, removeAllowed } from "./allowlist.ts"; import { costSince, recordUsage } from "./usage.ts"; import { prefetch as prefetchModelCatalog } from "../client/models-dev.ts"; import { billingWebhookImplemented, checkEntitlement, effectivePlan, isFreeModel, PLANS, planFor, periodStart, setPlan, WINDOW_MS, windowStart, type PlanName } from "./plans.ts"; import { checkoutUrl, createCheckout, getCheckout, setCheckoutPlan } from "./billing.ts"; import { createKelviqCheckout, getKelviqCatalog, handleKelviqWebhook, kelviqEnabled, verifyKelviqSignature, type KelviqEvent } from "./kelviq.ts"; import { computeAnalytics } from "./analytics.ts"; /** * Where a request came from, for analytics — deliberately coarse. * * The timezone is what the CLIENT says it is (x-ada-tz), not something we infer. It answers the two * questions the dashboard actually asks — roughly which part of the world, and what the local hour * was — and it costs no geo-IP lookup and no address on disk. A client that sends nothing is simply * uncounted, which is the correct default for something the user did not opt into. * * The country is only read, never resolved: if a proxy in front of us already put one on the * request we keep the two letters. Direct-to-Cloud-Run traffic has no such header and stays null. * We never store the IP that produced it. */ function originOf(req: IncomingMessage): { tz?: string; country?: string } { const h = (name: string): string | undefined => { const v = req.headers[name]; return (Array.isArray(v) ? v[0] : v)?.trim() || undefined; }; // Validated, not trusted: these are attacker-controlled strings that become GROUP BY keys and // then dashboard text. An IANA zone is letters, digits, _ + - and /; anything else is dropped // rather than stored, and the length cap stops a long string bloating every row. const raw = h("x-ada-tz"); const tz = raw && raw.length <= 64 && /^[A-Za-z0-9_+/-]+$/.test(raw) ? raw : undefined; const cc = h("cf-ipcountry") ?? h("x-vercel-ip-country") ?? h("x-appengine-country") ?? h("x-client-geo-country"); const country = cc && /^[A-Za-z]{2}$/.test(cc) ? cc.toUpperCase() : undefined; return { ...(tz ? { tz } : {}), ...(country ? { country } : {}) }; } import { ANALYTICS_PAGE } from "./analytics-page.ts"; /** The anonymous free-tier pseudo-identity — no account, so nothing to meter or bill. */ const isAnonymous = (who: Identity): boolean => who.user === "anon" && String(who.role) === "free"; // ./auth.ts is imported ON DEMAND, never at startup. It builds Better Auth eagerly, which opens a // database — and without DATABASE_URL that means better-sqlite3, which the DESKTOP BUNDLE // deliberately ships without (ada-app's extraResources filter drops it). A static import here made // the packaged app's local gateway die on "Cannot find module 'better-sqlite3'" the instant it // started, with the app silently falling back to the hosted backend. Better Auth is off unless // BETTER_AUTH_ENABLED is set, so on that path nothing here needs a database at all. const betterAuthEnabled = (): boolean => process.env.BETTER_AUTH_ENABLED === "1" || process.env.BETTER_AUTH_ENABLED === "true"; let authHandler: ((req: IncomingMessage, res: ServerResponse) => unknown) | null = null; async function betterAuthHandler(req: IncomingMessage, res: ServerResponse): Promise { if (!authHandler) { const [{ auth }, { toNodeHandler }] = await Promise.all([import("./auth.ts"), import("better-auth/node")]); authHandler = toNodeHandler(auth); } return authHandler(req, res); } // Device sign-in page: Continue with GitHub → (return signed in) → auto-approve the code. const DEVICE_PAGE = `Ada — sign in

Sign in to Ada

Approve this device to finish signing in.

`; import { assertOidcConfig, discover, isProvisionAllowed, mapIdentityToSeatFields, oidcConfig, oidcEnabled, verifyOidcToken } from "./oidc.ts"; import { adapterFor } from "./providers/registry.ts"; import { route } from "./router.ts"; import { clientAbort, proxyUpstream, upstream, upstreamModels } from "./upstream.ts"; import { hasSubscription } from "./providers/subscription-oauth.ts"; function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = ""; req.on("data", (c) => (data += c)); req.on("end", () => resolve(data)); req.on("error", reject); }); } function locked(): boolean { // OIDC must lock the backend the instant ADA_OIDC_ISSUER is set — BEFORE any seat exists — else a // fresh SSO deployment with zero seats would fall through identify() to dev-open. // adminUsers() still locks: setting it means the operator intends a gated server, and it kept that // meaning under its old name (ADA_ALLOWED_USERS). Dropping it here would silently open a backend // on upgrade for anyone who only ever set that one variable. return enterpriseMode() || clientKeys() !== null || adminUsers() !== null || oidcEnabled() || betterAuthEnabled() || !!process.env.ADA_REQUIRE_LOGIN; } /** Resolve a request to WHO is making it. Order: seat key / ADA_ADMIN_KEY (enterprise), legacy * static client key, GitHub/Google login. With no auth configured, the backend is open (dev mode). * Returns "corrupt" if the seat store can't be read — the caller MUST 503, never fall through to * dev-open. Null = unauthorized. */ async function identify(req: IncomingMessage): Promise { const h = req.headers["authorization"]; const token = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : ""; if (token) { let seat: Identity | null; try { seat = identifySeat(token); } catch (e) { if (e instanceof CorruptStore) return "corrupt"; throw e; } if (seat) return seat; // Dev-open backend (nothing configured): everyone is "dev". Return NOW — before any GitHub/Google/ // BetterAuth verification. Those are for LOCKED backends only; running them for a `dev` token just // to attribute an open backend is pointless and (on a slow/unreachable network) hangs the request. if (!locked()) return { user: "dev", role: "dev" }; // Legacy ADA_CLIENT_KEYS are NOT honored once seats/admin-key exist — enterprise supersedes them, // so a disabled seat can't be resurrected via a still-configured shared key. They're ALSO refused // whenever OIDC is the org's IdP (single identity authority) — else a still-set shared key would // bypass SSO verification during the window before the first seat is minted. if (!oidcEnabled() && !enterpriseMode() && clientKeys()?.includes(token)) return { user: "team", role: "dev" }; // One identity authority: when OIDC is the org's IdP, the GitHub/Google login path is disabled so // a person disabled in the IdP can't re-enter via a still-allowed GitHub account. (SSO users // authenticate at /v1/auth/oidc/exchange and then carry a seat key, not an id_token, per request.) if (!oidcEnabled()) { const id = await verifyIdentity(token); // GitHub / Google login // No allow-list here any more. Membership was the wrong gate for a product anyone can sign up // for — everyone authenticated gets in, and plans.ts decides what they're entitled to. A new // account lands on `free`, which permits `:free` models only and costs nothing upstream. if (id) return { user: id.user, role: "dev" }; } // Better Auth session token (accounts served at /api/auth/*) — attributed to the real user. // GATED on betterAuthEnabled(): the /api/auth/* signup route is always mounted (pre-auth) with // emailAndPassword on, so honoring these tokens unconditionally would let anyone self-register an // account and bypass a backend locked by seats / admin key / ADA_CLIENT_KEYS / allowlist / OIDC. // Accounts are a valid credential only when Better Auth is the intended gate. Allowlist applies too. if (betterAuthEnabled()) { const acct = await (await import("./auth.ts")).verifyBetterAuth(token); if (acct) return { user: acct, role: "dev" }; } } return locked() ? null : { user: "dev", role: "dev" }; // dev mode: open } function json(res: ServerResponse, status: number, obj: unknown): void { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); } // Freemium: with ADA_FREE_TIER=1, unauthenticated requests may use free-tier models only (`:free` // suffix, plus any listed in ADA_FREE_MODELS). Signed-in users get the full catalog. Off by // default — locked stays locked. function freeTierEnabled(): boolean { return process.env.ADA_FREE_TIER === "1" || process.env.ADA_FREE_TIER === "true"; } async function handleModels(res: ServerResponse, freeOnly = false): Promise { const data: Array<{ id: string; object: "model"; owned_by: string; free?: true }> = []; for (const p of configuredProviders()) { const ids = await adapterFor(p).listModels(p); for (const id of ids) { const free = isFreeModel(id); if (freeOnly && !free) continue; // `free` tells clients what the free tier covers — ADA_FREE_MODELS entries have no `:free` // suffix, so a client guessing from the id would mark them locked. data.push(free ? { id, object: "model", owned_by: p, free } : { id, object: "model", owned_by: p }); } } // Everything the upstream can serve, minus anything we already serve ourselves — a locally-held // subscription must win, or the same id would be listed twice and the picker would send it to // whichever entry it saw first. const up = upstream(); if (up) { const mine = new Set(data.map((m) => m.id)); for (const m of await upstreamModels(up)) { if (mine.has(m.id)) continue; if (freeOnly && m.free !== true) continue; data.push(m.free ? { id: m.id, object: "model", owned_by: m.owned_by ?? "upstream", free: true } : { id: m.id, object: "model", owned_by: m.owned_by ?? "upstream" }); } } json(res, 200, { object: "list", data }); } async function handleChat(req: IncomingMessage, res: ServerResponse, who: Identity): Promise { const raw = await readBody(req); let body: Record; try { body = JSON.parse(raw); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } const model = String(body.model ?? ""); if (!model) return json(res, 400, { error: { message: "missing 'model'" } }); // Anonymous free tier may only touch free-tier models — everything else needs sign-in. if (who.user === "anon" && String(who.role) === "free" && !isFreeModel(model)) { return json(res, 403, { error: { message: `sign in to use ${model} — without an account only free-tier models are available` } }); } // Org policy: model allowlist (enterprise). Enforced server-side so a modified client can't skip it. let policy: import("./enterprise.ts").Policy; try { policy = loadPolicy(); } catch (e) { if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests (fail-closed)" } }); throw e; } if (!modelAllowed(model, policy)) { appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: model }); return json(res, 403, { error: { message: `model '${model}' is not allowed by org policy (allowed: ${policy.models!.join(", ")})` } }); } // Routed early, because whether Ada's plan applies at all depends on WHO pays for this request. // When an allowlist is active, IGNORE the client's `provider` hint — else a seat holder could // send an allowlisted model id with a different provider and leak the body to it before the // upstream rejects the id. Route by the model id only. const explicit = policy.models?.length ? undefined : typeof body.provider === "string" ? body.provider : undefined; const provider = route(model, explicit); // A request served by a subscription on THIS machine is paid for by that plan, direct to the // vendor — Ada never sees a token of it. Metering it against Ada's own quota would bill the user // twice over: once to Anthropic/OpenAI, and again against an allowance they aren't consuming. // (Only credentials held locally count. Anything forwarded upstream is metered there as before.) const paidBySubscription = (provider === "anthropic" || provider === "chatgpt") && hasSubscription(provider) && isConfigured(provider); // Requests we're about to FORWARD aren't ours to police either. The upstream applies its own // plan and quota — it's the one paying — and a local gateway's plan store is a private, usually // empty file in which every user looks like a fresh free account. Enforcing it here denied // perfectly valid models before they ever reached the backend that would have allowed them. const willForward = !isConfigured(provider) && !!upstream(); // Plan quota. Skipped for the anonymous free tier (already restricted to `:free` above, and there // is no account to meter against) and for enterprise seats, which are governed by org policy and // billed by contract rather than by plan. if (!isAnonymous(who) && !enterpriseMode() && !paidBySubscription && !willForward) { const ent = await checkEntitlement(who.user, model); if (!ent.ok) { appendAudit({ ts: Date.now(), user: who.user, event: ent.status === 402 ? "quota_exceeded" : "plan_denied_model", detail: model }); // The body carries plan/used/limit so a client can render "you're out" without a second call. return json(res, ent.status!, { error: { message: ent.message, type: ent.status === 402 ? "insufficient_quota" : "plan_restricted" }, plan: ent.plan, usedUsd: ent.usedUsd, capUsd: ent.capUsd, resetsAt: ent.resetsAt }); } } if (!isConfigured(provider)) { // Can't serve it here — but if this gateway fronts a hosted backend, that one probably can. // This is what lets a local gateway use the subscription for Claude while OpenRouter, quotas // and metering keep working through the hosted server exactly as before. const up = upstream(); if (up) { delete body.provider; // our routing hint, never forwarded return await proxyUpstream(up, "/chat/completions", body, res, clientAbort(req, res)); } return json(res, 400, { error: { // chatgpt has no practical API-key route — naming its env var here would send people // looking for a key that doesn't exist. Point at the sign-in instead. message: provider === "chatgpt" ? "not signed in to ChatGPT — run `ada login chatgpt` (needs a Plus/Pro plan)" : `provider '${provider}' not configured — set ${PROVIDERS[provider].keyEnv} on the backend`, }, }); } // Metering must not be client-suppressible: force the upstream to emit a usage object on streams // (OpenAI-compat only sends it when include_usage is set). Harmless for providers that ignore it. if (body.stream) body.stream_options = { ...((body.stream_options as Record) ?? {}), include_usage: true }; // Usage metering: tee the response (streamed or not) and record the last usage object the // upstream reported. Wrapping res keeps this in ONE place for every adapter. { let tail = ""; // Timing lives here for the same reason the token count does: one place, every adapter. Split // into two numbers because they fail differently — a long ttft is the model queueing or // thinking before it says anything (and a throttled subscription looks exactly like this), // while a long tail after first byte is just generation. One combined number hides which. const started = Date.now(); let ttft: number | null = null; // `res.write` is typed `never` here by the cast below, so the check lives in its own function // where the argument can be typed honestly as unknown. const firstOutput = (c: unknown): boolean => (typeof c === "string" || Buffer.isBuffer(c)) && /"(content|tool_calls|reasoning_content)":/.test(c.toString()); const scan = (c: unknown): void => { if (typeof c === "string" || Buffer.isBuffer(c)) tail = (tail + c.toString()).slice(-16_384); }; const write = res.write.bind(res); const end = res.end.bind(res); res.write = ((c: never, ...a: never[]) => { // First chunk carrying real MODEL output, not our own opening frame. Every adapter emits a // `{role:"assistant"}` chunk the moment it writes headers, so timing the first write measured // us, not the model — it read as ~1ms every time and made the number worthless. if (ttft === null && firstOutput(c)) { ttft = Date.now() - started; } scan(c); return write(c, ...a); }) as typeof res.write; res.end = ((c?: never, ...a: never[]) => { scan(c); const u = extractLastUsage(tail); if (u) { // Same row to both sinks: the file is the self-hosted record, the table is the one that // survives a container restart and can therefore be billed against. const row = { ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: u.completionTokens, ...(u.cacheRead != null ? { cacheRead: u.cacheRead } : {}), ...(u.cacheWrite != null ? { cacheWrite: u.cacheWrite } : {}), ms: Date.now() - started, ...(ttft != null ? { ttftMs: ttft } : {}), ...originOf(req), }; appendUsage(row); void recordUsage(row); // fire-and-forget: this is response teardown, nothing can await here } return end(c, ...a); }) as typeof res.end; } delete body.provider; // our routing hint; never forward it upstream await adapterFor(provider).chat({ provider, model, body, res }); } // Which upstream serves embeddings, and its default model. OpenRouter has no embeddings endpoint, so // the hosted backend routes to whatever embedding-capable provider is configured. Auto-pick: an // explicit ADA_EMBED_PROVIDER wins, else the first configured cloud embedder (Gemini free tier → // OpenAI), else local Ollama for dev. Set ADA_EMBED_MODEL to override the model per provider. const EMBED_DEFAULT_MODEL: Partial> = { google: "text-embedding-004", // Gemini, free tier, 768-dim, via Google's OpenAI-compatible endpoint openai: "text-embedding-3-small", ollama: "nomic-embed-text", }; function embedProvider(): ProviderName { const forced = process.env.ADA_EMBED_PROVIDER as ProviderName | undefined; if (forced && PROVIDERS[forced]) return forced; if (isConfigured("google")) return "google"; if (isConfigured("openai")) return "openai"; return "ollama"; // keyless local dev; no-op in the cloud where it isn't reachable } /** Embeddings for @codebase semantic search — forwarded to a configured embedding provider * (Gemini/OpenAI in the cloud, Ollama locally). Subject to the same org model allowlist as chat, * and metered/attributed. */ async function handleEmbeddings(req: IncomingMessage, res: ServerResponse, who: Identity): Promise { const raw = await readBody(req); let body: Record; try { body = JSON.parse(raw); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } const provider = embedProvider(); // The client may send a model name tied to a different provider (e.g. the local default // nomic-embed-text). Substitute this provider's embedding model so the upstream call is valid. const model = process.env.ADA_EMBED_MODEL || EMBED_DEFAULT_MODEL[provider] || String(body.model ?? ""); let policy: import("./enterprise.ts").Policy; try { policy = loadPolicy(); } catch (e) { if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests" } }); throw e; } if (model && !modelAllowed(model, policy)) { appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: `embeddings:${model}` }); return json(res, 403, { error: { message: `embedding model '${model}' is not allowed by org policy` } }); } if (provider !== "ollama" && !providerKey(provider)) { return json(res, 503, { error: { message: `semantic search needs an embedding provider — set a key for '${provider}' (e.g. GEMINI_API_KEY) on the backend` } }); } const key = providerKey(provider); const upstream = await fetch(`${PROVIDERS[provider].baseURL}/embeddings`, { method: "POST", headers: { "content-type": "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) }, body: JSON.stringify({ ...body, model }), }); const text = await upstream.text(); const u = extractLastUsage(text); // embedding responses report prompt_tokens if (u) { const row = { ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: 0, ...originOf(req) }; appendUsage(row); void recordUsage(row); } res.writeHead(upstream.status, { "content-type": "application/json" }); res.end(text); } // Prefer a FREE image model when the catalog has one, else the cheapest sensible paid default. // OpenRouter's free tier comes and goes, so this is resolved at runtime (cached an hour) rather than // hardcoded — the day a free image model appears, image generation starts costing nothing. const IMAGE_FALLBACK = "google/gemini-2.5-flash-image"; let imageModelCache: { at: number; id: string } | null = null; async function pickImageModel(): Promise { if (process.env.ADA_IMAGE_MODEL) return process.env.ADA_IMAGE_MODEL; if (imageModelCache && Date.now() - imageModelCache.at < 3_600_000) return imageModelCache.id; let chosen = IMAGE_FALLBACK; try { const r = await fetch(`${PROVIDERS.openrouter.baseURL}/models`, { signal: AbortSignal.timeout(8000) }); if (r.ok) { const list = ((await r.json()) as { data?: Array> }).data ?? []; const isFree = (m: Record): boolean => { const p = (m.pricing ?? {}) as Record; return ["prompt", "completion", "image", "request"].every((k) => p[k] === undefined || Number(p[k]) === 0); }; const emitsImages = (m: Record): boolean => (((m.architecture ?? {}) as { output_modalities?: string[] }).output_modalities ?? []).includes("image"); const free = list.filter((m) => emitsImages(m) && isFree(m)); // ":free" variants are the explicit free tier; otherwise any zero-priced image model. const pick = free.find((m) => String(m.id).endsWith(":free")) ?? free[0]; if (pick?.id) chosen = String(pick.id); } } catch { /* catalog unreachable — fall back */ } imageModelCache = { at: Date.now(), id: chosen }; return chosen; } /** Image generation for `generate_image` — proxied to whichever image provider the backend has, so * app users need no key of their own. Same auth/metering path as chat. */ async function handleImages(req: IncomingMessage, res: ServerResponse, who: Identity): Promise { const raw = await readBody(req); let body: Record; try { body = JSON.parse(raw); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } // Use whichever image-capable provider is configured. OpenRouter exposes an OpenAI-compatible // /images/generations surface, so the same request shape works for both — no extra key needed. const provider: ProviderName = providerKey("openai") ? "openai" : "openrouter"; const key = providerKey(provider); if (!key) return json(res, 503, { error: { message: "image generation is not configured on this backend (no OPENAI_API_KEY or OPENROUTER_API_KEY)" } }); const model = String(body.model ?? (provider === "openrouter" ? await pickImageModel() : "gpt-image-1")); const upstream = await fetch(`${PROVIDERS[provider].baseURL}/images/generations`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, body: JSON.stringify({ ...body, model }), }); const text = await upstream.text(); if (upstream.ok) { const row = { ts: Date.now(), user: who.user, model, provider: "openai" as const, promptTokens: 0, completionTokens: 0, ...originOf(req) }; appendUsage(row); void recordUsage(row); } res.writeHead(upstream.status, { "content-type": "application/json" }); res.end(text); } /** Public: advertise enabled login methods so the terminal client can self-configure (no OIDC env on * the client). For OIDC it returns the issuer + client id + device/token endpoints (all public * discovery values) plus the exchange path. Unauthenticated by design. */ async function handleAuthMethods(res: ServerResponse): Promise { const methods: string[] = []; const out: Record = {}; if (oidcEnabled()) { try { const cfg = oidcConfig(); const d = await discover(); if (d.device_authorization_endpoint && d.token_endpoint) { methods.push("oidc"); out.oidc = { issuer: cfg.issuer, clientId: cfg.clientId, deviceAuthEndpoint: d.device_authorization_endpoint, tokenEndpoint: d.token_endpoint, scope: cfg.scope, exchangePath: "/v1/auth/oidc/exchange", }; } else { out.oidcError = "IdP does not advertise a device_authorization_endpoint (device flow unavailable)"; } } catch (e) { out.oidcError = e instanceof Error ? e.message : String(e); } } return json(res, 200, { methods, ...out }); } /** Public: exchange a verified OIDC id_token for a seat key (model B — the id_token is a one-time * provisioning artifact; every later request carries the returned ada_sk_ seat key). This is the * ONLY endpoint that accepts a JWT, so an id_token never reaches the per-request seat/identity path. */ async function handleOidcExchange(req: IncomingMessage, res: ServerResponse): Promise { if (!oidcEnabled()) return json(res, 404, { error: { message: "OIDC not enabled" } }); const h = req.headers["authorization"]; const idToken = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : ""; if (!idToken) return json(res, 401, { error: { message: "missing id_token bearer" } }); let identity: Awaited>; try { identity = await verifyOidcToken(idToken); } catch { identity = null; } if (!identity) return json(res, 401, { error: { message: "invalid or unverifiable id_token" } }); if (!isProvisionAllowed(identity)) { appendAudit({ ts: Date.now(), user: identity.email ?? identity.sub, event: "sso_login_denied", detail: `not in allowed group/domain: ${identity.iss}#${identity.sub}` }); return json(res, 403, { error: { message: "not authorized by org group/domain policy" } }); } const { externalId, iss, name, role } = mapIdentityToSeatFields(identity); let key: string | null; try { key = upsertSeatForSSO(externalId, iss, name, role); } catch (e) { if (e instanceof CorruptStore) return json(res, 503, { error: { message: "seat store unreadable — refusing to provision (fail-closed)" } }); throw e; } if (!key) { appendAudit({ ts: Date.now(), user: name, event: "sso_login_denied", detail: `seat disabled: ${externalId}` }); return json(res, 403, { error: { message: "your seat has been disabled — contact your admin" } }); } appendAudit({ ts: Date.now(), user: name, event: "sso_login", detail: externalId }); return json(res, 200, { seat_key: key, user: name, role }); } async function handleRequest(req: IncomingMessage, res: ServerResponse) { try { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname === "/" || url.pathname === "/health") { res.writeHead(200, { "content-type": "text/plain" }); return res.end("ada backend ok"); } // The website is a different origin and reads the public billing routes from the browser // (plan catalog, session status, purchase). CORS opens exactly those routes — they carry no // bearer credential by design, so "*" widens nothing. Authenticated APIs stay CORS-closed. if (url.pathname.startsWith("/v1/billing/")) { res.setHeader("access-control-allow-origin", "*"); res.setHeader("access-control-allow-headers", "content-type"); res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); } } // Pre-auth login routes (a locked backend must still let a new user authenticate). if (req.method === "GET" && url.pathname === "/v1/auth/methods") return await handleAuthMethods(res); if (req.method === "POST" && url.pathname === "/v1/auth/oidc/exchange") return await handleOidcExchange(req, res); // Which connector token endpoints this deployment can complete an exchange for. Pre-auth and // deliberately contentless — it is a capability probe, so the desktop app can decide whether to // route through here or finish the exchange itself, before a user is anywhere near a sign-in. if (req.method === "GET" && url.pathname === "/v1/mcp/oauth/hosts") return json(res, 200, { hosts: exchangeHosts(), clients: exchangeClients(), misconfigured: exchangeMisconfigured() }); // Better Auth: accounts, sessions, social login, API keys, device flow. if (url.pathname.startsWith("/api/auth")) return betterAuthHandler(req, res); // The public plan catalog. PRE-AUTH: it powers the website's pricing/upgrade pages, which are // static and unauthenticated. With Kelviq configured, plans and prices come from the Kelviq // dashboard (edit there, live within a minute); PLANS in code stays the quota truth per tier. if (req.method === "GET" && url.pathname === "/v1/billing/plans") { if (!kelviqEnabled()) { const plans = (Object.keys(PLANS) as PlanName[]).map((p) => ({ plan: p, kelviqPlan: null, label: PLANS[p].label, models: PLANS[p].models, capUsd: PLANS[p].capUsd, // null = uncapped (enterprise, billed by contract) windowHours: WINDOW_MS / 3_600_000, prices: null, })); return json(res, 200, { source: "static", currency: "USD", symbol: "$", plans }); } try { const c = await getKelviqCatalog(); const plans = c.plans.map((k) => ({ plan: k.plan, kelviqPlan: k.identifier, label: k.name, models: PLANS[k.plan].models, capUsd: PLANS[k.plan].capUsd, windowHours: WINDOW_MS / 3_600_000, prices: k.prices, })); return json(res, 200, { source: "kelviq", currency: c.currency, symbol: c.symbol, plans }); } catch (e) { return json(res, 502, { error: { message: `plan catalog unavailable: ${e instanceof Error ? e.message : e}` } }); } } // Pick a plan on a pending session → the hosted payment URL. PRE-AUTH like the session read: // the 256-bit session id is the authorization, and this is the only thing it authorizes. const purchase = url.pathname.match(/^\/v1\/billing\/checkout\/([^/]+)\/purchase$/); if (req.method === "POST" && purchase) { const s = await getCheckout(decodeURIComponent(purchase[1]!)); if (!s) return json(res, 404, { error: { message: "unknown or invalid checkout session" } }); if (s.status !== "pending") return json(res, 410, { error: { message: `this upgrade link is ${s.status}` } }); if (!kelviqEnabled()) return json(res, 501, { error: { message: "payments not configured on this server" } }); let b: { plan?: string; chargePeriod?: string }; try { b = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } try { const catalog = await getKelviqCatalog(); // Accept either our tier name ("pro") or Kelviq's identifier ("pro-monthly"). const want = String(b.plan ?? s.plan); const target = catalog.plans.find((k) => k.identifier === want) ?? catalog.plans.find((k) => k.plan === want); if (!target || target.plan === "free") return json(res, 400, { error: { message: `pick a paid plan from the catalog` } }); if (target.plan !== s.plan) await setCheckoutPlan(s.id, target.plan); const back = checkoutUrl(s.id); const co = await createKelviqCheckout({ kelviqPlan: target.identifier, user: s.user, sessionId: s.id, successUrl: `${back}&paid=1`, cancelUrl: back, chargePeriod: typeof b.chargePeriod === "string" ? b.chargePeriod : undefined, }); appendAudit({ ts: Date.now(), user: s.user, event: "checkout_payment_started", detail: `${target.identifier} (${target.plan})` }); return json(res, 200, { url: co.checkoutUrl }); } catch (e) { return json(res, 502, { error: { message: e instanceof Error ? e.message : String(e) } }); } } // Read a checkout session. PRE-AUTH deliberately: the website that renders it is static and has // no credential. Safe because the id is 256 bits of randomness and reading it reveals only the // plan being bought — it grants no API access and can't be replayed once spent. if (req.method === "GET" && url.pathname.startsWith("/v1/billing/checkout/")) { const s = await getCheckout(decodeURIComponent(url.pathname.slice("/v1/billing/checkout/".length))); if (!s) return json(res, 404, { error: { message: "unknown or invalid checkout session" } }); const def = PLANS[s.plan]; // The user id is NOT returned. The page doesn't need it, and not sending it means a leaked // link can't be used to enumerate accounts. return json(res, 200, { plan: s.plan, label: def.label, models: def.models, capUsd: def.capUsd, windowHours: WINDOW_MS / 3_600_000, status: s.status, expiresAt: s.expiresAt, }); } // Payment provider callback — PRE-AUTH by necessity. A webhook carries no bearer token; it // authenticates by signing the body, so behind identify() it would 401 forever and the provider // would only ever show delivery failures. Closed until that signature check exists (see the note // on billingWebhookImplemented). 501 rather than 404 so a misconfigured provider fails loudly // instead of looking like a typo'd URL. if (url.pathname === "/v1/billing/webhook") { if (kelviqEnabled() && process.env.KELVIQ_WEBHOOK_SECRET) { if (req.method !== "POST") return json(res, 405, { error: { message: "POST only" } }); const raw = await readBody(req); const ok = verifyKelviqSignature( { id: String(req.headers["webhook-id"] ?? ""), timestamp: String(req.headers["webhook-timestamp"] ?? ""), signature: String(req.headers["webhook-signature"] ?? ""), }, raw, ); if (!ok) return json(res, 401, { error: { message: "invalid webhook signature" } }); let evt: KelviqEvent; try { evt = JSON.parse(raw) as KelviqEvent; } catch { return json(res, 400, { error: { message: "invalid JSON" } }); } const outcome = await handleKelviqWebhook(evt); if (outcome) appendAudit({ ts: Date.now(), user: "kelviq", event: "billing_webhook", detail: outcome }); return json(res, 200, { received: true }); } if (!billingWebhookImplemented()) { return json(res, 501, { error: { message: "billing webhook not implemented — set plans via POST /v1/plans until a payment provider is wired" } }); } } // Device-flow approval page (the verification_uri the CLI prints). if (req.method === "GET" && url.pathname === "/device") { res.writeHead(200, { "content-type": "text/html" }); return res.end(DEVICE_PAGE); } // Analytics dashboard shell. PRE-AUTH on purpose: the page is an empty instrument panel that // holds no data — it asks for a credential and calls the (gated) API below with it. if (req.method === "GET" && url.pathname === "/admin/analytics") { res.writeHead(200, { "content-type": "text/html" }); return res.end(ANALYTICS_PAGE); } // Analytics via shared password. ADA_ANALYTICS_PASSWORD grants the dashboard WITHOUT an admin // account — for operators who want to hand a viewer credential to someone (or themselves) // before the admin list is set up. The password lives in the deployment env, never in source; // unset ⇒ this path is off and only the authenticated admin route below serves the data. if (req.method === "GET" && url.pathname === "/v1/admin/analytics" && process.env.ADA_ANALYTICS_PASSWORD) { const h = req.headers["authorization"]; const token = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : ""; const want = Buffer.from(process.env.ADA_ANALYTICS_PASSWORD); const got = Buffer.from(token); if (got.length === want.length && timingSafeEqual(got, want)) { const days = Math.min(365, Math.max(1, Number(url.searchParams.get("days")) || 30)); try { return json(res, 200, await computeAnalytics(days)); } catch (e) { return json(res, 500, { error: { message: e instanceof Error ? e.message : String(e) } }); } } // fall through: a non-matching token may still be a real admin identity } let who = await identify(req); if (who === "corrupt") return json(res, 503, { error: { message: "auth store unreadable — refusing all requests (fail-closed). Fix ~/.ada/server/users.json." } }); if (!who && freeTierEnabled()) { // Anonymous free tier: models list (free subset) + chat on `:free` models only. Everything // else still requires sign-in — enforced below via the "free" role. if ((req.method === "GET" && url.pathname === "/v1/models") || (req.method === "POST" && url.pathname === "/v1/chat/completions")) { who = { user: "anon", role: "free" as never }; } } if (!who) return json(res, 401, { error: { message: "unauthorized — invalid client key, seat key, or login" } }); const isAnon = who.user === "anon" && String(who.role) === "free"; // Finish a connector sign-in with this deployment's client secret. The tokens are handed back // and never stored — the user's machine stays the only place they live. // // Behind the auth gate ON PURPOSE, unlike the /hosts probe above. That one publishes a client // id, which is public anyway. This one spends Ada's client SECRET, and leaving it open would // let anyone exchange codes against Ada's Google app for their own product. if (req.method === "POST" && url.pathname === "/v1/mcp/oauth/exchange") { let b: ExchangeRequest; try { b = JSON.parse(await readBody(req)) as ExchangeRequest; } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } const r = await handleMcpOauthExchange(b); return json(res, r.status, r.json); } if (req.method === "GET" && url.pathname === "/v1/whoami") { return json(res, 200, { ok: true, user: who.user, role: who.role }); } // What am I on, and how much is left? Self-serve: a client shouldn't have to hit a 402 to learn // it's near the limit. Anonymous callers get the free plan's shape with no usage attached. if (req.method === "GET" && url.pathname === "/v1/plan") { const since = windowStart(); const resetsAt = since + WINDOW_MS; const windowHours = WINDOW_MS / 3_600_000; if (isAnonymous(who)) { return json(res, 200, { plan: "free", status: "active", models: PLANS.free.models, used: 0, usedUsd: 0, capUsd: PLANS.free.capUsd, windowHours, resetsAt }); } const up = await planFor(who.user); // Costed from stored tokens at today's prices. Free-tier models price at 0, so they fall out // of the spend total on their own — no special case, no second definition of "billable". const spend = await costSince(who.user, since).catch(() => ({ usd: 0, promptTokens: 0, completionTokens: 0, requests: 0 })); // `used` stays TOKENS, deliberately: the composer's live turn meter reads it to show how many // tokens a turn has burned while it runs. `usedUsd` is what the plan meter shows. const used = spend.promptTokens + spend.completionTokens; // God mode mirrors checkEntitlement: env-listed admins are unmetered, and the UI should say so // rather than show "free — upgrade" to an account the gate will never stop. if (adminUsers()?.includes(who.user)) { return json(res, 200, { plan: "team", subscribed: up.plan, status: "active", models: "all", used, usedUsd: spend.usd, capUsd: null, windowHours, resetsAt, periodStart: periodStart(up), paidThrough: null, god: true }); } // effectivePlan, not a second copy of the rule: this endpoint had its own // `status === "active" ? plan : "free"` and would have kept reporting a lapsed plan as live. const def = effectivePlan(up); const capUsd = up.maxUsd ?? def.capUsd; // per-user override beats the plan's cap; null = uncapped return json(res, 200, { plan: def.name, // what they actually GET — a lapsed pro is a free account subscribed: up.plan, // what they signed up for, so the UI can say "expired" rather than lie status: up.status, models: def.models, used, usedUsd: spend.usd, capUsd, remainingUsd: capUsd == null ? null : Math.max(0, capUsd - spend.usd), windowHours, resetsAt, periodStart: periodStart(up), // the SUBSCRIPTION period, for "renews on" — not the spend window paidThrough: up.paidThrough, }); } // Start a checkout. Authenticated: this is where the identity comes from, and it is the ONLY // place it does — everything downstream carries the session id, never a credential. if (req.method === "POST" && url.pathname === "/v1/billing/checkout") { if (isAnonymous(who)) return json(res, 401, { error: { message: "sign in to upgrade" } }); let b: { plan?: string }; try { b = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } const plan = String(b.plan ?? ""); if (!(plan in PLANS) || plan === "free") { return json(res, 400, { error: { message: `pick a paid plan (${Object.keys(PLANS).filter((p) => p !== "free").join(", ")})` } }); } const s = await createCheckout(who.user, plan as PlanName); appendAudit({ ts: Date.now(), user: who.user, event: "checkout_started", detail: plan }); return json(res, 200, { url: checkoutUrl(s.id), expiresAt: s.expiresAt, plan }); } // Admin: the analytics aggregate — usage, funnel, revenue, and computed improvement areas. // Same gate as plan administration; on a dev-open backend the operator IS the only user. if (req.method === "GET" && url.pathname === "/v1/admin/analytics") { const admin = who.role === "admin" || (adminUsers()?.includes(who.user) ?? false) || !locked(); if (!admin) return json(res, 403, { error: { message: "admin only" } }); const days = Math.min(365, Math.max(1, Number(url.searchParams.get("days")) || 30)); try { return json(res, 200, await computeAnalytics(days)); } catch (e) { return json(res, 500, { error: { message: e instanceof Error ? e.message : String(e) } }); } } // Admin: set a plan. Payment webhooks will call setPlan() directly; until then this is how a // subscription becomes real. Admin identity comes from env, never from the table it edits. if (req.method === "POST" && url.pathname === "/v1/plans") { const admin = who.role === "admin" || (adminUsers()?.includes(who.user) ?? false); if (!admin) return json(res, 403, { error: { message: "admin only" } }); let b: { user?: string; plan?: string; status?: string; maxUsd?: number | null }; try { b = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } if (!b.user || !b.plan) return json(res, 400, { error: { message: "need { user, plan }" } }); if (!(b.plan in PLANS)) return json(res, 400, { error: { message: `unknown plan '${b.plan}' (${Object.keys(PLANS).join(", ")})` } }); const status = (b.status ?? "active") as import("./plans.ts").PlanStatus; if (!["active", "past_due", "canceled", "banned"].includes(status)) { return json(res, 400, { error: { message: `unknown status '${status}' (active, past_due, canceled, banned)` } }); } // maxUsd: absent = leave any override alone, null = clear it, a positive number = set it. // Per-window spend cap in dollars, same unit as the plan's own cap. let maxUsd: number | null | undefined; if ("maxUsd" in b) { maxUsd = b.maxUsd === null ? null : Number(b.maxUsd); if (maxUsd !== null && (!Number.isFinite(maxUsd) || maxUsd <= 0)) { return json(res, 400, { error: { message: "maxUsd must be a positive number of dollars, or null to clear" } }); } } await setPlan(b.user, b.plan as PlanName, status, true, null, maxUsd); appendAudit({ ts: Date.now(), user: who.user, event: "plan_set", detail: `${b.user} -> ${b.plan}/${status}${maxUsd !== undefined ? ` max=$${maxUsd}` : ""}` }); return json(res, 200, { ok: true, user: b.user, plan: b.plan, status, ...(maxUsd !== undefined ? { maxUsd } : {}) }); } if (req.method === "GET" && url.pathname === "/v1/models") { return await handleModels(res, isAnon); } if (req.method === "GET" && url.pathname === "/v1/providers") { // Which services this backend can route to, and how each is configured. Ollama is keyless so // "configured" says nothing — probe it (fast, localhost) so clients can show "not running". const list: Array> = providerStatus(); const o = list.find((p) => p.name === "ollama"); if (o) { o.reachable = await fetch(`${PROVIDERS.ollama.baseURL}/models`, { signal: AbortSignal.timeout(700) }).then((r) => r.ok, () => false); } return json(res, 200, { providers: list }); } if (req.method === "POST" && url.pathname === "/v1/chat/completions") { return await handleChat(req, res, who); } if (req.method === "POST" && url.pathname === "/v1/embeddings") { return await handleEmbeddings(req, res, who); } if (req.method === "POST" && url.pathname === "/v1/images/generations") { return await handleImages(req, res, who); } // ---- enterprise control plane ---- if (url.pathname === "/v1/policy") { if (req.method === "GET") { // any seat — clients fetch this and apply the tool rules locally let policy: unknown; try { policy = loadPolicy(); } catch (e) { if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable" } }); throw e; } appendAudit({ ts: Date.now(), user: who.user, event: "policy_fetched", detail: "" }); // spot seats that never fetch // Env defaults UNDER the stored policy: an admin PUT is an explicit act and outranks them. return json(res, 200, { ...envDefaults(), ...(policy as Record) }); } if (req.method === "PUT") { if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } }); let parsed: unknown; try { parsed = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: "invalid JSON body" } }); } const v = validatePolicy(parsed); if ("error" in v) return json(res, 400, { error: { message: v.error } }); savePolicy(v.policy); return json(res, 200, { ok: true }); } } if (url.pathname === "/v1/allowed-users" || url.pathname.startsWith("/v1/allowed-users/")) { // Managed by the env-seeded founders (or an enterprise admin). The env list is the // key to this door on purpose: a bad DB write can never lock the founders out. const admin = who.role === "admin" || (adminUsers()?.includes(who.user) ?? false); if (!admin) return json(res, 403, { error: { message: "admins only — users in env ADA_ALLOWED_USERS" } }); if (req.method === "GET" && url.pathname === "/v1/allowed-users") { return json(res, 200, { env: adminUsers() ?? [], db: await listAllowed() }); } if (req.method === "POST" && url.pathname === "/v1/allowed-users") { let user = ""; try { user = String((JSON.parse(await readBody(req)) as { user?: string }).user ?? "").trim(); } catch { /* falls through to the check below */ } if (!user) return json(res, 400, { error: { message: "missing 'user' (an email or GitHub login)" } }); await addAllowed(user, who.user); return json(res, 200, { ok: true, user }); } if (req.method === "DELETE" && url.pathname.startsWith("/v1/allowed-users/")) { const user = decodeURIComponent(url.pathname.slice("/v1/allowed-users/".length)).trim(); if (!user) return json(res, 400, { error: { message: "missing user in path" } }); return json(res, 200, { ok: true, removed: await removeAllowed(user) }); } return json(res, 405, { error: { message: "GET, POST or DELETE" } }); } if (url.pathname === "/v1/users") { if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } }); if (req.method === "GET") return json(res, 200, { users: listSeats() }); if (req.method === "POST") { let name = ""; let role: "admin" | "dev" = "dev"; try { const b = JSON.parse(await readBody(req)) as { name?: string; role?: string }; name = String(b.name ?? "").trim(); if (b.role === "admin") role = "admin"; } catch { /* falls through to the name check */ } if (!name) return json(res, 400, { error: { message: "missing 'name'" } }); return json(res, 200, { key: createSeat(name, role), name, role, note: "shown once — store it now" }); } } { const m = req.method === "DELETE" && url.pathname.match(/^\/v1\/users\/([\w]+)$/); if (m) { if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } }); const name = disableSeat(m[1]!); return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "unknown or ambiguous key prefix (send ≥12 chars)" } }); } } // Immediate offboarding by OIDC externalId (`iss#sub`) — the entry point an admin (or Stage-3 // SCIM) uses to kill a leaver's access without waiting for the id_token to expire. if (req.method === "POST" && url.pathname === "/v1/users/disable-by-external") { if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } }); let externalId = ""; try { externalId = String((JSON.parse(await readBody(req)) as { externalId?: string }).externalId ?? "").trim(); } catch { /* falls through to the empty check */ } if (!externalId) return json(res, 400, { error: { message: "missing 'externalId' (iss#sub)" } }); let name: string | null; try { name = disableSeatByExternalId(externalId); } catch (e) { if (e instanceof CorruptStore) return json(res, 503, { error: { message: "seat store unreadable" } }); throw e; } return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "no seat for that externalId" } }); } if (req.method === "GET" && url.pathname === "/v1/usage") { if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } }); return json(res, 200, usageSummary(Math.min(Number(url.searchParams.get("days")) || 30, 365))); } if (req.method === "GET" && url.pathname === "/v1/audit") { if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } }); return json(res, 200, { events: auditTail(Math.min(Number(url.searchParams.get("limit")) || 200, 2000)) }); } return json(res, 404, { error: { message: "not found" } }); } catch (err) { if (!res.headersSent) json(res, 500, { error: { message: err instanceof Error ? err.message : String(err) } }); else try { res.end(); } catch { /* ignore */ } } } /** Build the ada backend HTTP server WITHOUT listening — for embedding, tests, and the hosted control * plane to WRAP (it sits in front over HTTP and proxies, adding tenancy/billing). Validates OIDC * config (throws on misconfig — never construct a server that would provision seats unsafely). */ export function createAdaServer(): Server { assertOidcConfig(); // Model prices decide which models the free tier covers (plans.ts) and what a request cost // (usage.ts). The baked catalog.json already seeds both synchronously, so this only refreshes; // not awaited, because a slow models.dev must never delay the port opening. void prefetchModelCatalog(); return createServer(handleRequest); } /** Construct the server and listen — the `ada-server` entrypoint (called by bin/ada-server.mjs). */ export function startAdaServer(port: number = PORT): Server { let server: Server; try { server = createAdaServer(); } catch (e) { console.error(`\x1b[31m[fatal] OIDC misconfigured: ${e instanceof Error ? e.message : e}\x1b[0m`); process.exit(1); } server.listen(port, () => { if ((enterpriseMode() || oidcEnabled()) && clientKeys()) console.warn("\x1b[33m[warn] ADA_CLIENT_KEYS is set but ignored in enterprise/OIDC mode (seats/SSO supersede it) — unset it to avoid confusion.\x1b[0m"); const seats = listSeats().filter((s) => !s.disabled).length; const sso = oidcEnabled() ? " + OIDC SSO" : ""; const auth = enterpriseMode() ? `ENTERPRISE (${seats} seat${seats === 1 ? "" : "s"}${process.env.ADA_ADMIN_KEY ? " + admin key" : ""}${sso})` : oidcEnabled() ? `OIDC SSO (0 seats — awaiting first login)` : locked() ? `auth ON (client keys + GitHub/Google login${adminUsers() ? `, admins: ${adminUsers()!.length}` : ""})` : "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ADMIN_KEY to lock down"; const provs = configuredProviders(); console.log(`ada backend → http://localhost:${port} [${auth}]`); console.log(`providers: ${provs.length ? provs.join(", ") : "(none configured — set provider API keys)"}`); // Connector sign-in, separate from the model providers above. Silence here means nobody can // sign in to Google/GitHub/Slack, which otherwise only shows up as a dead button in the app. const signin = exchangeHosts(); console.log(`connector sign-in: ${signin.length ? signin.join(", ") : "(none — set ADA_MCP_OAUTH_*_CLIENT_ID/_SECRET)"}`); for (const bad of exchangeMisconfigured()) console.warn(` WARNING: ${bad} — this provider will not be offered`); }); return server; }