Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions docs/coderouter-handoff-protocol.md
Original file line number Diff line number Diff line change
@@ -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 <Stack access token>
X-Stack-Refresh-Token: <Stack refresh token>
X-Cmux-Team-Id: <selected Stack 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.
7 changes: 7 additions & 0 deletions docs/coderouter-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion web/app/api/account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -588,6 +591,12 @@ async function markAccountDeletionTombstonePending(userId: string): Promise<Acco
return await db.transaction(async (tx) => {
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,
Expand Down Expand Up @@ -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,
Expand Down
Loading