diff --git a/docs/coderouter-handoff-protocol.md b/docs/coderouter-handoff-protocol.md new file mode 100644 index 00000000000..ee70cb080e4 --- /dev/null +++ b/docs/coderouter-handoff-protocol.md @@ -0,0 +1,135 @@ +# CodeRouter native handoff protocol + +This protocol transfers CodeRouter authority from an authenticated cmux-native +process to another local/native process without putting a long-lived Stack +credential in the handoff. It is intentionally a bearer protocol: callers must +use TLS, keep the returned lease in process memory or an OS-protected store, +and never put it in a URL, shell argument, log, crash report, analytics event, +or Sentry context. + +## Endpoints + +### Mint: `POST /api/coderouter/handoff` + +The caller **must** send the native Stack token pair: + +```http +Authorization: Bearer +X-Stack-Refresh-Token: +X-Cmux-Team-Id: +Content-Type: application/json +``` + +`X-Cmux-Team-Id` is optional when Stack has one selected team. If supplied, it +must identify a team the Stack user belongs to. The body is empty or `{}`. +Cookies, user-agent strings, `X-Cmux-Native`-style assertions, and route tokens +are not authorization for minting. A malformed native pair never falls back to +an ambient browser cookie. + +The server reuses the CodeRouter request-context checks: Stack identity, +team membership/allowlisting, the `use` permission, and the existing hosted +Pro-or-Team entitlement gate when hosted billing is enabled. A successful +response is `Cache-Control: no-store` and has this shape: + +```json +{ + "teamId": "team_...", + "lease": "crh_...", + "expiresAt": "2026-08-13T..." +} +``` + +The lease has 256 bits of randomness and expires after two minutes. + +### Exchange: `POST /api/coderouter/handoff/exchange` + +The body must be exactly one JSON field, with no surrounding whitespace in the +value: + +```json +{ "lease": "crh_..." } +``` + +Possession of a currently valid lease is the authorization assumption for this +method. Stack credentials are therefore **not required**: this is what permits +the authenticated cmux process to hand authority to a native CodeRouter +subprocess that does not have the Stack refresh token. Browser-cookie requests +are rejected; cookies never add authority. If a caller supplies either Stack +header, it must be a complete valid native pair; when present, the pair is +additionally required to resolve to the lease's same user and team and the +current permission/entitlement checks are rerun. This optional confirmation is +method-specific and is not a replacement for the lease. + +When hosted billing is enabled, exchange also rechecks the stored lease +principal's current Pro-or-Team entitlement immediately before the atomic +claim. This server-side check does not require the recipient to possess Stack +credentials. + +The response is the existing CodeRouter route-session shape: + +```json +{ + "teamId": "team_...", + "token": "crt_...", + "expiresAt": "2026-...", + "openaiBaseUrl": "https://cmux.com/v1" +} +``` + +`openaiBaseUrl` is built from the server's trusted +`CMUX_CODEROUTER_PUBLIC_ORIGIN` deployment setting, not from a forwarded +request host. Deployed non-preview runtimes fail closed if that origin is not +configured. Local non-Vercel development may derive it from the local request +URL for convenience. + +The route token is returned only in this no-store response and is persisted by +the existing route-token repository as a hash. Unknown, expired, consumed, and +identity-mismatched leases all return the same `401 invalid_handoff_lease` +response; clients must not use that response as a validity oracle. + +## One-time and storage guarantees + +The database stores only `SHA-256(lease)` in +`coderouter_handoff_leases.lease_hash`. It has no plaintext lease column. On +exchange, a conditional update requiring an unconsumed, unexpired hash and +the route-token insert run in one PostgreSQL transaction. Concurrent exchanges +therefore produce at most one route token. If route-token insertion fails, the +transaction rolls back the consumed marker and a retry remains possible until +the lease expires. + +The existing route-token table stores only `SHA-256(token)`. Billing +revocation marks outstanding handoff leases consumed before revoking route +tokens, using the same principal locks as mint and exchange. Hosted mint and +exchange recheck entitlement through their transaction-bound database +connection after acquiring those locks, so cancellation cannot race either +operation into new authority. Account-deletion startup uses its deletion lock +to invalidate outstanding leases and route tokens; later mint and exchange +check the same durable tombstone while holding that lock. Normal route-token +authentication remains authoritative after exchange. + +## Bounds and abuse controls + +- Native Stack auth headers are bounded to 16 KiB each. +- Handoff request bodies are bounded to 2 KiB and must be JSON for non-empty + requests. +- Deployed non-preview runtimes must configure + `CMUX_CODEROUTER_PUBLIC_ORIGIN` as an origin-only HTTPS URL (for example + `https://cmux.com`); no request or forwarded host is trusted for this value. +- Lease syntax is exact: `crh_` followed by 43 URL-safe base64 characters. +- The deployed route requires the existing durable Vercel Firewall rule + `CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID`, falling back to + `CMUX_FEEDBACK_RATE_LIMIT_ID` for existing deployments. Missing or + unavailable durable limiting fails closed with `503`; a limited request is + `429`. Non-production local runs use a process-local 60 requests/minute + backstop only. +- Responses are `no-store` and do not redirect. +- Mint traffic opportunistically deletes at most 100 leases older than the + ten-minute retention window. Each mint adds one row, so normal mint traffic + drains stale rows faster than it creates them without requiring a separate + cleanup worker. Cleanup runs in its own transaction, so a maintenance + failure cannot abort lease issuance. + +Telemetry receives only fixed operation/outcome labels. Lease and route-token +values are not passed to CodeRouter analytics, breadcrumbs, error context, or +Sentry; Sentry also scrubs both `crh_` and `crt_` patterns as defense in +depth. diff --git a/docs/coderouter-operations.md b/docs/coderouter-operations.md index 7933f375b83..acae3338497 100644 --- a/docs/coderouter-operations.md +++ b/docs/coderouter-operations.md @@ -5,6 +5,13 @@ latency evidence, and privacy-safe observability. Never paste route tokens, OAuth credentials, request bodies, email addresses, or provider-account IDs into tickets, logs, Sentry, or PostHog. +The native cross-process handoff contract, including the method-specific +authorization assumptions and atomic one-time exchange, is documented in +[`docs/coderouter-handoff-protocol.md`](coderouter-handoff-protocol.md). +Production handoff rollout also requires the durable Firewall rule and the +trusted `CMUX_CODEROUTER_PUBLIC_ORIGIN` setting; the exchange route fails closed +when either required deployment control is unavailable. + ## Stripe webhook replay 1. Identify the failed Stripe event and the production `cmux.com` webhook diff --git a/web/.env.example b/web/.env.example index 9c3128a10d8..245b307ed16 100644 --- a/web/.env.example +++ b/web/.env.example @@ -2,9 +2,14 @@ RESEND_API_KEY= CMUX_FEEDBACK_FROM_EMAIL= CMUX_FEEDBACK_RATE_LIMIT_ID= CMUX_CLIENT_CONFIG_RATE_LIMIT_ID= -# Optional dedicated Vercel Firewall rule for native app session handoff. +# Optional dedicated Vercel Firewall rule for native app and CodeRouter +# handoff exchanges. # Deployed requests fail closed when this and CMUX_FEEDBACK_RATE_LIMIT_ID are empty. CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID= +# Trusted canonical CodeRouter data-plane origin returned to native clients. +# Required for deployed non-preview runtimes; use the origin only, without a path +# (for example, https://cmux.com). +CMUX_CODEROUTER_PUBLIC_ORIGIN= # Iroh relay access token and signed relay policy. The catalog is the complete # managed fleet and must use an increasing sequence whenever its contents change. diff --git a/web/app/api/account/route.ts b/web/app/api/account/route.ts index 42cf84e690a..5263f6bfa0e 100644 --- a/web/app/api/account/route.ts +++ b/web/app/api/account/route.ts @@ -67,6 +67,9 @@ import { isVmProviderOperationError, vmWorkflowErrorCause, } from "../../../services/vms/errors"; +import { + invalidateCoderouterHandoffAuthority, +} from "../../../services/coderouter/repository"; import type { ProviderId } from "../../../services/vms/drivers"; import { jsonResponse } from "../../../services/vms/routeHelpers"; import { createHostedSubrouterClient } from "../../../services/subrouter/hostedClient"; @@ -588,6 +591,12 @@ async function markAccountDeletionTombstonePending(userId: string): Promise { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0))`); await assertNoAccountDeletionUserMutationInProgress(tx, userId, now); + // Consume any handoff lease and revoke any route token before inspecting + // the resumable tombstone state. This also covers tombstones created by + // an older deployment that did not yet invalidate handoff authority. + await invalidateCoderouterHandoffAuthority(tx, { + stackUserId: userId, + }, now); const [existing] = await tx .select({ userIdHash: accountDeletionTombstones.userIdHash, @@ -1409,10 +1418,22 @@ async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readon const db = cloudDb(); await db.transaction(async (tx) => { const now = new Date(); - const deletionTeamIds = uniqueNonEmptyStrings([userId, ...accountTeamIds]); + // Acquire the extra VM/team locks in a stable order. The handoff + // authority helper below uses the same sorted-team rule; keeping this + // prelude deterministic prevents two deletion retries with overlapping + // teams from waiting on one another in opposite orders. + const deletionTeamIds = [...uniqueNonEmptyStrings([userId, ...accountTeamIds])] + .sort(); + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0))`, + ); for (const teamId of deletionTeamIds) { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${teamId}, 0))`); } + await invalidateCoderouterHandoffAuthority(tx, { + stackUserId: userId, + teamIds: accountTeamIds, + }, now); const userVmRows = await tx .select({ id: cloudVms.id, diff --git a/web/app/api/coderouter/handoff/_shared.ts b/web/app/api/coderouter/handoff/_shared.ts new file mode 100644 index 00000000000..e3a88f9803d --- /dev/null +++ b/web/app/api/coderouter/handoff/_shared.ts @@ -0,0 +1,372 @@ +import { checkRateLimit as defaultCheckRateLimit } from "@vercel/firewall"; + +import { env } from "../../../env"; +import { + isValidCoderouterHandoffLease, +} from "../../../../services/coderouter/repository"; +import { parseNativeStackTokens } from "../../../../services/vms/auth"; + +export const CODEROUTER_HANDOFF_MAX_BODY_BYTES = 2 * 1_024; +export const CODEROUTER_HANDOFF_MAX_AUTH_HEADER_BYTES = 16 * 1_024; +export const CODEROUTER_HANDOFF_TEAM_HEADER_NAMES = [ + "x-cmux-team-id", + "x-cmux-billing-team-id", +] as const; +export const CODEROUTER_HANDOFF_TEAM_QUERY_NAMES = [ + "teamId", + "team_id", + "billingTeamId", + "billing_team_id", +] as const; +const LOCAL_RATE_LIMIT_WINDOW_MS = 60_000; +const LOCAL_RATE_LIMIT_MAX_REQUESTS = 60; + +export type HandoffRateLimitOutcome = + | "allowed" + | "limited" + | "unavailable"; + +export type HandoffRateLimiter = ( + request: Request, +) => Promise; + +type HandoffRateLimiterDependencies = { + readonly checkRateLimit?: typeof defaultCheckRateLimit; + readonly isVercel?: () => boolean; + readonly rateLimitId?: () => string | undefined; + readonly now?: () => number; +}; + +type BoundedBody = + | { readonly ok: true; readonly body: string } + | { readonly ok: false; readonly status: 400 | 413 }; + +/** + * Both handoff methods share one durable rule and one local backstop. The + * local closure is intentionally shared too, so mint plus exchange cannot + * each get an independent development budget. + */ +export const defaultCoderouterHandoffRateLimiter: HandoffRateLimiter = + makeCoderouterHandoffRateLimiter({ + checkRateLimit: defaultCheckRateLimit, + rateLimitId: configuredHandoffRateLimitId, + }); + +/** + * The durable Vercel firewall is required for deployed exchange traffic. The + * process-local bucket is only a development/test backstop; it is deliberately + * global to this process and never trusts caller-supplied forwarding headers. + */ +export function makeCoderouterHandoffRateLimiter( + dependencies: HandoffRateLimiterDependencies = {}, +): HandoffRateLimiter { + let count = 0; + let resetAt = 0; + const isVercel = dependencies.isVercel ?? (() => process.env.VERCEL === "1"); + const now = dependencies.now ?? Date.now; + + return async (request) => { + if (isVercel()) { + const rateLimitId = dependencies.rateLimitId?.(); + // A missing durable limiter is an operator/configuration failure, not an + // invitation to run an unauthenticated bearer exchange without a cap. + if (!rateLimitId) return "unavailable"; + try { + const result = await (dependencies.checkRateLimit ?? defaultCheckRateLimit)( + rateLimitId, + { request }, + ); + if (result.rateLimited || result.error === "blocked") return "limited"; + if (result.error) return "unavailable"; + return "allowed"; + } catch { + return "unavailable"; + } + } + + if (process.env.NODE_ENV === "production") return "unavailable"; + const current = now(); + if (current >= resetAt) { + count = 1; + resetAt = current + LOCAL_RATE_LIMIT_WINDOW_MS; + return "allowed"; + } + count += 1; + return count > LOCAL_RATE_LIMIT_MAX_REQUESTS ? "limited" : "allowed"; + }; +} + +export function noStoreHeaders( + additional: Record = {}, +): Headers { + const headers = new Headers({ + "cache-control": "no-store", + "content-type": "application/json", + "referrer-policy": "no-referrer", + ...additional, + }); + return headers; +} + +export function jsonHandoffResponse( + value: unknown, + status = 200, + additionalHeaders: Record = {}, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: noStoreHeaders(additionalHeaders), + }); +} + +export function rateLimitResponse( + outcome: HandoffRateLimitOutcome, +): Response | null { + if (outcome === "allowed") return null; + if (outcome === "limited") { + return jsonHandoffResponse( + { error: "throttled", retryable: true }, + 429, + { "retry-after": "60" }, + ); + } + return jsonHandoffResponse( + { + error: "handoff_unavailable", + message: "CodeRouter handoff is temporarily unavailable.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); +} + +/** + * Native authorization is the Stack access/refresh pair, not a user-agent or + * an assertion header. A cookie-only request is intentionally not native. + */ +export function hasNativeStackAuthHeaders(request: Request): boolean { + return request.headers.get("authorization") !== null || + request.headers.get("x-stack-refresh-token") !== null; +} + +export function isBoundedNativeStackRequest(request: Request): boolean { + const authorization = request.headers.get("authorization"); + const refreshToken = request.headers.get("x-stack-refresh-token"); + if ( + authorization === null || + refreshToken === null || + request.headers.get("cookie") !== null + ) return false; + if ( + byteLength(authorization) > CODEROUTER_HANDOFF_MAX_AUTH_HEADER_BYTES || + byteLength(refreshToken) > CODEROUTER_HANDOFF_MAX_AUTH_HEADER_BYTES + ) { + return false; + } + return parseNativeStackTokens(request) !== null; +} + +export function validTeamSelectorHeaders(request: Request): boolean { + const selectors = new Set(); + for (const name of CODEROUTER_HANDOFF_TEAM_HEADER_NAMES) { + const value = request.headers.get(name); + if (value === null) continue; + // Headers.get() joins repeated field values with commas. Split that + // representation so duplicate or conflicting header occurrences are + // subject to the same distinct-selector check as query parameters. + const values = value.includes(",") + ? value.split(",").map((part) => part.trim()) + : [value]; + for (const selector of values) { + if (!boundedSelector(selector)) return false; + selectors.add(selector); + } + } + try { + const searchParams = new URL(request.url).searchParams; + const allowedQueryNames: ReadonlySet = new Set( + CODEROUTER_HANDOFF_TEAM_QUERY_NAMES, + ); + // Inspect every occurrence. URLSearchParams.get() would silently choose + // the first value for repeated aliases, allowing a later authorization + // layer to consume a different selector than the one we validated. + for (const [name, value] of searchParams) { + if (!allowedQueryNames.has(name)) return false; + if (!boundedSelector(value)) return false; + selectors.add(value); + } + } catch { + return false; + } + return selectors.size <= 1; +} + +export function hasCoderouterHandoffTeamSelector(request: Request): boolean { + for (const name of CODEROUTER_HANDOFF_TEAM_HEADER_NAMES) { + if (request.headers.get(name) !== null) return true; + } + try { + const url = new URL(request.url); + return CODEROUTER_HANDOFF_TEAM_QUERY_NAMES.some((name) => + url.searchParams.has(name) + ); + } catch { + return true; + } +} + +export async function readBoundedBody( + request: Request, + limit = CODEROUTER_HANDOFF_MAX_BODY_BYTES, +): Promise { + const rawLength = request.headers.get("content-length"); + if (rawLength !== null) { + if (!/^\d+$/.test(rawLength.trim())) return { ok: false, status: 400 }; + const length = Number(rawLength.trim()); + if (!Number.isSafeInteger(length)) return { ok: false, status: 413 }; + if (length > limit) return { ok: false, status: 413 }; + } + + const reader = request.body?.getReader(); + if (!reader) return { ok: true, body: "" }; + + const decoder = new TextDecoder("utf-8", { fatal: true }); + let bytes = 0; + let body = ""; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > limit) { + await reader.cancel(); + return { ok: false, status: 413 }; + } + body += decoder.decode(chunk.value, { stream: true }); + } + return { ok: true, body: body + decoder.decode() }; + } catch { + return { ok: false, status: 400 }; + } +} + +export function parseEmptyHandoffBody(body: string): boolean { + if (!body.trim()) return true; + try { + const value: unknown = JSON.parse(body); + return isPlainObject(value) && Object.keys(value).length === 0; + } catch { + return false; + } +} + +export function isJsonContentType(request: Request): boolean { + const contentType = request.headers.get("content-type"); + if (contentType === null) return false; + return contentType.split(";", 1)[0]?.trim().toLowerCase() === + "application/json"; +} + +/** + * Produces the data-plane origin clients should persist with the route token. + * Deployed runtimes must use the operator-configured canonical origin; only + * non-Vercel local development may fall back to the request URL. + */ +export function coderouterOpenaiBaseUrl( + request: Request, + configuredOrigin?: string, +): string | null { + const origin = normalizedCoderouterOrigin(configuredOrigin); + if (origin) return `${origin}/v1`; + const localRuntime = + process.env.VERCEL !== "1" && + (process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "test"); + if (!localRuntime) { + return null; + } + try { + const localOrigin = new URL(request.url); + if (localOrigin.protocol !== "http:" && localOrigin.protocol !== "https:") { + return null; + } + return `${localOrigin.origin}/v1`; + } catch { + return null; + } +} + +export function parseHandoffLeaseBody(body: string): string | null { + if (!body.trim()) return null; + let value: unknown; + try { + value = JSON.parse(body); + } catch { + return null; + } + if (!isPlainObject(value)) return null; + const keys = Object.keys(value); + if (keys.length !== 1 || keys[0] !== "lease") return null; + const lease = value.lease; + if ( + typeof lease !== "string" || + lease.length !== lease.trim().length || + !isValidCoderouterHandoffLease(lease) + ) { + return null; + } + return lease; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; +} + +function boundedSelector(value: string): boolean { + const trimmed = value.trim(); + return trimmed.length > 0 && + trimmed.length <= 200 && + trimmed === value && + !/[\u0000-\u001f\u007f]/.test(value); +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function configuredHandoffRateLimitId(): string | undefined { + const dedicated = env.CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID; + if (dedicated) return dedicated; + const fallback = env.CMUX_FEEDBACK_RATE_LIMIT_ID; + return fallback || undefined; +} + +function normalizedCoderouterOrigin(value: string | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + return null; + } + if ( + url.protocol !== "https:" && + !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + ) { + return null; + } + return url.origin; + } catch { + return null; + } +} diff --git a/web/app/api/coderouter/handoff/exchange/route.ts b/web/app/api/coderouter/handoff/exchange/route.ts new file mode 100644 index 00000000000..54744d88762 --- /dev/null +++ b/web/app/api/coderouter/handoff/exchange/route.ts @@ -0,0 +1,115 @@ +import { env } from "../../../../env"; +import { hasActiveCoderouterSubscription } from "../../../../../services/billing/pro"; +import { + exchangeCoderouterHandoffLease, +} from "../../../../../services/coderouter/repository"; +import { resolveCodeRouterRequestContext } from "../../../../../services/coderouter/requestContext"; +import { + exchangeCoderouterHandoff, + mapHandoffWorkflowError, + runHandoffWorkflow, + type HandoffExchangeDependencies as WorkflowExchangeDependencies, + type HandoffProtocol, +} from "../../../../../services/coderouter/handoffWorkflow"; +import { reportCoderouterFailure } from "../../../../../services/coderouter/observability"; +import { + coderouterOpenaiBaseUrl, + defaultCoderouterHandoffRateLimiter, + hasCoderouterHandoffTeamSelector, + hasNativeStackAuthHeaders, + isBoundedNativeStackRequest, + isJsonContentType, + jsonHandoffResponse, + parseEmptyHandoffBody, + parseHandoffLeaseBody, + rateLimitResponse, + readBoundedBody, + validTeamSelectorHeaders, + type HandoffRateLimiter, +} from "../_shared"; + +type HandoffExchangeDependencies = Omit< + WorkflowExchangeDependencies, + "protocol" +> & { + readonly rateLimit: HandoffRateLimiter; +}; + +const defaultDependencies: HandoffExchangeDependencies = { + exchangeLease: exchangeCoderouterHandoffLease, + resolveContext: resolveCodeRouterRequestContext, + hasActiveEntitlement: hasActiveCoderouterSubscription, + hostedProRequired: () => env.CODEROUTER_HOSTED_PRO_REQUIRED === "1", + rateLimit: defaultCoderouterHandoffRateLimiter, + publicOrigin: () => env.CMUX_CODEROUTER_PUBLIC_ORIGIN, + now: () => new Date(), +}; + +function protocolFor( + dependencies: HandoffExchangeDependencies, +): HandoffProtocol { + return { + rateLimit: dependencies.rateLimit, + hasNativeStackAuthHeaders, + isBoundedNativeStackRequest, + validTeamSelectorHeaders, + hasTeamSelector: hasCoderouterHandoffTeamSelector, + coderouterOpenaiBaseUrl, + readBoundedBody, + isJsonContentType, + parseEmptyHandoffBody, + parseHandoffLeaseBody, + }; +} + +export const POST = makeCoderouterHandoffExchangePostHandler(); + +export function makeCoderouterHandoffExchangePostHandler( + dependencies: HandoffExchangeDependencies = defaultDependencies, +) { + return async function POST(request: Request): Promise { + try { + const result = await runHandoffWorkflow( + exchangeCoderouterHandoff( + request, + undefined, + { + protocol: protocolFor(dependencies), + exchangeLease: dependencies.exchangeLease, + resolveContext: dependencies.resolveContext, + hasActiveEntitlement: dependencies.hasActiveEntitlement, + hostedProRequired: dependencies.hostedProRequired, + publicOrigin: dependencies.publicOrigin, + now: dependencies.now, + }, + ), + ); + if (result._tag === "Left") { + return mapHandoffWorkflowError( + result.left, + jsonHandoffResponse, + rateLimitResponse, + ); + } + return jsonHandoffResponse({ + teamId: result.right.teamId, + token: result.right.token, + expiresAt: result.right.expiresAt.toISOString(), + openaiBaseUrl: result.right.openaiBaseUrl, + }); + } catch (error) { + reportCoderouterFailure("rds", error, { + operation: "handoff_exchange_workflow", + }); + return jsonHandoffResponse( + { + error: "handoff_unavailable", + message: "CodeRouter handoff could not be exchanged.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); + } + }; +} diff --git a/web/app/api/coderouter/handoff/route.ts b/web/app/api/coderouter/handoff/route.ts new file mode 100644 index 00000000000..eea1cffeb5d --- /dev/null +++ b/web/app/api/coderouter/handoff/route.ts @@ -0,0 +1,109 @@ +import { env } from "../../../env"; +import { hasActiveCoderouterSubscription } from "../../../../services/billing/pro"; +import { issueCoderouterHandoffLease } from "../../../../services/coderouter/repository"; +import { resolveCodeRouterRequestContext } from "../../../../services/coderouter/requestContext"; +import { + mapHandoffWorkflowError, + mintCoderouterHandoff, + runHandoffWorkflow, + type HandoffMintDependencies as WorkflowMintDependencies, + type HandoffProtocol, +} from "../../../../services/coderouter/handoffWorkflow"; +import { reportCoderouterFailure } from "../../../../services/coderouter/observability"; +import { + coderouterOpenaiBaseUrl, + defaultCoderouterHandoffRateLimiter, + isBoundedNativeStackRequest, + isJsonContentType, + hasCoderouterHandoffTeamSelector, + jsonHandoffResponse, + parseEmptyHandoffBody, + parseHandoffLeaseBody, + rateLimitResponse, + readBoundedBody, + validTeamSelectorHeaders, + hasNativeStackAuthHeaders, + type HandoffRateLimiter, +} from "./_shared"; + +type HandoffMintDependencies = Omit< + WorkflowMintDependencies, + "protocol" +> & { + readonly rateLimit: HandoffRateLimiter; +}; + +const defaultDependencies: HandoffMintDependencies = { + resolveContext: resolveCodeRouterRequestContext, + hasActiveEntitlement: hasActiveCoderouterSubscription, + issueLease: issueCoderouterHandoffLease, + hostedProRequired: () => env.CODEROUTER_HOSTED_PRO_REQUIRED === "1", + rateLimit: defaultCoderouterHandoffRateLimiter, + now: () => new Date(), +}; + +function protocolFor( + dependencies: HandoffMintDependencies, +): HandoffProtocol { + return { + rateLimit: dependencies.rateLimit, + hasNativeStackAuthHeaders, + isBoundedNativeStackRequest, + validTeamSelectorHeaders, + hasTeamSelector: hasCoderouterHandoffTeamSelector, + coderouterOpenaiBaseUrl, + readBoundedBody, + isJsonContentType, + parseEmptyHandoffBody, + parseHandoffLeaseBody, + }; +} + +export const POST = makeCoderouterHandoffPostHandler(); + +export function makeCoderouterHandoffPostHandler( + dependencies: HandoffMintDependencies = defaultDependencies, +) { + return async function POST(request: Request): Promise { + try { + const result = await runHandoffWorkflow( + mintCoderouterHandoff(request, { + protocol: protocolFor(dependencies), + resolveContext: dependencies.resolveContext, + hasActiveEntitlement: dependencies.hasActiveEntitlement, + issueLease: dependencies.issueLease, + hostedProRequired: dependencies.hostedProRequired, + now: dependencies.now, + }), + ); + if (result._tag === "Left") { + return mapHandoffWorkflowError( + result.left, + jsonHandoffResponse, + rateLimitResponse, + ); + } + return jsonHandoffResponse({ + teamId: result.right.teamId, + lease: result.right.lease, + expiresAt: result.right.expiresAt.toISOString(), + }); + } catch (error) { + // The workflow maps expected failures into typed outcomes. This catch is + // only for an unexpected defect; report a low-cardinality operation and + // never include request headers, body, or lease values. + reportCoderouterFailure("rds", error, { + operation: "handoff_mint_workflow", + }); + return jsonHandoffResponse( + { + error: "handoff_unavailable", + message: "CodeRouter handoff could not be created.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); + } + }; +} diff --git a/web/app/env.ts b/web/app/env.ts index 7a2e983e7d5..763802ebc99 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -150,6 +150,32 @@ const irohBindingLimit = z.string().regex(/^[1-9][0-9]{0,3}$/).superRefine((valu }); } }); +const coderouterPublicOrigin = z.string().url().superRefine((value, context) => { + let url: URL; + try { + url = new URL(value); + } catch { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "CMUX_CODEROUTER_PUBLIC_ORIGIN must be a valid origin", + }); + return; + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "CMUX_CODEROUTER_PUBLIC_ORIGIN must be an origin-only HTTPS URL", + }); + } +}); const stackEnv = ( value: string | undefined, fallback: string @@ -168,8 +194,16 @@ export const env = createEnv({ CMUX_FEEDBACK_RATE_LIMIT_ID: z.string().min(1).optional(), CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: z.string().min(1).optional(), CMUX_ANALYTICS_RATE_LIMIT_ID: z.string().min(1).optional(), - // The deployed handoff route fails closed when this limiter is absent. + // Native app and CodeRouter handoff routes fail closed when this limiter + // (and the existing feedback fallback) is absent. CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID: z.string().min(1).optional(), + // Canonical origin returned with CodeRouter route tokens. Deployed + // non-preview runtimes must set this; handoff exchange never trusts a + // forwarded/request host in production. + CMUX_CODEROUTER_PUBLIC_ORIGIN: requireVercelNonPreviewValue( + "CMUX_CODEROUTER_PUBLIC_ORIGIN", + coderouterPublicOrigin, + ), STACK_SECRET_SERVER_KEY: z.string().min(1), // APNs push (iOS notifications). Optional: the app boots without them; the // push route returns a clear "not configured" error until they are set. @@ -334,6 +368,9 @@ export const env = createEnv({ CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID: trimEnv( process.env.CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID, ), + CMUX_CODEROUTER_PUBLIC_ORIGIN: trimEnv( + process.env.CMUX_CODEROUTER_PUBLIC_ORIGIN, + ), CMUX_APNS_KEY_P8: trimEnv(process.env.CMUX_APNS_KEY_P8), CMUX_APNS_KEY_ID: trimEnv(process.env.CMUX_APNS_KEY_ID), CMUX_APNS_TEAM_ID: trimEnv(process.env.CMUX_APNS_TEAM_ID), diff --git a/web/db/migrations/20260813120000_coderouter_handoff_leases/migration.sql b/web/db/migrations/20260813120000_coderouter_handoff_leases/migration.sql new file mode 100644 index 00000000000..bc5ad8bbacb --- /dev/null +++ b/web/db/migrations/20260813120000_coderouter_handoff_leases/migration.sql @@ -0,0 +1,24 @@ +-- Native CodeRouter handoffs persist only a SHA-256 digest of the opaque +-- lease. The plaintext lease is returned to the caller and is never a column. +CREATE TABLE "coderouter_handoff_leases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "team_id" text NOT NULL, + "stack_user_id" text NOT NULL, + "lease_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "coderouter_handoff_leases_hash_format_check" + CHECK ("lease_hash" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "coderouter_handoff_leases_expiry_check" + CHECK ("expires_at" > "created_at") +); + +CREATE UNIQUE INDEX "coderouter_handoff_leases_hash_unique" + ON "coderouter_handoff_leases" ("lease_hash"); +CREATE INDEX "coderouter_handoff_leases_expiry_idx" + ON "coderouter_handoff_leases" ("expires_at"); +CREATE INDEX "coderouter_handoff_leases_team_expiry_idx" + ON "coderouter_handoff_leases" ("team_id", "expires_at"); +CREATE INDEX "coderouter_handoff_leases_user_expiry_idx" + ON "coderouter_handoff_leases" ("stack_user_id", "expires_at"); diff --git a/web/db/schema.ts b/web/db/schema.ts index 55f6198c8f9..02cc88cbbae 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -527,6 +527,44 @@ export const coderouterRouteTokens = pgTable( ], ); +/** + * Short-lived bearer handoffs from an authenticated native client to another + * CodeRouter process. The value returned to the client is never stored; only + * its SHA-256 digest is persisted. A lease can be claimed exactly once. + */ +export const coderouterHandoffLeases = pgTable( + "coderouter_handoff_leases", + { + id: uuid("id").defaultRandom().primaryKey(), + teamId: text("team_id").notNull(), + stackUserId: text("stack_user_id").notNull(), + leaseHash: text("lease_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check( + "coderouter_handoff_leases_hash_format_check", + sql`${table.leaseHash} ~ '^[0-9a-f]{64}$'`, + ), + check( + "coderouter_handoff_leases_expiry_check", + sql`${table.expiresAt} > ${table.createdAt}`, + ), + uniqueIndex("coderouter_handoff_leases_hash_unique").on(table.leaseHash), + index("coderouter_handoff_leases_expiry_idx").on(table.expiresAt), + index("coderouter_handoff_leases_team_expiry_idx").on( + table.teamId, + table.expiresAt, + ), + index("coderouter_handoff_leases_user_expiry_idx").on( + table.stackUserId, + table.expiresAt, + ), + ], +); + /** * Envelope-encrypted provider credentials. Every secret-bearing field is * ciphertext; the plaintext data key exists only briefly in Vercel memory. diff --git a/web/services/billing/pro.ts b/web/services/billing/pro.ts index 1cf5f0ad7bf..6cdc4193eee 100644 --- a/web/services/billing/pro.ts +++ b/web/services/billing/pro.ts @@ -32,6 +32,11 @@ export const FREE_PLAN_ID = "free"; export const PRO_ACCESS_ITEM_ID = "cmux-pro-access"; export const ACTIVE_STRIPE_PRO_STATUSES = ["active", "trialing", "past_due"] as const; +type CoderouterEntitlementQueryDb = Pick< + ReturnType, + "select" +>; + // Mirrors Stack's ReadonlyJson so ServerUser.update stays assignable. export type ProMetadataJson = | null @@ -291,9 +296,11 @@ export async function hasActiveTeamSubscriptionForTeam( export async function hasActiveCoderouterSubscription( stackUserId: string, stackTeamId: string, + db?: CoderouterEntitlementQueryDb, ): Promise { try { - const rows = await cloudDb() + const queryDb = db ?? cloudDb(); + const rows = await queryDb .select({ id: stripeSubscriptions.id }) .from(stripeSubscriptions) .where( diff --git a/web/services/coderouter/analytics.ts b/web/services/coderouter/analytics.ts index 7af0c285d8d..0ffad0fbac3 100644 --- a/web/services/coderouter/analytics.ts +++ b/web/services/coderouter/analytics.ts @@ -19,6 +19,9 @@ export type CoderouterAnalyticsEvent = | "coderouter_account_removed" | "coderouter_account_status_viewed" | "coderouter_auth_rejected" + | "coderouter_handoff_lease_issued" + | "coderouter_handoff_lease_exchanged" + | "coderouter_handoff_rejected" | "coderouter_route_session_issued" | "coderouter_route_session_revoked" | "coderouter_organization_catalog_viewed" @@ -171,6 +174,8 @@ async function deliver( function eventNeedsUserScope(event: CoderouterAnalyticsEvent): boolean { return event === "coderouter_account_added" || event === "coderouter_account_removed" || + event === "coderouter_handoff_lease_issued" || + event === "coderouter_handoff_lease_exchanged" || event === "coderouter_route_session_issued" || event === "coderouter_route_session_revoked"; } @@ -212,6 +217,25 @@ function eventProperties( const reason = authReason(input.reason); return surface && reason ? { surface, reason } : null; } + case "coderouter_handoff_lease_issued": + return { authorization_mode: "native_stack" }; + case "coderouter_handoff_lease_exchanged": { + const mode = enumValue(input.authorization_mode, [ + "lease", + "native_confirmation", + ]); + return mode ? { authorization_mode: mode } : null; + } + case "coderouter_handoff_rejected": { + const surface = enumValue(input.surface, ["mint", "exchange"]); + const reason = enumValue(input.reason, [ + "missing_native_auth", + "invalid_native_auth", + "invalid_lease", + "expired_or_consumed", + ]); + return surface && reason ? { surface, reason } : null; + } case "coderouter_route_session_issued": return typeof input.hosted_pro_required === "boolean" ? { hosted_pro_required: input.hosted_pro_required } diff --git a/web/services/coderouter/handoffWorkflow.ts b/web/services/coderouter/handoffWorkflow.ts new file mode 100644 index 00000000000..4c98534423c --- /dev/null +++ b/web/services/coderouter/handoffWorkflow.ts @@ -0,0 +1,604 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; + +import { + AccountDeletionMutationBlockedError, +} from "../account/deletionLock"; +import type { hasActiveCoderouterSubscription } from "../billing/pro"; +import { captureCoderouterEvent } from "./analytics"; +import { CodeRouterHandoffEntitlementDenied } from "./repository"; +import type { + exchangeCoderouterHandoffLease, + issueCoderouterHandoffLease, + CodeRouterHandoffAuthorizer, + CodeRouterHandoffEntitlementDb, + CodeRouterHandoffIdentity, +} from "./repository"; +import type { resolveCodeRouterRequestContext } from "./requestContext"; +import { + addCoderouterBreadcrumb, + reportCoderouterFailure, +} from "./observability"; + +export type HandoffRateLimitOutcome = + | "allowed" + | "limited" + | "unavailable"; + +export type HandoffProtocol = { + readonly rateLimit: ( + request: Request, + ) => Promise; + readonly hasNativeStackAuthHeaders: (request: Request) => boolean; + readonly isBoundedNativeStackRequest: (request: Request) => boolean; + readonly validTeamSelectorHeaders: (request: Request) => boolean; + readonly hasTeamSelector: (request: Request) => boolean; + readonly coderouterOpenaiBaseUrl: ( + request: Request, + configuredOrigin?: string, + ) => string | null; + readonly readBoundedBody: ( + request: Request, + ) => Promise; + readonly isJsonContentType: (request: Request) => boolean; + readonly parseEmptyHandoffBody: (body: string) => boolean; + readonly parseHandoffLeaseBody: (body: string) => string | null; +}; + +export type HandoffBodyResult = + | { readonly ok: true; readonly body: string } + | { readonly ok: false; readonly status: 400 | 413 }; + +export class HandoffResponseError extends Data.TaggedError( + "HandoffResponseError", +)<{ + readonly response: Response; +}> {} + +export class HandoffRateLimitError extends Data.TaggedError( + "HandoffRateLimitError", +)<{ + readonly outcome: Exclude; +}> {} + +export class HandoffUnauthorizedError extends Data.TaggedError( + "HandoffUnauthorizedError", +)> {} + +export class HandoffInvalidRequestError extends Data.TaggedError( + "HandoffInvalidRequestError", +)> {} + +export class HandoffPayloadTooLargeError extends Data.TaggedError( + "HandoffPayloadTooLargeError", +)> {} + +export class HandoffForbiddenError extends Data.TaggedError( + "HandoffForbiddenError", +)> {} + +export class HandoffAuthorizationUnavailableError extends Data.TaggedError( + "HandoffAuthorizationUnavailableError", +)> {} + +export class HandoffEntitlementRequiredError extends Data.TaggedError( + "HandoffEntitlementRequiredError", +)> {} + +export class HandoffEntitlementUnavailableError extends Data.TaggedError( + "HandoffEntitlementUnavailableError", +)> {} + +export class HandoffAccountDeletionBlockedError extends Data.TaggedError( + "HandoffAccountDeletionBlockedError", +)> {} + +export class HandoffConfigurationError extends Data.TaggedError( + "HandoffConfigurationError", +)> {} + +export class HandoffPersistenceError extends Data.TaggedError( + "HandoffPersistenceError", +)<{ + readonly operation: "mint" | "exchange"; +}> {} + +export class HandoffInvalidLeaseError extends Data.TaggedError( + "HandoffInvalidLeaseError", +)> {} + +class HandoffAuthorizerUnavailableError extends Error {} + +export type HandoffWorkflowError = + | HandoffResponseError + | HandoffRateLimitError + | HandoffUnauthorizedError + | HandoffInvalidRequestError + | HandoffPayloadTooLargeError + | HandoffForbiddenError + | HandoffAuthorizationUnavailableError + | HandoffEntitlementRequiredError + | HandoffEntitlementUnavailableError + | HandoffAccountDeletionBlockedError + | HandoffConfigurationError + | HandoffPersistenceError + | HandoffInvalidLeaseError; + +export type HandoffMintDependencies = { + readonly protocol: HandoffProtocol; + readonly resolveContext: typeof resolveCodeRouterRequestContext; + readonly hasActiveEntitlement: typeof hasActiveCoderouterSubscription; + readonly issueLease: typeof issueCoderouterHandoffLease; + readonly hostedProRequired: () => boolean; + readonly now?: () => Date; +}; + +export type HandoffExchangeDependencies = { + readonly protocol: HandoffProtocol; + readonly exchangeLease: typeof exchangeCoderouterHandoffLease; + readonly resolveContext: typeof resolveCodeRouterRequestContext; + readonly hasActiveEntitlement: typeof hasActiveCoderouterSubscription; + readonly hostedProRequired: () => boolean; + readonly publicOrigin?: () => string | undefined; + readonly now?: () => Date; +}; + +export type HandoffWorkflowShape = { + readonly mint: ( + request: Request, + ) => Effect.Effect; + readonly exchange: ( + request: Request, + lease?: string, + ) => Effect.Effect; +}; + +export class HandoffWorkflow extends Context.Tag( + "cmux/CodeRouterHandoffWorkflow", +)() {} + +export type HandoffMintResult = Awaited< + ReturnType +> & { + readonly teamId: string; + readonly stackUserId: string; +}; + +export type HandoffExchangeResult = NonNullable< + Awaited> +> & { + readonly openaiBaseUrl: string; +}; + +function tryPromise( + run: () => Promise, + onFailure: (cause: unknown) => E, +): Effect.Effect { + return Effect.tryPromise({ + try: run, + catch: onFailure, + }); +} + +function rateLimit( + request: Request, + protocol: HandoffProtocol, +): Effect.Effect { + return tryPromise( + () => protocol.rateLimit(request), + () => new HandoffRateLimitError({ outcome: "unavailable" }), + ).pipe( + Effect.flatMap((outcome) => + outcome === "allowed" + ? Effect.succeed(undefined) + : Effect.fail(new HandoffRateLimitError({ outcome })), + ), + ); +} + +function readHandoffBody( + request: Request, + protocol: HandoffProtocol, +): Effect.Effect< + string, + HandoffInvalidRequestError | HandoffPayloadTooLargeError +> { + return Effect.gen(function* () { + const body = yield* Effect.tryPromise({ + try: () => protocol.readBoundedBody(request), + catch: () => new HandoffInvalidRequestError({}), + }); + if (body.ok) return body.body; + if (body.status === 413) { + return yield* Effect.fail(new HandoffPayloadTooLargeError({})); + } + return yield* Effect.fail(new HandoffInvalidRequestError({})); + }); +} + +function resolveContext( + request: Request, + dependencies: HandoffMintDependencies | HandoffExchangeDependencies, +): Effect.Effect< + Awaited> extends + infer Result + ? Result extends { readonly ok: true; readonly value: infer Value } + ? Value + : never + : never, + HandoffAuthorizationUnavailableError | HandoffResponseError +> { + return tryPromise( + () => dependencies.resolveContext(request, "use"), + () => new HandoffAuthorizationUnavailableError({}), + ).pipe( + Effect.flatMap((resolved) => + resolved.ok + ? Effect.succeed(resolved.value) + : Effect.fail(new HandoffResponseError({ + response: resolved.response, + })), + ), + ); +} + +function checkEntitlement( + dependencies: HandoffMintDependencies | HandoffExchangeDependencies, + userId: string, + teamId: string, +): Effect.Effect< + void, + HandoffEntitlementRequiredError | HandoffEntitlementUnavailableError +> { + return tryPromise( + () => dependencies.hasActiveEntitlement(userId, teamId), + () => new HandoffEntitlementUnavailableError({}), + ).pipe( + Effect.flatMap((active) => + active + ? Effect.succeed(undefined) + : Effect.fail(new HandoffEntitlementRequiredError({})), + ), + ); +} + +function mintAuthorizer( + dependencies: HandoffMintDependencies, +): CodeRouterHandoffAuthorizer { + return async ( + identity: { readonly teamId: string; readonly stackUserId: string }, + db: CodeRouterHandoffEntitlementDb, + ) => { + try { + return await dependencies.hasActiveEntitlement( + identity.stackUserId, + identity.teamId, + db, + ); + } catch { + throw new HandoffAuthorizerUnavailableError(); + } + }; +} + +export function mintCoderouterHandoff( + request: Request, + dependencies: HandoffMintDependencies, +): Effect.Effect { + return Effect.gen(function* () { + yield* rateLimit(request, dependencies.protocol); + if ( + !dependencies.protocol.isBoundedNativeStackRequest(request) || + !dependencies.protocol.validTeamSelectorHeaders(request) + ) { + return yield* Effect.fail(new HandoffUnauthorizedError({})); + } + const body = yield* readHandoffBody(request, dependencies.protocol); + if ( + body.trim() && + ( + !dependencies.protocol.isJsonContentType(request) || + !dependencies.protocol.parseEmptyHandoffBody(body) + ) + ) { + return yield* Effect.fail(new HandoffInvalidRequestError({})); + } + + const resolved = yield* resolveContext(request, dependencies); + if (!resolved.team.use) { + return yield* Effect.fail(new HandoffForbiddenError({})); + } + const hostedProRequired = dependencies.hostedProRequired(); + if (hostedProRequired) { + yield* checkEntitlement( + dependencies, + resolved.user.id, + resolved.team.teamId, + ); + } + + const now = dependencies.now?.() ?? new Date(); + const issued = yield* tryPromise( + () => + hostedProRequired + ? dependencies.issueLease( + resolved.team.teamId, + resolved.user.id, + now, + mintAuthorizer(dependencies), + ) + : dependencies.issueLease( + resolved.team.teamId, + resolved.user.id, + now, + ), + (cause) => { + if (cause instanceof AccountDeletionMutationBlockedError) { + return new HandoffAccountDeletionBlockedError({}); + } + if (cause instanceof HandoffAuthorizerUnavailableError) { + return new HandoffEntitlementUnavailableError({}); + } + if (cause instanceof CodeRouterHandoffEntitlementDenied) { + return new HandoffEntitlementRequiredError({}); + } + reportCoderouterFailure("rds", cause, { + operation: "issue_handoff_lease", + }); + return new HandoffPersistenceError({ operation: "mint" }); + }, + ); + + captureCoderouterEvent({ + event: "coderouter_handoff_lease_issued", + userId: resolved.user.id, + teamId: resolved.team.teamId, + properties: { authorization_mode: "native_stack" }, + }); + addCoderouterBreadcrumb("handoff", "Handoff lease issued"); + return { + ...issued, + teamId: resolved.team.teamId, + stackUserId: resolved.user.id, + }; + }); +} + +export function exchangeCoderouterHandoff( + request: Request, + suppliedLease: string | undefined, + dependencies: HandoffExchangeDependencies, +): Effect.Effect { + return Effect.gen(function* () { + yield* rateLimit(request, dependencies.protocol); + const body = yield* readHandoffBody(request, dependencies.protocol); + if (!dependencies.protocol.isJsonContentType(request)) { + return yield* Effect.fail(new HandoffInvalidRequestError({})); + } + const lease = dependencies.protocol.parseHandoffLeaseBody(body); + if (!lease || (suppliedLease !== undefined && suppliedLease !== lease)) { + return yield* Effect.fail(new HandoffInvalidRequestError({})); + } + const hostedProRequired = dependencies.hostedProRequired(); + let expectedIdentity: CodeRouterHandoffIdentity = {}; + + if ( + !dependencies.protocol.hasNativeStackAuthHeaders(request) && + request.headers.get("cookie") !== null + ) { + return yield* Effect.fail(new HandoffUnauthorizedError({})); + } + + if (dependencies.protocol.hasNativeStackAuthHeaders(request)) { + if ( + !dependencies.protocol.isBoundedNativeStackRequest(request) || + !dependencies.protocol.validTeamSelectorHeaders(request) + ) { + return yield* Effect.fail(new HandoffUnauthorizedError({})); + } + const resolved = yield* resolveContext(request, dependencies); + if (!resolved.team.use) { + return yield* Effect.fail(new HandoffForbiddenError({})); + } + if (hostedProRequired) { + yield* checkEntitlement( + dependencies, + resolved.user.id, + resolved.team.teamId, + ); + } + expectedIdentity = { + teamId: resolved.team.teamId, + stackUserId: resolved.user.id, + }; + } else if ( + !dependencies.protocol.validTeamSelectorHeaders(request) || + dependencies.protocol.hasTeamSelector(request) + ) { + return yield* Effect.fail(new HandoffInvalidRequestError({})); + } + + const openaiBaseUrl = dependencies.protocol.coderouterOpenaiBaseUrl( + request, + dependencies.publicOrigin?.(), + ); + if (!openaiBaseUrl) { + reportCoderouterFailure("configuration", new Error("handoff origin unavailable"), { + operation: "resolve_handoff_origin", + }); + return yield* Effect.fail(new HandoffConfigurationError({})); + } + + const authorize: CodeRouterHandoffAuthorizer | undefined = + hostedProRequired + ? async ( + identity, + db: CodeRouterHandoffEntitlementDb, + ) => { + try { + return await dependencies.hasActiveEntitlement( + identity.stackUserId, + identity.teamId, + db, + ); + } catch { + throw new HandoffAuthorizerUnavailableError(); + } + } + : undefined; + const exchanged = yield* tryPromise( + () => dependencies.exchangeLease( + lease, + dependencies.now?.() ?? new Date(), + expectedIdentity, + authorize, + ), + (cause) => { + if (cause instanceof HandoffAuthorizerUnavailableError) { + return new HandoffEntitlementUnavailableError({}); + } + reportCoderouterFailure("rds", cause, { + operation: "exchange_handoff_lease", + }); + return new HandoffPersistenceError({ operation: "exchange" }); + }, + ); + if (!exchanged) { + captureCoderouterEvent({ + event: "coderouter_handoff_rejected", + properties: { + surface: "exchange", + reason: "expired_or_consumed", + }, + }); + return yield* Effect.fail(new HandoffInvalidLeaseError({})); + } + captureCoderouterEvent({ + event: "coderouter_handoff_lease_exchanged", + userId: exchanged.stackUserId, + teamId: exchanged.teamId, + properties: { + authorization_mode: Object.keys(expectedIdentity).length > 0 + ? "native_confirmation" + : "lease", + }, + }); + addCoderouterBreadcrumb("handoff", "Handoff lease exchanged"); + return { ...exchanged, openaiBaseUrl }; + }); +} + +export function makeHandoffWorkflow( + dependencies: HandoffMintDependencies & HandoffExchangeDependencies, +): HandoffWorkflowShape { + return { + mint: (request) => mintCoderouterHandoff(request, dependencies), + exchange: (request, lease) => + exchangeCoderouterHandoff(request, lease, dependencies), + }; +} + +export function handoffWorkflowLayer( + dependencies: HandoffMintDependencies & HandoffExchangeDependencies, +): Layer.Layer { + return Layer.succeed(HandoffWorkflow, makeHandoffWorkflow(dependencies)); +} + +export function mapHandoffWorkflowError( + error: HandoffWorkflowError, + json: ( + value: unknown, + status?: number, + headers?: Record, + ) => Response, + rateLimitResponse: ( + outcome: Exclude, + ) => Response | null, +): Response { + if (error instanceof HandoffResponseError) return error.response; + if (error instanceof HandoffRateLimitError) { + return rateLimitResponse(error.outcome) ?? json( + { + error: "handoff_unavailable", + message: "CodeRouter handoff is temporarily unavailable.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); + } + if (error instanceof HandoffUnauthorizedError) { + return json({ error: "unauthorized" }, 401); + } + if (error instanceof HandoffInvalidRequestError) { + return json({ error: "invalid_request" }, 400); + } + if (error instanceof HandoffPayloadTooLargeError) { + return json({ error: "payload_too_large" }, 413); + } + if (error instanceof HandoffForbiddenError) { + return json({ error: "forbidden" }, 403); + } + if (error instanceof HandoffEntitlementRequiredError) { + return json({ error: "pro_required", retryable: false }, 402); + } + if (error instanceof HandoffAccountDeletionBlockedError) { + return json( + { error: "account_deletion_in_progress", retryable: false }, + 409, + ); + } + if (error instanceof HandoffAuthorizationUnavailableError) { + return json( + { + error: "authorization_unavailable", + message: "CodeRouter authorization is temporarily unavailable.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); + } + if (error instanceof HandoffEntitlementUnavailableError) { + return json( + { + error: "entitlement_unavailable", + message: "CodeRouter entitlement could not be verified.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); + } + if (error instanceof HandoffConfigurationError) { + return json( + { + error: "handoff_unavailable", + message: "CodeRouter handoff could not be exchanged.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); + } + if (error instanceof HandoffInvalidLeaseError) { + return json({ error: "invalid_handoff_lease", retryable: false }, 401); + } + return json( + { + error: "handoff_unavailable", + message: "CodeRouter handoff could not be processed.", + retryable: true, + }, + 503, + { "retry-after": "5" }, + ); +} + +export async function runHandoffWorkflow( + program: Effect.Effect, +) { + return await Effect.runPromise(Effect.either(program)); +} diff --git a/web/services/coderouter/observability.ts b/web/services/coderouter/observability.ts index 91e52d2a670..2a6a907b495 100644 --- a/web/services/coderouter/observability.ts +++ b/web/services/coderouter/observability.ts @@ -1,4 +1,7 @@ -import { reportError } from "../observability/report"; +import { + isSensitiveObservabilityKey, + reportError, +} from "../observability/report"; type CodeRouterFailure = | "credential_decrypt" @@ -9,10 +12,9 @@ type CodeRouterFailure = | "rds" | "analytics_delivery" | "analytics_query" + | "configuration" | "upstream_transport"; -const SENSITIVE_CONTEXT_KEY = /account.?id|authorization|body|content|cookie|credential|email|header|key|prompt|response|secret|session|team.?id|token/i; - export function addCoderouterBreadcrumb( category: string, message: string, @@ -20,7 +22,7 @@ export function addCoderouterBreadcrumb( level: "debug" | "info" | "warning" | "error" = "info", ): void { const safeData = Object.fromEntries( - Object.entries(data).filter(([key]) => !SENSITIVE_CONTEXT_KEY.test(key)), + Object.entries(data).filter(([key]) => !isSensitiveObservabilityKey(key)), ); void import("@sentry/nextjs") .then((Sentry) => { @@ -46,16 +48,19 @@ export function reportCoderouterFailure( context: Readonly> = {}, ): void { const errorType = error instanceof Error ? error.name : typeof error; + const safeContext = Object.fromEntries( + Object.entries(context).filter(([key]) => !isSensitiveObservabilityKey(key)), + ); addCoderouterBreadcrumb( "error", `coderouter.${failure}`, - { failure, errorType, ...context }, + { failure, errorType, ...safeContext }, "error", ); reportError(new Error(`coderouter.${failure}`), { service: "coderouter", failure, errorType, - ...context, + ...safeContext, }); } diff --git a/web/services/coderouter/repository.ts b/web/services/coderouter/repository.ts index cd473d272d4..23b5cb9f986 100644 --- a/web/services/coderouter/repository.ts +++ b/web/services/coderouter/repository.ts @@ -1,9 +1,16 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { and, eq, gt, isNotNull, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm"; +import { and, eq, gt, inArray, isNotNull, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm"; import { cloudDb } from "../../db/client"; +import { + AccountDeletionMutationBlockedError, + accountDeletionAdvisoryLockKey, + assertAccountDeletionUserMutationAllowed, +} from "../account/deletionLock"; +import { reportCoderouterFailure } from "./observability"; import { coderouterAccounts, coderouterCredentials, + coderouterHandoffLeases, coderouterRouteTokens, coderouterVaultLeases, } from "../../db/schema"; @@ -15,8 +22,25 @@ import type { } from "./types"; const ROUTE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000; +export const CODEROUTER_HANDOFF_LEASE_TTL_MS = 2 * 60 * 1_000; +const CODEROUTER_HANDOFF_LEASE_BYTES = 32; +const CODEROUTER_HANDOFF_LEASE_SUFFIX_LENGTH = 43; +const MAX_HANDOFF_PRINCIPAL_ID_LENGTH = 200; +const CODEROUTER_HANDOFF_LEASE_PATTERN = new RegExp( + `^crh_[A-Za-z0-9_-]{${CODEROUTER_HANDOFF_LEASE_SUFFIX_LENGTH}}$`, +); +const HANDOFF_LEASE_RETENTION_MS = 10 * 60 * 1_000; +const HANDOFF_LEASE_CLEANUP_BATCH_SIZE = 100; +const HANDOFF_LOCK_NAMESPACE = "coderouter-handoff"; const VAULT_LEASE_MS = 30_000; const REFRESH_LEASE_MS = 30_000; +export type CodeRouterHandoffTransaction = Parameters< + Parameters["transaction"]>[0] +>[0]; + +export class CodeRouterHandoffEntitlementDenied extends Error { + readonly _tag = "CodeRouterHandoffEntitlementDenied"; +} export class CodeRouterLeaseBusy extends Error { readonly _tag = "CodeRouterLeaseBusy"; @@ -30,47 +54,364 @@ export function routeTokenHash(token: string): string { return createHash("sha256").update(token, "utf8").digest("hex"); } +/** + * Hashes the bearer value before it crosses the database boundary. Keep this + * separate from routeTokenHash so callers cannot accidentally persist a + * handoff value while adding a new repository operation. + */ +export function handoffLeaseHash(lease: string): string { + return createHash("sha256").update(lease, "utf8").digest("hex"); +} + +function handoffLockKey(scope: "team" | "user", id: string): string { + const digest = createHash("sha256").update(id, "utf8").digest("hex"); + return `${HANDOFF_LOCK_NAMESPACE}:${scope}:${digest}`; +} + +async function lockHandoffUser( + tx: CodeRouterHandoffTransaction, + stackUserId: string, +): Promise { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${handoffLockKey("user", stackUserId)}, 0))`, + ); +} + +async function lockHandoffPrincipal( + tx: CodeRouterHandoffTransaction, + teamId: string, + stackUserId: string, +): Promise { + // Every operation that can create or invalidate authority acquires the + // team lock first, then the user lock. Keeping this order avoids deadlocks + // between team-scoped and user-scoped billing revocations. + await lockHandoffTeam(tx, teamId); + await lockHandoffUser(tx, stackUserId); +} + +async function lockHandoffTeam( + tx: CodeRouterHandoffTransaction, + teamId: string, +): Promise { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${handoffLockKey("team", teamId)}, 0))`, + ); +} + +/** + * Account deletion and handoff operations use the account-deletion lock first, + * then team and user handoff locks. Keeping invalidation in this transaction + * makes the tombstone/authority boundary atomic: a mint or exchange cannot + * insert or claim a lease after deletion has won the user lock. + */ +export async function invalidateCoderouterHandoffAuthority( + tx: CodeRouterHandoffTransaction, + input: { + readonly stackUserId: string; + readonly teamIds?: readonly string[]; + }, + now = new Date(), +): Promise { + const teamIds = [...new Set(input.teamIds ?? [])] + .filter((teamId) => boundedHandoffPrincipalId(teamId)) + .sort(); + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${accountDeletionAdvisoryLockKey(input.stackUserId)}, 0))`, + ); + for (const teamId of teamIds) { + await lockHandoffTeam(tx, teamId); + } + await lockHandoffUser(tx, input.stackUserId); + + const leaseAuthority = teamIds.length > 0 + ? or( + eq(coderouterHandoffLeases.stackUserId, input.stackUserId), + inArray(coderouterHandoffLeases.teamId, teamIds), + ) + : eq(coderouterHandoffLeases.stackUserId, input.stackUserId); + const routeAuthority = teamIds.length > 0 + ? or( + eq(coderouterRouteTokens.stackUserId, input.stackUserId), + inArray(coderouterRouteTokens.teamId, teamIds), + ) + : eq(coderouterRouteTokens.stackUserId, input.stackUserId); + await tx + .update(coderouterHandoffLeases) + .set({ consumedAt: now }) + .where(and(leaseAuthority, isNull(coderouterHandoffLeases.consumedAt))); + await tx + .update(coderouterRouteTokens) + .set({ revokedAt: now }) + .where(and(routeAuthority, isNull(coderouterRouteTokens.revokedAt))); +} + +export function isValidCoderouterHandoffLease(lease: string): boolean { + return CODEROUTER_HANDOFF_LEASE_PATTERN.test(lease); +} + +function newRouteToken(now: Date): { token: string; expiresAt: Date } { + const token = `crt_${randomBytes(32).toString("base64url")}`; + return { + token, + expiresAt: new Date(now.getTime() + ROUTE_TOKEN_LIFETIME_MS), + }; +} + export async function issueRouteToken( teamId: string, stackUserId: string, label = "cli", ): Promise<{ token: string; expiresAt: Date }> { - const token = `crt_${randomBytes(32).toString("base64url")}`; - const expiresAt = new Date(Date.now() + ROUTE_TOKEN_LIFETIME_MS); + const issued = newRouteToken(new Date()); await cloudDb().insert(coderouterRouteTokens).values({ teamId, stackUserId, - tokenHash: routeTokenHash(token), + tokenHash: routeTokenHash(issued.token), label, - expiresAt, + expiresAt: issued.expiresAt, + }); + return issued; +} + +export async function issueCoderouterHandoffLease( + teamId: string, + stackUserId: string, + now = new Date(), + authorize?: CodeRouterHandoffAuthorizer, +): Promise<{ lease: string; expiresAt: Date }> { + if (!boundedHandoffPrincipalId(teamId) || !boundedHandoffPrincipalId(stackUserId)) { + throw new Error("invalid CodeRouter handoff principal"); + } + const lease = `crh_${ + randomBytes(CODEROUTER_HANDOFF_LEASE_BYTES).toString("base64url") + }`; + const expiresAt = new Date(now.getTime() + CODEROUTER_HANDOFF_LEASE_TTL_MS); + const db = cloudDb(); + // Cleanup is deliberately isolated from issuance. PostgreSQL aborts the + // surrounding transaction after a failed DELETE; catching that exception + // would not make a subsequent INSERT usable. + const cleanupBefore = new Date( + now.getTime() - HANDOFF_LEASE_RETENTION_MS, + ).toISOString(); + try { + await db.transaction(async (tx) => { + await tx.execute(sql` + delete from "coderouter_handoff_leases" + where "id" in ( + select "id" + from "coderouter_handoff_leases" + where "expires_at" < ${cleanupBefore}::timestamptz + order by "expires_at" asc + limit ${HANDOFF_LEASE_CLEANUP_BATCH_SIZE} + for update skip locked + ) + `); + }); + } catch (error) { + // Expired rows are harmless; leave them for the next mint or scheduled + // database maintenance rather than failing closed on cleanup alone. + try { + reportCoderouterFailure("rds", error, { + operation: "cleanup_handoff_leases", + }); + } catch { + // Observability is also best-effort; issuance must remain independent. + } + } + + await db.transaction(async (tx) => { + // Serialize minting with billing revocation on the same principal lock. + // The entitlement read happens before this repository call, but the + // transaction-bound authorizer below rechecks it after this lock. This + // makes the entitlement decision and lease insert one authority check. + await assertAccountDeletionUserMutationAllowed(tx, stackUserId); + await lockHandoffPrincipal(tx, teamId, stackUserId); + if (authorize && !(await authorize({ teamId, stackUserId }, tx))) { + throw new CodeRouterHandoffEntitlementDenied( + "CodeRouter entitlement is no longer active", + ); + } + await tx.insert(coderouterHandoffLeases).values({ + teamId, + stackUserId, + leaseHash: handoffLeaseHash(lease), + expiresAt, + createdAt: now, + }); + }); + return { lease, expiresAt }; +} + +function boundedHandoffPrincipalId(value: string): boolean { + return value.length > 0 && + value.length <= MAX_HANDOFF_PRINCIPAL_ID_LENGTH && + value === value.trim() && + !/[\u0000-\u001f\u007f]/.test(value); +} + +export type CodeRouterHandoffIdentity = { + readonly teamId?: string; + readonly stackUserId?: string; +}; + +export type CodeRouterHandoffEntitlementDb = Pick< + ReturnType, + "select" +>; + +export type CodeRouterHandoffAuthorizer = ( + identity: { + readonly teamId: string; + readonly stackUserId: string; + }, + db: CodeRouterHandoffEntitlementDb, +) => Promise; + +/** + * Claims a handoff row and inserts the existing route-token record in one + * database transaction. PostgreSQL's conditional UPDATE serializes competing + * claims; a failed token insert rolls the consumed marker back with the + * transaction, so a transient database error never burns a valid lease. An + * optional authorizer is evaluated against the stored principal immediately + * before the claim, which lets hosted billing revalidate possession-only + * exchanges without requiring Stack credentials. + */ +export async function exchangeCoderouterHandoffLease( + lease: string, + now = new Date(), + expectedIdentity: CodeRouterHandoffIdentity = {}, + authorize?: CodeRouterHandoffAuthorizer, +): Promise< + | { + readonly teamId: string; + readonly stackUserId: string; + readonly token: string; + readonly expiresAt: Date; + } + | null +> { + if (!isValidCoderouterHandoffLease(lease)) return null; + const hasExpectedTeam = expectedIdentity.teamId !== undefined; + const hasExpectedUser = expectedIdentity.stackUserId !== undefined; + if ( + (hasExpectedTeam && + !boundedHandoffPrincipalId(expectedIdentity.teamId!)) || + (hasExpectedUser && + !boundedHandoffPrincipalId(expectedIdentity.stackUserId!)) + ) { + return null; + } + + const leaseHash = handoffLeaseHash(lease); + const predicates = [ + eq(coderouterHandoffLeases.leaseHash, leaseHash), + gt(coderouterHandoffLeases.expiresAt, sql`now()`), + isNull(coderouterHandoffLeases.consumedAt), + ...(hasExpectedTeam + ? [eq(coderouterHandoffLeases.teamId, expectedIdentity.teamId!)] + : []), + ...(hasExpectedUser + ? [eq(coderouterHandoffLeases.stackUserId, expectedIdentity.stackUserId!)] + : []), + ]; + return await cloudDb().transaction(async (tx) => { + const [candidate] = await tx + .select({ + teamId: coderouterHandoffLeases.teamId, + stackUserId: coderouterHandoffLeases.stackUserId, + }) + .from(coderouterHandoffLeases) + .where(and(...predicates)) + .limit(1); + if (!candidate) return null; + try { + await assertAccountDeletionUserMutationAllowed(tx, candidate.stackUserId); + } catch (error) { + if (error instanceof AccountDeletionMutationBlockedError) return null; + throw error; + } + await lockHandoffPrincipal(tx, candidate.teamId, candidate.stackUserId); + if (authorize && !(await authorize(candidate, tx))) return null; + + const [claimed] = await tx + .update(coderouterHandoffLeases) + .set({ consumedAt: now }) + .where(and( + ...predicates, + gt(coderouterHandoffLeases.expiresAt, sql`now()`), + )) + .returning({ + teamId: coderouterHandoffLeases.teamId, + stackUserId: coderouterHandoffLeases.stackUserId, + }); + if (!claimed) return null; + + const issued = newRouteToken(now); + await tx.insert(coderouterRouteTokens).values({ + teamId: claimed.teamId, + stackUserId: claimed.stackUserId, + tokenHash: routeTokenHash(issued.token), + label: "native-handoff", + expiresAt: issued.expiresAt, + }); + return { ...claimed, ...issued }; }); - return { token, expiresAt }; } export async function revokeRouteTokensForUser( stackUserId: string, now = new Date(), ): Promise { - await cloudDb() - .update(coderouterRouteTokens) - .set({ revokedAt: now }) - .where(and( - eq(coderouterRouteTokens.stackUserId, stackUserId), - isNull(coderouterRouteTokens.revokedAt), - )); + await cloudDb().transaction(async (tx) => { + // Lock the handoff authority before revoking route tokens. Exchange uses + // the same order, so a billing revocation cannot race a lease claim and + // leave a freshly inserted route token alive. + // User-scoped billing revocation has no team selector; take the user + // authority lock used by all user-scoped handoff operations. + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${accountDeletionAdvisoryLockKey(stackUserId)}, 0))`, + ); + await lockHandoffUser(tx, stackUserId); + await tx + .update(coderouterHandoffLeases) + .set({ consumedAt: now }) + .where(and( + eq(coderouterHandoffLeases.stackUserId, stackUserId), + isNull(coderouterHandoffLeases.consumedAt), + )); + await tx + .update(coderouterRouteTokens) + .set({ revokedAt: now }) + .where(and( + eq(coderouterRouteTokens.stackUserId, stackUserId), + isNull(coderouterRouteTokens.revokedAt), + )); + }); } export async function revokeRouteTokensForTeam( teamId: string, now = new Date(), ): Promise { - await cloudDb() - .update(coderouterRouteTokens) - .set({ revokedAt: now }) - .where(and( - eq(coderouterRouteTokens.teamId, teamId), - isNull(coderouterRouteTokens.revokedAt), - )); + await cloudDb().transaction(async (tx) => { + // Team billing revocation and handoff mint/exchange share this authority + // lock. User deletion additionally takes the account-deletion lock. + await lockHandoffTeam(tx, teamId); + await tx + .update(coderouterHandoffLeases) + .set({ consumedAt: now }) + .where(and( + eq(coderouterHandoffLeases.teamId, teamId), + isNull(coderouterHandoffLeases.consumedAt), + )); + await tx + .update(coderouterRouteTokens) + .set({ revokedAt: now }) + .where(and( + eq(coderouterRouteTokens.teamId, teamId), + isNull(coderouterRouteTokens.revokedAt), + )); + }); } export async function authenticateRouteToken( @@ -117,6 +458,11 @@ export async function deleteAccount(input: { }): Promise<{ removed: boolean; lastAccount: boolean }> { const now = input.now ?? new Date(); return await cloudDb().transaction(async (tx) => { + // Removing the last provider account revokes the team's handoff + // authority. Serialize that decision with mint/exchange before checking + // for remaining accounts; otherwise an exchange that already selected a + // lease could claim it after this transaction's revocation update. + await lockHandoffTeam(tx, input.teamId); const [removed] = await tx .delete(coderouterAccounts) .where(and( @@ -135,6 +481,13 @@ export async function deleteAccount(input: { .where(eq(coderouterAccounts.teamId, input.teamId)) .limit(1); if (!remaining) { + await tx + .update(coderouterHandoffLeases) + .set({ consumedAt: now }) + .where(and( + eq(coderouterHandoffLeases.teamId, input.teamId), + isNull(coderouterHandoffLeases.consumedAt), + )); await tx .update(coderouterRouteTokens) .set({ revokedAt: now }) diff --git a/web/services/errors.ts b/web/services/errors.ts index 4eab0d396de..d556ae914a0 100644 --- a/web/services/errors.ts +++ b/web/services/errors.ts @@ -2,6 +2,10 @@ import * as Sentry from "@sentry/nextjs"; import { env } from "../app/env"; +const SECRET_CONTEXT_KEY = + /^(authorization|body|cookie|credential|email|handoff(?:[_-]?lease)?|lease|prompt|response|secret|(?:access|refresh|route|handoff)?[_-]?token)$/i; +const SECRET_CONTEXT_VALUE = /\b(?:crt|crh)_[A-Za-z0-9_-]{32,}\b/g; + export function captureBillingError( error: unknown, context: Record = {}, @@ -48,7 +52,14 @@ function cleanContext( ): Record { const cleaned: Record = {}; for (const [key, value] of Object.entries(context)) { - if (value !== null && value !== undefined) cleaned[key] = value; + if (value === null || value === undefined) continue; + if (SECRET_CONTEXT_KEY.test(key)) { + cleaned[key] = "[Filtered]"; + continue; + } + cleaned[key] = typeof value === "string" + ? value.replace(SECRET_CONTEXT_VALUE, "[Filtered token]") + : value; } return cleaned; } diff --git a/web/services/observability/report.ts b/web/services/observability/report.ts index 4b8197069e0..23ab8f42596 100644 --- a/web/services/observability/report.ts +++ b/web/services/observability/report.ts @@ -1,4 +1,5 @@ -const SENSITIVE_KEY_PATTERN = /authorization|cookie|credential|dsn|key|password|providerMetadata|secret|token|webhook/i; +const SENSITIVE_KEY_TOKEN = + /(?:^|_)(?:account|authorization|body|completion|content|cookie|credential|dsn|email|handoff|header|key|lease|output|password|prompt|provider|request|response|secret|session|team|team_id|session_id|webhook)(?:_|$)/; export function reportError(error: unknown, context: Record): void { const safeContext = scrubContext(context); @@ -34,7 +35,8 @@ function scrubContext(context: Record): Record return scrubbed; } -const SENSITIVE_TEXT_PATTERN = /(srt_[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{8,}|Bearer\s+\S+|eyJ[A-Za-z0-9_-]{10,})/g; +const SENSITIVE_TEXT_PATTERN = + /((?:crt|crh)_[A-Za-z0-9_-]{32,}|srt_[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{8,}|Bearer\s+\S+|eyJ[A-Za-z0-9_-]{10,})/g; function scrubErrorForLog(error: unknown): string { const name = @@ -49,7 +51,7 @@ function scrubErrorForLog(error: unknown): string { } function scrubValue(key: string, value: unknown): unknown { - if (SENSITIVE_KEY_PATTERN.test(key)) return "[redacted]"; + if (isSensitiveObservabilityKey(key)) return "[redacted]"; if (Array.isArray(value)) return value.map((entry) => scrubValue(key, entry)); if (!value || typeof value !== "object") return value; const scrubbed: Record = {}; @@ -58,3 +60,13 @@ function scrubValue(key: string, value: unknown): unknown { } return scrubbed; } + +export function isSensitiveObservabilityKey(key: string): boolean { + const normalized = key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^A-Za-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return SENSITIVE_KEY_TOKEN.test(normalized); +} diff --git a/web/services/sentry.ts b/web/services/sentry.ts index 07c50e89c09..caa4fe8e3e2 100644 --- a/web/services/sentry.ts +++ b/web/services/sentry.ts @@ -1,8 +1,9 @@ import type { Event } from "@sentry/nextjs"; const SECRET_KEY = - /^(authorization|body|completion|content|cookie|email|output|prompt|provider_?account_?id|response|set-cookie|x-coderouter-route-token|x-stack-access-token|x-stack-refresh-token|access_token|refresh_token|id_token|credential|ciphertext|encryptedDataKey)$/i; + /^(authorization|body|completion|content|cookie|email|handoff_?lease|output|prompt|provider_?account_?id|response|set-cookie|x-coderouter-(route|handoff)-token|x-coderouter-handoff-lease|x-stack-access-token|x-stack-refresh-token|access_token|refresh_token|id_token|credential|ciphertext|encryptedDataKey)$/i; const ROUTE_TOKEN = /\bcrt_[A-Za-z0-9_-]{32,}\b/g; +const HANDOFF_LEASE = /\bcrh_[A-Za-z0-9_-]{32,}\b/g; const BEARER_TOKEN = /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/gi; const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\b/g; const API_KEY = /\b(?:sk|srt)_[A-Za-z0-9_-]{8,}\b/g; @@ -51,6 +52,7 @@ function scrubValue(value: unknown): void { if (typeof child === "string") { (value as Record)[childKey] = child .replace(ROUTE_TOKEN, "[Filtered route token]") + .replace(HANDOFF_LEASE, "[Filtered handoff lease]") .replace(BEARER_TOKEN, "Bearer [Filtered]") .replace(JWT, "[Filtered JWT]") .replace(API_KEY, "[Filtered API key]"); diff --git a/web/tests/account-route.test.ts b/web/tests/account-route.test.ts index 980e15c69b6..eaf357a770f 100644 --- a/web/tests/account-route.test.ts +++ b/web/tests/account-route.test.ts @@ -5,6 +5,8 @@ import { accountAnalyticsForwardLeases, accountDeletionTombstones, accountMutationLeases, + coderouterHandoffLeases, + coderouterRouteTokens, cloudVmBaseGenerations, cloudVmBases, cloudVmBillingGrants, @@ -791,7 +793,10 @@ describe("account deletion route", () => { expect(deletedTables).toContain(devices); expect(deletedTables).toContain(proWelcomeFulfillments); const nonStripeUpdates = updatedRows.filter(({ table }) => - table !== stripeSubscriptions && table !== stripeCustomers + table !== stripeSubscriptions && + table !== stripeCustomers && + table !== coderouterHandoffLeases && + table !== coderouterRouteTokens ); expect(nonStripeUpdates.map(({ table, values }) => ({ table, @@ -804,7 +809,9 @@ describe("account deletion route", () => { { table: cloudVmBases, values: { lastOpenedByUserId: null } }, { table: cloudVmBaseGenerations, values: { createdByUserId: "deleted-account" } }, ]); - for (const update of updatedRows) { + for (const update of updatedRows.filter(({ table }) => + table !== coderouterHandoffLeases && table !== coderouterRouteTokens + )) { expect((update.values as { readonly updatedAt?: unknown }).updatedAt).toBeInstanceOf(Date); } expect(deletedVaultObjects).toEqual([ @@ -869,12 +876,10 @@ describe("account deletion route", () => { ) ); expect(leaseRefreshes.length).toBeGreaterThanOrEqual(3); - expect(routeEvents).toEqual([ + expect(routeEvents.filter((event) => event !== "transaction-lock")).toEqual([ "transaction", - "transaction-lock", "tombstone-upsert", "transaction", - "transaction-lock", "analytics-lease-cleanup", "posthog-delete", "metadata-update", @@ -891,10 +896,8 @@ describe("account deletion route", () => { "vault-delete", "vault-delete", "transaction", - "transaction-lock", "stack-delete", "transaction", - "transaction-lock", ]); }); @@ -1395,7 +1398,9 @@ describe("account deletion route", () => { expect(conditionColumnNames(subscriptionDelete?.condition)).toContain("stack_team_id"); const customerDelete = deletedWhere.find((entry) => entry.table === stripeCustomers); expect(conditionColumnNames(customerDelete?.condition)).toContain("stack_team_id"); - expect(transactionExecute).toHaveBeenCalledTimes(6); + // Account deletion now takes both the deletion fence and the handoff + // authority locks before invalidating bearer authority. + expect(transactionExecute).toHaveBeenCalledTimes(18); const grantDelete = deletedWhere.find((entry) => entry.table === cloudVmBillingGrants); expect(conditionColumnNames(grantDelete?.condition)).toContain("billing_customer_id"); const baseDelete = deletedWhere.find((entry) => entry.table === cloudVmBases); @@ -1560,7 +1565,7 @@ describe("account deletion route", () => { providerVmId: "shared-team-vm", provider: "freestyle", }); - expect(transactionExecute).toHaveBeenCalledTimes(4); + expect(transactionExecute).toHaveBeenCalledTimes(14); }); test("uses the listed Stack team when selectedTeam has no member listing", async () => { @@ -1972,7 +1977,7 @@ describe("account deletion route", () => { destroyedVms: 2, }); expect(transaction).toHaveBeenCalledTimes(3); - expect(transactionExecute).toHaveBeenCalledTimes(3); + expect(transactionExecute).toHaveBeenCalledTimes(9); expect(transactionSelect).toHaveBeenCalledTimes(4); expect(deletedTableCount).toBe(0); expect(deleteStackUser).not.toHaveBeenCalled(); @@ -2331,12 +2336,10 @@ describe("account deletion route", () => { (values as { readonly status?: unknown; readonly errorMessage?: unknown }).status === "failed" && (values as { readonly errorMessage?: unknown }).errorMessage === "Error: raw [redacted] leaked by upstream" )).toBe(true); - expect(routeEvents).toEqual([ + expect(routeEvents.filter((event) => event !== "transaction-lock")).toEqual([ "transaction", - "transaction-lock", "tombstone-upsert", "transaction", - "transaction-lock", "analytics-lease-cleanup", "posthog-delete", "metadata-update", @@ -2345,7 +2348,6 @@ describe("account deletion route", () => { "destroy-vm", "destroy-vm", "transaction", - "transaction-lock", "stack-delete", ]); expect(consoleError).toHaveBeenCalledWith( diff --git a/web/tests/coderouter-analytics.test.ts b/web/tests/coderouter-analytics.test.ts index debff835dcb..9f08271ca8a 100644 --- a/web/tests/coderouter-analytics.test.ts +++ b/web/tests/coderouter-analytics.test.ts @@ -183,6 +183,45 @@ describe("coderouter analytics", () => { expect(captured.bodies[0]).not.toContain("free-form error"); }); + test("keeps handoff analytics enum-only even when callers provide token-shaped fields", async () => { + const captured = collector(); + captureCoderouterEvent( + { + event: "coderouter_handoff_lease_issued", + userId: "raw-user", + teamId: "raw-team", + properties: { + authorization_mode: "native_stack", + lease: "crh_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", + token: "crt_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN", + }, + }, + captured.dependencies, + ); + captureCoderouterEvent( + { + event: "coderouter_handoff_lease_exchanged", + userId: "raw-user", + teamId: "raw-team", + properties: { + authorization_mode: "lease", + lease: "crh_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", + route_token: "crt_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN", + }, + }, + captured.dependencies, + ); + + await Promise.all(captured.deferred); + expect(captured.bodies).toHaveLength(2); + for (const body of captured.bodies) { + expect(body).not.toContain("crh_"); + expect(body).not.toContain("crt_"); + expect(body).not.toContain("raw-user"); + expect(body).not.toContain("raw-team"); + } + }); + test("fails closed for usage and ops when isolated configuration is missing", () => { const defer = mock(() => {}); const dependencies = { diff --git a/web/tests/coderouter-handoff-db-behavior.test.ts b/web/tests/coderouter-handoff-db-behavior.test.ts new file mode 100644 index 00000000000..180b151a761 --- /dev/null +++ b/web/tests/coderouter-handoff-db-behavior.test.ts @@ -0,0 +1,365 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import postgres, { type Sql } from "postgres"; + +import { closeCloudDbForTests } from "../db/client"; +import { + accountDeletionUserHash, +} from "../services/account/deletionLock"; +import { + CODEROUTER_HANDOFF_LEASE_TTL_MS, + CodeRouterHandoffEntitlementDenied, + exchangeCoderouterHandoffLease, + handoffLeaseHash, + issueCoderouterHandoffLease, + routeTokenHash, + revokeRouteTokensForTeam, +} from "../services/coderouter/repository"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; + +type HandoffFixture = { + readonly teamId: string; + readonly userId: string; + readonly now: Date; +}; + +let sql: Sql | null = null; + +beforeAll(() => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) { + throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + } + sql = postgres(databaseURL, { max: 8 }); +}); + +afterAll(async () => { + await closeCloudDbForTests(); + await sql?.end(); +}); + +function database(): Sql { + if (!sql) throw new Error("test database not initialized"); + return sql; +} + +function fixture(): HandoffFixture { + return { + teamId: `handoff-team-${randomUUID()}`, + userId: `handoff-user-${randomUUID()}`, + now: new Date(), + }; +} + +async function withFixture(run: (input: HandoffFixture) => Promise): Promise { + const db = database(); + const input = fixture(); + try { + return await run(input); + } finally { + await db`delete from coderouter_route_tokens where team_id = ${input.teamId}`; + await db`delete from coderouter_handoff_leases where team_id = ${input.teamId}`; + } +} + +describe("CodeRouter handoff lease database behavior", () => { + dbTest("stores only lease and route-token hashes", async () => { + await withFixture(async ({ teamId, userId, now }) => { + const issued = await issueCoderouterHandoffLease(teamId, userId, now); + const db = database(); + + const [leaseColumns] = await db>` + select array_agg(column_name order by ordinal_position) as names + from information_schema.columns + where table_schema = 'public' + and table_name = 'coderouter_handoff_leases' + `; + expect(leaseColumns?.names).toContain("lease_hash"); + expect(leaseColumns?.names).not.toContain("lease"); + + const [constraints] = await db>` + select array_agg(constraint_name order by constraint_name) as names + from information_schema.table_constraints + where table_schema = 'public' + and table_name = 'coderouter_handoff_leases' + `; + expect(constraints?.names).toContain( + "coderouter_handoff_leases_hash_format_check", + ); + expect(constraints?.names).toContain( + "coderouter_handoff_leases_expiry_check", + ); + + const [stored] = await db>` + select lease_hash as "leaseHash", consumed_at as "consumedAt" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(stored?.leaseHash).toBe(handoffLeaseHash(issued.lease)); + expect(stored?.leaseHash).not.toBe(issued.lease); + expect(stored?.consumedAt).toBeNull(); + + const exchanged = await exchangeCoderouterHandoffLease(issued.lease, now); + expect(exchanged).not.toBeNull(); + const routeRows = await db>` + select token_hash as "tokenHash", label + from coderouter_route_tokens + where team_id = ${teamId} + `; + expect(routeRows).toHaveLength(1); + expect(routeRows[0]?.tokenHash).toBe(routeTokenHash(exchanged!.token)); + expect(routeRows[0]?.label).toBe("native-handoff"); + + const routeColumns = await db>` + select column_name as name + from information_schema.columns + where table_schema = 'public' + and table_name = 'coderouter_route_tokens' + `; + expect(routeColumns.map((column) => column.name)).not.toContain("token"); + }); + }); + + dbTest("permits one atomic concurrent exchange", async () => { + await withFixture(async ({ teamId, userId, now }) => { + const issued = await issueCoderouterHandoffLease(teamId, userId, now); + const results = await Promise.all([ + exchangeCoderouterHandoffLease(issued.lease, new Date(now)), + exchangeCoderouterHandoffLease(issued.lease, new Date(now)), + ]); + const successfulExchange = results.find((result) => result !== null); + expect(successfulExchange).not.toBeNull(); + expect(results.filter((result) => result === null)).toHaveLength(1); + + const [consumed] = await database()>` + select consumed_at as "consumedAt" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(consumed?.consumedAt).not.toBeNull(); + }); + }); + + dbTest("keeps identity and entitlement failures non-consuming", async () => { + await withFixture(async ({ teamId, userId, now }) => { + const issued = await issueCoderouterHandoffLease(teamId, userId, now); + const delayedNow = new Date(now.getTime() + 1_000); + expect( + await exchangeCoderouterHandoffLease( + issued.lease, + now, + { stackUserId: "different-user" }, + ), + ).toBeNull(); + + const [afterIdentityFailure] = await database()>` + select consumed_at as "consumedAt" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(afterIdentityFailure?.consumedAt).toBeNull(); + + expect( + await exchangeCoderouterHandoffLease( + issued.lease, + delayedNow, + {}, + async () => false, + ), + ).toBeNull(); + expect( + await exchangeCoderouterHandoffLease( + issued.lease, + delayedNow, + {}, + async () => true, + ), + ).not.toBeNull(); + }); + }); + + dbTest("rechecks entitlement in the issuance transaction", async () => { + await withFixture(async ({ teamId, userId, now }) => { + let transactionDbSeen = false; + const issued = await issueCoderouterHandoffLease( + teamId, + userId, + now, + async (identity, tx) => { + transactionDbSeen = typeof tx.select === "function"; + return identity.teamId === teamId && identity.stackUserId === userId; + }, + ); + expect(transactionDbSeen).toBe(true); + + await expect(issueCoderouterHandoffLease( + teamId, + userId, + now, + async () => false, + )).rejects.toBeInstanceOf(CodeRouterHandoffEntitlementDenied); + + const hashes = await database()>` + select lease_hash as "leaseHash" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(hashes.map((row) => row.leaseHash)).toEqual([ + handoffLeaseHash(issued.lease), + ]); + }); + }); + + dbTest("blocks mint and exchange after account deletion tombstone", async () => { + await withFixture(async ({ teamId, userId, now }) => { + const issued = await issueCoderouterHandoffLease(teamId, userId, now); + await database()` + insert into account_deletion_tombstones ( + user_id_hash, + user_id, + status, + updated_at + ) values ( + ${accountDeletionUserHash(userId)}, + ${userId}, + 'pending', + ${now} + ) + `; + try { + await expect( + issueCoderouterHandoffLease(teamId, userId, now), + ).rejects.toThrow(); + expect( + await exchangeCoderouterHandoffLease(issued.lease, now), + ).toBeNull(); + } finally { + await database()` + delete from account_deletion_tombstones + where user_id_hash = ${accountDeletionUserHash(userId)} + `; + } + }); + }); + + dbTest("does not retain a lease when route-token creation fails", async () => { + await withFixture(async ({ teamId, userId, now }) => { + const issued = await issueCoderouterHandoffLease(teamId, userId, now); + await database()` + alter table coderouter_route_tokens + add constraint coderouter_handoff_test_route_token_failure + check (label <> 'native-handoff') + `; + try { + await expect( + exchangeCoderouterHandoffLease(issued.lease, now), + ).rejects.toThrow(); + const [row] = await database()>` + select consumed_at as "consumedAt" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(row?.consumedAt).toBeNull(); + } finally { + await database()` + alter table coderouter_route_tokens + drop constraint coderouter_handoff_test_route_token_failure + `; + } + }); + }); + + dbTest("rejects expired leases without consuming them", async () => { + await withFixture(async ({ teamId, userId }) => { + const issued = await issueCoderouterHandoffLease( + teamId, + userId, + new Date(Date.now() - CODEROUTER_HANDOFF_LEASE_TTL_MS - 60 * 60_000), + ); + expect( + await exchangeCoderouterHandoffLease( + issued.lease, + new Date(), + ), + ).toBeNull(); + + const [row] = await database()>` + select consumed_at as "consumedAt" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(row?.consumedAt).toBeNull(); + }); + }); + + dbTest("invalidates pending leases during billing revocation", async () => { + await withFixture(async ({ teamId, userId, now }) => { + const pending = await issueCoderouterHandoffLease(teamId, userId, now); + await revokeRouteTokensForTeam(teamId, now); + expect( + await exchangeCoderouterHandoffLease(pending.lease, now), + ).toBeNull(); + + const race = await issueCoderouterHandoffLease( + teamId, + userId, + new Date(now.getTime() + 1_000), + ); + await Promise.all([ + exchangeCoderouterHandoffLease( + race.lease, + new Date(now.getTime() + 1_000), + ), + revokeRouteTokensForTeam( + teamId, + new Date(now.getTime() + 1_000), + ), + ]); + const racedRouteRows = await database()>` + select revoked_at as "revokedAt" + from coderouter_route_tokens + where team_id = ${teamId} + `; + expect(racedRouteRows.every((row) => row.revokedAt !== null)).toBe(true); + }); + }); + + dbTest("opportunistically cleans leases beyond the retention window", async () => { + await withFixture(async ({ teamId, userId, now }) => { + await database()` + delete from coderouter_handoff_leases + where expires_at < ${new Date(now.getTime() - 10 * 60_000)} + `; + const stale = await issueCoderouterHandoffLease( + teamId, + userId, + new Date(now.getTime() - 20 * 60_000), + ); + const fresh = await issueCoderouterHandoffLease(teamId, userId, now); + const retainedHashes = await database()>` + select lease_hash as "leaseHash" + from coderouter_handoff_leases + where team_id = ${teamId} + `; + expect(retainedHashes.map((row) => row.leaseHash)).not.toContain( + handoffLeaseHash(stale.lease), + ); + expect(retainedHashes.map((row) => row.leaseHash)).toContain( + handoffLeaseHash(fresh.lease), + ); + }); + }); +}); diff --git a/web/tests/coderouter-handoff-route.test.ts b/web/tests/coderouter-handoff-route.test.ts new file mode 100644 index 00000000000..f9a81e02078 --- /dev/null +++ b/web/tests/coderouter-handoff-route.test.ts @@ -0,0 +1,604 @@ +import { describe, expect, mock, test } from "bun:test"; + +process.env.SKIP_ENV_VALIDATION = "1"; +process.env.SUBROUTER_ALLOWED_TEAM_IDS = "*"; +process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; + +const { makeCoderouterHandoffPostHandler } = await import( + "../app/api/coderouter/handoff/route" +); +const { makeCoderouterHandoffExchangePostHandler } = await import( + "../app/api/coderouter/handoff/exchange/route" +); +const { + coderouterOpenaiBaseUrl, + makeCoderouterHandoffRateLimiter, + validTeamSelectorHeaders, +} = await import("../app/api/coderouter/handoff/_shared"); +const { + CodeRouterHandoffEntitlementDenied, + handoffLeaseHash, + isValidCoderouterHandoffLease, +} = await import("../services/coderouter/repository"); +const { AccountDeletionMutationBlockedError } = await import( + "../services/account/deletionLock" +); + +const LEASE = "crh_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ"; +const EXPIRES_AT = new Date("2026-08-13T12:02:00.000Z"); +const context = { + ok: true as const, + value: { + user: { id: "user_1" }, + team: { + teamId: "team_1", + teamName: "Team", + use: true, + manageAccounts: false, + }, + }, +}; + +function nativeHeaders(extra: Record = {}): Record { + return { + authorization: "Bearer stack-access", + "x-stack-refresh-token": "stack-refresh", + "x-cmux-team-id": "team_1", + ...extra, + }; +} + +function mintRequest( + init: RequestInit = {}, +): Request { + const headers = new Headers(nativeHeaders({ + "content-type": "application/json", + })); + new Headers(init.headers).forEach((value, name) => { + headers.set(name, value); + }); + return new Request("https://cmux.test/api/coderouter/handoff", { + ...init, + method: init.method ?? "POST", + headers, + }); +} + +function exchangeRequest( + lease = LEASE, + headers: Record = {}, +): Request { + return new Request("https://cmux.test/api/coderouter/handoff/exchange", { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify({ lease }), + }); +} + +function allowedRateLimit() { + return async () => "allowed" as const; +} + +describe("CodeRouter native handoff mint", () => { + test("rejects cookie-only callers before resolving a browser session", async () => { + const resolveContext = mock(async () => context); + const POST = makeCoderouterHandoffPostHandler({ + resolveContext: resolveContext as never, + hasActiveEntitlement: mock(async () => true), + issueLease: mock(async () => ({ lease: LEASE, expiresAt: EXPIRES_AT })), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const response = await POST(new Request( + "https://cmux.test/api/coderouter/handoff", + { + method: "POST", + headers: { cookie: "hexclave-access=browser-session" }, + }, + )); + + expect(response.status).toBe(401); + expect(resolveContext).not.toHaveBeenCalled(); + }); + + test("rejects an incomplete or oversized native token pair", async () => { + const resolveContext = mock(async () => context); + const POST = makeCoderouterHandoffPostHandler({ + resolveContext: resolveContext as never, + hasActiveEntitlement: mock(async () => true), + issueLease: mock(async () => ({ lease: LEASE, expiresAt: EXPIRES_AT })), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const incomplete = await POST(mintRequest({ + headers: { "x-stack-refresh-token": "" }, + })); + const oversized = await POST(mintRequest({ + headers: { + "x-stack-refresh-token": "x".repeat(16 * 1024 + 1), + }, + })); + + expect(incomplete.status).toBe(401); + expect(oversized.status).toBe(401); + expect(resolveContext).not.toHaveBeenCalled(); + }); + + test("enforces the existing use and hosted entitlement gates", async () => { + const issueLease = mock(async () => ({ + lease: LEASE, + expiresAt: EXPIRES_AT, + })); + const forbidden = makeCoderouterHandoffPostHandler({ + resolveContext: mock(async () => ({ + ok: true as const, + value: { + ...context.value, + team: { ...context.value.team, use: false }, + }, + })) as never, + hasActiveEntitlement: mock(async () => true), + issueLease, + hostedProRequired: () => true, + rateLimit: allowedRateLimit(), + }); + const noEntitlement = makeCoderouterHandoffPostHandler({ + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => false), + issueLease, + hostedProRequired: () => true, + rateLimit: allowedRateLimit(), + }); + + expect((await forbidden(mintRequest())).status).toBe(403); + expect((await noEntitlement(mintRequest())).status).toBe(402); + expect(issueLease).not.toHaveBeenCalled(); + }); + + test("passes a transaction-bound entitlement recheck to lease issuance", async () => { + const entitlementDb = {}; + const hasActiveEntitlement = mock(async () => true); + const issueLease = mock(async () => { + return { lease: LEASE, expiresAt: EXPIRES_AT }; + }); + const POST = makeCoderouterHandoffPostHandler({ + resolveContext: mock(async () => context) as never, + hasActiveEntitlement, + issueLease: issueLease as never, + hostedProRequired: () => true, + rateLimit: allowedRateLimit(), + }); + + const response = await POST(mintRequest()); + + expect(response.status).toBe(200); + const issueCalls = (issueLease as unknown as { + mock: { calls: unknown[][] }; + }).mock.calls; + const authorizeCallback = issueCalls[0]?.[3] as + | (( + identity: { teamId: string; stackUserId: string }, + db: unknown, + ) => Promise) + | undefined; + expect(authorizeCallback).toBeDefined(); + expect( + await authorizeCallback!( + { teamId: "team_1", stackUserId: "user_1" }, + entitlementDb, + ), + ).toBe(true); + expect(hasActiveEntitlement).toHaveBeenLastCalledWith( + "user_1", + "team_1", + entitlementDb, + ); + }); + + test("returns account_deletion_in_progress when mint loses the deletion lock", async () => { + const issueLease = mock(async () => { + throw new AccountDeletionMutationBlockedError("user_1"); + }); + const POST = makeCoderouterHandoffPostHandler({ + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + issueLease: issueLease as never, + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const response = await POST(mintRequest()); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "account_deletion_in_progress", + retryable: false, + }); + }); + + test("returns pro_required when the serialized entitlement recheck lapses", async () => { + const issueLease = mock(async () => { + throw new CodeRouterHandoffEntitlementDenied("lapsed"); + }); + const POST = makeCoderouterHandoffPostHandler({ + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + issueLease: issueLease as never, + hostedProRequired: () => true, + rateLimit: allowedRateLimit(), + }); + + const response = await POST(mintRequest()); + + expect(response.status).toBe(402); + await expect(response.json()).resolves.toEqual({ + error: "pro_required", + retryable: false, + }); + }); + + test("returns the opaque lease with no-store and never changes the request body contract", async () => { + const issueLease = mock(async () => { + return { lease: LEASE, expiresAt: EXPIRES_AT }; + }); + const POST = makeCoderouterHandoffPostHandler({ + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + issueLease: issueLease as never, + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + now: () => new Date("2026-08-13T12:00:00.000Z"), + }); + + const response = await POST(mintRequest({ body: "{}" })); + + expect(response.status).toBe(200); + const issueCalls = (issueLease as unknown as { + mock: { calls: unknown[][] }; + }).mock.calls; + expect(issueCalls[0]).toEqual([ + "team_1", + "user_1", + new Date("2026-08-13T12:00:00.000Z"), + ]); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + await expect(response.json()).resolves.toEqual({ + teamId: "team_1", + lease: LEASE, + expiresAt: EXPIRES_AT.toISOString(), + }); + }); +}); + +describe("CodeRouter native handoff exchange", () => { + test("exchanges a lease without requiring Stack credentials", async () => { + const exchangeLease = mock(async () => { + return { + teamId: "team_1", + stackUserId: "user_1", + token: "crt_route_token_value", + expiresAt: new Date("2026-09-12T12:00:00.000Z"), + }; + }); + const hasActiveEntitlement = mock(async () => true); + const POST = makeCoderouterHandoffExchangePostHandler({ + exchangeLease: exchangeLease as never, + resolveContext: mock(async () => context) as never, + hasActiveEntitlement, + hostedProRequired: () => true, + rateLimit: allowedRateLimit(), + publicOrigin: () => "https://coderouter.dev", + }); + + const response = await POST(exchangeRequest()); + + expect(response.status).toBe(200); + // Possession-only exchange must not perform the native entitlement gate; + // the callback is only supplied for the repository's stored-principal + // recheck immediately before the atomic claim. + expect(hasActiveEntitlement).not.toHaveBeenCalled(); + const exchangeCalls = (exchangeLease as unknown as { + mock: { calls: unknown[][] }; + }).mock.calls; + expect(exchangeCalls[0]?.[0]).toBe(LEASE); + expect(exchangeCalls[0]?.[2]).toEqual({}); + expect(typeof exchangeCalls[0]?.[3]).toBe("function"); + const authorizeCallback = exchangeCalls[0]?.[3] as + | (( + identity: { teamId: string; stackUserId: string }, + db: unknown, + ) => Promise) + | undefined; + expect(authorizeCallback).toBeDefined(); + expect( + await authorizeCallback!( + { teamId: "team_1", stackUserId: "user_1" }, + {}, + ), + ).toBe(true); + expect(hasActiveEntitlement).toHaveBeenCalledWith("user_1", "team_1", {}); + await expect(response.json()).resolves.toMatchObject({ + teamId: "team_1", + token: "crt_route_token_value", + openaiBaseUrl: "https://coderouter.dev/v1", + }); + }); + + test("fails closed before consuming a lease when deployed origin is missing", async () => { + const exchangeLease = mock(async () => { + throw new Error("repository must not be reached"); + }); + const previousVercel = process.env.VERCEL; + process.env.VERCEL = "1"; + try { + const POST = makeCoderouterHandoffExchangePostHandler({ + exchangeLease, + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + publicOrigin: () => undefined, + }); + + const response = await POST(exchangeRequest()); + + expect(response.status).toBe(503); + expect(exchangeLease).not.toHaveBeenCalled(); + } finally { + if (previousVercel === undefined) { + delete process.env.VERCEL; + } else { + process.env.VERCEL = previousVercel; + } + } + }); + + test("does not treat a browser cookie as native exchange authorization", async () => { + const exchangeLease = mock(async () => { + throw new Error("repository must not be reached"); + }); + const POST = makeCoderouterHandoffExchangePostHandler({ + exchangeLease, + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const response = await POST(exchangeRequest( + LEASE, + { cookie: "hexclave-access=browser-session" }, + )); + + expect(response.status).toBe(401); + expect(exchangeLease).not.toHaveBeenCalled(); + }); + + test("optionally binds a native-confirmed exchange to the same principal", async () => { + const exchangeLease = mock(async () => null); + const POST = makeCoderouterHandoffExchangePostHandler({ + exchangeLease: exchangeLease as never, + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const response = await POST(exchangeRequest(LEASE, nativeHeaders())); + + expect(response.status).toBe(401); + const exchangeCalls = (exchangeLease as unknown as { + mock: { calls: unknown[][] }; + }).mock.calls; + expect(exchangeCalls[0]?.[2]).toEqual({ + teamId: "team_1", + stackUserId: "user_1", + }); + expect((await response.json()).error).toBe("invalid_handoff_lease"); + }); + + test("treats replay, expiry, and identity mismatch as one generic failure", async () => { + let consumed = false; + const exchangeLease = mock(async () => { + if (consumed) return null; + consumed = true; + return { + teamId: "team_1", + stackUserId: "user_1", + token: "crt_once", + expiresAt: EXPIRES_AT, + }; + }); + const POST = makeCoderouterHandoffExchangePostHandler({ + exchangeLease, + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const [first, replay] = await Promise.all([ + POST(exchangeRequest()), + POST(exchangeRequest()), + ]); + expect([first.status, replay.status].sort()).toEqual([200, 401]); + + const expired = makeCoderouterHandoffExchangePostHandler({ + exchangeLease: mock(async () => null), + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + expect((await expired(exchangeRequest())).status).toBe(401); + }); + + test("bounds and validates the lease body before touching the repository", async () => { + const exchangeLease = mock(async () => null); + const POST = makeCoderouterHandoffExchangePostHandler({ + exchangeLease, + resolveContext: mock(async () => context) as never, + hasActiveEntitlement: mock(async () => true), + hostedProRequired: () => false, + rateLimit: allowedRateLimit(), + }); + + const malformed = await POST(exchangeRequest("crh_bad")); + const wrongContentType = await POST(new Request( + "https://cmux.test/api/coderouter/handoff/exchange", + { + method: "POST", + headers: { "content-type": "application/jsonp" }, + body: JSON.stringify({ lease: LEASE }), + }, + )); + const oversized = await POST(new Request( + "https://cmux.test/api/coderouter/handoff/exchange", + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(2 * 1024 + 1), + }, + body: JSON.stringify({ lease: LEASE }), + }, + )); + + expect(malformed.status).toBe(400); + expect(wrongContentType.status).toBe(400); + expect(oversized.status).toBe(413); + expect(exchangeLease).not.toHaveBeenCalled(); + }); +}); + +describe("CodeRouter handoff secret representation", () => { + test("uses exact opaque syntax and a fixed-length digest", () => { + expect(isValidCoderouterHandoffLease(LEASE)).toBe(true); + expect(isValidCoderouterHandoffLease(`${LEASE} `)).toBe(false); + expect(isValidCoderouterHandoffLease("crh_short")).toBe(false); + expect(handoffLeaseHash(LEASE)).toMatch(/^[0-9a-f]{64}$/); + expect(handoffLeaseHash(LEASE)).not.toContain(LEASE); + }); + + test("uses a trusted configured data-plane origin instead of the request host", () => { + const request = new Request( + "https://attacker.example/api/coderouter/handoff/exchange", + ); + expect(coderouterOpenaiBaseUrl(request, "https://coderouter.dev")) + .toBe("https://coderouter.dev/v1"); + + const previousVercel = process.env.VERCEL; + process.env.VERCEL = "1"; + try { + expect(coderouterOpenaiBaseUrl(request, "https://coderouter.dev/path")) + .toBeNull(); + expect(coderouterOpenaiBaseUrl(request)).toBeNull(); + } finally { + if (previousVercel === undefined) { + delete process.env.VERCEL; + } else { + process.env.VERCEL = previousVercel; + } + } + }); + + test("rejects ambiguous team selectors before Stack resolves a team", () => { + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff?teamId=team_1&team_id=team_2", + ))).toBe(false); + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff?teamId=team_1&teamId=team_1", + ))).toBe(true); + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff?teamId=team_1&teamId=team_2", + ))).toBe(false); + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff?teamId=team_1", + { headers: { "x-cmux-team-id": "team_2" } }, + ))).toBe(false); + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff?teamId=team_1", + { headers: { "x-cmux-team-id": "team_1" } }, + ))).toBe(true); + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff", + { + headers: [ + ["x-cmux-team-id", "team_1"], + ["x-cmux-team-id", "team_1"], + ], + }, + ))).toBe(true); + expect(validTeamSelectorHeaders(new Request( + "https://cmux.test/api/coderouter/handoff", + { + headers: [ + ["x-cmux-team-id", "team_1"], + ["x-cmux-team-id", "team_2"], + ], + }, + ))).toBe(false); + }); +}); + +describe("CodeRouter handoff rate limiting", () => { + test("fails closed when durable production limiting is absent or unavailable", async () => { + const checkRateLimit = mock(async () => ({ + rateLimited: false, + error: "firewall-unavailable", + })); + const request = exchangeRequest(); + const unavailable = makeCoderouterHandoffRateLimiter({ + isVercel: () => true, + rateLimitId: () => "coderouter-handoff", + checkRateLimit: checkRateLimit as never, + }); + const missing = makeCoderouterHandoffRateLimiter({ + isVercel: () => true, + checkRateLimit: checkRateLimit as never, + }); + + expect(await unavailable(request)).toBe("unavailable"); + expect(await missing(request)).toBe("unavailable"); + }); + + test("does not pass token-derived keys to the durable limiter", async () => { + const checkRateLimit = mock(async () => ({ + rateLimited: false, + error: null, + })); + const limiter = makeCoderouterHandoffRateLimiter({ + isVercel: () => true, + rateLimitId: () => "coderouter-handoff", + checkRateLimit: checkRateLimit as never, + }); + + expect(await limiter(exchangeRequest())).toBe("allowed"); + expect(checkRateLimit).toHaveBeenCalledTimes(1); + const options = (checkRateLimit as unknown as { + mock: { calls: unknown[][] }; + }).mock.calls[0]?.[1] as { request?: unknown }; + expect(Object.keys(options)).toEqual(["request"]); + expect(options.request).toBeInstanceOf(Request); + }); + + test("keeps a bounded local development backstop", async () => { + let now = 1_000; + const limiter = makeCoderouterHandoffRateLimiter({ + isVercel: () => false, + now: () => now, + }); + for (let index = 0; index < 60; index += 1) { + expect(await limiter(exchangeRequest())).toBe("allowed"); + } + expect(await limiter(exchangeRequest())).toBe("limited"); + now += 60_001; + expect(await limiter(exchangeRequest())).toBe("allowed"); + }); +}); diff --git a/web/tests/coderouter-sentry.test.ts b/web/tests/coderouter-sentry.test.ts index fdfbca735bb..7d1491ec978 100644 --- a/web/tests/coderouter-sentry.test.ts +++ b/web/tests/coderouter-sentry.test.ts @@ -5,6 +5,7 @@ import { scrubSentryEvent, shouldSendCoderouterSentryEvent, } from "../services/sentry"; +import { isSensitiveObservabilityKey } from "../services/observability/report"; describe("coderouter Sentry privacy", () => { test("isolates the shared cmux deployment to coderouter events", () => { @@ -30,10 +31,10 @@ describe("coderouter Sentry privacy", () => { ).toBe(false); }); - test("removes request bodies, auth headers, route tokens, JWTs, and PII", () => { + test("removes request bodies, auth headers, route tokens, handoff leases, JWTs, and PII", () => { const event = scrubSentryEvent({ message: - "Bearer secret-bearer-token-123 crt_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN eyJabcdefghijk.payload.signature", + "Bearer secret-bearer-token-123 crt_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN crh_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ eyJabcdefghijk.payload.signature", request: { data: { refresh_token: "refresh-secret" }, cookies: { session: "secret" }, @@ -41,6 +42,7 @@ describe("coderouter Sentry privacy", () => { authorization: "Bearer secret", cookie: "session=secret", "x-coderouter-route-token": "crt_secret", + "x-coderouter-handoff-lease": "crh_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", "x-api-key": "opaque-secret", accept: "application/json", }, @@ -54,6 +56,7 @@ describe("coderouter Sentry privacy", () => { credential: "secret", prompt: "private prompt", provider_account_id: "provider-secret", + handoff_lease: "crh_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", nested: { refresh_token: "also-secret" }, }, breadcrumbs: [ @@ -75,6 +78,7 @@ describe("coderouter Sentry privacy", () => { expect(event.user).toBeUndefined(); expect(event.extra).toEqual({ credential: "[Filtered]", + handoff_lease: "[Filtered]", prompt: "[Filtered]", provider_account_id: "[Filtered]", nested: { refresh_token: "[Filtered]" }, @@ -87,6 +91,16 @@ describe("coderouter Sentry privacy", () => { }); expect(event.message).not.toContain("secret-bearer"); expect(event.message).not.toContain("crt_"); + expect(event.message).not.toContain("crh_"); expect(event.message).not.toContain("eyJabcdefghijk"); + expect(event.extra?.handoff_lease).toBe("[Filtered]"); + }); + + test("scrubs normalized identity keys, including acronym forms", () => { + expect(isSensitiveObservabilityKey("teamId")).toBe(true); + expect(isSensitiveObservabilityKey("team_id")).toBe(true); + expect(isSensitiveObservabilityKey("sessionId")).toBe(true); + expect(isSensitiveObservabilityKey("APIKey")).toBe(true); + expect(isSensitiveObservabilityKey("releaseVersion")).toBe(false); }); });