diff --git a/web/app/[locale]/dashboard/dashboard-shell.tsx b/web/app/[locale]/dashboard/dashboard-shell.tsx index dfe32b7bc6c8..769316019b38 100644 --- a/web/app/[locale]/dashboard/dashboard-shell.tsx +++ b/web/app/[locale]/dashboard/dashboard-shell.tsx @@ -34,11 +34,6 @@ export function DashboardShell({ children }: { children: React.ReactNode }) { label: t("vaultSessions"), active: pathname.startsWith("/dashboard/vault/sessions"), }, - { - href: "/dashboard/vault/cli-auth", - label: t("vaultCliSetup"), - active: pathname.startsWith("/dashboard/vault/cli-auth"), - }, ], }, { diff --git a/web/app/[locale]/dashboard/layout.tsx b/web/app/[locale]/dashboard/layout.tsx index 9c993db91e8e..ce5495bbba13 100644 --- a/web/app/[locale]/dashboard/layout.tsx +++ b/web/app/[locale]/dashboard/layout.tsx @@ -8,9 +8,8 @@ import { DashboardShell } from "./dashboard-shell"; // Auth redirects are owned by each page, not this layout: a layout cannot see // the requested URL, so redirecting here would send unauthenticated visitors -// to a fixed return path and drop page-specific query params (e.g. the -// ?code=... on /dashboard/vault/cli-auth). Every page under /dashboard must -// check getUser() itself and build its own sign-in return path. +// to a fixed return path and drop page-specific query params. Every page under +// /dashboard must check getUser() itself and build its own sign-in return path. export default async function DashboardLayout({ children, }: { diff --git a/web/app/[locale]/dashboard/subrouter/page.tsx b/web/app/[locale]/dashboard/subrouter/page.tsx index afef3c91b349..7f909bb21f9f 100644 --- a/web/app/[locale]/dashboard/subrouter/page.tsx +++ b/web/app/[locale]/dashboard/subrouter/page.tsx @@ -3,20 +3,17 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { buildAlternates, openGraphDefaults, seoDescription, twitterSummary } from "@/i18n/seo"; import { Link } from "@/i18n/navigation"; -import { cloudDb } from "@/db/client"; -import { isStackConfigured } from "@/app/lib/stack"; +import { getStackServerApp, isStackConfigured } from "@/app/lib/stack"; import { localizedVaultPath, vaultSignInHref } from "@/app/lib/vault-auth"; -import { - createSubrouterClient, - subrouterRuntimeConfig, - type SubrouterAccount, -} from "@/services/subrouter/client"; -import { getTenantForTeam } from "@/services/subrouter/tenants"; +import type { SubrouterAccount } from "@/services/subrouter/types"; +import { hostedSubrouterCutoverReadyForTeam } from "@/services/subrouter/cutover"; +import { createHostedSubrouterClient } from "@/services/subrouter/hostedClient"; import { authorizedSubrouterTeams, } from "@/services/subrouter/routeHelpers"; import { isSubrouterAuthorizationError, + SubrouterAuthorizationUnavailableError, verifySubrouterRequest, withSubrouterAuthorizationDeadline, } from "@/services/vms/auth"; @@ -35,11 +32,13 @@ type PageProps = { type DashboardTeam = { readonly id: string; readonly name: string; + readonly use: boolean; readonly manageAccounts: boolean; }; type AccountState = | { readonly kind: "ok"; readonly accounts: readonly SubrouterAccount[] } + | { readonly kind: "migrationPending" } | { readonly kind: "notConfigured" } | { readonly kind: "error" }; @@ -71,9 +70,15 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa redirect("/"); } const requestHeaders = await headers(); - let authorized: Awaited> | null; + const tokenStore = { + headers: { get: (name: string) => requestHeaders.get(name) }, + }; + let authenticated: { + readonly authorized: Awaited>; + readonly accessToken: string | null; + } | null; try { - authorized = await withSubrouterAuthorizationDeadline( + authenticated = await withSubrouterAuthorizationDeadline( async (signal) => { const user = await verifySubrouterRequest( new Request("https://cmux.com/dashboard/subrouter", { @@ -82,7 +87,20 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa signal, { allowCookie: true, listAllTeams: true }, ); - return user ? authorizedSubrouterTeams(user) : null; + if (!user) return null; + const authorized = await authorizedSubrouterTeams(user); + let authJson: Awaited["getAuthJson"]>>; + try { + authJson = await getStackServerApp().getAuthJson({ tokenStore }); + } catch { + throw new SubrouterAuthorizationUnavailableError( + "Stack session refresh unavailable", + ); + } + return { + authorized, + accessToken: authJson?.accessToken ?? null, + }; }, ); } catch (error) { @@ -101,7 +119,10 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa ); } - if (!authorized) { + if (!authenticated) { + redirect(vaultSignInHref(localizedVaultPath(locale, "/dashboard/subrouter"))); + } + if (!authenticated.accessToken) { redirect(vaultSignInHref(localizedVaultPath(locale, "/dashboard/subrouter"))); } @@ -109,18 +130,19 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa getTranslations({ locale, namespace: "dashboard.subrouter" }), getTranslations({ locale, namespace: "dashboard.aiAccounts" }), ]); - const teams = authorized + const teams = authenticated.authorized .filter((candidate) => candidate.use || candidate.manageAccounts) .map((candidate) => ({ id: candidate.teamId, name: candidate.teamName, + use: candidate.use, manageAccounts: candidate.manageAccounts, })); if (teams.length === 0) { redirect("/dashboard"); } const selectedTeam = selectTeam(teams, team); - const accountState = await loadAccounts(selectedTeam); + const accountState = await loadAccounts(selectedTeam, authenticated.accessToken); const dateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", @@ -155,6 +177,8 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa {accountState.kind === "notConfigured" ? ( + ) : accountState.kind === "migrationPending" ? ( + ) : accountState.kind === "error" ? ( ) : ( @@ -262,19 +286,24 @@ function selectTeam(teams: readonly DashboardTeam[], requestedTeamId: string | u return teams[0]; } -async function loadAccounts(team: DashboardTeam): Promise { - const config = subrouterRuntimeConfig(); - if (!config) return { kind: "notConfigured" }; - +async function loadAccounts( + team: DashboardTeam, + accessToken: string, +): Promise { try { - const client = createSubrouterClient({ - baseUrl: config.baseUrl, - adminToken: config.adminToken, - }); - const tenant = await getTenantForTeam(cloudDb(), team.id, { - tenantKeySecret: config.tenantKeySecret, + if (!await hostedSubrouterCutoverReadyForTeam(team.id)) { + return { kind: "migrationPending" }; + } + const client = createHostedSubrouterClient(); + if (!client.tenantControlConfigured) { + return { kind: "notConfigured" }; + } + const tenant = await client.exchangeTeam(accessToken, { + teamId: team.id, + teamName: team.name, + use: team.use, + manageAccounts: team.manageAccounts, }); - if (!tenant) return { kind: "ok", accounts: [] }; const accounts = await client.listAccounts(tenant.tenantKey); return { kind: "ok", accounts }; } catch { diff --git a/web/app/api/account/route.ts b/web/app/api/account/route.ts index 84c6c8cc3a7b..fd4b3de07917 100644 --- a/web/app/api/account/route.ts +++ b/web/app/api/account/route.ts @@ -46,10 +46,6 @@ import { } from "../../../services/asc/testflight"; import { captureAscError } from "../../../services/errors"; import { isStripeBillingConfigured, stripe } from "../../../services/billing/stripe"; -import { - createSubrouterClientFromEnv, - SubrouterClientError, -} from "../../../services/subrouter/client"; import { deleteObject } from "../../../services/vault/storage"; import { withVaultUserQuotaLock } from "../../../services/vault/usage"; import { @@ -61,7 +57,10 @@ import { assertNoAccountDeletionUserMutationInProgress, isBlockingAccountDeletionTombstone, } from "../../../services/account/deletionLock"; -import { unauthorized } from "../../../services/vms/auth"; +import { + unauthorized, + withSubrouterAuthorizationDeadline, +} from "../../../services/vms/auth"; import { VmAccountDeletionIdentityRevocationError, isVmAccountDeletionIdentityRevocationError, @@ -70,6 +69,11 @@ import { } from "../../../services/vms/errors"; import type { ProviderId } from "../../../services/vms/drivers"; import { jsonResponse } from "../../../services/vms/routeHelpers"; +import { createHostedSubrouterClient } from "../../../services/subrouter/hostedClient"; +import { + createLegacySubrouterRetirementClient, + legacySubrouterRetirementConfig, +} from "../../../services/subrouter/legacyRetirementClient"; import { destroyVm, listUserVms, @@ -84,6 +88,10 @@ const VAULT_OBJECT_DELETE_BATCH_SIZE = 100; const DELETED_ACCOUNT_ACTOR_ID = "deleted-account"; const POSTHOG_DEFAULT_API_HOST = "https://us.posthog.com"; const POSTHOG_PERSON_DELETE_TIMEOUT_MS = 10_000; +const LEGACY_TENANT_RETIRE_BATCH_SIZE = 2; +const LEGACY_TENANT_RETIRE_PHASE_TIMEOUT_MS = 20_000; +const HOSTED_TENANT_DELETE_BATCH_SIZE = 2; +const HOSTED_TENANT_DELETE_PHASE_TIMEOUT_MS = 20_000; type DeletableStackUser = { readonly id: string; @@ -93,6 +101,11 @@ type DeletableStackUser = { readonly delete: () => Promise; } & ProMetadataCustomer; +type DeletableStackSession = { + readonly user: DeletableStackUser; + readonly accessToken: string; +}; + type AccountDeletionStackTeam = { readonly id: string; readonly listUsers?: (options?: StackPaginationOptions) => Promise; @@ -118,16 +131,37 @@ type StackPaginationPage = readonly unknown[] & { readonly nextCursor?: string | null; }; +type AccountDeletionResumeCheckpoint = "legacy" | "hosted" | null; + type AccountDeletionTombstoneStart = - | { readonly kind: "started" } + | { + readonly kind: "started"; + readonly legacySubrouterRetiredTenantIds: readonly string[]; + readonly hostedSubrouterDeletedTeamIds: readonly string[]; + readonly hostedSubrouterDeletionStarted: boolean; + readonly resumeCheckpoint: AccountDeletionResumeCheckpoint; + } | { readonly kind: "pending" } | { readonly kind: "completed" } | { readonly kind: "cleanupIncomplete" }; export async function DELETE(request: Request): Promise { - const stackUser = await currentDeletableStackUser(request); - if (!stackUser) return unauthorized(); + let stackSession: DeletableStackSession | null; + try { + stackSession = await currentDeletableStackUser(request); + } catch (error) { + console.error("account.delete.stack_auth_unavailable", { + errorType: error instanceof Error ? error.name : typeof error, + }); + return jsonResponse({ + error: "account_delete_retryable", + retryable: true, + destroyedVms: 0, + }, 503); + } + if (!stackSession) return unauthorized(); + const { user: stackUser, accessToken } = stackSession; const userId = stackUser.id; const originalStackMetadata = stackUser.clientReadOnlyMetadata; let stackMetadataMarked = false; @@ -135,11 +169,15 @@ export async function DELETE(request: Request): Promise { let cmuxOwnedRowsDeleted = false; let analyticsCleanupStarted = false; let destructiveCleanupStarted = false; + let resumeCheckpoint: AccountDeletionResumeCheckpoint = null; let destroyedVms = 0; let restoreBillingEntitlementsOnFailure = true; try { const tombstoneStart = await markAccountDeletionTombstonePending(userId); accountDeletionTombstoneStarted = tombstoneStart.kind === "started"; + if (tombstoneStart.kind === "started") { + resumeCheckpoint = tombstoneStart.resumeCheckpoint; + } if (tombstoneStart.kind === "pending") { return jsonResponse({ ok: true, deletionPending: true, destroyedVms: 0 }, 202); } @@ -158,12 +196,33 @@ export async function DELETE(request: Request): Promise { await markAccountDeletionTombstoneCompleted(userId); return jsonResponse({ ok: true, destroyedVms: 0 }, 200); } + const hostedSubrouter = createHostedSubrouterClient(); // Validate required production configuration before metadata, billing, // access, VM, vault, or tenant cleanup can mutate the account. Pass the // validated snapshot to the later request so environment changes cannot // introduce a second validation failure after destructive work begins. const postHogDeletionConfig = postHogPersonDeletionConfig(); const accountScope = await accountDeletionScopeForUser(stackUser); + const legacyTenantIds = await legacySubrouterTenantIdsForTeams( + accountScope.teamIds, + ); + const hostedSubrouterDeletionRequired = shouldDeleteHostedSubrouterTenants({ + clientConfigured: hostedSubrouter.tenantControlConfigured, + hostedDeletionStarted: tombstoneStart.hostedSubrouterDeletionStarted, + completedTeamIds: tombstoneStart.hostedSubrouterDeletedTeamIds, + legacyTenantIds, + }); + if (hostedSubrouterDeletionRequired) { + hostedSubrouter.assertTenantDeletionConfigured(); + } + // Resolve the legacy credential before PostHog, billing, VM, vault, or + // hosted tenant cleanup. Existing DB mappings are retained until both + // services confirm retirement, so a failed request remains retryable. + const legacySubrouter = legacyTenantIds.length > 0 + ? createLegacySubrouterRetirementClient( + legacySubrouterRetirementConfig(), + ) + : null; // The tombstone blocks new forwards before this fail-prone external call. // Complete analytics deletion before billing, access, VM, vault, tenant, // or Stack cleanup so a retryable PostHog failure leaves those resources @@ -242,12 +301,51 @@ export async function DELETE(request: Request): Promise { await refreshAccountDeletionTombstoneLease(userId); }, }); - await deletePersonalSubrouterTenant(userId, { - afterExternalMutation: () => { - destructiveCleanupStarted = true; - }, - }, accountScope.teamIds); - await refreshAccountDeletionTombstoneLease(userId); + if (legacyTenantIds.length > 0) { + await refreshAccountDeletionTombstoneLease(userId); + resumeCheckpoint = "legacy"; + const legacyRetirement = await retireLegacySubrouterTenantsForAccount({ + userId, + tenantIds: legacyTenantIds, + completedTenantIds: tombstoneStart.legacySubrouterRetiredTenantIds, + client: legacySubrouter!, + afterRetirement: (revoked) => { + if (revoked) destructiveCleanupStarted = true; + }, + }); + if (!legacyRetirement.complete) { + await markAccountDeletionTombstoneLegacyDeletePending(userId); + return jsonResponse({ + error: "account_delete_retryable", + retryable: true, + destroyedVms, + }, 503); + } + resumeCheckpoint = null; + } + if (hostedSubrouterDeletionRequired) { + await refreshAccountDeletionTombstoneLease(userId); + resumeCheckpoint = "hosted"; + const hostedDeletion = await deleteHostedSubrouterTenantsForAccount({ + userId, + accessToken, + teamIds: accountScope.teamIds, + completedTeamIds: tombstoneStart.hostedSubrouterDeletedTeamIds, + client: hostedSubrouter, + beforeDeletion: () => { + destructiveCleanupStarted = true; + }, + }); + if (!hostedDeletion.complete) { + await markAccountDeletionTombstoneHostedDeletePending(userId); + return jsonResponse({ + error: "account_delete_retryable", + retryable: true, + destroyedVms, + }, 503); + } + resumeCheckpoint = null; + } // Delete cmux-owned data before the Stack user so a Stack-side failure does // not strand retained app data behind an account the user can no longer use. // These deletes are idempotent, so the same signed-in user can retry the @@ -259,7 +357,13 @@ export async function DELETE(request: Request): Promise { await stackUser.delete(); } catch (error) { logAccountDeleteError("account.delete.stack_user_failed_after_data_delete", error); - if (accountDeletionTombstoneStarted) await markAccountDeletionTombstoneFailed(userId, error); + if (accountDeletionTombstoneStarted) { + await markAccountDeletionFailureCheckpoint( + userId, + error, + resumeCheckpoint, + ); + } return jsonResponse({ error: "account_delete_retryable", retryable: true, @@ -289,7 +393,13 @@ export async function DELETE(request: Request): Promise { return jsonResponse({ ok: true, destroyedVms }); } catch (error) { if (destructiveCleanupStarted || cmuxOwnedRowsDeleted) { - if (accountDeletionTombstoneStarted) await markAccountDeletionTombstoneFailed(userId, error); + if (accountDeletionTombstoneStarted) { + await markAccountDeletionFailureCheckpoint( + userId, + error, + resumeCheckpoint, + ); + } logAccountDeleteError("account.delete.partial_after_destructive_cleanup", error); return jsonResponse({ error: "account_delete_retryable", @@ -302,7 +412,13 @@ export async function DELETE(request: Request): Promise { restoreBillingEntitlements: restoreBillingEntitlementsOnFailure, }); } - if (accountDeletionTombstoneStarted) await markAccountDeletionTombstoneFailed(userId, error); + if (accountDeletionTombstoneStarted) { + await markAccountDeletionFailureCheckpoint( + userId, + error, + resumeCheckpoint, + ); + } logAccountDeleteError("account.delete.failed", error); if ( analyticsCleanupStarted || @@ -319,7 +435,94 @@ export async function DELETE(request: Request): Promise { } } -async function currentDeletableStackUser(request: Request): Promise { +async function legacySubrouterTenantIdsForTeams( + teamIds: readonly string[], +): Promise { + if (teamIds.length === 0) return []; + const rows = await cloudDb() + .select({ tenantId: subrouterTenants.tenantId }) + .from(subrouterTenants) + .where(inArray(subrouterTenants.teamId, teamIds)); + return uniqueNonEmptyStrings(rows.map((row) => row.tenantId)); +} + +function shouldDeleteHostedSubrouterTenants(input: { + readonly clientConfigured: boolean; + readonly hostedDeletionStarted: boolean; + readonly completedTeamIds: readonly string[]; + readonly legacyTenantIds: readonly string[]; +}): boolean { + const docsDeployment = + process.env.CMUX_DOCS_CHANNEL === "release" || + process.env.CMUX_DOCS_CHANNEL === "nightly"; + const managedDeployment = + process.env.VERCEL === "1" && + process.env.VERCEL_ENV !== "preview" && + !docsDeployment; + return input.clientConfigured || + input.hostedDeletionStarted || + input.completedTeamIds.length > 0 || + input.legacyTenantIds.length > 0 || + managedDeployment || + Boolean(process.env.SUBROUTER_HOSTED_URL?.trim()); +} + +async function retireLegacySubrouterTenantsForAccount(input: { + readonly userId: string; + readonly tenantIds: readonly string[]; + readonly completedTenantIds: readonly string[]; + readonly client: ReturnType; + readonly afterRetirement: (revoked: boolean) => void; +}): Promise<{ readonly complete: boolean }> { + const completed = new Set(input.completedTenantIds); + const remaining = uniqueNonEmptyStrings(input.tenantIds).filter( + (tenantId) => !completed.has(tenantId), + ); + const batch = remaining.slice(0, LEGACY_TENANT_RETIRE_BATCH_SIZE); + const deadline = Date.now() + LEGACY_TENANT_RETIRE_PHASE_TIMEOUT_MS; + let confirmed = 0; + for (const tenantId of batch) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const retirement = await input.client.revokeTenant(tenantId, { + signal: AbortSignal.timeout(Math.min(remainingMs, 10_000)), + }); + input.afterRetirement(retirement.revoked); + await markAccountDeletionTombstoneLegacyTenantRetired(input.userId, tenantId); + confirmed += 1; + } + return { complete: confirmed === remaining.length }; +} + +async function deleteHostedSubrouterTenantsForAccount(input: { + readonly userId: string; + readonly accessToken: string; + readonly teamIds: readonly string[]; + readonly completedTeamIds: readonly string[]; + readonly client: ReturnType; + readonly beforeDeletion: () => void; +}): Promise<{ readonly complete: boolean }> { + const completed = new Set(input.completedTeamIds); + const remaining = uniqueNonEmptyStrings(input.teamIds).filter( + (teamId) => !completed.has(teamId), + ); + const batch = remaining.slice(0, HOSTED_TENANT_DELETE_BATCH_SIZE); + const deadline = Date.now() + HOSTED_TENANT_DELETE_PHASE_TIMEOUT_MS; + let deleted = 0; + for (const teamId of batch) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + input.beforeDeletion(); + await input.client.deleteTenant(input.accessToken, teamId, { + signal: AbortSignal.timeout(Math.min(remainingMs, 10_000)), + }); + await markAccountDeletionTombstoneHostedTeamDeleted(input.userId, teamId); + deleted += 1; + } + return { complete: deleted === remaining.length }; +} + +async function currentDeletableStackUser(request: Request): Promise { if (!isStackConfigured()) return null; const authHeader = request.headers.get("authorization"); @@ -330,12 +533,21 @@ async function currentDeletableStackUser(request: Request): Promise { + const user = await app.getUser({ tokenStore }); + const candidate = user as Partial; + if (!user || typeof candidate.delete !== "function" || typeof candidate.update !== "function") { + return null; + } + const authoritativeTokens = await app.getAuthJson({ tokenStore }); + if (!authoritativeTokens?.accessToken) return null; + return { + user: user as DeletableStackUser, + accessToken: authoritativeTokens.accessToken, + }; }); - const candidate = user as Partial; - if (!user || typeof candidate.delete !== "function" || typeof candidate.update !== "function") return null; - return user as DeletableStackUser; } async function markAccountDeletionTombstonePending(userId: string): Promise { @@ -350,12 +562,45 @@ async function markAccountDeletionTombstonePending(userId: string): Promise 0, + resumeCheckpoint: + existing.status === "legacy_delete_pending" ? "legacy" : "hosted", + }; + } if (existing && isBlockingAccountDeletionTombstone(existing, now)) { return { kind: "pending" }; } @@ -380,10 +625,77 @@ async function markAccountDeletionTombstonePending(userId: string): Promise 0, + resumeCheckpoint: null, + }; }); } +async function markAccountDeletionTombstoneLegacyTenantRetired( + userId: string, + tenantId: string, +): Promise { + await cloudDb() + .update(accountDeletionTombstones) + .set({ + legacySubrouterRetiredTenantIds: + sql`${accountDeletionTombstones.legacySubrouterRetiredTenantIds} || ${JSON.stringify([tenantId])}::jsonb`, + updatedAt: new Date(), + }) + .where(eq(accountDeletionTombstones.userIdHash, accountDeletionUserHash(userId))); +} + +async function markAccountDeletionTombstoneHostedTeamDeleted( + userId: string, + teamId: string, +): Promise { + await cloudDb() + .update(accountDeletionTombstones) + .set({ + hostedSubrouterDeletedTeamIds: + sql`${accountDeletionTombstones.hostedSubrouterDeletedTeamIds} || ${JSON.stringify([teamId])}::jsonb`, + updatedAt: new Date(), + }) + .where(eq(accountDeletionTombstones.userIdHash, accountDeletionUserHash(userId))); +} + +async function markAccountDeletionTombstoneLegacyDeletePending( + userId: string, +): Promise { + await cloudDb() + .update(accountDeletionTombstones) + .set({ + status: "legacy_delete_pending", + updatedAt: new Date(), + errorMessage: null, + }) + .where(eq(accountDeletionTombstones.userIdHash, accountDeletionUserHash(userId))); +} + +async function markAccountDeletionTombstoneHostedDeletePending( + userId: string, +): Promise { + await cloudDb() + .update(accountDeletionTombstones) + .set({ + status: "hosted_delete_pending", + updatedAt: new Date(), + errorMessage: null, + }) + .where(eq(accountDeletionTombstones.userIdHash, accountDeletionUserHash(userId))); +} + async function markAccountDeletionTombstoneCompleted(userId: string): Promise { const now = new Date(); await cloudDb() @@ -393,6 +705,8 @@ async function markAccountDeletionTombstoneCompleted(userId: string): Promise { + if (resumeCheckpoint === "legacy") { + await markAccountDeletionTombstoneLegacyDeletePending(userId); + return; + } + if (resumeCheckpoint === "hosted") { + await markAccountDeletionTombstoneHostedDeletePending(userId); + return; + } + await markAccountDeletionTombstoneFailed(userId, error); +} + async function markAccountDeletionTombstoneStackDeletePending(userId: string): Promise { await cloudDb() .update(accountDeletionTombstones) @@ -437,6 +767,8 @@ async function markAccountDeletionTombstoneCleanupIncomplete(userId: string, err status: "cleanup_incomplete", updatedAt: now, completedAt: now, + legacySubrouterRetiredTenantIds: [], + hostedSubrouterDeletedTeamIds: [], errorMessage: sanitizedErrorSummary(error), }) .where(eq(accountDeletionTombstones.userIdHash, accountDeletionUserHash(userId))); @@ -1199,36 +1531,15 @@ async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readon eq(devices.userId, userId), inArray(devices.teamId, deletionTeamIds), )); - - await tx.delete(vaultCliAuthRequests).where(eq(vaultCliAuthRequests.userId, userId)); + await tx.delete(subrouterTenants).where( + inArray(subrouterTenants.teamId, deletionTeamIds), + ); + await tx.delete(vaultCliAuthRequests).where( + eq(vaultCliAuthRequests.userId, userId), + ); }); } -async function deletePersonalSubrouterTenant( - userId: string, - options: { readonly afterExternalMutation?: () => void } = {}, - accountTeamIds: readonly string[] = [userId], -): Promise { - const db = cloudDb(); - const teamIds = uniqueNonEmptyStrings([userId, ...accountTeamIds]); - const tenants = await db - .select({ tenantId: subrouterTenants.tenantId }) - .from(subrouterTenants) - .where(inArray(subrouterTenants.teamId, teamIds)); - if (tenants.length === 0) return; - - const client = createSubrouterClientFromEnv(); - for (const tenant of tenants) { - try { - await client.revokeTenant(tenant.tenantId); - options.afterExternalMutation?.(); - } catch (error) { - if (!(error instanceof SubrouterClientError && error.status === 404)) throw error; - } - } - await db.delete(subrouterTenants).where(inArray(subrouterTenants.teamId, teamIds)); -} - async function accountDeletionScopeForUser(user: DeletableStackUser): Promise<{ readonly teamIds: readonly string[]; readonly retainedTeamBillingOwners: readonly RetainedTeamBillingOwner[]; diff --git a/web/app/api/cli/config/route.ts b/web/app/api/cli/config/route.ts new file mode 100644 index 000000000000..e194d61f9c7b --- /dev/null +++ b/web/app/api/cli/config/route.ts @@ -0,0 +1,67 @@ +import { + defaultHostedSubrouterURL, + hostedSubrouterBaseURL, +} from "@/services/subrouter/constants"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const DEFAULT_STACK_API_URL = "https://api.stack-auth.com/api/v1"; + +export function GET(request: Request): Response { + const projectId = process.env.NEXT_PUBLIC_STACK_PROJECT_ID?.trim(); + const publishableClientKey = + process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY?.trim(); + const tenantControlToken = + process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN?.trim(); + if (!projectId || !publishableClientKey || !tenantControlToken) { + return unavailableResponse(); + } + let subrouterURL: string; + try { + subrouterURL = hostedSubrouterBaseURL( + process.env.SUBROUTER_HOSTED_URL?.trim() || + defaultHostedSubrouterURL(), + ); + } catch { + return unavailableResponse(); + } + + return Response.json( + { + version: 2, + auth: { + apiUrl: + process.env.NEXT_PUBLIC_STACK_API_URL?.trim() || + DEFAULT_STACK_API_URL, + projectId, + publishableClientKey, + // Start and complete the CLI login against the same deployment so the + // confirmation page uses the Stack project that issued the login code. + confirmUrl: new URL("/handler/cli-auth-confirm", request.url).toString(), + }, + subrouter: { + url: subrouterURL, + exchangeUrl: new URL( + "/api/subrouter/exchange", + request.url, + ).toString(), + }, + }, + { + headers: { + "cache-control": "public, max-age=300", + }, + }, + ); +} + +function unavailableResponse(): Response { + return Response.json( + { error: "cli_auth_unavailable" }, + { + status: 503, + headers: { "cache-control": "no-store" }, + }, + ); +} diff --git a/web/app/api/subrouter/accounts/[accountId]/repair/route.ts b/web/app/api/subrouter/accounts/[accountId]/repair/route.ts index e0464d47dfdd..fdc46f0b754c 100644 --- a/web/app/api/subrouter/accounts/[accountId]/repair/route.ts +++ b/web/app/api/subrouter/accounts/[accountId]/repair/route.ts @@ -1,11 +1,9 @@ -import { cloudDb } from "../../../../../../db/client"; import { readSubrouterAccountInput } from "../../../../../../services/subrouter/accountInput"; import { resolveSubrouterRequestContext } from "../../../../../../services/subrouter/requestContext"; import { normalizeAccountId, subrouterErrorResponse, } from "../../../../../../services/subrouter/routeHelpers"; -import { getTenantForTeam } from "../../../../../../services/subrouter/tenants"; import { jsonResponse } from "../../../../../../services/vms/routeHelpers"; export const runtime = "nodejs"; @@ -36,12 +34,7 @@ export async function POST( return jsonResponse({ error: "invalid_request" }, input.status); } try { - const tenant = await getTenantForTeam( - cloudDb(), - context.team.teamId, - { tenantKeySecret: context.config.tenantKeySecret }, - ); - if (!tenant) return jsonResponse({ error: "account_not_found" }, 404); + const tenant = await context.client.exchangeTeam(context.accessToken, context.team); const account = await context.client.repairAccount( tenant.tenantKey, accountId, diff --git a/web/app/api/subrouter/accounts/[accountId]/route.ts b/web/app/api/subrouter/accounts/[accountId]/route.ts index 3c954cf0bba1..375228e1b4d1 100644 --- a/web/app/api/subrouter/accounts/[accountId]/route.ts +++ b/web/app/api/subrouter/accounts/[accountId]/route.ts @@ -1,4 +1,3 @@ -import { cloudDb } from "../../../../../db/client"; import { jsonResponse, } from "../../../../../services/vms/routeHelpers"; @@ -7,7 +6,6 @@ import { subrouterErrorResponse, } from "../../../../../services/subrouter/routeHelpers"; import { resolveSubrouterRequestContext } from "../../../../../services/subrouter/requestContext"; -import { getTenantForTeam } from "../../../../../services/subrouter/tenants"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,19 +25,10 @@ export async function DELETE(request: Request, context: RouteContext): Promise { const context = resolved.value; try { - const tenant = await getTenantForTeam( - cloudDb(), - context.team.teamId, - { - tenantKeySecret: context.config.tenantKeySecret, - }, - ); - if (!tenant) { - return jsonResponse({ teamId: context.team.teamId, accounts: [] }); - } + const tenant = await context.client.exchangeTeam(context.accessToken, context.team); const accounts = await context.client.listAccounts(tenant.tenantKey); return jsonResponse({ teamId: context.team.teamId, accounts }); } catch (err) { @@ -52,15 +38,7 @@ export async function POST(request: Request): Promise { return jsonResponse({ error: "invalid_request" }, input.status); } try { - const tenant = await getOrCreateTenantForTeam( - cloudDb(), - context.team.teamId, - context.team.teamName, - { - client: context.client, - tenantKeySecret: context.config.tenantKeySecret, - }, - ); + const tenant = await context.client.exchangeTeam(context.accessToken, context.team); const account = await context.client.createAccount( tenant.tenantKey, input.value, diff --git a/web/app/api/subrouter/exchange/route.ts b/web/app/api/subrouter/exchange/route.ts new file mode 100644 index 000000000000..f613455d9b25 --- /dev/null +++ b/web/app/api/subrouter/exchange/route.ts @@ -0,0 +1,25 @@ +import { resolveSubrouterRequestContext } from "../../../../services/subrouter/requestContext"; +import { subrouterErrorResponse } from "../../../../services/subrouter/routeHelpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + const resolved = await resolveSubrouterRequestContext(request, { + permission: "use-or-manage", + allowCookie: false, + }); + if (!resolved.ok) return resolved.response; + + try { + const tenant = await resolved.value.client.exchangeTeam( + resolved.value.accessToken, + resolved.value.team, + ); + return Response.json(tenant, { + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return subrouterErrorResponse(error); + } +} diff --git a/web/app/api/subrouter/leases/[leaseId]/events/route.ts b/web/app/api/subrouter/leases/[leaseId]/events/route.ts index 9d629d8425d3..b5357754fa02 100644 --- a/web/app/api/subrouter/leases/[leaseId]/events/route.ts +++ b/web/app/api/subrouter/leases/[leaseId]/events/route.ts @@ -1,12 +1,8 @@ -import { cloudDb } from "../../../../../../db/client"; -import { jsonResponse } from "../../../../../../services/vms/routeHelpers"; -import type { - SubrouterCredentialLeaseOutcome, -} from "../../../../../../services/subrouter/client"; import { readBoundedJsonRecord } from "../../../../../../services/subrouter/boundedJson"; import { resolveSubrouterRequestContext } from "../../../../../../services/subrouter/requestContext"; import { subrouterErrorResponse } from "../../../../../../services/subrouter/routeHelpers"; -import { getTenantForTeam } from "../../../../../../services/subrouter/tenants"; +import type { SubrouterCredentialLeaseOutcome } from "../../../../../../services/subrouter/types"; +import { jsonResponse } from "../../../../../../services/vms/routeHelpers"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -57,12 +53,10 @@ export async function POST( } try { - const tenant = await getTenantForTeam( - cloudDb(), - context.team.teamId, - { tenantKeySecret: context.config.tenantKeySecret }, + const tenant = await context.client.exchangeTeam( + context.accessToken, + context.team, ); - if (!tenant) return jsonResponse({ error: "lease_not_found" }, 404); const result = await context.client.reportCredentialLease( tenant.tenantKey, leaseId, @@ -72,7 +66,7 @@ export async function POST( }, ); return jsonResponse(result); - } catch (err) { - return subrouterErrorResponse(err); + } catch (error) { + return subrouterErrorResponse(error); } } diff --git a/web/app/api/subrouter/leases/route.ts b/web/app/api/subrouter/leases/route.ts index 249ddde4b0e5..bf48f73d02ad 100644 --- a/web/app/api/subrouter/leases/route.ts +++ b/web/app/api/subrouter/leases/route.ts @@ -1,12 +1,8 @@ -import { cloudDb } from "../../../../db/client"; -import { jsonResponse } from "../../../../services/vms/routeHelpers"; -import type { - SubrouterCredentialLeaseInput, -} from "../../../../services/subrouter/client"; import { readBoundedJsonRecord } from "../../../../services/subrouter/boundedJson"; import { resolveSubrouterRequestContext } from "../../../../services/subrouter/requestContext"; import { subrouterErrorResponse } from "../../../../services/subrouter/routeHelpers"; -import { getTenantForTeam } from "../../../../services/subrouter/tenants"; +import type { SubrouterCredentialLeaseInput } from "../../../../services/subrouter/types"; +import { jsonResponse } from "../../../../services/vms/routeHelpers"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -22,14 +18,10 @@ export async function POST(request: Request): Promise { if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status); try { - const tenant = await getTenantForTeam( - cloudDb(), - context.team.teamId, - { tenantKeySecret: context.config.tenantKeySecret }, + const tenant = await context.client.exchangeTeam( + context.accessToken, + context.team, ); - if (!tenant) { - return jsonResponse({ error: "no_shared_accounts" }, 404); - } const lease = await context.client.createCredentialLease( tenant.tenantKey, body.value, @@ -44,8 +36,8 @@ export async function POST(request: Request): Promise { "content-type": "application/json", }, }); - } catch (err) { - return subrouterErrorResponse(err); + } catch (error) { + return subrouterErrorResponse(error); } } @@ -58,13 +50,9 @@ async function readLeaseInput( const parsed = await readBoundedJsonRecord(request, MAX_REQUEST_BYTES); if (!parsed.ok) return parsed; const value = parsed.value; - const provider = value.provider; const sessionId = normalizedString(value.sessionId, 512); - if ( - (provider !== "codex" && provider !== "claude") || - !sessionId - ) { + if ((provider !== "codex" && provider !== "claude") || !sessionId) { return { ok: false, status: 400 }; } const agentType = normalizedString(value.agentType, 64); diff --git a/web/app/api/subrouter/logout/route.ts b/web/app/api/subrouter/logout/route.ts index dbcf7463e2b6..39cb45e24f34 100644 --- a/web/app/api/subrouter/logout/route.ts +++ b/web/app/api/subrouter/logout/route.ts @@ -6,9 +6,7 @@ import { parseNativeStackTokens, unauthorized, } from "../../../../services/vms/auth"; -import { - subrouterErrorResponse, -} from "../../../../services/subrouter/routeHelpers"; +import { subrouterErrorResponse } from "../../../../services/subrouter/routeHelpers"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; diff --git a/web/app/api/subrouter/teams/route.ts b/web/app/api/subrouter/teams/route.ts index af23ede0e174..35afc3a2003b 100644 --- a/web/app/api/subrouter/teams/route.ts +++ b/web/app/api/subrouter/teams/route.ts @@ -27,9 +27,7 @@ export async function GET(request: Request): Promise { let selectedTeamId: string | null = null; const teams = []; for (const team of authorized) { - if (team.teamId === preferredTeamId) { - selectedTeamId = preferredTeamId; - } + if (team.teamId === preferredTeamId) selectedTeamId = preferredTeamId; teams.push({ id: team.teamId, name: team.teamName, @@ -40,11 +38,7 @@ export async function GET(request: Request): Promise { }, }); } - - return jsonResponse({ - selectedTeamId, - teams, - }); + return jsonResponse({ selectedTeamId, teams }); }); } catch (error) { if (isSubrouterAuthorizationError(error)) { diff --git a/web/app/api/vault/cli/auth/poll/route.ts b/web/app/api/vault/cli/auth/poll/route.ts index 50363e1c5414..bc5a024f05f4 100644 --- a/web/app/api/vault/cli/auth/poll/route.ts +++ b/web/app/api/vault/cli/auth/poll/route.ts @@ -34,7 +34,6 @@ async function mintStackTokens( if (!tokens.accessToken || !tokens.refreshToken) return null; return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }; } - export async function POST(request: Request): Promise { return withCliAuthApiRoute( request, diff --git a/web/app/api/vault/cli/auth/start/route.ts b/web/app/api/vault/cli/auth/start/route.ts index b5b43f224e99..57cdc9b3cde3 100644 --- a/web/app/api/vault/cli/auth/start/route.ts +++ b/web/app/api/vault/cli/auth/start/route.ts @@ -110,7 +110,6 @@ export async function POST(request: Request): Promise { }, ); } - function hashDeviceCode(deviceCode: string): string { return createHash("sha256").update(deviceCode).digest("hex"); } diff --git a/web/app/env.ts b/web/app/env.ts index 4e1891173627..27b7a460b4cf 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -13,11 +13,6 @@ import { const trimEnv = (value: string | undefined): string | undefined => typeof value === "string" ? value.trim() : value; -const defaultSubrouterBaseUrl = (): string => - process.env.VERCEL_ENV === "production" - ? "https://subrouter.cmux.dev" - : "https://subrouter-staging.cmux.dev"; - const isDocsZone = process.env.CMUX_DOCS_CHANNEL === "release" || process.env.CMUX_DOCS_CHANNEL === "nightly"; @@ -218,9 +213,15 @@ export const env = createEnv({ // /api/enterprise/contact route falls back to the waitlist webhook, then // skips Slack if neither is set. SLACK_ENTERPRISE_WEBHOOK_URL: z.string().url().optional(), + // Temporary retirement credentials for DB-mapped tenants created before + // hosted Stack onboarding. Remove after subrouter_tenants is empty. SUBROUTER_BASE_URL: z.string().url().optional(), - SUBROUTER_ADMIN_TOKEN: z.string().min(1).optional(), - SUBROUTER_TENANT_KEY_SECRET: z.string().min(1).optional(), + SUBROUTER_ADMIN_TOKEN: z.string().min(1).max(1_024).optional(), + SUBROUTER_HOSTED_URL: z.string().url().optional(), + SUBROUTER_STACK_TENANT_DELETE_TOKEN: requireVercelNonPreviewValue( + "SUBROUTER_STACK_TENANT_DELETE_TOKEN", + z.string().min(32).max(1_024), + ), SUBROUTER_ENFORCE_STACK_PERMISSIONS: requireVercelNonPreviewValue( "SUBROUTER_ENFORCE_STACK_PERMISSIONS", z.enum(["0", "1"]), @@ -351,9 +352,12 @@ export const env = createEnv({ CMUX_VM_ALERT_EXPIRED_LEASES: trimEnv(process.env.CMUX_VM_ALERT_EXPIRED_LEASES), SLACK_WAITLIST_WEBHOOK_URL: trimEnv(process.env.SLACK_WAITLIST_WEBHOOK_URL), SLACK_ENTERPRISE_WEBHOOK_URL: trimEnv(process.env.SLACK_ENTERPRISE_WEBHOOK_URL), - SUBROUTER_BASE_URL: trimEnv(process.env.SUBROUTER_BASE_URL) ?? defaultSubrouterBaseUrl(), + SUBROUTER_BASE_URL: trimEnv(process.env.SUBROUTER_BASE_URL), SUBROUTER_ADMIN_TOKEN: trimEnv(process.env.SUBROUTER_ADMIN_TOKEN), - SUBROUTER_TENANT_KEY_SECRET: trimEnv(process.env.SUBROUTER_TENANT_KEY_SECRET), + SUBROUTER_HOSTED_URL: trimEnv(process.env.SUBROUTER_HOSTED_URL), + SUBROUTER_STACK_TENANT_DELETE_TOKEN: trimEnv( + process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN, + ), SUBROUTER_ENFORCE_STACK_PERMISSIONS: trimEnv( process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS, ), diff --git a/web/db/migrations/20260803230000_subrouter_hosted_cutover_gate/migration.sql b/web/db/migrations/20260803230000_subrouter_hosted_cutover_gate/migration.sql new file mode 100644 index 000000000000..f79974f89a74 --- /dev/null +++ b/web/db/migrations/20260803230000_subrouter_hosted_cutover_gate/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "subrouter_tenants" + ADD COLUMN "hosted_finalization_started_at" timestamp with time zone, + ADD COLUMN "hosted_ready_at" timestamp with time zone; diff --git a/web/db/migrations/20260804020000_account_deletion_hosted_progress/migration.sql b/web/db/migrations/20260804020000_account_deletion_hosted_progress/migration.sql new file mode 100644 index 000000000000..98fce176ceca --- /dev/null +++ b/web/db/migrations/20260804020000_account_deletion_hosted_progress/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "account_deletion_tombstones" + ADD COLUMN "hosted_subrouter_deleted_team_ids" jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/web/db/migrations/20260804030000_account_deletion_legacy_progress/migration.sql b/web/db/migrations/20260804030000_account_deletion_legacy_progress/migration.sql new file mode 100644 index 000000000000..9b032e88a7fb --- /dev/null +++ b/web/db/migrations/20260804030000_account_deletion_legacy_progress/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "account_deletion_tombstones" + ADD COLUMN "legacy_subrouter_retired_tenant_ids" jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/web/db/schema.ts b/web/db/schema.ts index 8d4b7fab5031..64e3291f872d 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -85,13 +85,21 @@ export const accountDeletionTombstones = pgTable( { userIdHash: text("user_id_hash").primaryKey(), userId: text("user_id"), - status: text("status").$type<"pending" | "in_progress" | "stack_delete_pending" | "stack_delete_in_progress" | "completed" | "cleanup_incomplete" | "failed">().notNull().default("pending"), + status: text("status").$type<"pending" | "in_progress" | "legacy_delete_pending" | "hosted_delete_pending" | "stack_delete_pending" | "stack_delete_in_progress" | "completed" | "cleanup_incomplete" | "failed">().notNull().default("pending"), attemptCount: integer("attempt_count").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), startedAt: timestamp("started_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }), analyticsDeletedAt: timestamp("analytics_deleted_at", { withTimezone: true }), + legacySubrouterRetiredTenantIds: jsonb("legacy_subrouter_retired_tenant_ids") + .$type() + .notNull() + .default(sql`'[]'::jsonb`), + hostedSubrouterDeletedTeamIds: jsonb("hosted_subrouter_deleted_team_ids") + .$type() + .notNull() + .default(sql`'[]'::jsonb`), errorMessage: text("error_message"), }, (table) => [ @@ -393,6 +401,30 @@ export const notificationSendEvents = pgTable( ], ); +// Hosted Subrouter owns live tenant state. This legacy mapping remains only so +// account deletion can purge credential-bearing rows retained for recovery. +export const subrouterTenants = pgTable( + "subrouter_tenants", + { + teamId: text("team_id").primaryKey(), + tenantId: text("tenant_id").notNull(), + tenantName: text("tenant_name").notNull(), + encryptedTenantKey: text("encrypted_tenant_key").notNull(), + // Durable recovery marker for the external source-finalization phase. + hostedFinalizationStartedAt: timestamp("hosted_finalization_started_at", { + withTimezone: true, + }), + // The hosted control plane must not serve a mapped legacy tenant until + // the credential-safe operator has verified its hosted copy. + hostedReadyAt: timestamp("hosted_ready_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("subrouter_tenants_tenant_id_unique").on(table.tenantId), + ], +); + export const stripeCustomers = pgTable( "stripe_customers", { @@ -484,21 +516,6 @@ export const billingEmailClaims = pgTable( ], ); -export const subrouterTenants = pgTable( - "subrouter_tenants", - { - teamId: text("team_id").primaryKey(), - tenantId: text("tenant_id").notNull(), - tenantName: text("tenant_name").notNull(), - encryptedTenantKey: text("encrypted_tenant_key").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - uniqueIndex("subrouter_tenants_tenant_id_unique").on(table.tenantId), - ], -); - export const vaultSessions = pgTable( "vault_sessions", { diff --git a/web/messages/en.json b/web/messages/en.json index 0c258d04976c..04312271699f 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -99,7 +99,6 @@ "vaultGroup": "vault", "vaultOverview": "overview", "vaultSessions": "sessions", - "vaultCliSetup": "cli setup", "subrouterGroup": "subrouter", "subrouterOverview": "overview", "accountGroup": "account", @@ -237,6 +236,8 @@ "teamSwitcherLabel": "Team", "notConfiguredTitle": "AI account management isn't available yet", "notConfiguredBody": "Team AI accounts aren't enabled for this deployment. Try again later or contact support.", + "migrationPendingTitle": "Account migration in progress", + "migrationPendingBody": "Shared accounts are temporarily unavailable while migration finishes. Try again shortly.", "loadErrorTitle": "Accounts could not load", "loadErrorBody": "The account service could not be reached. Try again shortly.", "accountsTitle": "Connected accounts", diff --git a/web/messages/ja.json b/web/messages/ja.json index c4d8d1d5b1d0..541b4060934a 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -99,7 +99,6 @@ "vaultGroup": "vault", "vaultOverview": "概要", "vaultSessions": "セッション", - "vaultCliSetup": "CLI設定", "subrouterGroup": "subrouter", "subrouterOverview": "概要", "accountGroup": "アカウント", @@ -237,6 +236,8 @@ "teamSwitcherLabel": "チーム", "notConfiguredTitle": "AIアカウント管理はまだ利用できません", "notConfiguredBody": "このデプロイではチームAIアカウントが有効になっていません。しばらくしてから再試行するか、サポートにお問い合わせください。", + "migrationPendingTitle": "アカウントを移行中です", + "migrationPendingBody": "共有アカウントは移行が完了するまで一時的に利用できません。しばらくしてからもう一度お試しください。", "loadErrorTitle": "アカウントを読み込めませんでした", "loadErrorBody": "アカウントサービスに接続できませんでした。しばらくしてから再試行してください。", "accountsTitle": "接続済みアカウント", diff --git a/web/package.json b/web/package.json index b7e80b745c80..a49f32d890b6 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "build": "bun tools/generate-managed-iroh-relay-catalog.ts --check && bun tools/build-docs-search.mjs && next build", "vercel-build": "bun tools/generate-managed-iroh-relay-catalog.ts --check && bun tools/build-docs-search.mjs && VERCEL_PREVIEW_COMMENTS_ENABLED=0 next build", "search:index": "bun tools/build-docs-search.mjs", + "subrouter:migrate-legacy": "bun scripts/subrouter/migrate-legacy-tenants.ts", "testflight:backfill-legacy-ownership": "bun scripts/stripe/backfill-pro-testflight-legacy-ownership.ts", "cloud-vm:env:audit": "bun scripts/cloud-vm/audit-vercel-env.mjs", "cloud-vm:migrate": "bun scripts/cloud-vm/migrate-vercel-aurora-iam.mjs", diff --git a/web/scripts/run-tests.sh b/web/scripts/run-tests.sh index c02a7a54fef2..811f5b39fdb6 100755 --- a/web/scripts/run-tests.sh +++ b/web/scripts/run-tests.sh @@ -116,24 +116,31 @@ if arguments_require_bun_discovery "$@" || default_config_controls_discovery; th exec bun test --isolate "$@" fi -discovered_test_files="" -if ! discovered_test_files="$( +discovery_file="$(mktemp "${TMPDIR:-/tmp}/cmux-web-test-discovery.XXXXXX")" +cleanup_discovery_file() { + rm -f "$discovery_file" +} +trap cleanup_discovery_file EXIT HUP INT TERM + +if ! ( find . \ \( -type d \( -name node_modules -o -name '.*' \) ! -path . -prune \) -o \ -type f -print | awk '/(\.test|_test|\.spec|_spec)\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$/' | LC_ALL=C sort -)"; then +) > "$discovery_file"; then echo "Web test discovery failed" >&2 exit 1 fi test_files=() -if [[ -n "$discovered_test_files" ]]; then - while IFS= read -r test_file; do +while IFS= read -r test_file; do + if [[ -n "$test_file" ]]; then test_files+=("$test_file") - done <<< "$discovered_test_files" -fi + fi +done < "$discovery_file" +cleanup_discovery_file +trap - EXIT HUP INT TERM if (( ${#test_files[@]} == 0 )); then echo "No web test files found" >&2 diff --git a/web/scripts/subrouter/migrate-legacy-tenants.ts b/web/scripts/subrouter/migrate-legacy-tenants.ts new file mode 100644 index 000000000000..d139b695d4ec --- /dev/null +++ b/web/scripts/subrouter/migrate-legacy-tenants.ts @@ -0,0 +1,491 @@ +#!/usr/bin/env bun + +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { StackServerApp } from "@stackframe/stack"; +import { Pool } from "pg"; + +import { + createLegacySubrouterRetirementClient, + legacySubrouterRetirementConfig, +} from "../../services/subrouter/legacyRetirementClient"; +import { + loadTargetEnv, + parseBoolean, + projects, +} from "../cloud-vm/projects.mjs"; + +export type LegacyTenantMapping = { + readonly teamId: string; + readonly tenantId: string; + readonly tenantName: string; +}; + +type StackMigrationSession = { + readonly accessToken: string; + readonly close: () => Promise; +}; + +type HostedTenantExchange = { + readonly tenantId: string; + readonly tenantKey: string; +}; + +type LegacyTenantMigrationTarget = "staging" | "production"; + +export function legacySubrouterRetirementConfigForTarget( + target: LegacyTenantMigrationTarget, + runtimeEnv: Record, +) { + const targetBaseUrl = target === "production" + ? "https://subrouter.cmux.dev" + : "https://subrouter-staging.cmux.dev"; + const configuredBaseUrl = runtimeEnv.SUBROUTER_BASE_URL?.trim().replace(/\/+$/, ""); + if (configuredBaseUrl && configuredBaseUrl !== targetBaseUrl) { + throw new Error(`legacy Subrouter source does not match ${target} target`); + } + return legacySubrouterRetirementConfig({ + ...runtimeEnv, + SUBROUTER_BASE_URL: targetBaseUrl, + }); +} + +export async function runLegacyTenantMigration(options: { + readonly mappings: readonly LegacyTenantMapping[]; + readonly apply: boolean; + readonly finalizeSource: boolean; + readonly destinationUrl: string; + readonly openStackSession: ( + mapping: LegacyTenantMapping, + ) => Promise; + readonly exchangeHostedTenant: (input: { + readonly teamId: string; + readonly teamName: string; + readonly accessToken: string; + readonly destinationUrl: string; + }) => Promise; + readonly migrateLegacyTenant: (input: { + readonly legacyTenantId: string; + readonly destinationUrl: string; + readonly tenantKey: string; + readonly finalizeSource: boolean; + }) => Promise<{ + readonly migrated: number; + readonly sourceFinalized: boolean; + }>; + readonly markFinalizationStarted: (teamId: string) => Promise; + readonly markHostedReady: (teamId: string) => Promise; + readonly log: (value: unknown) => void; +}): Promise<{ + readonly planned: number; + readonly migrated: number; + readonly sourceFinalized: boolean; +}> { + if (options.finalizeSource && !options.apply) { + throw new Error("--finalize-source requires --apply"); + } + assertDestination(options.destinationUrl); + const mappings = validatedMappings(options.mappings); + if (!options.apply) { + options.log({ + mode: "dry-run", + destinationUrl: options.destinationUrl, + tenants: mappings.map((mapping) => ({ + teamId: mapping.teamId, + legacyTenantId: mapping.tenantId, + })), + }); + return { planned: mappings.length, migrated: 0, sourceFinalized: false }; + } + + // Resolve every destination before any source finalization. This catches a + // stale DB mapping or missing Stack membership while all legacy tenants are + // still serving traffic. + const destinations: Array<{ + readonly mapping: LegacyTenantMapping; + readonly tenantKey: string; + }> = []; + for (const mapping of mappings) { + const session = await options.openStackSession(mapping); + let operationError: unknown; + try { + const destination = await options.exchangeHostedTenant({ + teamId: mapping.teamId, + teamName: mapping.tenantName, + accessToken: session.accessToken, + destinationUrl: options.destinationUrl, + }); + if (destination.tenantId !== mapping.teamId) { + throw new Error(`hosted tenant id does not match DB team mapping for ${mapping.teamId}`); + } + destinations.push({ mapping, tenantKey: destination.tenantKey }); + } catch (error) { + operationError = error; + throw error; + } finally { + try { + await session.close(); + } catch (closeError) { + if (operationError === undefined) throw closeError; + } + } + } + + let migrated = 0; + for (const destination of destinations) { + if (options.finalizeSource) { + // Persist the recoverable side of the two-phase transition before the + // external finalization. Subrouter v0.1.54 returns its completed receipt + // for an identical retry, so rerunning this command can finish the gate + // write after a database interruption without touching Hosted again. + await options.markFinalizationStarted(destination.mapping.teamId); + } + const result = await options.migrateLegacyTenant({ + legacyTenantId: destination.mapping.tenantId, + destinationUrl: options.destinationUrl, + tenantKey: destination.tenantKey, + finalizeSource: options.finalizeSource, + }); + if (result.sourceFinalized !== options.finalizeSource) { + throw new Error( + `legacy source finalization mismatch for ${destination.mapping.tenantId}`, + ); + } + if (result.sourceFinalized) { + await options.markHostedReady(destination.mapping.teamId); + } + migrated += result.migrated; + options.log({ + mode: options.finalizeSource ? "finalize" : "pre-copy", + teamId: destination.mapping.teamId, + legacyTenantId: destination.mapping.tenantId, + migrated: result.migrated, + sourceFinalized: result.sourceFinalized, + }); + } + return { + planned: mappings.length, + migrated, + sourceFinalized: options.finalizeSource, + }; +} + +async function main(): Promise { + const { target, apply, finalizeSource } = parseArguments(process.argv.slice(2)); + const project = projects[target]; + const runtimeEnv = loadTargetEnv(project); + const destinationUrl = target === "production" + ? "https://sr.cmux.com" + : "https://staging.sr.cmux.com"; + const store = await openLegacyTenantMigrationStore(runtimeEnv); + try { + const mappings = await store.loadMappings(); + const legacyClient = createLegacySubrouterRetirementClient( + legacySubrouterRetirementConfigForTarget(target, runtimeEnv), + ); + const stackApp = stackAppFromEnv(runtimeEnv); + + const result = await runLegacyTenantMigration({ + mappings, + apply, + finalizeSource, + destinationUrl, + openStackSession: (mapping) => + openStackMigrationSession(stackApp, mapping, runtimeEnv), + exchangeHostedTenant: (input) => + exchangeHostedTenant({ + ...input, + controlToken: requiredEnv( + runtimeEnv, + "SUBROUTER_STACK_TENANT_DELETE_TOKEN", + ), + }), + migrateLegacyTenant: async (input) => + await legacyClient.migrateTenant(input.legacyTenantId, { + destinationUrl: input.destinationUrl, + tenantKey: input.tenantKey, + finalizeSource: input.finalizeSource, + }), + markFinalizationStarted: store.markFinalizationStarted, + markHostedReady: store.markHostedReady, + log: (value) => console.log(JSON.stringify(value)), + }); + console.log(JSON.stringify({ ok: true, target, ...result })); + } finally { + await store.close(); + } +} + +function parseArguments(args: readonly string[]): { + readonly target: "staging" | "production"; + readonly apply: boolean; + readonly finalizeSource: boolean; +} { + const targetArg = args.find((arg) => !arg.startsWith("--")); + const target = targetArg === "prod" ? "production" : targetArg; + if (target !== "staging" && target !== "production") { + throw new Error( + "Usage: migrate-legacy-tenants.ts [--apply] [--finalize-source]", + ); + } + const allowed = new Set([targetArg!, "--apply", "--finalize-source"]); + const unknown = args.find((arg) => !allowed.has(arg)); + if (unknown) throw new Error(`unknown option: ${unknown}`); + return { + target, + apply: args.includes("--apply"), + finalizeSource: args.includes("--finalize-source"), + }; +} + +async function openLegacyTenantMigrationStore( + runtimeEnv: Record, +): Promise<{ + readonly loadMappings: () => Promise; + readonly markFinalizationStarted: (teamId: string) => Promise; + readonly markHostedReady: (teamId: string) => Promise; + readonly close: () => Promise; +}> { + for (const key of ["AWS_REGION", "PGHOST", "PGPORT", "PGUSER", "PGDATABASE"]) { + if (!runtimeEnv[key]?.trim()) throw new Error(`migration environment is missing ${key}`); + } + const port = Number(runtimeEnv.PGPORT); + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new Error("migration environment has an invalid PGPORT"); + } + const password = execFileSync(process.env.AWS_CLI ?? "aws", [ + "rds", + "generate-db-auth-token", + "--hostname", + runtimeEnv.PGHOST!, + "--port", + String(port), + "--region", + runtimeEnv.AWS_REGION!, + "--username", + runtimeEnv.PGUSER!, + ], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).trim(); + const pool = new Pool({ + host: runtimeEnv.PGHOST, + port, + user: runtimeEnv.PGUSER, + database: runtimeEnv.PGDATABASE, + password, + ssl: { + rejectUnauthorized: parseBoolean( + runtimeEnv.CMUX_DB_SSL_REJECT_UNAUTHORIZED, + true, + ), + }, + max: 1, + }); + return { + loadMappings: async () => { + const result = await pool.query<{ + teamId: string; + tenantId: string; + tenantName: string; + }>( + `select team_id as "teamId", tenant_id as "tenantId", tenant_name as "tenantName" + from subrouter_tenants + order by team_id`, + ); + return result.rows; + }, + markFinalizationStarted: async (teamId) => { + const result = await pool.query( + `update subrouter_tenants + set hosted_finalization_started_at = coalesce(hosted_finalization_started_at, now()), + updated_at = now() + where team_id = $1`, + [teamId], + ); + if (result.rowCount !== 1) { + throw new Error(`legacy tenant mapping disappeared for ${teamId}`); + } + }, + markHostedReady: async (teamId) => { + const result = await pool.query( + `update subrouter_tenants + set hosted_ready_at = coalesce(hosted_ready_at, now()), updated_at = now() + where team_id = $1 and hosted_finalization_started_at is not null`, + [teamId], + ); + if (result.rowCount !== 1) { + throw new Error(`legacy tenant finalization was not prepared for ${teamId}`); + } + }, + close: async () => await pool.end(), + }; +} + +function stackAppFromEnv(runtimeEnv: Record) { + const projectId = requiredEnv(runtimeEnv, "NEXT_PUBLIC_STACK_PROJECT_ID"); + const publishableClientKey = requiredEnv( + runtimeEnv, + "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY", + ); + const secretServerKey = requiredEnv(runtimeEnv, "STACK_SECRET_SERVER_KEY"); + return new StackServerApp({ + projectId, + publishableClientKey, + secretServerKey, + tokenStore: null, + noAutomaticPrefetch: true, + }); +} + +async function openStackMigrationSession( + app: ReturnType, + mapping: LegacyTenantMapping, + runtimeEnv: Record, +): Promise { + const team = await app.getTeam(mapping.teamId); + const teamUsers = team ? await team.listUsers() : []; + const directUser = teamUsers.length === 0 + ? await app.getUser(mapping.teamId) + : null; + const user = [...teamUsers, ...(directUser ? [directUser] : [])] + .sort((left, right) => left.id.localeCompare(right.id))[0]; + if (!user) { + throw new Error(`Stack mapping has no member for ${mapping.teamId}`); + } + const session = await user.createSession({ + expiresInMillis: 5 * 60 * 1_000, + isImpersonation: true, + }); + const tokens = await session.getTokens(); + if (!tokens.accessToken || !tokens.refreshToken) { + throw new Error(`Stack session is incomplete for ${mapping.teamId}`); + } + return { + accessToken: tokens.accessToken, + close: async () => { + await revokeStackSession(runtimeEnv, tokens.accessToken!, tokens.refreshToken!); + }, + }; +} + +async function exchangeHostedTenant(input: { + readonly teamId: string; + readonly teamName: string; + readonly accessToken: string; + readonly destinationUrl: string; + readonly controlToken: string; +}): Promise { + let response: Response; + try { + response = await fetch(`${input.destinationUrl}/_subrouter/auth/stack`, { + method: "POST", + headers: { + authorization: `Bearer ${input.accessToken}`, + "content-type": "application/json", + "x-subrouter-stack-control-token": input.controlToken, + }, + body: JSON.stringify({ + teamId: input.teamId, + teamName: input.teamName, + capabilities: ["manage_accounts"], + }), + signal: AbortSignal.timeout(30_000), + }); + } catch { + throw new Error(`hosted tenant exchange is unavailable for ${input.teamId}`); + } + if (!response.ok) { + throw new Error( + `hosted tenant exchange failed for ${input.teamId} with status ${response.status}`, + ); + } + const body = await response.json().catch(() => null) as Record | null; + if ( + !body || + typeof body.tenantId !== "string" || + typeof body.tenantKey !== "string" || + !/^srt_[0-9a-f]{32}$/.test(body.tenantKey) + ) { + throw new Error(`hosted tenant exchange was invalid for ${input.teamId}`); + } + return { tenantId: body.tenantId, tenantKey: body.tenantKey }; +} + +async function revokeStackSession( + runtimeEnv: Record, + accessToken: string, + refreshToken: string, +): Promise { + const projectId = requiredEnv(runtimeEnv, "NEXT_PUBLIC_STACK_PROJECT_ID"); + const publishableClientKey = requiredEnv( + runtimeEnv, + "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY", + ); + const apiUrl = (runtimeEnv.NEXT_PUBLIC_STACK_API_URL?.trim() || + "https://api.stack-auth.com/api/v1").replace(/\/+$/, ""); + const headers = new Headers({ + "content-type": "application/json", + "x-stack-refresh-token": refreshToken, + "x-hexclave-refresh-token": refreshToken, + }); + for (const prefix of ["x-stack", "x-hexclave"]) { + headers.set(`${prefix}-project-id`, projectId); + headers.set(`${prefix}-access-type`, "client"); + headers.set(`${prefix}-publishable-client-key`, publishableClientKey); + headers.set(`${prefix}-access-token`, accessToken); + } + const response = await fetch(`${apiUrl}/auth/sessions/current`, { + method: "DELETE", + headers, + body: "{}", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok && response.status !== 401 && response.status !== 404) { + throw new Error(`Stack session revocation failed with status ${response.status}`); + } +} + +function validatedMappings( + values: readonly LegacyTenantMapping[], +): readonly LegacyTenantMapping[] { + const mappings = [...values].sort((left, right) => + left.teamId.localeCompare(right.teamId) + ); + const teamIds = new Set(); + const tenantIds = new Set(); + for (const mapping of mappings) { + if (!mapping.teamId.trim() || !mapping.tenantId.trim() || !mapping.tenantName.trim()) { + throw new Error("legacy tenant mapping contains an empty identifier"); + } + if (teamIds.has(mapping.teamId) || tenantIds.has(mapping.tenantId)) { + throw new Error("legacy tenant mapping contains a duplicate identifier"); + } + teamIds.add(mapping.teamId); + tenantIds.add(mapping.tenantId); + } + return mappings; +} + +function assertDestination(value: string): void { + if ( + value !== "https://sr.cmux.com" && + value !== "https://staging.sr.cmux.com" + ) { + throw new Error("hosted migration destination is not allowed"); + } +} + +function requiredEnv( + runtimeEnv: Record, + key: string, +): string { + const value = runtimeEnv[key]?.trim(); + if (!value) throw new Error(`migration environment is missing ${key}`); + return value; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/web/services/subrouter/README.md b/web/services/subrouter/README.md index b512979ecff1..6d335cc33db4 100644 --- a/web/services/subrouter/README.md +++ b/web/services/subrouter/README.md @@ -1,18 +1,42 @@ -# Subrouter tenant service +# Hosted Subrouter -Server-side integration between the cmux web app and the subrouter multi-tenant -account API. Powers `/dashboard/ai-accounts` and the `/api/subrouter/accounts` -routes. +The dashboard and `/api/subrouter/accounts` use the signed-in Stack access +token to exchange a Stack team for a deterministic tenant on `sr.cmux.com`. +The Go service verifies the token and team membership. The trusted web broker +also enforces the team allowlist, Stack permissions, and hosted cutover gate +before it requests a capability-scoped tenant key. Direct client exchange is +rejected. The web app stores no tenant keys and needs no Subrouter admin token +or database row. -## Configuration +Production defaults to `https://sr.cmux.com`; previews and local development +default to `https://staging.sr.cmux.com`. `SUBROUTER_HOSTED_URL` overrides the +environment default. -The dashboard and API routes stay disabled (HTTP 503, "AI account management -isn't available yet") until both secrets are set: +`/api/cli/config` publishes the same-origin `/api/subrouter/exchange` broker +for native clients. `SUBROUTER_STACK_TENANT_DELETE_TOKEN` authenticates the +web broker to hosted Subrouter for both exchange and tenant retirement. -- `SUBROUTER_ADMIN_TOKEN` — admin bearer token for tenant provisioning. -- `SUBROUTER_TENANT_KEY_SECRET` — base64-encoded 32-byte key used to encrypt - tenant keys (AES-256-GCM) before they are stored in `subrouter_tenants`. - Generate with `openssl rand -base64 32`. Rotating this secret invalidates - previously stored tenant keys. -- `SUBROUTER_BASE_URL` — optional; defaults to `https://subrouter.cmux.dev` in - production and `https://subrouter-staging.cmux.dev` elsewhere. +The legacy `subrouter_tenants` table remains a cutover gate, recovery map, and +retirement map until every pre-hosted tenant has moved. A mapped team cannot +use the hosted control plane until the migration operator verifies its copy +and records `hosted_ready_at`. Run the operator from the worktree root in three +explicit phases: + +```sh +bun --cwd web subrouter:migrate-legacy production +bun --cwd web subrouter:migrate-legacy production --apply +bun --cwd web subrouter:migrate-legacy production --apply --finalize-source +``` + +The first command reads and prints only DB identifiers. `--apply` stages a +credential-safe copy without changing legacy traffic. `--finalize-source` +persists a durable finalization marker, refreshes and atomically activates the +hosted copy, quiesces the legacy source, then opens the hosted gate. Subrouter +v0.1.54 returns its completed receipt for an identical retry, so rerun the same +finalization command after an interrupted gate write. The operator derives +destination tenant keys from short-lived Stack impersonation sessions and +revokes each session without logging tokens or keys. + +`SUBROUTER_BASE_URL` and `SUBROUTER_ADMIN_TOKEN` remain deployed until the +mapping table is empty. Account deletion retires both mapped legacy tenants and +hosted tenants before removing the Stack user. diff --git a/web/services/subrouter/accountInput.ts b/web/services/subrouter/accountInput.ts index a8cd282b42c9..52cb6e12485c 100644 --- a/web/services/subrouter/accountInput.ts +++ b/web/services/subrouter/accountInput.ts @@ -1,4 +1,4 @@ -import type { SubrouterAccountInput } from "./client"; +import type { SubrouterAccountInput } from "./types"; import { readBoundedJsonRecord } from "./boundedJson"; const MAX_REQUEST_BYTES = 64 * 1024; diff --git a/web/services/subrouter/client.ts b/web/services/subrouter/client.ts deleted file mode 100644 index 2c9027051d6e..000000000000 --- a/web/services/subrouter/client.ts +++ /dev/null @@ -1,517 +0,0 @@ -export type SubrouterFetch = typeof fetch; - -export type SubrouterRuntimeEnv = Record; - -export type SubrouterTenant = { - readonly id: string; - readonly name: string; - readonly key: string; -}; - -export type SubrouterAccount = { - readonly id: string; - readonly kind: string; - readonly label?: string | null; - readonly createdAt?: string; - readonly health?: { - readonly ok: boolean; - readonly message?: string; - }; -}; - -export type ClaudeAccountInput = { - readonly provider: "claude"; - readonly label?: string; - readonly claudeAiOauth: { - readonly accessToken: string; - readonly refreshToken: string; - readonly expiresAt: number; - readonly subscriptionType?: string; - readonly rateLimitTier?: string; - }; -}; - -export type AnthropicApiKeyAccountInput = { - readonly provider: "anthropic-apikey"; - readonly label?: string; - readonly apiKey: string; -}; - -export type CodexAccountInput = { - readonly provider: "codex"; - readonly label?: string; - readonly tokens: { - readonly accessToken: string; - readonly refreshToken: string; - readonly idToken: string; - readonly accountID: string; - }; -}; - -export type OpenAiApiKeyAccountInput = { - readonly provider: "openai-apikey"; - readonly label?: string; - readonly apiKey: string; -}; - -export type SubrouterAccountInput = - | ClaudeAccountInput - | AnthropicApiKeyAccountInput - | CodexAccountInput - | OpenAiApiKeyAccountInput; - -export type SubrouterCredentialLeaseInput = { - readonly provider: "codex" | "claude"; - readonly agentType?: string; - readonly sessionId: string; - readonly userEmail?: string; - readonly preferAccountId?: string; - readonly model?: string; - readonly requiredAuthMode?: "oauth" | "apikey"; -}; - -export type SubrouterCredentialLease = { - readonly leaseId: string; - readonly accountId: string; - readonly provider: "codex" | "claude"; - readonly authMode: "oauth" | "apikey"; - readonly token: string; - readonly providerAccountId?: string; - readonly label: string; - readonly email?: string; - readonly credentialGeneration: number; - readonly issuedAt: string; - readonly expiresAt: string; - readonly credentialExpiresAt?: string; -}; - -export type SubrouterCredentialLeaseOutcome = - | "success" - | "unauthorized" - | "rate_limited" - | "provider_error"; - -export type SubrouterClient = { - readonly createTenant: (input: { readonly name: string }) => Promise; - readonly rotateTenant: (tenantId: string) => Promise<{ readonly id: string; readonly key: string }>; - readonly revokeTenant: (tenantId: string) => Promise; - readonly listAccounts: (tenantKey: string) => Promise; - readonly createAccount: ( - tenantKey: string, - input: SubrouterAccountInput, - ) => Promise; - readonly deleteAccount: (tenantKey: string, accountId: string) => Promise; - readonly repairAccount: ( - tenantKey: string, - accountId: string, - input: SubrouterAccountInput, - ) => Promise; - readonly createCredentialLease: ( - tenantKey: string, - input: SubrouterCredentialLeaseInput, - ) => Promise; - readonly reportCredentialLease: ( - tenantKey: string, - leaseId: string, - input: { - readonly outcome: SubrouterCredentialLeaseOutcome; - readonly statusCode?: number; - }, - ) => Promise<{ readonly ok: true; readonly refreshState?: "refreshed" }>; -}; - -export type SubrouterRuntimeConfig = { - readonly baseUrl: string; - readonly adminToken: string; - readonly tenantKeySecret: string; -}; - -export class SubrouterNotConfiguredError extends Error { - constructor() { - super("subrouter not configured"); - this.name = "SubrouterNotConfiguredError"; - } -} - -export class SubrouterClientError extends Error { - readonly operation: string; - readonly status: number | null; - - constructor(operation: string, status: number | null) { - super("subrouter request failed"); - this.name = "SubrouterClientError"; - this.operation = operation; - this.status = status; - } -} - -export function subrouterRuntimeConfig( - env: SubrouterRuntimeEnv = process.env, -): SubrouterRuntimeConfig | null { - const adminToken = trimEnv(env.SUBROUTER_ADMIN_TOKEN); - const tenantKeySecret = trimEnv(env.SUBROUTER_TENANT_KEY_SECRET); - if (!adminToken || !tenantKeySecret) return null; - - return { - baseUrl: trimEnv(env.SUBROUTER_BASE_URL) ?? defaultSubrouterBaseUrl(env), - adminToken, - tenantKeySecret, - }; -} - -export function isSubrouterConfigured(env: SubrouterRuntimeEnv = process.env): boolean { - return subrouterRuntimeConfig(env) !== null; -} - -export function createSubrouterClientFromEnv(options: { - readonly fetch?: SubrouterFetch; - readonly env?: SubrouterRuntimeEnv; -} = {}): SubrouterClient { - const config = subrouterRuntimeConfig(options.env); - if (!config) throw new SubrouterNotConfiguredError(); - return createSubrouterClient({ - baseUrl: config.baseUrl, - adminToken: config.adminToken, - fetch: options.fetch, - }); -} - -export function createSubrouterClient(options: { - readonly baseUrl: string; - readonly adminToken: string; - readonly fetch?: SubrouterFetch; -}): SubrouterClient { - const baseUrl = options.baseUrl.replace(/\/+$/, ""); - const fetchImpl = options.fetch ?? fetch; - const adminToken = options.adminToken; - - return { - createTenant: (input) => - requestJson( - fetchImpl, - `${baseUrl}/admin/tenants`, - "createTenant", - { - method: "POST", - headers: adminHeaders(adminToken), - body: JSON.stringify({ name: input.name }), - }, - parseTenant, - ), - rotateTenant: (tenantId) => - requestJson( - fetchImpl, - `${baseUrl}/admin/tenants/${encodeURIComponent(tenantId)}/rotate`, - "rotateTenant", - { - method: "POST", - headers: adminHeaders(adminToken), - }, - parseTenantRotation, - ), - revokeTenant: async (tenantId) => { - await requestNoBody(fetchImpl, `${baseUrl}/admin/tenants/${encodeURIComponent(tenantId)}/revoke`, "revokeTenant", { - method: "POST", - headers: adminHeaders(adminToken), - }); - }, - listAccounts: (tenantKey) => - requestJson( - fetchImpl, - `${baseUrl}/tenant/accounts`, - "listAccounts", - { - method: "GET", - headers: tenantHeaders(tenantKey), - }, - parseAccountList, - ), - createAccount: (tenantKey, input) => - requestJson( - fetchImpl, - `${baseUrl}/tenant/accounts?adopt=1&validate=1`, - "createAccount", - { - method: "POST", - headers: tenantHeaders(tenantKey), - body: JSON.stringify(input), - }, - parseAccount, - ), - deleteAccount: async (tenantKey, accountId) => { - await requestNoBody( - fetchImpl, - `${baseUrl}/tenant/accounts/${encodeURIComponent(accountId)}`, - "deleteAccount", - { - method: "DELETE", - headers: tenantHeaders(tenantKey), - }, - ); - }, - repairAccount: (tenantKey, accountId, input) => - requestJson( - fetchImpl, - `${baseUrl}/tenant/accounts/${encodeURIComponent(accountId)}/repair?adopt=1&validate=1`, - "repairAccount", - { - method: "POST", - headers: tenantHeaders(tenantKey), - body: JSON.stringify(input), - }, - parseAccount, - ), - createCredentialLease: (tenantKey, input) => - requestJson( - fetchImpl, - `${baseUrl}/tenant/leases`, - "createCredentialLease", - { - method: "POST", - headers: tenantHeaders(tenantKey), - body: JSON.stringify(input), - }, - parseCredentialLease, - ), - reportCredentialLease: (tenantKey, leaseId, input) => - requestJson( - fetchImpl, - `${baseUrl}/tenant/leases/${encodeURIComponent(leaseId)}/events`, - "reportCredentialLease", - { - method: "POST", - headers: tenantHeaders(tenantKey), - body: JSON.stringify(input), - }, - parseCredentialLeaseReport, - ), - }; -} - -function defaultSubrouterBaseUrl(env: SubrouterRuntimeEnv): string { - return env.VERCEL_ENV === "production" - ? "https://subrouter.cmux.dev" - : "https://subrouter-staging.cmux.dev"; -} - -function trimEnv(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function adminHeaders(adminToken: string): HeadersInit { - return { - authorization: `Bearer ${adminToken}`, - "content-type": "application/json", - }; -} - -function tenantHeaders(tenantKey: string): HeadersInit { - return { - authorization: `Bearer ${tenantKey}`, - "content-type": "application/json", - }; -} - -async function requestJson( - fetchImpl: SubrouterFetch, - url: string, - operation: string, - init: RequestInit, - parse: (value: unknown) => T, -): Promise { - const response = await subrouterFetch(fetchImpl, url, operation, init); - let parsed: unknown; - try { - parsed = await response.json(); - } catch { - throw new SubrouterClientError(operation, response.status); - } - return parse(parsed); -} - -async function requestNoBody( - fetchImpl: SubrouterFetch, - url: string, - operation: string, - init: RequestInit, -): Promise { - await subrouterFetch(fetchImpl, url, operation, init); -} - -async function subrouterFetch( - fetchImpl: SubrouterFetch, - url: string, - operation: string, - init: RequestInit, -): Promise { - let response: Response; - try { - response = await fetchImpl(url, { - ...init, - signal: init.signal ?? AbortSignal.timeout(10_000), - }); - } catch { - throw new SubrouterClientError(operation, null); - } - if (!response.ok) { - throw new SubrouterClientError(operation, response.status); - } - return response; -} - -function parseTenant(value: unknown): SubrouterTenant { - if (!isRecord(value)) throw new SubrouterClientError("parseTenant", null); - const { id, name, key } = value; - if (typeof id !== "string" || typeof name !== "string" || typeof key !== "string") { - throw new SubrouterClientError("parseTenant", null); - } - return { id, name, key }; -} - -function parseTenantRotation(value: unknown): { readonly id: string; readonly key: string } { - if (!isRecord(value)) throw new SubrouterClientError("parseTenantRotation", null); - const { id, key } = value; - if (typeof id !== "string" || typeof key !== "string") { - throw new SubrouterClientError("parseTenantRotation", null); - } - return { id, key }; -} - -function parseAccountList(value: unknown): readonly SubrouterAccount[] { - const accounts = Array.isArray(value) - ? value - : isRecord(value) && Array.isArray(value.accounts) - ? value.accounts - : null; - if (!accounts) throw new SubrouterClientError("parseAccountList", null); - return accounts.map(parseAccount); -} - -function parseAccount(value: unknown): SubrouterAccount { - if (!isRecord(value)) throw new SubrouterClientError("parseAccount", null); - const { id, label } = value; - const createdAt = value.createdAt ?? value.created_at; - const kind = typeof value.kind === "string" - ? value.kind - : accountKindFromProvider(value.provider, value.auth_mode); - if (typeof id !== "string" || !kind) { - throw new SubrouterClientError("parseAccount", null); - } - if (label !== undefined && label !== null && typeof label !== "string") { - throw new SubrouterClientError("parseAccount", null); - } - if (createdAt !== undefined && typeof createdAt !== "string") { - throw new SubrouterClientError("parseAccount", null); - } - const health = parseAccountHealth(value.health); - // Whitelist the browser-facing shape: never forward unknown upstream fields - // across this trust boundary, even though the worker sanitizes accounts. - return { - id, - kind, - ...(label !== undefined ? { label } : {}), - ...(createdAt !== undefined ? { createdAt } : {}), - ...(health ? { health } : {}), - }; -} - -function parseAccountHealth( - value: unknown, -): { readonly ok: boolean; readonly message?: string } | undefined { - if (value === undefined || value === null) return undefined; - if (!isRecord(value) || typeof value.ok !== "boolean") { - throw new SubrouterClientError("parseAccountHealth", null); - } - if (value.message !== undefined && typeof value.message !== "string") { - throw new SubrouterClientError("parseAccountHealth", null); - } - return { - ok: value.ok, - ...(typeof value.message === "string" ? { message: value.message } : {}), - }; -} - -function accountKindFromProvider(provider: unknown, authMode: unknown): string | null { - if (provider === "codex" && authMode === "oauth") return "codex"; - if (provider === "codex" && authMode === "apikey") return "openai-apikey"; - if (provider === "claude" && authMode === "oauth") return "claude"; - if (provider === "claude" && authMode === "apikey") return "anthropic-apikey"; - return null; -} - -function parseCredentialLease(value: unknown): SubrouterCredentialLease { - if (!isRecord(value)) { - throw new SubrouterClientError("parseCredentialLease", null); - } - const { - leaseId, - accountId, - provider, - authMode, - token, - providerAccountId, - label, - email, - credentialGeneration, - issuedAt, - expiresAt, - credentialExpiresAt, - } = value; - if ( - typeof leaseId !== "string" || - typeof accountId !== "string" || - (provider !== "codex" && provider !== "claude") || - (authMode !== "oauth" && authMode !== "apikey") || - typeof token !== "string" || - typeof label !== "string" || - typeof credentialGeneration !== "number" || - typeof issuedAt !== "string" || - typeof expiresAt !== "string" - ) { - throw new SubrouterClientError("parseCredentialLease", null); - } - if (providerAccountId !== undefined && typeof providerAccountId !== "string") { - throw new SubrouterClientError("parseCredentialLease", null); - } - if (email !== undefined && typeof email !== "string") { - throw new SubrouterClientError("parseCredentialLease", null); - } - if (credentialExpiresAt !== undefined && typeof credentialExpiresAt !== "string") { - throw new SubrouterClientError("parseCredentialLease", null); - } - return { - leaseId, - accountId, - provider, - authMode, - token, - ...(providerAccountId ? { providerAccountId } : {}), - label, - ...(email ? { email } : {}), - credentialGeneration, - issuedAt, - expiresAt, - ...(credentialExpiresAt ? { credentialExpiresAt } : {}), - }; -} - -function parseCredentialLeaseReport( - value: unknown, -): { readonly ok: true; readonly refreshState?: "refreshed" } { - if (!isRecord(value) || value.ok !== true) { - throw new SubrouterClientError("parseCredentialLeaseReport", null); - } - const refreshState = value.refreshState; - if (refreshState !== undefined && refreshState !== "refreshed") { - throw new SubrouterClientError("parseCredentialLeaseReport", null); - } - return { - ok: true, - ...(refreshState ? { refreshState } : {}), - }; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/web/services/subrouter/constants.ts b/web/services/subrouter/constants.ts new file mode 100644 index 000000000000..4913791c351c --- /dev/null +++ b/web/services/subrouter/constants.ts @@ -0,0 +1,30 @@ +const PRODUCTION_HOSTED_SUBROUTER_URL = "https://sr.cmux.com"; +const STAGING_HOSTED_SUBROUTER_URL = "https://staging.sr.cmux.com"; + +export function defaultHostedSubrouterURL( + deploymentEnvironment = process.env.VERCEL_ENV, +): string { + return deploymentEnvironment === "production" + ? PRODUCTION_HOSTED_SUBROUTER_URL + : STAGING_HOSTED_SUBROUTER_URL; +} + +export function hostedSubrouterBaseURL(value: string): string { + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + throw new Error("invalid hosted Subrouter URL"); + } + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + throw new Error("invalid hosted Subrouter URL"); + } + return parsed.origin; +} diff --git a/web/services/subrouter/crypto.ts b/web/services/subrouter/crypto.ts deleted file mode 100644 index 896def9cdc98..000000000000 --- a/web/services/subrouter/crypto.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - createCipheriv, - createDecipheriv, - randomBytes, -} from "node:crypto"; - -const CIPHER = "aes-256-gcm"; -const VERSION = "v1"; -const IV_BYTES = 12; -const KEY_BYTES = 32; - -export class SubrouterTenantKeySecretError extends Error { - constructor(message = "subrouter tenant key secret is invalid") { - super(message); - this.name = "SubrouterTenantKeySecretError"; - } -} - -export class SubrouterTenantKeyDecryptionError extends Error { - constructor(message = "subrouter tenant key could not be decrypted") { - super(message); - this.name = "SubrouterTenantKeyDecryptionError"; - } -} - -export function encryptTenantKey( - tenantKey: string, - secret = process.env.SUBROUTER_TENANT_KEY_SECRET, -): string { - const key = decodeTenantKeySecret(secret); - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(CIPHER, key, iv); - const ciphertext = Buffer.concat([ - cipher.update(tenantKey, "utf8"), - cipher.final(), - ]); - const tag = cipher.getAuthTag(); - - return [ - VERSION, - iv.toString("base64"), - tag.toString("base64"), - ciphertext.toString("base64"), - ].join(":"); -} - -export function decryptTenantKey( - encryptedTenantKey: string, - secret = process.env.SUBROUTER_TENANT_KEY_SECRET, -): string { - const key = decodeTenantKeySecret(secret); - const parts = encryptedTenantKey.split(":"); - if (parts.length !== 4 || parts[0] !== VERSION) { - throw new SubrouterTenantKeyDecryptionError(); - } - - try { - const iv = Buffer.from(parts[1], "base64"); - const tag = Buffer.from(parts[2], "base64"); - const ciphertext = Buffer.from(parts[3], "base64"); - if (iv.length !== IV_BYTES || tag.length !== 16 || ciphertext.length === 0) { - throw new SubrouterTenantKeyDecryptionError(); - } - - const decipher = createDecipheriv(CIPHER, key, iv); - decipher.setAuthTag(tag); - return Buffer.concat([ - decipher.update(ciphertext), - decipher.final(), - ]).toString("utf8"); - } catch (err) { - if (err instanceof SubrouterTenantKeyDecryptionError) throw err; - throw new SubrouterTenantKeyDecryptionError(); - } -} - -function decodeTenantKeySecret(secret: string | undefined): Buffer { - const normalized = secret?.trim().replace(/\s+/g, ""); - if (!normalized) { - throw new SubrouterTenantKeySecretError(); - } - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized)) { - throw new SubrouterTenantKeySecretError(); - } - - const decoded = Buffer.from(normalized, "base64"); - if (decoded.length !== KEY_BYTES || decoded.toString("base64") !== normalized) { - throw new SubrouterTenantKeySecretError(); - } - return decoded; -} diff --git a/web/services/subrouter/cutover.ts b/web/services/subrouter/cutover.ts new file mode 100644 index 000000000000..c63336f0d75c --- /dev/null +++ b/web/services/subrouter/cutover.ts @@ -0,0 +1,33 @@ +import { eq } from "drizzle-orm"; + +import { cloudDb } from "../../db/client"; +import { subrouterTenants } from "../../db/schema"; + +type LegacyTenantCutover = { + readonly hostedReadyAt: Date | null; +}; + +type LegacyTenantCutoverLookup = ( + teamId: string, +) => Promise; + +export async function hostedSubrouterCutoverReadyForTeam( + teamId: string, + lookup: LegacyTenantCutoverLookup = loadLegacyTenantCutover, +): Promise { + const legacyMapping = await lookup(teamId); + if (!legacyMapping) return true; + return legacyMapping.hostedReadyAt instanceof Date && + !Number.isNaN(legacyMapping.hostedReadyAt.getTime()); +} + +async function loadLegacyTenantCutover( + teamId: string, +): Promise { + const rows = await cloudDb() + .select({ hostedReadyAt: subrouterTenants.hostedReadyAt }) + .from(subrouterTenants) + .where(eq(subrouterTenants.teamId, teamId)) + .limit(1); + return rows[0]; +} diff --git a/web/services/subrouter/hostedClient.ts b/web/services/subrouter/hostedClient.ts new file mode 100644 index 000000000000..6c3bdcdc572a --- /dev/null +++ b/web/services/subrouter/hostedClient.ts @@ -0,0 +1,515 @@ +import { env } from "../../app/env"; +import { + defaultHostedSubrouterURL, + hostedSubrouterBaseURL, +} from "./constants"; +import type { + SubrouterAccount, + SubrouterAccountInput, + SubrouterCredentialLease, + SubrouterCredentialLeaseInput, + SubrouterCredentialLeaseOutcome, +} from "./types"; + +export type HostedTenant = { + readonly tenantId: string; + readonly tenantName: string; + readonly tenantKey: string; + readonly proxyUrl: string; + readonly capabilities: readonly HostedTenantCapability[]; +}; + +export type HostedTenantCapability = "use" | "manage_accounts"; + +export type HostedTeam = { + readonly teamId: string; + readonly teamName: string; + readonly use: boolean; + readonly manageAccounts: boolean; +}; + +export type HostedSubrouterClient = { + readonly tenantControlConfigured: boolean; + readonly assertTenantDeletionConfigured: () => void; + readonly exchangeTeam: ( + accessToken: string, + team: HostedTeam, + ) => Promise; + readonly deleteTenant: ( + accessToken: string, + teamId: string, + options?: { readonly signal?: AbortSignal }, + ) => Promise; + readonly listAccounts: (tenantKey: string) => Promise; + readonly createAccount: ( + tenantKey: string, + input: SubrouterAccountInput, + ) => Promise; + readonly repairAccount: ( + tenantKey: string, + accountId: string, + input: SubrouterAccountInput, + ) => Promise; + readonly deleteAccount: (tenantKey: string, accountId: string) => Promise; + readonly createCredentialLease: ( + tenantKey: string, + input: SubrouterCredentialLeaseInput, + ) => Promise; + readonly reportCredentialLease: ( + tenantKey: string, + leaseId: string, + input: { + readonly outcome: SubrouterCredentialLeaseOutcome; + readonly statusCode?: number; + }, + ) => Promise<{ readonly ok: true; readonly refreshState?: "refreshed" }>; +}; + +export function createHostedSubrouterClient(options: { + readonly baseUrl?: string; + readonly tenantDeleteToken?: string; + readonly fetch?: typeof fetch; +} = {}): HostedSubrouterClient { + const baseUrl = hostedSubrouterBaseURL( + options.baseUrl ?? env.SUBROUTER_HOSTED_URL ?? defaultHostedSubrouterURL(), + ); + const fetchImpl = options.fetch ?? fetch; + const tenantDeleteToken = ( + options.tenantDeleteToken ?? + process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN ?? + "" + ).trim(); + const assertTenantControlConfigured = (): void => { + if (!tenantDeleteToken) { + throw new HostedSubrouterError( + "hosted Subrouter tenant control is not configured", + 503, + ); + } + }; + + const tenantRequest = ( + tenantKey: string, + path: string, + init: RequestInit, + ): Promise => { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${tenantKey}`); + return requestJson(fetchImpl, `${baseUrl}${path}`, { ...init, headers }); + }; + const tenantRequestResponse = ( + tenantKey: string, + path: string, + init: RequestInit, + ): Promise => { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${tenantKey}`); + return requestResponse(fetchImpl, `${baseUrl}${path}`, { ...init, headers }); + }; + const tenantRequestWithoutResponse = async ( + tenantKey: string, + path: string, + init: RequestInit, + ): Promise => { + await tenantRequestResponse(tenantKey, path, init); + }; + const uploadAccount = async ( + tenantKey: string, + input: SubrouterAccountInput, + targetAccountID?: string, + ): Promise => { + const response = await tenantRequest( + tenantKey, + "/_subrouter/accounts", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...input, + ...(targetAccountID ? { targetAccountID } : {}), + }), + }, + ); + if (!isRecord(response) || !isRecord(response.account)) { + throw new HostedSubrouterError("invalid account response", 502); + } + return parseAccountEnvelope(response.account); + }; + + return { + tenantControlConfigured: tenantDeleteToken.length > 0, + assertTenantDeletionConfigured: assertTenantControlConfigured, + exchangeTeam: async (accessToken, team) => { + assertTenantControlConfigured(); + const capabilities = hostedTenantCapabilities(team); + if (capabilities.length === 0) { + throw new HostedSubrouterError( + "hosted Subrouter team has no capabilities", + 403, + "caller", + ); + } + const response = await requestJson( + fetchImpl, + `${baseUrl}/_subrouter/auth/stack`, + { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "x-subrouter-stack-control-token": tenantDeleteToken, + }, + body: JSON.stringify({ + teamId: team.teamId, + teamName: team.teamName, + capabilities, + }), + }, + "caller", + ); + const tenant = parseHostedTenant(response); + if (tenant.tenantId !== team.teamId) { + throw new HostedSubrouterError( + "hosted Subrouter returned a tenant for a different team", + 502, + ); + } + if (!sameCapabilities(tenant.capabilities, capabilities)) { + throw new HostedSubrouterError( + "hosted Subrouter returned mismatched capabilities", + 502, + ); + } + return tenant; + }, + deleteTenant: async (accessToken, teamId, options) => { + assertTenantControlConfigured(); + const upstreamResponse = await requestResponse( + fetchImpl, + `${baseUrl}/_subrouter/auth/stack/tenant`, + { + method: "DELETE", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "x-subrouter-tenant-delete-token": tenantDeleteToken, + }, + body: JSON.stringify({ teamId }), + signal: options?.signal, + }, + ); + const response = await responseJson(upstreamResponse); + if (!isRecord(response)) { + throw new HostedSubrouterError("invalid tenant deletion response", 502); + } + if (response.deletionPending === true) { + throw new HostedSubrouterError( + "hosted Subrouter tenant deletion is pending", + 503, + ); + } + if ( + upstreamResponse.status !== 200 || + response.ok !== true || + typeof response.deleted !== "boolean" || + "deletionPending" in response + ) { + throw new HostedSubrouterError("invalid tenant deletion response", 502); + } + }, + listAccounts: async (tenantKey) => { + const response = await tenantRequest( + tenantKey, + "/_subrouter/accounts", + { method: "GET" }, + ); + if (!Array.isArray(response)) throw new HostedSubrouterError("invalid account list", 502); + return response.map(parseHostedAccount); + }, + createAccount: async (tenantKey, input) => + await uploadAccount(tenantKey, input), + repairAccount: async (tenantKey, accountId, input) => + await uploadAccount(tenantKey, input, accountId), + deleteAccount: async (tenantKey, accountId) => { + await tenantRequestWithoutResponse( + tenantKey, + `/_subrouter/accounts/${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + }, + createCredentialLease: async (tenantKey, input) => { + const response = await tenantRequest( + tenantKey, + "/_subrouter/leases", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!isRecord(response) || !isRecord(response.lease)) { + throw new HostedSubrouterError("invalid credential lease", 502); + } + return parseCredentialLease(response.lease); + }, + reportCredentialLease: async (tenantKey, leaseId, input) => { + const response = await tenantRequestResponse( + tenantKey, + `/_subrouter/leases/${encodeURIComponent(leaseId)}/events`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (response.status === 204) return { ok: true }; + const body = await responseJson(response); + if ( + !isRecord(body) || + body.ok !== true || + (body.refreshState !== undefined && body.refreshState !== "refreshed") + ) { + throw new HostedSubrouterError("invalid credential lease report", 502); + } + return { + ok: true, + ...(body.refreshState === "refreshed" + ? { refreshState: "refreshed" as const } + : {}), + }; + }, + }; +} + +export class HostedSubrouterError extends Error { + constructor( + message: string, + readonly status: number, + readonly authentication: "caller" | "internal" = "internal", + ) { + super(message); + this.name = "HostedSubrouterError"; + } +} + +async function requestJson( + fetchImpl: typeof fetch, + url: string, + init: RequestInit, + authentication: "caller" | "internal" = "internal", +): Promise { + const response = await requestResponse( + fetchImpl, + url, + init, + authentication, + ); + return await responseJson(response); +} + +async function responseJson(response: Response): Promise { + try { + return await response.json(); + } catch { + throw new HostedSubrouterError("hosted Subrouter returned invalid JSON", 502); + } +} + +async function requestResponse( + fetchImpl: typeof fetch, + url: string, + init: RequestInit, + authentication: "caller" | "internal" = "internal", +): Promise { + let response: Response; + try { + response = await fetchImpl(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(10_000), + }); + } catch { + throw new HostedSubrouterError("hosted Subrouter unavailable", 503); + } + if (!response.ok) { + throw new HostedSubrouterError( + "hosted Subrouter request failed", + response.status, + authentication, + ); + } + return response; +} + +function parseHostedTenant(value: unknown): HostedTenant { + if ( + !isRecord(value) || + !isString(value.tenantId) || + !isString(value.tenantName) || + !isString(value.tenantKey) || + !isString(value.proxyUrl) || + !Array.isArray(value.capabilities) || + !value.capabilities.every(isHostedTenantCapability) + ) { + throw new HostedSubrouterError("invalid hosted tenant response", 502); + } + return { + tenantId: value.tenantId, + tenantName: value.tenantName, + tenantKey: value.tenantKey, + proxyUrl: value.proxyUrl, + capabilities: value.capabilities, + }; +} + +function hostedTenantCapabilities( + team: Pick, +): HostedTenantCapability[] { + return [ + ...(team.use ? ["use" as const] : []), + ...(team.manageAccounts ? ["manage_accounts" as const] : []), + ]; +} + +function isHostedTenantCapability( + value: unknown, +): value is HostedTenantCapability { + return value === "use" || value === "manage_accounts"; +} + +function sameCapabilities( + left: readonly HostedTenantCapability[], + right: readonly HostedTenantCapability[], +): boolean { + const leftSet = new Set(left); + const rightSet = new Set(right); + return leftSet.size === left.length && + rightSet.size === right.length && + leftSet.size === rightSet.size && + [...leftSet].every((capability) => rightSet.has(capability)); +} + +function parseHostedAccount(value: unknown): SubrouterAccount { + if (!isRecord(value) || !isString(value.id) || !isString(value.provider)) { + throw new HostedSubrouterError("invalid hosted account", 502); + } + const explicitLabel = value.label; + const createdAt = value.createdAt ?? value.created_at; + if ( + (explicitLabel !== undefined && + explicitLabel !== null && + typeof explicitLabel !== "string") || + (createdAt !== undefined && typeof createdAt !== "string") + ) { + throw new HostedSubrouterError("invalid hosted account", 502); + } + const kind = accountKindFromProvider(value.provider, value.auth_mode); + if (!kind) { + throw new HostedSubrouterError("invalid hosted account", 502); + } + const fallbackLabel = isString(value.email) ? value.email : value.id; + const apiKeyPrefix = `apikey:${kind}:`; + const label = explicitLabel !== undefined + ? explicitLabel + : fallbackLabel.startsWith(apiKeyPrefix) + ? fallbackLabel.slice(apiKeyPrefix.length) + : fallbackLabel; + return { + id: value.id, + kind, + label, + ...(createdAt !== undefined ? { createdAt } : {}), + ...parseHealth(value.health), + }; +} + +function accountKindFromProvider( + provider: unknown, + authMode: unknown, +): SubrouterAccount["kind"] | null { + if (provider === "codex" && authMode === "oauth") return "codex"; + if (provider === "codex" && authMode === "apikey") return "openai-apikey"; + if (provider === "claude" && authMode === "oauth") return "claude"; + if (provider === "claude" && authMode === "apikey") return "anthropic-apikey"; + return null; +} + +function parseAccountEnvelope(value: Record): SubrouterAccount { + if (!isString(value.id) || !isString(value.kind)) { + throw new HostedSubrouterError("invalid hosted account", 502); + } + return { + id: value.id, + kind: value.kind, + label: isString(value.label) ? value.label : undefined, + ...parseHealth(value.health), + }; +} + +function parseCredentialLease(value: unknown): SubrouterCredentialLease { + if (!isRecord(value)) { + throw new HostedSubrouterError("invalid credential lease", 502); + } + const { + leaseId, + accountId, + provider, + authMode, + token, + providerAccountId, + label, + email, + credentialGeneration, + issuedAt, + expiresAt, + credentialExpiresAt, + } = value; + if ( + !isString(leaseId) || + !isString(accountId) || + (provider !== "codex" && provider !== "claude") || + (authMode !== "oauth" && authMode !== "apikey") || + !isString(token) || + !isString(label) || + typeof credentialGeneration !== "number" || + !isString(issuedAt) || + !isString(expiresAt) || + (providerAccountId !== undefined && typeof providerAccountId !== "string") || + (email !== undefined && typeof email !== "string") || + (credentialExpiresAt !== undefined && typeof credentialExpiresAt !== "string") + ) { + throw new HostedSubrouterError("invalid credential lease", 502); + } + return { + leaseId, + accountId, + provider, + authMode, + token, + ...(providerAccountId ? { providerAccountId } : {}), + label, + ...(email ? { email } : {}), + credentialGeneration, + issuedAt, + expiresAt, + ...(credentialExpiresAt ? { credentialExpiresAt } : {}), + }; +} + +function parseHealth( + value: unknown, +): Pick { + if (!isRecord(value) || typeof value.ok !== "boolean") return {}; + return { + health: { + ok: value.ok, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/web/services/subrouter/legacyRetirementClient.ts b/web/services/subrouter/legacyRetirementClient.ts new file mode 100644 index 000000000000..571c6c1f6b5e --- /dev/null +++ b/web/services/subrouter/legacyRetirementClient.ts @@ -0,0 +1,166 @@ +export type LegacySubrouterRuntimeEnv = Record; + +export type LegacySubrouterRetirementConfig = { + readonly baseUrl: string; + readonly adminToken: string; +}; + +export type LegacySubrouterMigrationInput = { + readonly destinationUrl: string; + readonly tenantKey: string; + readonly finalizeSource: boolean; +}; + +export type LegacySubrouterRetirementClient = { + readonly revokeTenant: ( + tenantId: string, + options?: { readonly signal?: AbortSignal }, + ) => Promise<{ readonly revoked: boolean }>; + readonly migrateTenant: ( + tenantId: string, + input: LegacySubrouterMigrationInput, + ) => Promise<{ + readonly migrated: number; + readonly sourceFinalized: boolean; + }>; +}; + +export class LegacySubrouterRetirementError extends Error { + constructor( + readonly operation: "revoke" | "migrate", + readonly status: number | null, + ) { + super(`legacy Subrouter ${operation} failed`); + this.name = "LegacySubrouterRetirementError"; + } +} + +export function legacySubrouterRetirementConfig( + runtimeEnv: LegacySubrouterRuntimeEnv = process.env, +): LegacySubrouterRetirementConfig { + const adminToken = runtimeEnv.SUBROUTER_ADMIN_TOKEN?.trim(); + if (!adminToken) { + throw new Error("legacy Subrouter retirement is not configured"); + } + const baseUrl = ( + runtimeEnv.SUBROUTER_BASE_URL?.trim() || + (runtimeEnv.VERCEL_ENV === "production" + ? "https://subrouter.cmux.dev" + : "https://subrouter-staging.cmux.dev") + ).replace(/\/+$/, ""); + assertSafeBaseUrl(baseUrl); + return { baseUrl, adminToken }; +} + +export function createLegacySubrouterRetirementClient( + options: LegacySubrouterRetirementConfig & { + readonly fetch?: typeof fetch; + }, +): LegacySubrouterRetirementClient { + const baseUrl = options.baseUrl.replace(/\/+$/, ""); + assertSafeBaseUrl(baseUrl); + const adminToken = options.adminToken.trim(); + if (!adminToken) { + throw new Error("legacy Subrouter retirement is not configured"); + } + const fetchImpl = options.fetch ?? fetch; + + const request = async ( + tenantId: string, + action: "revoke" | "migrate", + init: RequestInit, + ): Promise => { + let response: Response; + try { + response = await fetchImpl( + `${baseUrl}/admin/tenants/${encodeURIComponent(tenantId)}/${ + action === "revoke" ? "revoke" : "migrate-hosted" + }`, + { + ...init, + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + ...init.headers, + }, + signal: init.signal ?? AbortSignal.timeout(30_000), + }, + ); + } catch { + throw new LegacySubrouterRetirementError(action, null); + } + return response; + }; + + return { + revokeTenant: async (tenantId, options) => { + const response = await request(tenantId, "revoke", { + method: "POST", + signal: options?.signal, + }); + if (response.status === 404) return { revoked: false }; + if (!response.ok) { + throw new LegacySubrouterRetirementError("revoke", response.status); + } + return { revoked: true }; + }, + migrateTenant: async (tenantId, input) => { + const response = await request(tenantId, "migrate", { + method: "POST", + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new LegacySubrouterRetirementError("migrate", response.status); + } + const body = await safeJson(response, "migrate"); + if ( + !isRecord(body) || + body.ok !== true || + !Number.isSafeInteger(body.migrated) || + (body.migrated as number) < 0 || + body.sourceFinalized !== input.finalizeSource + ) { + throw new LegacySubrouterRetirementError("migrate", response.status); + } + return { + migrated: body.migrated as number, + sourceFinalized: body.sourceFinalized as boolean, + }; + }, + }; +} + +async function safeJson( + response: Response, + operation: "revoke" | "migrate", +): Promise { + try { + return await response.json(); + } catch { + throw new LegacySubrouterRetirementError(operation, response.status); + } +} + +function assertSafeBaseUrl(raw: string): void { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error("legacy Subrouter retirement URL is invalid"); + } + const loopback = parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]"; + if ( + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) + ) { + throw new Error("legacy Subrouter retirement URL is invalid"); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/web/services/subrouter/requestContext.ts b/web/services/subrouter/requestContext.ts index 01f5e6831b08..df440a42f144 100644 --- a/web/services/subrouter/requestContext.ts +++ b/web/services/subrouter/requestContext.ts @@ -11,13 +11,14 @@ import { verifySubrouterRequest, withSubrouterAuthorizationDeadline, type AuthedUser, + parseNativeStackTokens, } from "../vms/auth"; +import { getStackServerApp } from "../../app/lib/stack"; import { - createSubrouterClient, - subrouterRuntimeConfig, - type SubrouterClient, - type SubrouterRuntimeConfig, -} from "./client"; + createHostedSubrouterClient, + type HostedSubrouterClient, +} from "./hostedClient"; +import { hostedSubrouterCutoverReadyForTeam } from "./cutover"; import { resolveTeam, serviceUnavailableResponse, @@ -31,14 +32,15 @@ export type SubrouterRequestContext = { readonly use: boolean; readonly manageAccounts: boolean; }; - readonly config: SubrouterRuntimeConfig; - readonly client: SubrouterClient; + readonly accessToken: string; + readonly client: HostedSubrouterClient; }; export async function resolveSubrouterRequestContext( request: Request, options: { readonly permission?: "use" | "manage" | "use-or-manage"; + readonly allowCookie?: boolean; } = {}, ): Promise< | { readonly ok: true; readonly value: SubrouterRequestContext } @@ -49,7 +51,7 @@ export async function resolveSubrouterRequestContext( const requestedTeamId = requestedVmTeamIdFromRequest(request); const user = await verifySubrouterRequest(request, signal, { requestedTeamId, - allowCookie: true, + allowCookie: options.allowCookie ?? true, }); if (!user) return { ok: false, response: unauthorized() }; @@ -79,24 +81,75 @@ export async function resolveSubrouterRequestContext( }; } - const config = subrouterRuntimeConfig(); - if (!config) { + let hostedCutoverReady: boolean; + try { + hostedCutoverReady = await hostedSubrouterCutoverReadyForTeam( + team.teamId, + ); + } catch (error) { + console.error("Subrouter cutover state unavailable", { + errorType: error instanceof Error ? error.name : typeof error, + }); return { ok: false, response: serviceUnavailableResponse(), }; } + if (!hostedCutoverReady) { + return { + ok: false, + response: jsonResponse( + { error: "subrouter_migration_pending" }, + 503, + ), + }; + } + + const client = createHostedSubrouterClient(); + if (!client.tenantControlConfigured) { + return { + ok: false, + response: serviceUnavailableResponse(), + }; + } + + const nativeTokens = parseNativeStackTokens(request); + const tokenStore = nativeTokens ?? { + headers: { + get: (name: string): string | null => request.headers.get(name), + }, + }; + // Stack may refresh a native session while verifying it. Forward the + // authoritative token instead of the possibly stale request header. + let accessToken: string | null | undefined; + try { + const authoritativeTokens = await getStackServerApp().getAuthJson({ + tokenStore, + }); + accessToken = authoritativeTokens?.accessToken; + } catch (error) { + console.error("Subrouter Stack token refresh unavailable", { + errorType: error instanceof Error ? error.name : typeof error, + }); + return { + ok: false, + response: serviceUnavailableResponse(), + }; + } + if (!accessToken) { + return { + ok: false, + response: unauthorized(), + }; + } return { ok: true, value: { user, team, - config, - client: createSubrouterClient({ - baseUrl: config.baseUrl, - adminToken: config.adminToken, - }), + accessToken, + client, }, }; }); diff --git a/web/services/subrouter/routeHelpers.ts b/web/services/subrouter/routeHelpers.ts index f782e8e6dbea..7f8b698ddc92 100644 --- a/web/services/subrouter/routeHelpers.ts +++ b/web/services/subrouter/routeHelpers.ts @@ -6,11 +6,7 @@ import { subrouterAllowedTeamIds, type AuthedUser, } from "../vms/auth"; -import { SubrouterClientError, SubrouterNotConfiguredError } from "./client"; -import { - SubrouterTenantKeyDecryptionError, - SubrouterTenantKeySecretError, -} from "./crypto"; +import { HostedSubrouterError } from "./hostedClient"; export type TeamResolution = | { @@ -176,22 +172,16 @@ export function serviceUnavailableResponse(): Response { } export function subrouterErrorResponse(err: unknown): Response { - if ( - err instanceof SubrouterNotConfiguredError || - err instanceof SubrouterTenantKeySecretError || - err instanceof SubrouterTenantKeyDecryptionError - ) { - console.error("Subrouter control-plane configuration failed", { - errorType: err.name, - }); - return serviceUnavailableResponse(); - } - if (err instanceof SubrouterClientError) { + if (err instanceof HostedSubrouterError) { console.error("Subrouter upstream request failed", { - operation: err.operation, status: err.status, + authentication: err.authentication, }); - const status = err.status !== null && err.status >= 400 && err.status < 500 + const internalAuthenticationFailure = + (err.status === 401 || err.status === 403) && + err.authentication !== "caller"; + const status = !internalAuthenticationFailure && + err.status >= 400 && err.status < 500 ? err.status : 502; return jsonResponse({ error: "upstream_request_failed" }, status); diff --git a/web/services/subrouter/tenants.ts b/web/services/subrouter/tenants.ts deleted file mode 100644 index 47d9c26dc74d..000000000000 --- a/web/services/subrouter/tenants.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { eq, sql } from "drizzle-orm"; -import type { cloudDb } from "../../db/client"; -import { subrouterTenants } from "../../db/schema"; -import { - createSubrouterClient, - subrouterRuntimeConfig, - SubrouterNotConfiguredError, - type SubrouterClient, - type SubrouterRuntimeEnv, -} from "./client"; -import { decryptTenantKey, encryptTenantKey } from "./crypto"; - -type CloudDb = ReturnType; - -export type SubrouterTenantAccess = { - readonly tenantId: string; - readonly tenantKey: string; -}; - -export async function getTenantForTeam( - db: CloudDb, - teamId: string, - options: { - readonly env?: SubrouterRuntimeEnv; - readonly tenantKeySecret?: string; - } = {}, -): Promise { - const config = subrouterRuntimeConfig(options.env); - const tenantKeySecret = options.tenantKeySecret ?? config?.tenantKeySecret; - if (!tenantKeySecret) { - throw new SubrouterNotConfiguredError(); - } - - const [existing] = await db - .select({ - tenantId: subrouterTenants.tenantId, - encryptedTenantKey: subrouterTenants.encryptedTenantKey, - }) - .from(subrouterTenants) - .where(eq(subrouterTenants.teamId, teamId)) - .limit(1); - - if (!existing) return null; - - return { - tenantId: existing.tenantId, - tenantKey: decryptTenantKey(existing.encryptedTenantKey, tenantKeySecret), - }; -} - -export async function getOrCreateTenantForTeam( - db: CloudDb, - teamId: string, - teamName: string, - options: { - readonly client?: SubrouterClient; - readonly env?: SubrouterRuntimeEnv; - readonly tenantKeySecret?: string; - } = {}, -): Promise { - const config = subrouterRuntimeConfig(options.env); - const tenantKeySecret = options.tenantKeySecret ?? config?.tenantKeySecret; - const client = options.client ?? (config - ? createSubrouterClient({ - baseUrl: config.baseUrl, - adminToken: config.adminToken, - }) - : null); - if (!tenantKeySecret || !client) { - throw new SubrouterNotConfiguredError(); - } - - const normalizedTeamName = teamName.trim() || teamId; - - return await db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${teamId}, 8))`); - - const [existing] = await tx - .select({ - tenantId: subrouterTenants.tenantId, - encryptedTenantKey: subrouterTenants.encryptedTenantKey, - }) - .from(subrouterTenants) - .where(eq(subrouterTenants.teamId, teamId)) - .limit(1); - - if (existing) { - return { - tenantId: existing.tenantId, - tenantKey: decryptTenantKey(existing.encryptedTenantKey, tenantKeySecret), - }; - } - - encryptTenantKey("subrouter-tenant-key-secret-probe", tenantKeySecret); - const tenant = await client.createTenant({ name: normalizedTeamName }); - - try { - const encryptedTenantKey = encryptTenantKey(tenant.key, tenantKeySecret); - const now = new Date(); - await tx.insert(subrouterTenants).values({ - teamId, - tenantId: tenant.id, - tenantName: tenant.name, - encryptedTenantKey, - createdAt: now, - updatedAt: now, - }); - } catch (err) { - await revokeTenantBestEffort(client, tenant.id); - throw err; - } - - return { - tenantId: tenant.id, - tenantKey: tenant.key, - }; - }); -} - -async function revokeTenantBestEffort(client: SubrouterClient, tenantId: string): Promise { - // The upstream tenant was already provisioned; revoke it (best effort) - // so a failed local persistence step does not leave an orphaned tenant behind. - try { - await client.revokeTenant(tenantId); - } catch { - // Ignore revoke failures: the original persistence/encryption error is actionable. - } -} diff --git a/web/services/subrouter/types.ts b/web/services/subrouter/types.ts new file mode 100644 index 000000000000..143483313d61 --- /dev/null +++ b/web/services/subrouter/types.ts @@ -0,0 +1,82 @@ +export type SubrouterAccount = { + readonly id: string; + readonly kind: string; + readonly label?: string | null; + readonly createdAt?: string; + readonly health?: { + readonly ok: boolean; + readonly message?: string; + }; +}; + +export type ClaudeAccountInput = { + readonly provider: "claude"; + readonly label?: string; + readonly claudeAiOauth: { + readonly accessToken: string; + readonly refreshToken: string; + readonly expiresAt: number; + readonly subscriptionType?: string; + readonly rateLimitTier?: string; + }; +}; + +export type AnthropicApiKeyAccountInput = { + readonly provider: "anthropic-apikey"; + readonly label?: string; + readonly apiKey: string; +}; + +export type CodexAccountInput = { + readonly provider: "codex"; + readonly label?: string; + readonly tokens: { + readonly accessToken: string; + readonly refreshToken: string; + readonly idToken: string; + readonly accountID: string; + }; +}; + +export type OpenAiApiKeyAccountInput = { + readonly provider: "openai-apikey"; + readonly label?: string; + readonly apiKey: string; +}; + +export type SubrouterAccountInput = + | ClaudeAccountInput + | AnthropicApiKeyAccountInput + | CodexAccountInput + | OpenAiApiKeyAccountInput; + +export type SubrouterCredentialLeaseInput = { + readonly provider: "codex" | "claude"; + readonly agentType?: string; + readonly sessionId: string; + readonly userEmail?: string; + readonly preferAccountId?: string; + readonly model?: string; + readonly requiredAuthMode?: "oauth" | "apikey"; +}; + +export type SubrouterCredentialLease = { + readonly leaseId: string; + readonly accountId: string; + readonly provider: "codex" | "claude"; + readonly authMode: "oauth" | "apikey"; + readonly token: string; + readonly providerAccountId?: string; + readonly label: string; + readonly email?: string; + readonly credentialGeneration: number; + readonly issuedAt: string; + readonly expiresAt: string; + readonly credentialExpiresAt?: string; +}; + +export type SubrouterCredentialLeaseOutcome = + | "success" + | "unauthorized" + | "rate_limited" + | "provider_error"; diff --git a/web/services/vault/cliAuth.ts b/web/services/vault/cliAuth.ts index 01caf1ee6054..0533cec7d2a7 100644 --- a/web/services/vault/cliAuth.ts +++ b/web/services/vault/cliAuth.ts @@ -109,7 +109,6 @@ export async function claimCliAuthTokens( refreshToken: tokens.refreshToken, }; } - export async function pendingCliAuthClientForUserCode( userCode: string, now: Date, diff --git a/web/tests/account-route.test.ts b/web/tests/account-route.test.ts index 002b610c12d4..7273e0a839f4 100644 --- a/web/tests/account-route.test.ts +++ b/web/tests/account-route.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { getTableName } from "drizzle-orm"; import { accountAnalyticsForwardLeases, @@ -29,6 +30,11 @@ process.env.CMUX_FEEDBACK_RATE_LIMIT_ID ??= "test-feedback-rate-limit"; process.env.STACK_SECRET_SERVER_KEY ??= "test-stack-secret"; process.env.NEXT_PUBLIC_STACK_PROJECT_ID ??= "00000000-0000-4000-8000-000000000000"; process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ??= "test-stack-publishable"; +process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN ??= + "0123456789abcdef0123456789abcdef-test"; +process.env.SUBROUTER_ALLOWED_TEAM_IDS ??= "*"; +process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS ??= "0"; +process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS ??= "10000"; const ACCOUNT_USER_ID = "account-user-1"; const originalPostHogPersonalApiKey = process.env.POSTHOG_PERSONAL_API_KEY; @@ -54,8 +60,6 @@ const storageModule = await import("../services/vault/storage"); const realDeleteObject = storageModule.deleteObject; const vaultUsageModule = await import("../services/vault/usage"); const realWithVaultUserQuotaLock = vaultUsageModule.withVaultUserQuotaLock; -const subrouterClientModule = await import("../services/subrouter/client"); -const realCreateSubrouterClientFromEnv = subrouterClientModule.createSubrouterClientFromEnv; const vmErrorsModule = await import("../services/vms/errors"); const workflowsModule = await import("../services/vms/workflows"); const realDestroyVm = workflowsModule.destroyVm; @@ -73,12 +77,22 @@ type StackList = const deleteStackUser = mock(async () => { routeEvents.push("stack-delete"); + accountLifecycleEvents.push("stack-delete"); if (stackDeleteError) throw stackDeleteError; }); const updateStackUser = mock(async () => { routeEvents.push("metadata-update"); }); const getUser = mock(async () => stackUser(stackUserIds.shift())); +let authoritativeAccessToken = "access-token"; +let stackAuthJsonError: Error | null = null; +const getAuthJson = mock(async () => { + if (stackAuthJsonError) throw stackAuthJsonError; + return { + accessToken: authoritativeAccessToken, + refreshToken: "refresh-token", + }; +}); const transaction = mock(async (...args: unknown[]) => { const [callback] = args as [(tx: MockTransaction) => Promise]; routeEvents.push("transaction"); @@ -278,16 +292,29 @@ const removeTester = mock(async (...args: unknown[]) => { const captureAscError = mock((..._args: unknown[]) => { routeEvents.push("testflight-error"); }); -const revokeTenant = mock(async (...args: unknown[]) => { - const [tenantId] = args as [string]; - routeEvents.push(`subrouter-revoke:${tenantId}`); - const sequenceError = subrouterRevokeErrors.shift(); - if (sequenceError) throw sequenceError; - if (subrouterRevokeError) throw subrouterRevokeError; -}); const realFetch = globalThis.fetch; const postHogDeleteFetch = mock(async (...args: unknown[]) => { const fetchArgs = args as Parameters; + const [input, init] = fetchArgs; + if (String(input).includes("/admin/tenants/") && String(input).endsWith("/revoke")) { + legacySubrouterRevokeRequests.push(fetchArgs); + const tenantId = decodeURIComponent( + new URL(String(input)).pathname.split("/").at(-2) ?? "", + ); + accountLifecycleEvents.push(`legacy-subrouter-revoke:${tenantId}`); + return Response.json({ ok: legacySubrouterRevokeStatus < 400 }, { + status: legacySubrouterRevokeStatus, + }); + } + if (String(input).endsWith("/_subrouter/auth/stack/tenant")) { + hostedTenantDeleteRequests.push(fetchArgs); + const body = JSON.parse(String(init?.body)) as { readonly teamId?: unknown }; + accountLifecycleEvents.push(`subrouter-delete:${String(body.teamId)}`); + await beforeHostedTenantDeleteResponse?.(); + return Response.json(hostedTenantDeleteResponse, { + status: hostedTenantDeleteStatus, + }); + } routeEvents.push("posthog-delete"); postHogDeleteRequests.push(fetchArgs); if (postHogDeleteError) throw postHogDeleteError; @@ -303,6 +330,7 @@ let tombstoneUpdates: unknown[] = []; let tombstoneCompleteError: unknown = null; let tombstoneCleanupIncompleteError: unknown = null; let routeEvents: string[] = []; +let accountLifecycleEvents: string[] = []; let stackDeleteError: unknown = null; let stackUserIds: Array = []; let selectResults: unknown[][] = []; @@ -332,9 +360,6 @@ let listedPersonalVmIds: ListedAccountVm[] = []; let listedPersonalVmIdsByBillingTeam: Record = {}; let revokeIdentityLeasesError: unknown = null; let revokedIdentityLeaseCount = 2; -let subrouterClientCreateError: unknown = null; -let subrouterRevokeError: unknown = null; -let subrouterRevokeErrors: unknown[] = []; let stackUserSelectedTeam: unknown = null; let stackUserTeams: StackList = []; let stackUserClientReadOnlyMetadata: unknown = { cmuxPlan: "pro" }; @@ -342,6 +367,13 @@ let useAccountRouteStubs = false; let lastRevokeIdentityCall: { readonly userId: string; readonly afterBatch?: unknown } | null = null; let vaultLockUsers: string[] = []; let postHogDeleteRequests: Parameters[] = []; +let hostedTenantDeleteRequests: Parameters[] = []; +let legacySubrouterRevokeRequests: Parameters[] = []; +let legacySubrouterRevokeStatus = 200; +let legacyTenantRows: Array<{ readonly tenantId: string }> = []; +let hostedTenantDeleteStatus = 200; +let hostedTenantDeleteResponse: unknown = { ok: true, deleted: true }; +let beforeHostedTenantDeleteResponse: (() => Promise) | null = null; let postHogDeleteError: unknown = null; let postHogDeleteStatus = 202; let postHogDeleteResponse: unknown = { @@ -469,6 +501,9 @@ const mockDb = { return { where: (condition: unknown) => { selectedWhere.push({ table: selectedTable, condition }); + if (selectedTable === subrouterTenants) { + return chainableSelectResult(legacyTenantRows); + } return chainableSelectResult(nextSelectResult()); }, innerJoin: () => ({ @@ -488,7 +523,7 @@ const mockDb = { mock.module("../app/lib/stack", () => ({ ...stackModule, - getStackServerApp: () => useAccountRouteStubs ? { getUser } : realGetStackServerApp(), + getStackServerApp: () => useAccountRouteStubs ? { getUser, getAuthJson } : realGetStackServerApp(), isStackConfigured: () => useAccountRouteStubs ? true : realIsStackConfigured(), })); @@ -553,17 +588,6 @@ mock.module("../services/errors", () => ({ }) as typeof realCaptureAscError, })); -mock.module("../services/subrouter/client", () => ({ - ...subrouterClientModule, - createSubrouterClientFromEnv: () => { - if (!useAccountRouteStubs) return realCreateSubrouterClientFromEnv(); - if (subrouterClientCreateError) throw subrouterClientCreateError; - return { - revokeTenant, - }; - }, -})); - mock.module("../services/vms/workflows", () => ({ ...workflowsModule, destroyVm: ((...args: Parameters) => { @@ -604,6 +628,8 @@ beforeEach(() => { process.env.POSTHOG_PERSONAL_API_KEY = "test-posthog-personal-api-key"; process.env.POSTHOG_API_HOST = "https://posthog.test"; process.env.POSTHOG_ENVIRONMENT_ID = "env-244066"; + process.env.SUBROUTER_BASE_URL = "https://subrouter.cmux.dev"; + process.env.SUBROUTER_ADMIN_TOKEN = "test-legacy-subrouter-admin"; consoleError.mockClear(); deleteStackUser.mockClear(); updateStackUser.mockClear(); @@ -627,7 +653,6 @@ beforeEach(() => { updateSubscription.mockClear(); removeTester.mockClear(); captureAscError.mockClear(); - revokeTenant.mockClear(); postHogDeleteFetch.mockClear(); deletedTableCount = 0; deletedTables = []; @@ -638,8 +663,14 @@ beforeEach(() => { tombstoneCompleteError = null; tombstoneCleanupIncompleteError = null; routeEvents = []; + accountLifecycleEvents = []; stackDeleteError = null; stackUserIds = []; + authoritativeAccessToken = "access-token"; + stackAuthJsonError = null; + getAuthJson.mockClear(); + process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN = + "0123456789abcdef0123456789abcdef-test"; selectResults = [[], [], [], [], [], []]; transactionSelectResults = []; transactionTombstoneSelectResults = []; @@ -668,14 +699,18 @@ beforeEach(() => { revokeIdentityLeasesError = null; revokedIdentityLeaseCount = 2; lastRevokeIdentityCall = null; - subrouterClientCreateError = null; - subrouterRevokeError = null; - subrouterRevokeErrors = []; stackUserSelectedTeam = null; stackUserTeams = []; stackUserClientReadOnlyMetadata = { cmuxPlan: "pro" }; vaultLockUsers = []; postHogDeleteRequests = []; + hostedTenantDeleteRequests = []; + legacySubrouterRevokeRequests = []; + legacySubrouterRevokeStatus = 200; + legacyTenantRows = []; + hostedTenantDeleteStatus = 200; + hostedTenantDeleteResponse = { ok: true, deleted: true }; + beforeHostedTenantDeleteResponse = null; postHogDeleteError = null; postHogDeleteStatus = 202; postHogDeleteResponse = { @@ -697,6 +732,22 @@ afterEach(() => { }); describe("account deletion route", () => { + test("returns retryable service unavailable when Stack token refresh fails", async () => { + stackAuthJsonError = new Error("Stack refresh unavailable"); + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "account_delete_retryable", + retryable: true, + destroyedVms: 0, + }); + expect(transaction).not.toHaveBeenCalled(); + expect(postHogDeleteRequests).toHaveLength(0); + expect(hostedTenantDeleteRequests).toHaveLength(0); + }); + test("requires native auth headers", async () => { const response = await DELETE(new Request("https://cmux.test/api/account", { method: "DELETE" })); @@ -771,6 +822,29 @@ describe("account deletion route", () => { }); expect(getUser).toHaveBeenCalledTimes(1); expect(deleteStackUser).toHaveBeenCalledTimes(1); + expect(hostedTenantDeleteRequests).toHaveLength(1); + const [tenantDeleteUrl, tenantDeleteInit] = hostedTenantDeleteRequests[0]!; + expect(String(tenantDeleteUrl)).toBe( + "https://staging.sr.cmux.com/_subrouter/auth/stack/tenant", + ); + expect(new Headers(tenantDeleteInit?.headers).get("authorization")).toBe( + "Bearer access-token", + ); + expect( + new Headers(tenantDeleteInit?.headers).get( + "x-subrouter-tenant-delete-token", + ), + ).toBe("0123456789abcdef0123456789abcdef-test"); + expect(JSON.parse(String(tenantDeleteInit?.body))).toEqual({ + teamId: "account-user-1", + }); + expect(deletedTables.map((table) => getTableName(table as never))).toContain( + "subrouter_tenants", + ); + expect(accountLifecycleEvents).toEqual([ + "subrouter-delete:account-user-1", + "stack-delete", + ]); const completedTombstone = tombstoneUpdates.find((update) => Boolean( update && @@ -849,6 +923,256 @@ describe("account deletion route", () => { )).toBe(true); }); + test("keeps Stack identity retryable while hosted tenant requests drain", async () => { + hostedTenantDeleteStatus = 202; + hostedTenantDeleteResponse = { ok: false, deletionPending: true }; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "account_delete_retryable", + retryable: true, + destroyedVms: 2, + }); + expect(hostedTenantDeleteRequests).toHaveLength(1); + expect(deleteStackUser).not.toHaveBeenCalled(); + expect(accountLifecycleEvents).toEqual([ + "subrouter-delete:account-user-1", + ]); + }); + + test("keeps Stack identity when hosted tenant deletion is unconfirmed", async () => { + hostedTenantDeleteResponse = { ok: true }; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "account_delete_retryable", + retryable: true, + destroyedVms: 2, + }); + expect(hostedTenantDeleteRequests).toHaveLength(1); + expect(deleteStackUser).not.toHaveBeenCalled(); + expect(accountLifecycleEvents).toEqual([ + "subrouter-delete:account-user-1", + ]); + }); + + test("keeps the active deletion lease while hosted deletion is in flight", async () => { + let signalHostedDeleteStarted!: () => void; + let releaseHostedDelete!: () => void; + const hostedDeleteStarted = new Promise((resolve) => { + signalHostedDeleteStarted = resolve; + }); + const hostedDeleteReleased = new Promise((resolve) => { + releaseHostedDelete = resolve; + }); + beforeHostedTenantDeleteResponse = async () => { + signalHostedDeleteStarted(); + await hostedDeleteReleased; + }; + + const deletion = DELETE(accountDeletionRequest()); + try { + await hostedDeleteStarted; + expect(tombstoneUpdates.some((values) => + (values as { readonly status?: unknown }).status === "hosted_delete_pending" + )).toBe(false); + } finally { + releaseHostedDelete(); + } + + const response = await deletion; + expect(response.status).toBe(200); + expect(deleteStackUser).toHaveBeenCalledTimes(1); + }); + + test("checkpoints bounded hosted tenant deletion and resumes with fresh auth", async () => { + stackUserTeams = [ + stackTeam("team-personal-1", [ACCOUNT_USER_ID]), + stackTeam("team-personal-2", [ACCOUNT_USER_ID]), + stackTeam("team-personal-3", [ACCOUNT_USER_ID]), + ]; + authoritativeAccessToken = "first-access"; + + const pending = await DELETE(accountDeletionRequest()); + + expect(pending.status).toBe(503); + expect(await pending.json()).toEqual({ + error: "account_delete_retryable", + retryable: true, + destroyedVms: 2, + }); + expect(hostedTenantDeleteRequests.map(([, init]) => ({ + authorization: new Headers(init?.headers).get("authorization"), + teamId: (JSON.parse(String(init?.body)) as { readonly teamId: string }).teamId, + }))).toEqual([ + { authorization: "Bearer first-access", teamId: ACCOUNT_USER_ID }, + { authorization: "Bearer first-access", teamId: "team-personal-1" }, + ]); + expect(deleteStackUser).not.toHaveBeenCalled(); + expect(hostedTenantDeleteRequests.every(([, init]) => + init?.signal instanceof AbortSignal + )).toBe(true); + + transactionTombstoneSelectResults = [[{ + userIdHash: "existing-hash", + status: "hosted_delete_pending", + updatedAt: new Date(), + hostedSubrouterDeletedTeamIds: [ACCOUNT_USER_ID, "team-personal-1"], + }]]; + authoritativeAccessToken = "refreshed-access"; + + const completed = await DELETE(accountDeletionRequest()); + + expect(completed.status).toBe(200); + expect(await completed.json()).toEqual({ ok: true, destroyedVms: 2 }); + expect(hostedTenantDeleteRequests.slice(2).map(([, init]) => ({ + authorization: new Headers(init?.headers).get("authorization"), + teamId: (JSON.parse(String(init?.body)) as { readonly teamId: string }).teamId, + }))).toEqual([ + { authorization: "Bearer refreshed-access", teamId: "team-personal-2" }, + { authorization: "Bearer refreshed-access", teamId: "team-personal-3" }, + ]); + expect(deleteStackUser).toHaveBeenCalledTimes(1); + }); + + test("uses the refreshed Stack token for hosted tenant deletion", async () => { + authoritativeAccessToken = "refreshed-access"; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(200); + expect(hostedTenantDeleteRequests).toHaveLength(1); + expect(new Headers(hostedTenantDeleteRequests[0]?.[1]?.headers).get("authorization")).toBe( + "Bearer refreshed-access", + ); + }); + + test("retires mapped legacy and hosted tenants before deleting the Stack user", async () => { + listedPersonalVmIds = []; + revokedIdentityLeaseCount = 0; + legacyTenantRows = [{ tenantId: "legacy-personal" }]; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(200); + expect(legacySubrouterRevokeRequests).toHaveLength(1); + const [legacyUrl, legacyInit] = legacySubrouterRevokeRequests[0]!; + expect(String(legacyUrl)).toBe( + "https://subrouter.cmux.dev/admin/tenants/legacy-personal/revoke", + ); + expect(new Headers(legacyInit?.headers).get("authorization")).toBe( + "Bearer test-legacy-subrouter-admin", + ); + expect(hostedTenantDeleteRequests).toHaveLength(1); + expect(accountLifecycleEvents.indexOf("legacy-subrouter-revoke:legacy-personal")) + .toBeLessThan(accountLifecycleEvents.indexOf("stack-delete")); + expect(accountLifecycleEvents.indexOf("subrouter-delete:account-user-1")) + .toBeLessThan(accountLifecycleEvents.indexOf("stack-delete")); + }); + + test("checkpoints bounded legacy tenant retirement and resumes without replay", async () => { + legacyTenantRows = [ + { tenantId: "legacy-1" }, + { tenantId: "legacy-2" }, + { tenantId: "legacy-3" }, + ]; + + const pending = await DELETE(accountDeletionRequest()); + + expect(pending.status).toBe(503); + expect(await pending.json()).toEqual({ + error: "account_delete_retryable", + retryable: true, + destroyedVms: 2, + }); + expect(legacySubrouterRevokeRequests.map(([url]) => String(url))).toEqual([ + "https://subrouter.cmux.dev/admin/tenants/legacy-1/revoke", + "https://subrouter.cmux.dev/admin/tenants/legacy-2/revoke", + ]); + expect(hostedTenantDeleteRequests).toHaveLength(0); + expect(deleteStackUser).not.toHaveBeenCalled(); + + transactionTombstoneSelectResults = [[{ + userIdHash: "existing-hash", + status: "legacy_delete_pending", + updatedAt: new Date(), + legacySubrouterRetiredTenantIds: ["legacy-1", "legacy-2"], + hostedSubrouterDeletedTeamIds: [], + }]]; + + const completed = await DELETE(accountDeletionRequest()); + + expect(completed.status).toBe(200); + expect(legacySubrouterRevokeRequests.slice(2).map(([url]) => String(url))).toEqual([ + "https://subrouter.cmux.dev/admin/tenants/legacy-3/revoke", + ]); + expect(deleteStackUser).toHaveBeenCalledTimes(1); + }); + + test("validates legacy tenant retirement before destructive account cleanup", async () => { + legacyTenantRows = [{ tenantId: "legacy-personal" }]; + delete process.env.SUBROUTER_ADMIN_TOKEN; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "account_delete_failed" }); + expect(postHogDeleteRequests).toHaveLength(0); + expect(hostedTenantDeleteRequests).toHaveLength(0); + expect(legacySubrouterRevokeRequests).toHaveLength(0); + expect(updateStackUser).not.toHaveBeenCalled(); + expect(deleteStackUser).not.toHaveBeenCalled(); + }); + + test("fails before mutation when hosted tenant deletion is missing in a managed deployment", async () => { + const originalVercel = process.env.VERCEL; + const originalVercelEnv = process.env.VERCEL_ENV; + try { + process.env.VERCEL = "1"; + process.env.VERCEL_ENV = "production"; + delete process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "account_delete_failed" }); + expect(postHogDeleteRequests).toHaveLength(0); + expect(hostedTenantDeleteRequests).toHaveLength(0); + expect(updateStackUser).not.toHaveBeenCalled(); + expect(deleteStackUser).not.toHaveBeenCalled(); + } finally { + restoreEnv("VERCEL", originalVercel); + restoreEnv("VERCEL_ENV", originalVercelEnv); + } + }); + + test("deletes an account when hosted Subrouter has never been enabled", async () => { + const originalVercel = process.env.VERCEL; + const originalVercelEnv = process.env.VERCEL_ENV; + const originalHostedUrl = process.env.SUBROUTER_HOSTED_URL; + try { + delete process.env.VERCEL; + delete process.env.VERCEL_ENV; + delete process.env.SUBROUTER_HOSTED_URL; + delete process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN; + + const response = await DELETE(accountDeletionRequest()); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, destroyedVms: 2 }); + expect(hostedTenantDeleteRequests).toHaveLength(0); + expect(deleteStackUser).toHaveBeenCalledTimes(1); + } finally { + restoreEnv("VERCEL", originalVercel); + restoreEnv("VERCEL_ENV", originalVercelEnv); + restoreEnv("SUBROUTER_HOSTED_URL", originalHostedUrl); + } + }); + test("blocks Stack deletion when PostHog reports partial deletion errors", async () => { postHogDeleteResponse = { persons_found: 1, @@ -950,7 +1274,7 @@ describe("account deletion route", () => { expect(response.status).toBe(200); expect(routeEvents).toContain("analytics-lease-cleanup"); - expect(postHogDeleteFetch).toHaveBeenCalledTimes(1); + expect(postHogDeleteRequests).toHaveLength(1); expect(deleteStackUser).toHaveBeenCalledTimes(1); }); @@ -971,7 +1295,6 @@ describe("account deletion route", () => { expect(listUserVms).not.toHaveBeenCalled(); expect(destroyVm).not.toHaveBeenCalled(); expect(deleteObject).not.toHaveBeenCalled(); - expect(revokeTenant).not.toHaveBeenCalled(); expect(consoleError).toHaveBeenCalledWith( "account.delete.failed", "Error: POSTHOG_ENVIRONMENT_ID is required for account deletion", @@ -1017,7 +1340,6 @@ describe("account deletion route", () => { [], [], [], - [{ tenantId: "tenant-team-personal" }], ]; const response = await DELETE(accountDeletionRequest()); @@ -1043,12 +1365,17 @@ 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(revokeTenant).toHaveBeenCalledWith("tenant-team-personal"); expect(transactionExecute).toHaveBeenCalledTimes(6); const grantDelete = deletedWhere.find((entry) => entry.table === cloudVmBillingGrants); expect(conditionColumnNames(grantDelete?.condition)).toContain("billing_customer_id"); const baseDelete = deletedWhere.find((entry) => entry.table === cloudVmBases); expect(conditionColumnNames(baseDelete?.condition)).toContain("scope_id"); + expect(hostedTenantDeleteRequests.map(([, init]) => + JSON.parse(String(init?.body)) + )).toEqual([ + { teamId: "account-user-1" }, + { teamId: "team-personal" }, + ]); }); test("deletes account-owned team VM rows created by another user", async () => { @@ -1373,27 +1700,6 @@ describe("account deletion route", () => { expect(deleteStackUser).not.toHaveBeenCalled(); }); - test("revokes the personal Subrouter tenant before deleting local rows", async () => { - selectResults = [ - [], - [], - [], - [], - [], - [], - [{ tenantId: "tenant-personal" }], - ]; - - const response = await DELETE(accountDeletionRequest()); - - expect(response.status).toBe(200); - expect(revokeTenant).toHaveBeenCalledWith("tenant-personal"); - expect(deletedTables).toContain(subrouterTenants); - expect(routeEvents.indexOf("subrouter-revoke:tenant-personal")).toBeLessThan( - routeEvents.lastIndexOf("transaction"), - ); - }); - test("removes TestFlight access during account deletion when ASC is configured", async () => { ascConfigured = true; listedPersonalVmIds = []; @@ -1620,144 +1926,6 @@ describe("account deletion route", () => { ); }); - test("keeps deletion retryable after Subrouter 404 removes local tenant state", async () => { - listedPersonalVmIds = []; - revokedIdentityLeaseCount = 0; - selectResults = [ - [], - [], - [], - [], - [], - [], - [{ tenantId: "tenant-personal" }], - ]; - subrouterRevokeError = new subrouterClientModule.SubrouterClientError("revokeTenant", 404); - stackDeleteError = new Error("stack unavailable after subrouter cleanup"); - - const response = await DELETE(accountDeletionRequest()); - - expect(response.status).toBe(500); - expect(await response.json()).toEqual({ - error: "account_delete_retryable", - retryable: true, - destroyedVms: 0, - }); - expect(revokeTenant).toHaveBeenCalledWith("tenant-personal"); - expect(deletedTables).toContain(subrouterTenants); - expect(transaction).toHaveBeenCalledTimes(3); - expect(deleteStackUser).toHaveBeenCalledTimes(1); - expect(updateStackUser).toHaveBeenNthCalledWith(1, { - clientReadOnlyMetadata: { cmuxAccountDeleting: true }, - }); - expect(updateStackUser).toHaveBeenCalledTimes(1); - }); - - test("restores Stack metadata when Subrouter revoke fails before external mutation", async () => { - listedPersonalVmIds = []; - revokedIdentityLeaseCount = 0; - selectResults = [ - [], - [], - [], - [], - [], - [], - [{ tenantId: "tenant-personal" }], - ]; - subrouterRevokeError = new Error("subrouter request timed out"); - - const response = await DELETE(accountDeletionRequest()); - - expect(response.status).toBe(500); - expect(await response.json()).toEqual({ - error: "account_delete_retryable", - retryable: true, - destroyedVms: 0, - }); - expect(revokeTenant).toHaveBeenCalledWith("tenant-personal"); - expect(deletedTables).not.toContain(subrouterTenants); - expect(transaction).toHaveBeenCalledTimes(2); - expect(deleteStackUser).not.toHaveBeenCalled(); - expect(updateStackUser).toHaveBeenNthCalledWith(1, { - clientReadOnlyMetadata: { cmuxAccountDeleting: true }, - }); - expect(updateStackUser).toHaveBeenNthCalledWith(2, { - clientReadOnlyMetadata: { cmuxPlan: "pro" }, - }); - }); - - test("restores Stack metadata when Subrouter 404 is followed by a pre-mutation failure", async () => { - listedPersonalVmIds = []; - revokedIdentityLeaseCount = 0; - selectResults = [ - [], - [], - [], - [], - [], - [], - [{ tenantId: "tenant-missing" }, { tenantId: "tenant-timeout" }], - ]; - subrouterRevokeErrors = [ - new subrouterClientModule.SubrouterClientError("revokeTenant", 404), - new Error("subrouter request timed out"), - ]; - - const response = await DELETE(accountDeletionRequest()); - - expect(response.status).toBe(500); - expect(await response.json()).toEqual({ - error: "account_delete_retryable", - retryable: true, - destroyedVms: 0, - }); - expect(revokeTenant).toHaveBeenCalledWith("tenant-missing"); - expect(revokeTenant).toHaveBeenCalledWith("tenant-timeout"); - expect(deletedTables).not.toContain(subrouterTenants); - expect(deleteStackUser).not.toHaveBeenCalled(); - expect(updateStackUser).toHaveBeenNthCalledWith(1, { - clientReadOnlyMetadata: { cmuxAccountDeleting: true }, - }); - expect(updateStackUser).toHaveBeenNthCalledWith(2, { - clientReadOnlyMetadata: { cmuxPlan: "pro" }, - }); - }); - - test("restores Stack metadata when local Subrouter configuration fails before external mutation", async () => { - listedPersonalVmIds = []; - revokedIdentityLeaseCount = 0; - selectResults = [ - [], - [], - [], - [], - [], - [], - [{ tenantId: "tenant-personal" }], - ]; - subrouterClientCreateError = new Error("subrouter not configured"); - - const response = await DELETE(accountDeletionRequest()); - - expect(response.status).toBe(500); - expect(await response.json()).toEqual({ - error: "account_delete_retryable", - retryable: true, - destroyedVms: 0, - }); - expect(revokeTenant).not.toHaveBeenCalled(); - expect(deletedTables).not.toContain(subrouterTenants); - expect(transaction).toHaveBeenCalledTimes(2); - expect(deleteStackUser).not.toHaveBeenCalled(); - expect(updateStackUser).toHaveBeenNthCalledWith(1, { - clientReadOnlyMetadata: { cmuxAccountDeleting: true }, - }); - expect(updateStackUser).toHaveBeenNthCalledWith(2, { - clientReadOnlyMetadata: { cmuxPlan: "pro" }, - }); - }); - test("does not delete personal VM rows that gained a provider id before account row deletion", async () => { transactionSelectResults = [[{ id: "00000000-0000-4000-8000-000000000764", @@ -2168,7 +2336,6 @@ describe("account deletion route", () => { [], [], [], - [], [{ id: "post-stack-session", latestObjectKey: "vault/u/account-user-1/post-stack-latest.jsonl.zst" }], ]; @@ -2210,7 +2377,6 @@ describe("account deletion route", () => { [], [], [], - [], [{ id: "post-stack-session", latestObjectKey: "vault/u/account-user-1/post-stack-latest.jsonl.zst" }], ]; diff --git a/web/tests/cli-config-route.test.ts b/web/tests/cli-config-route.test.ts new file mode 100644 index 000000000000..7f473ab9e9fb --- /dev/null +++ b/web/tests/cli-config-route.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { GET } from "../app/api/cli/config/route"; + +type CliConfigEnvKey = + | "NEXT_PUBLIC_STACK_API_URL" + | "NEXT_PUBLIC_STACK_PROJECT_ID" + | "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY" + | "SUBROUTER_HOSTED_URL" + | "SUBROUTER_STACK_TENANT_DELETE_TOKEN" + | "VERCEL_ENV"; + +const testEnvironment = { + NEXT_PUBLIC_STACK_API_URL: "https://stack.example.test/api/v1", + NEXT_PUBLIC_STACK_PROJECT_ID: "test-stack-project-id", + NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "test-stack-publishable-key", + SUBROUTER_HOSTED_URL: "https://subrouter.example.test", + SUBROUTER_STACK_TENANT_DELETE_TOKEN: + "0123456789abcdef0123456789abcdef-test", + VERCEL_ENV: "preview", +} satisfies Record; + +async function withCliConfigEnvironment( + overrides: Partial>, + run: () => Promise, +): Promise { + const entries = Object.entries(overrides) as Array< + [CliConfigEnvKey, string | undefined] + >; + const originalValues = new Map( + entries.map(([key]) => [key, process.env[key]]), + ); + + try { + for (const [key, value] of entries) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await run(); + } finally { + for (const [key, value] of originalValues) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("CLI config route", () => { + test("publishes native Stack Auth and hosted Subrouter configuration", async () => { + await withCliConfigEnvironment(testEnvironment, async () => { + const response = GET(new Request("https://cmux.com/api/cli/config")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + version: 2, + auth: { + apiUrl: testEnvironment.NEXT_PUBLIC_STACK_API_URL, + projectId: testEnvironment.NEXT_PUBLIC_STACK_PROJECT_ID, + publishableClientKey: + testEnvironment.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, + confirmUrl: "https://cmux.com/handler/cli-auth-confirm", + }, + subrouter: { + url: testEnvironment.SUBROUTER_HOSTED_URL, + exchangeUrl: "https://cmux.com/api/subrouter/exchange", + }, + }); + }); + }); + + test("keeps CLI approval on the origin that issued the Stack login code", async () => { + await withCliConfigEnvironment(testEnvironment, async () => { + const response = GET( + new Request("http://127.0.0.1:4152/api/cli/config"), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.auth.confirmUrl).toBe( + "http://127.0.0.1:4152/handler/cli-auth-confirm", + ); + expect(body.subrouter.exchangeUrl).toBe( + "http://127.0.0.1:4152/api/subrouter/exchange", + ); + }); + }); + + test("defaults non-production deployments to staging Subrouter", async () => { + for (const deploymentEnvironment of [undefined, "development", "preview"]) { + await withCliConfigEnvironment( + { + ...testEnvironment, + SUBROUTER_HOSTED_URL: undefined, + VERCEL_ENV: deploymentEnvironment, + }, + async () => { + const response = GET(new Request("https://preview.example/api/cli/config")); + expect(response.status).toBe(200); + expect((await response.json()).subrouter.url).toBe( + "https://staging.sr.cmux.com", + ); + }, + ); + } + }); + + test("defaults production deployments to production Subrouter", async () => { + await withCliConfigEnvironment( + { + ...testEnvironment, + SUBROUTER_HOSTED_URL: undefined, + VERCEL_ENV: "production", + }, + async () => { + const response = GET(new Request("https://cmux.com/api/cli/config")); + expect(response.status).toBe(200); + expect((await response.json()).subrouter.url).toBe( + "https://sr.cmux.com", + ); + }, + ); + }); + + test("returns 503 instead of advertising incomplete Stack configuration", async () => { + await withCliConfigEnvironment( + { + ...testEnvironment, + NEXT_PUBLIC_STACK_PROJECT_ID: undefined, + NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: undefined, + SUBROUTER_STACK_TENANT_DELETE_TOKEN: undefined, + }, + async () => { + const response = GET(new Request("https://cmux.com/api/cli/config")); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "cli_auth_unavailable", + }); + }, + ); + }); + + test("returns 503 instead of advertising an insecure hosted Subrouter", async () => { + await withCliConfigEnvironment( + { + ...testEnvironment, + SUBROUTER_HOSTED_URL: "http://subrouter.example.test", + }, + async () => { + const response = GET(new Request("https://cmux.com/api/cli/config")); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "cli_auth_unavailable", + }); + }, + ); + }); +}); diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index 4e9cf0f244bb..a994bf6b8248 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -34,6 +34,8 @@ const requiredRelayProductionEnv = { }; const requiredSubrouterDeploymentEnv = { + SUBROUTER_ADMIN_TOKEN: "test-legacy-subrouter-admin", + SUBROUTER_STACK_TENANT_DELETE_TOKEN: "0123456789abcdef0123456789abcdef", SUBROUTER_ENFORCE_STACK_PERMISSIONS: "0", SUBROUTER_ALLOWED_TEAM_IDS: "test-team", }; @@ -95,6 +97,22 @@ describe("client config env validation", () => { expect(result.exitCode).toBe(0); }); + test("allows hosted-only production after the temporary legacy admin token is retired", () => { + const { SUBROUTER_ADMIN_TOKEN: _legacyToken, ...hostedSubrouterEnv } = + requiredSubrouterDeploymentEnv; + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + ...hostedSubrouterEnv, + ...requiredIrohProductionEnv, + ...requiredRelayProductionEnv, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("SUBROUTER_ADMIN_TOKEN"); + }); + test("allows credential-free docs channel deployments", () => { const result = importEnv({ PATH: requiredEnv.PATH, diff --git a/web/tests/dashboard-subrouter-page.test.tsx b/web/tests/dashboard-subrouter-page.test.tsx index 915e093af5c3..733af3718bf3 100644 --- a/web/tests/dashboard-subrouter-page.test.tsx +++ b/web/tests/dashboard-subrouter-page.test.tsx @@ -1,8 +1,13 @@ -import { describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import enMessages from "../messages/en.json"; const authorizationFailure = new Error("Stack authorization deadline exceeded"); +let authorizationAvailable = false; +let authJsonAvailable = true; +let cutoverReady = true; +let hostedControlConfigured = true; +let hostedExchangeCalls = 0; mock.module("next-intl/server", () => ({ getTranslations: async (input?: string | { namespace?: string }) => @@ -37,36 +42,60 @@ mock.module("@/i18n/navigation", () => ({ mock.module("../app/lib/stack", () => ({ isStackConfigured: () => true, + getStackServerApp: () => ({ + getAuthJson: async () => { + if (!authJsonAvailable) throw new Error("Stack refresh unavailable"); + return { accessToken: "test-access-token" }; + }, + }), })); +class TestSubrouterAuthorizationUnavailableError extends Error {} + mock.module("../services/vms/auth", () => ({ - withSubrouterAuthorizationDeadline: async () => { - throw authorizationFailure; + withSubrouterAuthorizationDeadline: async ( + operation: (signal: AbortSignal) => Promise, + ) => { + if (!authorizationAvailable) throw authorizationFailure; + return await operation(new AbortController().signal); }, - verifySubrouterRequest: async () => null, + verifySubrouterRequest: async () => ({ id: "user-1" }), + SubrouterAuthorizationUnavailableError: + TestSubrouterAuthorizationUnavailableError, isSubrouterAuthorizationError: (error: unknown) => - error === authorizationFailure, + error === authorizationFailure || + error instanceof TestSubrouterAuthorizationUnavailableError, })); mock.module("../services/subrouter/routeHelpers", () => ({ - authorizedSubrouterTeams: async () => [], + authorizedSubrouterTeams: async () => [{ + teamId: "team-1", + teamName: "Team One", + use: true, + manageAccounts: true, + }], })); -mock.module("../services/subrouter/client", () => ({ - createSubrouterClient: () => { - throw new Error("account client must not load during auth failure"); - }, - subrouterRuntimeConfig: () => null, +mock.module("../services/subrouter/hostedClient", () => ({ + createHostedSubrouterClient: () => ({ + tenantControlConfigured: hostedControlConfigured, + exchangeTeam: async () => { + hostedExchangeCalls += 1; + return { + tenantId: "team-1", + tenantKey: "srt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }; + }, + listAccounts: async () => [], + }), })); -mock.module("../services/subrouter/tenants", () => ({ - getTenantForTeam: async () => null, +mock.module("../services/subrouter/cutover", () => ({ + hostedSubrouterCutoverReadyForTeam: async () => cutoverReady, })); mock.module("../db/client", () => ({ - cloudDb: () => { - throw new Error("database must not load during auth failure"); - }, + cloudDb: () => ({}), })); mock.module("../app/[locale]/dashboard/components/ai-account-forms", () => ({ @@ -79,6 +108,14 @@ const { default: SubrouterOverviewPage } = await import( ); describe("Subrouter dashboard", () => { + beforeEach(() => { + authorizationAvailable = false; + authJsonAvailable = true; + cutoverReady = true; + hostedControlConfigured = true; + hostedExchangeCalls = 0; + }); + test("renders recovery UI when Stack authorization is unavailable", async () => { const page = await SubrouterOverviewPage({ params: Promise.resolve({ locale: "en" }), @@ -92,6 +129,54 @@ describe("Subrouter dashboard", () => { ); expect(html).not.toContain("unexpected redirect"); }); + + test("keeps a legacy-mapped team off hosted accounts until migration finishes", async () => { + authorizationAvailable = true; + cutoverReady = false; + + const page = await SubrouterOverviewPage({ + params: Promise.resolve({ locale: "en" }), + searchParams: Promise.resolve({}), + }); + const html = renderToStaticMarkup(page); + + expect(hostedExchangeCalls).toBe(0); + expect(html).toContain("Account migration in progress"); + expect(html).toContain( + "Shared accounts are temporarily unavailable while migration finishes. Try again shortly.", + ); + }); + + test("renders recovery UI when the bounded Stack session refresh fails", async () => { + authorizationAvailable = true; + authJsonAvailable = false; + + const page = await SubrouterOverviewPage({ + params: Promise.resolve({ locale: "en" }), + searchParams: Promise.resolve({}), + }); + const html = renderToStaticMarkup(page); + + expect(html).toContain("Accounts could not load"); + expect(html).toContain( + "The account service could not be reached. Try again shortly.", + ); + expect(hostedExchangeCalls).toBe(0); + }); + + test("renders setup guidance when hosted tenant control is not configured", async () => { + authorizationAvailable = true; + hostedControlConfigured = false; + + const page = await SubrouterOverviewPage({ + params: Promise.resolve({ locale: "en" }), + searchParams: Promise.resolve({}), + }); + const html = renderToStaticMarkup(page); + + expect(html).toContain("AI account management isn't available yet"); + expect(hostedExchangeCalls).toBe(0); + }); }); function translator(namespace?: string) { diff --git a/web/tests/hosted-subrouter-client.test.ts b/web/tests/hosted-subrouter-client.test.ts new file mode 100644 index 000000000000..cc9ab5e36832 --- /dev/null +++ b/web/tests/hosted-subrouter-client.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { createHostedSubrouterClient } from "../services/subrouter/hostedClient"; + +describe("hosted Subrouter client", () => { + test("exchanges a Stack team and uses the tenant-scoped account API", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init: init ?? {} }); + if (url.endsWith("/_subrouter/auth/stack")) { + return Response.json({ + tenantId: "team-1", + tenantName: "Acme", + tenantKey: "srt_0123456789abcdef0123456789abcdef", + proxyUrl: + "https://sr.example/t/srt_0123456789abcdef0123456789abcdef", + capabilities: ["use", "manage_accounts"], + }); + } + if (url.endsWith("/_subrouter/auth/stack/tenant")) { + return Response.json({ ok: true, deleted: true }); + } + return Response.json([ + { + id: "apikey:openai-apikey:work", + provider: "codex", + auth_mode: "apikey", + email: "apikey:openai-apikey:work", + health: { + ok: false, + message: "refresh failed", + }, + }, + ]); + }; + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + tenantDeleteToken: "0123456789abcdef0123456789abcdef-test", + fetch: fetchImpl as typeof fetch, + }); + const tenant = await client.exchangeTeam("stack-access", { + teamId: "team-1", + teamName: "Acme", + use: true, + manageAccounts: true, + }); + const accounts = await client.listAccounts(tenant.tenantKey); + await client.deleteTenant("stack-access", "team-1"); + + expect(calls[0]?.init.headers).toEqual({ + authorization: "Bearer stack-access", + "content-type": "application/json", + "x-subrouter-stack-control-token": + "0123456789abcdef0123456789abcdef-test", + }); + expect(calls[1]?.url).toBe( + "https://sr.example/_subrouter/accounts", + ); + expect(new Headers(calls[1]?.init.headers).get("authorization")).toBe( + "Bearer srt_0123456789abcdef0123456789abcdef", + ); + expect(accounts).toEqual([ + { + id: "apikey:openai-apikey:work", + kind: "openai-apikey", + label: "work", + health: { + ok: false, + }, + }, + ]); + expect(calls[2]?.url).toBe( + "https://sr.example/_subrouter/auth/stack/tenant", + ); + expect(calls[2]?.init.headers).toEqual({ + authorization: "Bearer stack-access", + "content-type": "application/json", + "x-subrouter-tenant-delete-token": + "0123456789abcdef0123456789abcdef-test", + }); + expect(JSON.parse(String(calls[2]?.init.body))).toEqual({ teamId: "team-1" }); + }); + + test("treats a structured already-absent tenant result as successfully deleted", async () => { + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + tenantDeleteToken: "0123456789abcdef0123456789abcdef-test", + fetch: (async () => + Response.json({ ok: true, deleted: false })) as typeof fetch, + }); + + await expect(client.deleteTenant("stack-access", "team-1")).resolves.toBeUndefined(); + }); + + test("rejects a hosted tenant credential for a different Stack team", async () => { + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + tenantDeleteToken: "0123456789abcdef0123456789abcdef-test", + fetch: (async () => + Response.json({ + tenantId: "team-other", + tenantName: "Other", + tenantKey: "srt_0123456789abcdef0123456789abcdef", + proxyUrl: + "https://sr.example/t/srt_0123456789abcdef0123456789abcdef", + capabilities: ["use"], + })) as typeof fetch, + }); + + await expect( + client.exchangeTeam("stack-access", { + teamId: "team-1", + teamName: "Acme", + use: true, + manageAccounts: false, + }), + ).rejects.toMatchObject({ status: 502 }); + }); + + test("rejects duplicated hosted tenant capabilities", async () => { + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + tenantDeleteToken: "0123456789abcdef0123456789abcdef-test", + fetch: (async () => + Response.json({ + tenantId: "team-1", + tenantName: "Acme", + tenantKey: "srt_0123456789abcdef0123456789abcdef", + proxyUrl: + "https://sr.example/t/srt_0123456789abcdef0123456789abcdef", + capabilities: ["use", "use"], + })) as typeof fetch, + }); + + await expect( + client.exchangeTeam("stack-access", { + teamId: "team-1", + teamName: "Acme", + use: true, + manageAccounts: true, + }), + ).rejects.toMatchObject({ status: 502 }); + }); + + test("rejects insecure or credential-bearing hosted Subrouter URLs", () => { + for (const baseUrl of [ + "http://sr.example", + "https://user:secret@sr.example", + ]) { + expect(() => + createHostedSubrouterClient({ + baseUrl, + tenantDeleteToken: "0123456789abcdef0123456789abcdef-test", + }) + ).toThrow("invalid hosted Subrouter URL"); + } + }); + + test("rejects an unexpected tenant deletion route 404", async () => { + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + tenantDeleteToken: "0123456789abcdef0123456789abcdef-test", + fetch: (async () => + Response.json({ error: "not found" }, { status: 404 })) as typeof fetch, + }); + + await expect( + client.deleteTenant("stack-access", "team-1"), + ).rejects.toMatchObject({ status: 404 }); + }); + + test("preserves a hosted credential refresh result", async () => { + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + fetch: (async () => + Response.json({ ok: true, refreshState: "refreshed" })) as typeof fetch, + }); + + await expect( + client.reportCredentialLease( + "srt_0123456789abcdef0123456789abcdef", + "lease-1", + { outcome: "unauthorized", statusCode: 401 }, + ), + ).resolves.toEqual({ ok: true, refreshState: "refreshed" }); + }); + + test("rejects unsupported hosted account provider and auth-mode pairs", async () => { + for (const account of [ + { id: "kimi-account", provider: "kimi", auth_mode: "oauth" }, + { id: "codex-account", provider: "codex", auth_mode: "token" }, + ]) { + const client = createHostedSubrouterClient({ + baseUrl: "https://sr.example", + fetch: (async () => Response.json([account])) as typeof fetch, + }); + + await expect( + client.listAccounts("srt_0123456789abcdef0123456789abcdef"), + ).rejects.toMatchObject({ status: 502 }); + } + }); +}); diff --git a/web/tests/hosted-subrouter-routes.test.ts b/web/tests/hosted-subrouter-routes.test.ts new file mode 100644 index 000000000000..4273e059cf14 --- /dev/null +++ b/web/tests/hosted-subrouter-routes.test.ts @@ -0,0 +1,672 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; + +const modifiedEnvironment = [ + "SUBROUTER_ALLOWED_TEAM_IDS", + "SUBROUTER_ENFORCE_STACK_PERMISSIONS", + "SUBROUTER_STACK_AUTH_TIMEOUT_MS", + "SUBROUTER_HOSTED_URL", + "SUBROUTER_STACK_TENANT_DELETE_TOKEN", +] as const; +const originalEnvironment = Object.fromEntries( + modifiedEnvironment.map((name) => [name, process.env[name]]), +) as Record<(typeof modifiedEnvironment)[number], string | undefined>; + +process.env.SUBROUTER_ALLOWED_TEAM_IDS = "*"; +process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "0"; +process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "10000"; +process.env.SUBROUTER_HOSTED_URL = "https://sr.test"; +process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN = + "0123456789abcdef0123456789abcdef-test"; + +let currentUser: ReturnType | null = null; +let authJson = { + accessToken: "cookie-access", + refreshToken: "cookie-refresh", +}; +let authJsonError: Error | null = null; +const getUser = mock(async () => currentUser); +const getAuthJson = mock(async () => { + if (authJsonError) throw authJsonError; + return authJson; +}); +const signOut = mock(async () => {}); +let hostedCutoverReady = true; +const hostedSubrouterCutoverReadyForTeam = mock(async () => hostedCutoverReady); + +mock.module("../app/lib/stack", () => ({ + getStackServerApp: () => ({ getUser, getAuthJson }), + getNonRedirectingStackServerApp: () => ({ getUser, signOut }), + isStackConfigured: () => true, + stackServerApp: { getUser }, +})); +mock.module("../services/subrouter/cutover", () => ({ + hostedSubrouterCutoverReadyForTeam, +})); + +const accountsRoute = await import("../app/api/subrouter/accounts/route"); +const accountRoute = await import( + "../app/api/subrouter/accounts/[accountId]/route" +); +const repairRoute = await import( + "../app/api/subrouter/accounts/[accountId]/repair/route" +); +const leasesRoute = await import("../app/api/subrouter/leases/route"); +const leaseEventsRoute = await import( + "../app/api/subrouter/leases/[leaseId]/events/route" +); +const logoutRoute = await import("../app/api/subrouter/logout/route"); +const teamsRoute = await import("../app/api/subrouter/teams/route"); +const exchangeRoute = await import("../app/api/subrouter/exchange/route"); + +const originalFetch = globalThis.fetch; +const tenantKey = "srt_0123456789abcdef0123456789abcdef"; +let calls: Array<{ + readonly url: URL; + readonly method: string; + readonly headers: Headers; + readonly body: unknown; +}> = []; +let listedAccounts: unknown[] = []; +let exchangeStatus = 200; +let accountListStatus = 200; + +afterAll(() => { + globalThis.fetch = originalFetch; + for (const name of modifiedEnvironment) { + const value = originalEnvironment[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } +}); + +beforeEach(() => { + currentUser = stackUser(); + authJson = { + accessToken: "cookie-access", + refreshToken: "cookie-refresh", + }; + authJsonError = null; + calls = []; + listedAccounts = []; + hostedCutoverReady = true; + exchangeStatus = 200; + accountListStatus = 200; + getUser.mockClear(); + getAuthJson.mockClear(); + signOut.mockClear(); + hostedSubrouterCutoverReadyForTeam.mockClear(); + globalThis.fetch = hostedFetch as typeof fetch; +}); + +describe("hosted Subrouter account routes", () => { + test("brokers native tenant exchange only after the shared cutover gate", async () => { + hostedCutoverReady = false; + const pending = await exchangeRoute.POST( + request("/api/subrouter/exchange", { method: "POST", body: "{}" }), + ); + expect(pending.status).toBe(503); + expect(await pending.json()).toEqual({ + error: "subrouter_migration_pending", + }); + expect(calls).toHaveLength(0); + + hostedCutoverReady = true; + const response = await exchangeRoute.POST( + request("/api/subrouter/exchange", { method: "POST", body: "{}" }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(calls[0]?.headers.get("x-subrouter-stack-control-token")).toBe( + "0123456789abcdef0123456789abcdef-test", + ); + expect(calls[0]?.body).toEqual({ + teamId: "team-a", + teamName: "Team A", + capabilities: ["use", "manage_accounts"], + }); + expect(await response.json()).toEqual({ + tenantId: "team-a", + tenantName: "Team A", + tenantKey, + proxyUrl: `https://sr.test/t/${tenantKey}`, + capabilities: ["use", "manage_accounts"], + }); + }); + + test("never returns a tenant key from ambient browser cookies", async () => { + const response = await exchangeRoute.POST( + request("/api/subrouter/exchange", { + auth: "cookie", + method: "POST", + body: "{}", + }), + ); + expect(response.status).toBe(401); + expect(calls).toHaveLength(0); + }); + + test("returns service unavailable when hosted tenant control is not configured", async () => { + const configuredToken = process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN; + delete process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN; + try { + const response = await exchangeRoute.POST( + request("/api/subrouter/exchange", { method: "POST", body: "{}" }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "service_unavailable" }); + expect(calls).toHaveLength(0); + } finally { + if (configuredToken === undefined) { + delete process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN; + } else { + process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN = configuredToken; + } + } + }); + + test("returns 401 without a Stack user and never contacts hosted Subrouter", async () => { + currentUser = null; + + const response = await accountsRoute.GET(request("/api/subrouter/accounts")); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + expect(calls).toHaveLength(0); + }); + + test("returns 401 when cookie auth has no Stack access token", async () => { + authJson = { + accessToken: "", + refreshToken: "", + }; + + const response = await accountsRoute.GET( + request("/api/subrouter/accounts", { auth: "cookie" }), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + expect(calls).toHaveLength(0); + }); + + test("fails closed before hosted cutover for an unmigrated legacy team", async () => { + hostedCutoverReady = false; + + const response = await accountsRoute.GET( + request("/api/subrouter/accounts"), + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "subrouter_migration_pending", + }); + expect(calls).toHaveLength(0); + expect(getAuthJson).not.toHaveBeenCalled(); + }); + + test("rejects a team outside the caller's Stack memberships", async () => { + const response = await accountsRoute.GET( + request("/api/subrouter/accounts?teamId=other-team"), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "team_not_found" }); + expect(calls).toHaveLength(0); + }); + + test("blocks cross-site cookie mutations before exchanging a tenant", async () => { + const response = await accountsRoute.POST( + request("/api/subrouter/accounts", { + auth: "cookie", + method: "POST", + headers: { + origin: "https://evil.example", + "sec-fetch-site": "cross-site", + }, + body: JSON.stringify({ + provider: "openai-apikey", + label: "work", + apiKey: "sk-test", + }), + }), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "forbidden" }); + expect(calls).toHaveLength(0); + }); + + test("uses a cookie Stack token for same-origin account uploads", async () => { + const response = await accountsRoute.POST( + request("/api/subrouter/accounts", { + auth: "cookie", + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ + provider: "openai-apikey", + label: "work", + apiKey: "sk-test", + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + teamId: "team-a", + account: { + id: "apikey:openai-apikey:work", + kind: "openai-apikey", + label: "work", + }, + }); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer cookie-access"); + expect(calls[1]?.body).toEqual({ + provider: "openai-apikey", + label: "work", + apiKey: "sk-test", + }); + }); + + test("forwards the authoritative refreshed native Stack token", async () => { + authJson = { + accessToken: "refreshed-access", + refreshToken: "refreshed-refresh", + }; + + const response = await accountsRoute.GET( + request("/api/subrouter/accounts"), + ); + + expect(response.status).toBe(200); + expect(calls[0]?.headers.get("authorization")).toBe( + "Bearer refreshed-access", + ); + expect(getAuthJson).toHaveBeenCalledWith({ + tokenStore: { + accessToken: "access-token", + refreshToken: "refresh-token", + }, + }); + }); + + test("maps a Stack token refresh outage to service unavailable", async () => { + authJsonError = new Error("Stack refresh unavailable"); + + const response = await accountsRoute.GET( + request("/api/subrouter/accounts"), + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "service_unavailable" }); + expect(calls).toHaveLength(0); + }); + + test("strips unknown account fields returned by the Go service", async () => { + listedAccounts = [{ + id: "alice@example.com", + provider: "codex", + auth_mode: "oauth", + email: "alice@example.com", + label: "Alice work", + created_at: "2026-08-03T00:00:00Z", + health: { ok: false, message: "upstream detail must not leak" }, + refreshToken: "must-not-leak", + nested: { accessToken: "must-not-leak" }, + }]; + + const response = await accountsRoute.GET(request("/api/subrouter/accounts")); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(JSON.parse(text)).toEqual({ + teamId: "team-a", + accounts: [{ + id: "alice@example.com", + kind: "codex", + label: "Alice work", + createdAt: "2026-08-03T00:00:00Z", + health: { ok: false }, + }], + }); + expect(text).not.toContain("must-not-leak"); + expect(text).not.toContain("upstream detail must not leak"); + expect(text).not.toContain(tenantKey); + }); + + test("maps internal tenant-key authentication failures to an upstream error", async () => { + accountListStatus = 401; + + const response = await accountsRoute.GET( + request("/api/subrouter/accounts"), + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: "upstream_request_failed", + }); + }); + + test("preserves caller authentication failures from the Stack exchange", async () => { + exchangeStatus = 401; + + const response = await accountsRoute.GET( + request("/api/subrouter/accounts"), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: "upstream_request_failed", + }); + }); + + test("validates and bounds uploads before forwarding provider secrets", async () => { + const invalid = await accountsRoute.POST( + request("/api/subrouter/accounts", { + method: "POST", + body: JSON.stringify({ + provider: "anthropic-apikey", + apiKey: "wrong-prefix", + }), + }), + ); + expect(invalid.status).toBe(400); + expect(calls).toHaveLength(0); + + const oversized = await accountsRoute.POST( + request("/api/subrouter/accounts", { + method: "POST", + body: JSON.stringify({ + provider: "openai-apikey", + apiKey: "sk-test", + padding: "x".repeat(70 * 1024), + }), + }), + ); + expect(oversized.status).toBe(413); + expect(calls).toHaveLength(0); + }); + + test("keeps only supported Codex token fields before upload", async () => { + const response = await accountsRoute.POST( + request("/api/subrouter/accounts", { + method: "POST", + body: JSON.stringify({ + provider: "codex", + label: "Alice", + tokens: { + accessToken: "access", + refreshToken: "refresh", + idToken: "id", + accountID: "account", + tokenEndpoint: "https://attacker.example/collect", + }, + }), + }), + ); + + expect(response.status).toBe(200); + expect(calls[1]?.body).toEqual({ + provider: "codex", + label: "Alice", + tokens: { + accessToken: "access", + refreshToken: "refresh", + idToken: "id", + accountID: "account", + }, + }); + }); + + test("deletes and repairs only the requested tenant account", async () => { + const deleted = await accountRoute.DELETE( + request("/api/subrouter/accounts/old%40example.com", { method: "DELETE" }), + { params: Promise.resolve({ accountId: "old@example.com" }) }, + ); + expect(deleted.status).toBe(200); + expect(calls[1]?.url.pathname).toBe( + "/_subrouter/accounts/old%40example.com", + ); + expect(calls[1]?.headers.get("authorization")).toBe(`Bearer ${tenantKey}`); + expect(calls[1]?.url.href).not.toContain(tenantKey); + + calls = []; + const repaired = await repairRoute.POST( + request("/api/subrouter/accounts/apikey%3Aopenai-apikey%3Awork/repair", { + method: "POST", + body: JSON.stringify({ + provider: "openai-apikey", + label: "work", + apiKey: "sk-new", + }), + }), + { params: Promise.resolve({ accountId: "apikey:openai-apikey:work" }) }, + ); + expect(repaired.status).toBe(200); + expect(calls.map((call) => call.method)).toEqual(["POST", "POST"]); + expect(calls[1]?.url.pathname).toBe("/_subrouter/accounts"); + expect(calls[1]?.headers.get("authorization")).toBe(`Bearer ${tenantKey}`); + expect(calls[1]?.url.href).not.toContain(tenantKey); + expect(calls[1]?.body).toEqual({ + provider: "openai-apikey", + label: "work", + apiKey: "sk-new", + targetAccountID: "apikey:openai-apikey:work", + }); + }); + + test("keeps shipped lease, team, and logout routes working", async () => { + const leaseResponse = await leasesRoute.POST( + request("/api/subrouter/leases", { + method: "POST", + body: JSON.stringify({ + provider: "codex", + sessionId: "session-1", + agentType: "codex", + }), + }), + ); + expect(leaseResponse.status).toBe(200); + expect(leaseResponse.headers.get("cache-control")).toBe("no-store"); + expect(await leaseResponse.json()).toEqual({ + teamId: "team-a", + lease: { + leaseId: "lease-1", + accountId: "alice@example.com", + provider: "codex", + authMode: "oauth", + token: "leased-token", + label: "Alice", + credentialGeneration: 1, + issuedAt: "2026-08-03T00:00:00Z", + expiresAt: "2026-08-03T00:05:00Z", + }, + }); + expect(calls.map((call) => call.url.pathname)).toEqual([ + "/_subrouter/auth/stack", + "/_subrouter/leases", + ]); + + calls = []; + const eventResponse = await leaseEventsRoute.POST( + request("/api/subrouter/leases/lease-1/events", { + method: "POST", + body: JSON.stringify({ outcome: "success", statusCode: 200 }), + }), + { params: Promise.resolve({ leaseId: "lease-1" }) }, + ); + expect(eventResponse.status).toBe(200); + expect(await eventResponse.json()).toEqual({ ok: true }); + expect(calls.map((call) => call.url.pathname)).toEqual([ + "/_subrouter/auth/stack", + "/_subrouter/leases/lease-1/events", + ]); + + const teamsResponse = await teamsRoute.GET( + request("/api/subrouter/teams"), + ); + expect(teamsResponse.status).toBe(200); + expect(await teamsResponse.json()).toEqual({ + selectedTeamId: "team-a", + teams: [ + { + id: "team-a", + name: "Team A", + personal: false, + permissions: { use: true, manageAccounts: true }, + }, + { + id: "team-b", + name: "Team B", + personal: false, + permissions: { use: true, manageAccounts: true }, + }, + { + id: "user-1", + name: "User One", + personal: true, + permissions: { use: true, manageAccounts: true }, + }, + ], + }); + + const logoutResponse = await logoutRoute.POST( + request("/api/subrouter/logout", { method: "POST" }), + ); + expect(logoutResponse.status).toBe(200); + expect(await logoutResponse.json()).toEqual({ ok: true }); + expect(signOut).toHaveBeenCalledWith({ + tokenStore: { + accessToken: "access-token", + refreshToken: "refresh-token", + }, + }); + }); +}); + +type TestRequestInit = RequestInit & { + readonly auth?: "bearer" | "cookie"; +}; + +function request(path: string, init: TestRequestInit = {}): Request { + const headers = new Headers(init.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + if (init.auth !== "cookie") { + headers.set("authorization", "Bearer access-token"); + headers.set("x-stack-refresh-token", "refresh-token"); + } + return new Request(`https://cmux.test${path}`, { + method: init.method ?? "GET", + headers, + body: init.body, + }); +} + +function stackUser() { + return { + id: "user-1", + displayName: "User One", + primaryEmail: "user@example.com", + selectedTeam: { id: "team-a", displayName: "Team A" }, + listTeams: async () => [ + { id: "team-a", displayName: "Team A" }, + { id: "team-b", displayName: "Team B" }, + ], + }; +} + +async function hostedFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + const body = typeof init?.body === "string" + ? JSON.parse(init.body) + : undefined; + calls.push({ url, method, headers, body }); + + if (url.pathname === "/_subrouter/auth/stack" && method === "POST") { + if (exchangeStatus !== 200) { + return Response.json({ error: "unauthorized" }, { status: exchangeStatus }); + } + return Response.json({ + tenantId: "team-a", + tenantName: "Team A", + tenantKey, + proxyUrl: `https://sr.test/t/${tenantKey}`, + capabilities: body.capabilities, + }); + } + if ( + url.pathname === "/_subrouter/accounts" && + headers.get("authorization") === `Bearer ${tenantKey}` && + method === "GET" + ) { + if (accountListStatus !== 200) { + return Response.json( + { error: "tenant credential rejected" }, + { status: accountListStatus }, + ); + } + return Response.json(listedAccounts); + } + if ( + url.pathname === "/_subrouter/accounts" && + headers.get("authorization") === `Bearer ${tenantKey}` && + method === "POST" + ) { + const upload = body as { provider: string; label?: string }; + const id = upload.provider.endsWith("-apikey") + ? `apikey:${upload.provider}:${upload.label ?? "unlabeled"}` + : upload.label ?? "account"; + return Response.json({ + account: { + id, + kind: upload.provider, + label: upload.label, + refreshToken: "must-not-leak", + }, + }); + } + if ( + url.pathname === "/_subrouter/leases" && + headers.get("authorization") === `Bearer ${tenantKey}` && + method === "POST" + ) { + return Response.json({ + teamId: "team-a", + lease: { + leaseId: "lease-1", + accountId: "alice@example.com", + provider: "codex", + authMode: "oauth", + token: "leased-token", + label: "Alice", + credentialGeneration: 1, + issuedAt: "2026-08-03T00:00:00Z", + expiresAt: "2026-08-03T00:05:00Z", + }, + }); + } + if ( + url.pathname === "/_subrouter/leases/lease-1/events" && + headers.get("authorization") === `Bearer ${tenantKey}` && + method === "POST" + ) { + return new Response(null, { status: 204 }); + } + if ( + url.pathname.startsWith("/_subrouter/accounts/") && + headers.get("authorization") === `Bearer ${tenantKey}` && + method === "DELETE" + ) { + return new Response(null, { status: 204 }); + } + return Response.json({ error: "not found" }, { status: 404 }); +} diff --git a/web/tests/legacy-subrouter-retirement-client.test.ts b/web/tests/legacy-subrouter-retirement-client.test.ts new file mode 100644 index 000000000000..7f18f30700f3 --- /dev/null +++ b/web/tests/legacy-subrouter-retirement-client.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; + +import { + LegacySubrouterRetirementError, + createLegacySubrouterRetirementClient, + legacySubrouterRetirementConfig, +} from "../services/subrouter/legacyRetirementClient"; + +describe("legacy Subrouter retirement client", () => { + test("requires the legacy admin credential before retirement", () => { + expect(() => legacySubrouterRetirementConfig({ + VERCEL_ENV: "production", + SUBROUTER_ADMIN_TOKEN: "", + })).toThrow("legacy Subrouter retirement is not configured"); + }); + + test("revokes the exact legacy tenant without exposing the credential in the URL", async () => { + const calls: Parameters[] = []; + const client = createLegacySubrouterRetirementClient({ + baseUrl: "https://subrouter.example/", + adminToken: "legacy-admin-secret", + fetch: (async (...args: Parameters) => { + calls.push(args); + return Response.json({ ok: true }); + }) as typeof fetch, + }); + + await expect(client.revokeTenant("tenant/a b")).resolves.toEqual({ revoked: true }); + + expect(String(calls[0]?.[0])).toBe( + "https://subrouter.example/admin/tenants/tenant%2Fa%20b/revoke", + ); + expect(new Headers(calls[0]?.[1]?.headers).get("authorization")).toBe( + "Bearer legacy-admin-secret", + ); + expect(String(calls[0]?.[0])).not.toContain("legacy-admin-secret"); + }); + + test("treats an already-absent legacy tenant as idempotently retired", async () => { + const client = createLegacySubrouterRetirementClient({ + baseUrl: "https://subrouter.example", + adminToken: "legacy-admin-secret", + fetch: (async () => new Response("missing", { status: 404 })) as typeof fetch, + }); + + await expect(client.revokeTenant("tenant-1")).resolves.toEqual({ revoked: false }); + }); + + test("uses the credential-safe migration endpoint and validates its response", async () => { + const calls: Parameters[] = []; + const client = createLegacySubrouterRetirementClient({ + baseUrl: "https://subrouter.example", + adminToken: "legacy-admin-secret", + fetch: (async (...args: Parameters) => { + calls.push(args); + return Response.json({ ok: true, migrated: 4, sourceFinalized: false }); + }) as typeof fetch, + }); + + await expect(client.migrateTenant("tenant-1", { + destinationUrl: "https://sr.cmux.com", + tenantKey: "srt_0123456789abcdef0123456789abcdef", + finalizeSource: false, + })).resolves.toEqual({ migrated: 4, sourceFinalized: false }); + + expect(JSON.parse(String(calls[0]?.[1]?.body))).toEqual({ + destinationUrl: "https://sr.cmux.com", + tenantKey: "srt_0123456789abcdef0123456789abcdef", + finalizeSource: false, + }); + }); + + test("does not copy an upstream error body into the thrown error", async () => { + const client = createLegacySubrouterRetirementClient({ + baseUrl: "https://subrouter.example", + adminToken: "legacy-admin-secret", + fetch: (async () => new Response("credential=secret", { status: 409 })) as typeof fetch, + }); + + const error = await client.revokeTenant("tenant-1").catch((value) => value); + expect(error).toBeInstanceOf(LegacySubrouterRetirementError); + expect(error).toMatchObject({ status: 409 }); + expect(String(error)).not.toContain("credential=secret"); + }); +}); diff --git a/web/tests/subrouter-accounts-route.test.ts b/web/tests/subrouter-accounts-route.test.ts deleted file mode 100644 index 0c4076635187..000000000000 --- a/web/tests/subrouter-accounts-route.test.ts +++ /dev/null @@ -1,1322 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; - -const originalFetch = globalThis.fetch; -const secret = Buffer.alloc(32, 11).toString("base64"); -// Capture real implementations BY VALUE: bun's mock.module can mutate an -// already-loaded namespace in place, so calling through a captured namespace -// object at delegation time can recurse into the mock itself. -const dbClientModule = await import("../db/client"); -const realCloudDb = dbClientModule.cloudDb; -const realCloseCloudDbForTests = dbClientModule.closeCloudDbForTests; -const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; - -let currentUser: unknown; -let fakeDb: ReturnType; -let upstream: ReturnType; -let useStubDb = false; - -const getUser = mock(async () => currentUser); -const nonRedirectingSignOut = mock(async () => {}); -const cloudDb = mock(() => fakeDb); - -mock.module("../app/lib/stack", () => ({ - getStackServerApp: () => ({ getUser }), - getNonRedirectingStackServerApp: () => ({ - getUser, - signOut: nonRedirectingSignOut, - }), - isStackConfigured: () => true, - stackServerApp: { getUser }, -})); - -mock.module("../db/client", () => ({ - createAwsRdsIamPool: realCreateAwsRdsIamPool, - closeCloudDbForTests: realCloseCloudDbForTests, - cloudDb: (() => - useStubDb - ? (cloudDb() as unknown as ReturnType) - : realCloudDb()) as typeof realCloudDb, -})); - -const { encryptTenantKey } = await import("../services/subrouter/crypto"); -const accountsRoute = await import("../app/api/subrouter/accounts/route"); -const accountRoute = await import("../app/api/subrouter/accounts/[accountId]/route"); -const accountRepairRoute = await import("../app/api/subrouter/accounts/[accountId]/repair/route"); -const leasesRoute = await import("../app/api/subrouter/leases/route"); -const leaseEventsRoute = await import("../app/api/subrouter/leases/[leaseId]/events/route"); -const logoutRoute = await import("../app/api/subrouter/logout/route"); -const teamsRoute = await import("../app/api/subrouter/teams/route"); -const { - SubrouterAuthorizationTimeoutError, - withSubrouterAuthorizationDeadline, -} = await import("../services/vms/auth"); -const { readBoundedJsonRecord } = await import( - "../services/subrouter/boundedJson" -); - -beforeAll(() => { - useStubDb = true; -}); - -afterAll(() => { - useStubDb = false; - globalThis.fetch = originalFetch; -}); - -beforeEach(() => { - process.env.SUBROUTER_BASE_URL = "https://subrouter.test"; - process.env.SUBROUTER_ADMIN_TOKEN = "admin-test-token"; - process.env.SUBROUTER_TENANT_KEY_SECRET = secret; - process.env.SUBROUTER_ALLOWED_TEAM_IDS = "team-a,team-b,team-c,user-1"; - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "0"; - process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "10000"; - currentUser = stackUser(); - fakeDb = createFakeRouteDb(); - upstream = createMockSubrouter(); - globalThis.fetch = upstream.fetch as unknown as typeof fetch; - getUser.mockClear(); - nonRedirectingSignOut.mockClear(); - cloudDb.mockClear(); -}); - -describe("subrouter accounts route", () => { - test("authorization deadline rejects work that ignores abort signals", async () => { - process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "20"; - const operation = withSubrouterAuthorizationDeadline( - async () => await new Promise(() => {}), - ); - - await expect(operation).rejects.toBeInstanceOf( - SubrouterAuthorizationTimeoutError, - ); - }); - - test("bounded JSON releases a body reader after stream errors", async () => { - const body = new ReadableStream({ - start(controller) { - controller.error(new Error("stream failed")); - }, - }); - const result = await readBoundedJsonRecord({ - body, - headers: new Headers(), - } as Request, 1024); - - expect(result).toEqual({ ok: false, status: 400 }); - expect(body.locked).toBe(false); - }); - - test("revokes the exact native Stack session on logout", async () => { - const redirectingUserSignOut = mock(async () => { - throw new Error("NEXT_REDIRECT"); - }); - currentUser = { ...stackUser(), signOut: redirectingUserSignOut }; - - const response = await logoutRoute.POST( - new Request("https://cmux.test/api/subrouter/logout", { - method: "POST", - headers: { - authorization: "Bearer stack-access", - "x-stack-refresh-token": "stack-refresh", - }, - }), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true }); - expect(redirectingUserSignOut).not.toHaveBeenCalled(); - expect(nonRedirectingSignOut).toHaveBeenCalledWith({ - tokenStore: { - accessToken: "stack-access", - refreshToken: "stack-refresh", - }, - }); - expect(getUser).toHaveBeenCalledWith({ - tokenStore: { - accessToken: "stack-access", - refreshToken: "stack-refresh", - }, - }); - }); - - test("logout never falls back to an ambient browser session", async () => { - currentUser = stackUser(); - - const response = await logoutRoute.POST( - request("/api/subrouter/logout", { method: "POST", auth: "cookie" }), - ); - - expect(response.status).toBe(401); - expect(nonRedirectingSignOut).not.toHaveBeenCalled(); - expect(getUser).not.toHaveBeenCalled(); - }); - - test("returns 401 when unauthenticated", async () => { - currentUser = null; - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(401); - expect(JSON.parse(body)).toEqual({ error: "unauthorized" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("rejects a team the caller is not a member of", async () => { - const response = await accountsRoute.GET(request("/api/subrouter/accounts?teamId=team-not-mine")); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(403); - expect(JSON.parse(body)).toEqual({ error: "team_not_found" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("requires an explicit team when Stack has no selected scope", async () => { - currentUser = { - ...stackUser(), - selectedTeam: null, - listTeams: async () => [ - { id: "team-b", displayName: "Team B" }, - ], - }; - - const response = await accountsRoute.GET( - request("/api/subrouter/accounts"), - ); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: "team_selection_required", - }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("rejects teams outside the private beta allowlist", async () => { - process.env.SUBROUTER_ALLOWED_TEAM_IDS = "team-b"; - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(403); - expect(JSON.parse(body)).toEqual({ error: "team_not_allowed" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("separates team credential use from account management permissions", async () => { - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; - currentUser = { - ...stackUser(), - listPermissions: async () => [{ id: "subrouter:use" }], - }; - - const listResponse = await accountsRoute.GET( - request("/api/subrouter/accounts"), - ); - expect(listResponse.status).toBe(200); - - const uploadResponse = await accountsRoute.POST( - request("/api/subrouter/accounts", { - method: "POST", - body: JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - }), - }), - ); - expect(uploadResponse.status).toBe(403); - expect(await uploadResponse.json()).toEqual({ error: "forbidden" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("fails closed when Stack permission enforcement is not configured", async () => { - delete process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS; - - const response = await accountsRoute.GET( - request("/api/subrouter/accounts"), - ); - - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ error: "service_unavailable" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("fails closed when the team rollout allowlist is not configured", async () => { - delete process.env.SUBROUTER_ALLOWED_TEAM_IDS; - - const response = await accountsRoute.GET( - request("/api/subrouter/accounts"), - ); - - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ error: "service_unavailable" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("lets account managers enumerate metadata without leasing credentials", async () => { - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; - currentUser = { - ...stackUser(), - listPermissions: async () => [ - { id: "subrouter:manage_accounts" }, - ], - }; - - const response = await accountsRoute.GET( - request("/api/subrouter/accounts"), - ); - - expect(response.status).toBe(200); - }); - - test("returns 503 when subrouter env is not configured", async () => { - delete process.env.SUBROUTER_ADMIN_TOKEN; - delete process.env.SUBROUTER_TENANT_KEY_SECRET; - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(503); - expect(JSON.parse(body)).toEqual({ error: "service_unavailable" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("returns 503 when a stored tenant key cannot be decrypted", async () => { - fakeDb.rows.push({ - teamId: "team-a", - tenantId: "tenant-team-a", - tenantName: "Team A", - encryptedTenantKey: "not-a-valid-encrypted-key", - }); - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(503); - expect(JSON.parse(body)).toEqual({ error: "service_unavailable" }); - expect(upstream.tenantListCalls).toBe(0); - }); - - test("rejects request bodies larger than the byte limit", async () => { - const oversized = JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - padding: "x".repeat(70 * 1024), - }); - - const response = await accountsRoute.POST( - request("/api/subrouter/accounts", { method: "POST", body: oversized }), - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(413); - expect(JSON.parse(body)).toEqual({ error: "invalid_request" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("validates account upload shapes before proxying secrets", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts?validate=1", { - method: "POST", - body: JSON.stringify({ - provider: "anthropic-apikey", - apiKey: "definitely-not-an-anthropic-key", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(400); - expect(JSON.parse(body)).toEqual({ error: "invalid_request" }); - expect(body).not.toContain("definitely-not-an-anthropic-key"); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("strips provider endpoint overrides before central credential storage", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts", { - method: "POST", - body: JSON.stringify({ - provider: "codex", - label: "Canary", - tokens: { - accessToken: "access-secret", - refreshToken: "refresh-secret", - idToken: "id-secret", - accountID: "provider-account", - tokenEndpoint: "https://attacker.example/collect", - usageUrl: "https://attacker.example/usage", - clientId: "attacker-client", - }, - }), - }), - ); - - expect(response.status).toBe(200); - expect(upstream.lastCreateAccountBody).toEqual({ - provider: "codex", - label: "Canary", - tokens: { - accessToken: "access-secret", - refreshToken: "refresh-secret", - idToken: "id-secret", - accountID: "provider-account", - }, - }); - }); - - test("blocks cross-site cookie-authenticated account uploads before proxying", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts?validate=1", { - auth: "cookie", - method: "POST", - headers: { - origin: "https://evil.example", - "sec-fetch-site": "cross-site", - "content-type": "text/plain", - }, - body: JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(403); - expect(JSON.parse(body)).toEqual({ error: "forbidden" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("blocks cookie-authenticated account uploads without an Origin", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts?validate=1", { - auth: "cookie", - method: "POST", - body: JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(403); - expect(JSON.parse(body)).toEqual({ error: "forbidden" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("allows same-origin cookie-authenticated account uploads", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts?validate=1", { - auth: "cookie", - method: "POST", - headers: { origin: "https://cmux.test" }, - body: JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - const json = JSON.parse(body) as { account: { kind: string } }; - - expect(response.status).toBe(200); - expect(json.account.kind).toBe("openai-apikey"); - expect(upstream.lastCreateAccountUrl?.searchParams.get("validate")).toBe("1"); - expect(upstream.lastCreateAccountUrl?.searchParams.get("adopt")).toBe("1"); - }); - - test("returns an empty account list without provisioning when no tenant mapping exists", async () => { - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ teamId: "team-a", accounts: [] }); - expect(upstream.fetch).not.toHaveBeenCalled(); - expect(upstream.adminCreates).toBe(0); - expect(upstream.tenantListCalls).toBe(0); - expect(fakeDb.insertCalls).toBe(0); - expect(fakeDb.rows).toHaveLength(0); - }); - - test("lists sanitized accounts through an existing tenant", async () => { - seedTenantMapping(fakeDb); - upstream.accounts = [{ - id: "acct-1", - provider: "claude", - auth_mode: "oauth", - label: "Claude Team", - created_at: "2026-07-01T00:00:00.000Z", - health: { - ok: false, - message: "Credential requires repair before it can be leased.", - rawFailure: "refresh-secret", - }, - }]; - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - const json = JSON.parse(body) as { - teamId: string; - accounts: Array<{ id: string; kind: string; label: string }>; - }; - - expect(response.status).toBe(200); - expect(json.teamId).toBe("team-a"); - expect(json.accounts).toEqual([{ - id: "acct-1", - kind: "claude", - label: "Claude Team", - createdAt: "2026-07-01T00:00:00.000Z", - health: { - ok: false, - message: "Credential requires repair before it can be leased.", - }, - }]); - expect(body).not.toContain("refresh-secret"); - expect(upstream.adminCreates).toBe(0); - expect(upstream.tenantListCalls).toBe(1); - }); - - test("treats explicit null account health as absent", async () => { - seedTenantMapping(fakeDb); - upstream.accounts = [{ - id: "acct-without-health", - provider: "codex", - auth_mode: "oauth", - label: "Codex Team", - health: null, - }]; - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - teamId: "team-a", - accounts: [{ - id: "acct-without-health", - kind: "codex", - label: "Codex Team", - }], - }); - }); - - test("strips unknown upstream account fields before returning to the browser", async () => { - seedTenantMapping(fakeDb); - upstream.accounts = [{ - id: "acct-leaky", - kind: "claude", - label: "Leaky", - createdAt: "2026-07-01T00:00:00.000Z", - apiKey: "sk-ant-should-never-leak", - tokens: { refreshToken: "rt-should-never-leak" }, - }]; - - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); - const body = await textWithoutTenantKeys(response); - const json = JSON.parse(body) as { accounts: Array> }; - - expect(response.status).toBe(200); - expect(json.accounts).toEqual([{ - id: "acct-leaky", - kind: "claude", - label: "Leaky", - createdAt: "2026-07-01T00:00:00.000Z", - }]); - expect(body).not.toContain("should-never-leak"); - }); - - test("adopts refresh custody and validates the provider credential", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts?validate=1", { - method: "POST", - body: JSON.stringify({ - provider: "openai-apikey", - label: "OpenAI", - apiKey: "sk-test-openai", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - const json = JSON.parse(body) as { account: { kind: string; label: string } }; - - expect(response.status).toBe(200); - expect(json.account.kind).toBe("openai-apikey"); - expect(json.account.label).toBe("OpenAI"); - expect(upstream.lastCreateAccountUrl?.searchParams.get("validate")).toBe("1"); - expect(upstream.lastCreateAccountUrl?.searchParams.get("adopt")).toBe("1"); - expect(upstream.lastCreateAccountBody).toEqual({ - provider: "openai-apikey", - label: "OpenAI", - apiKey: "sk-test-openai", - }); - expect(upstream.adminCreates).toBe(1); - expect(fakeDb.rows).toHaveLength(1); - }); - - test("allows bearer-authenticated account uploads without an Origin", async () => { - const response = await accountsRoute.POST( - request("/api/subrouter/accounts?validate=1", { - method: "POST", - body: JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - const json = JSON.parse(body) as { account: { kind: string } }; - - expect(response.status).toBe(200); - expect(json.account.kind).toBe("openai-apikey"); - }); - - test("delete proxies to the tenant account endpoint", async () => { - seedTenantMapping(fakeDb); - const response = await accountRoute.DELETE( - request("/api/subrouter/accounts/acct-1?teamId=team-a", { method: "DELETE" }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ ok: true, teamId: "team-a" }); - expect(upstream.deletedAccountIds).toEqual(["acct-1"]); - }); - - test("repairs an account in place without returning credential fields", async () => { - seedTenantMapping(fakeDb); - const response = await accountRepairRoute.POST( - request("/api/subrouter/accounts/acct-1/repair?validate=1", { - method: "POST", - body: JSON.stringify({ - provider: "codex", - label: "Alice", - tokens: { - accessToken: "access-secret", - refreshToken: "refresh-secret", - idToken: "id-secret", - accountID: "provider-account", - }, - }), - }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const text = await textWithoutTenantKeys(response); - - expect(response.status).toBe(200); - expect(upstream.lastRepairAccount?.accountId).toBe("acct-1"); - expect(upstream.lastRepairAccount?.validate).toBe("1"); - expect(upstream.lastRepairAccount?.adopt).toBe("1"); - expect(upstream.lastRepairAccount?.body).toEqual({ - provider: "codex", - label: "Alice", - tokens: { - accessToken: "access-secret", - refreshToken: "refresh-secret", - idToken: "id-secret", - accountID: "provider-account", - }, - }); - expect(text).not.toContain("access-secret"); - expect(text).not.toContain("refresh-secret"); - expect(text).not.toContain("id-secret"); - }); - - test("delete is a no-op when no tenant mapping exists", async () => { - const response = await accountRoute.DELETE( - request("/api/subrouter/accounts/acct-1?teamId=team-a", { method: "DELETE" }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ ok: true, teamId: "team-a" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - expect(fakeDb.insertCalls).toBe(0); - expect(fakeDb.rows).toHaveLength(0); - }); - - test("blocks cross-site cookie-authenticated account deletes before proxying", async () => { - const response = await accountRoute.DELETE( - request("/api/subrouter/accounts/acct-1?teamId=team-a", { - auth: "cookie", - method: "DELETE", - headers: { - origin: "https://evil.example", - "sec-fetch-site": "cross-site", - }, - }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(403); - expect(JSON.parse(body)).toEqual({ error: "forbidden" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("blocks cookie-authenticated account deletes without an Origin", async () => { - const response = await accountRoute.DELETE( - request("/api/subrouter/accounts/acct-1?teamId=team-a", { - auth: "cookie", - method: "DELETE", - }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(403); - expect(JSON.parse(body)).toEqual({ error: "forbidden" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - }); - - test("allows same-origin cookie-authenticated account deletes", async () => { - seedTenantMapping(fakeDb); - const response = await accountRoute.DELETE( - request("/api/subrouter/accounts/acct-1?teamId=team-a", { - auth: "cookie", - method: "DELETE", - headers: { origin: "https://cmux.test" }, - }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ ok: true, teamId: "team-a" }); - expect(upstream.deletedAccountIds).toEqual(["acct-1"]); - }); - - test("allows bearer-authenticated account deletes without an Origin", async () => { - seedTenantMapping(fakeDb); - const response = await accountRoute.DELETE( - request("/api/subrouter/accounts/acct-1?teamId=team-a", { method: "DELETE" }), - { params: Promise.resolve({ accountId: "acct-1" }) }, - ); - const body = await textWithoutTenantKeys(response); - - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ ok: true, teamId: "team-a" }); - expect(upstream.deletedAccountIds).toEqual(["acct-1"]); - }); - - test("concurrent first account uploads create only one tenant and never expose tenant keys", async () => { - const accountBody = JSON.stringify({ - provider: "openai-apikey", - apiKey: "sk-test-openai", - }); - const responses = await Promise.all([ - accountsRoute.POST(request("/api/subrouter/accounts", { method: "POST", body: accountBody })), - accountsRoute.POST(request("/api/subrouter/accounts", { method: "POST", body: accountBody })), - ]); - const bodies = await Promise.all(responses.map(textWithoutTenantKeys)); - - expect(responses.map((response) => response.status)).toEqual([200, 200]); - expect(upstream.adminCreates).toBe(1); - expect(fakeDb.rows).toHaveLength(1); - expect(fakeDb.rows[0].encryptedTenantKey).not.toContain("srt_"); - for (const body of bodies) { - expect(body).not.toContain("srt_"); - } - }); - - test("returns an access-only credential lease without exposing tenant or refresh keys", async () => { - seedTenantMapping(fakeDb); - const response = await leasesRoute.POST( - request("/api/subrouter/leases", { - method: "POST", - body: JSON.stringify({ - provider: "codex", - agentType: "codex", - sessionId: "session-1", - model: "gpt-5", - requiredAuthMode: "oauth", - }), - }), - ); - const body = await textWithoutTenantKeys(response); - const parsed = JSON.parse(body) as { - teamId: string; - lease: { leaseId: string; token: string }; - }; - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(parsed.teamId).toBe("team-a"); - expect(parsed.lease.leaseId).toBe("lease-1"); - expect(parsed.lease.token).toBe("leased-access-token"); - expect(body).not.toContain("refresh-token-that-must-not-leak"); - expect(upstream.lastLeaseBody).toEqual({ - provider: "codex", - agentType: "codex", - sessionId: "session-1", - model: "gpt-5", - requiredAuthMode: "oauth", - }); - }); - - test("rejects an invalid required credential auth mode", async () => { - seedTenantMapping(fakeDb); - const response = await leasesRoute.POST( - request("/api/subrouter/leases", { - method: "POST", - body: JSON.stringify({ - provider: "codex", - sessionId: "session-1", - requiredAuthMode: "password", - }), - }), - ); - - expect(response.status).toBe(400); - expect(upstream.lastLeaseBody).toBeNull(); - }); - - test("does not provision a tenant when requesting a lease without shared accounts", async () => { - const response = await leasesRoute.POST( - request("/api/subrouter/leases", { - method: "POST", - body: JSON.stringify({ - provider: "claude", - sessionId: "session-2", - }), - }), - ); - - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ error: "no_shared_accounts" }); - expect(upstream.fetch).not.toHaveBeenCalled(); - expect(upstream.adminCreates).toBe(0); - }); - - test("bounds lease and outcome bodies before forwarding them", async () => { - seedTenantMapping(fakeDb); - const leaseResponse = await leasesRoute.POST( - request("/api/subrouter/leases", { - method: "POST", - body: JSON.stringify({ - provider: "codex", - sessionId: "x".repeat(20 * 1024), - }), - }), - ); - expect(leaseResponse.status).toBe(413); - - const eventResponse = await leaseEventsRoute.POST( - request("/api/subrouter/leases/lease-1/events", { - method: "POST", - body: JSON.stringify({ - outcome: "success", - padding: "x".repeat(8 * 1024), - }), - }), - { params: Promise.resolve({ leaseId: "lease-1" }) }, - ); - expect(eventResponse.status).toBe(413); - expect(upstream.lastLeaseBody).toBeNull(); - expect(upstream.lastLeaseEvent).toBeNull(); - }); - - test("reports a lease outcome through the same team tenant", async () => { - seedTenantMapping(fakeDb); - const response = await leaseEventsRoute.POST( - request("/api/subrouter/leases/lease-1/events", { - method: "POST", - body: JSON.stringify({ outcome: "rate_limited", statusCode: 429 }), - }), - { params: Promise.resolve({ leaseId: "lease-1" }) }, - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true }); - expect(upstream.lastLeaseEvent).toEqual({ - leaseId: "lease-1", - body: { outcome: "rate_limited", statusCode: 429 }, - }); - }); - - test("lists every Stack team plus the personal scope for CLI selection", async () => { - const response = await teamsRoute.GET(request("/api/subrouter/teams")); - const body = await response.json() as { - selectedTeamId: string; - teams: Array<{ id: string; personal: boolean }>; - }; - - expect(response.status).toBe(200); - expect(body.selectedTeamId).toBe("team-a"); - expect(body.teams.map((team) => team.id)).toEqual([ - "team-a", - "team-b", - "user-1", - ]); - expect(body.teams.find((team) => team.id === "user-1")?.personal).toBe(true); - }); - - test("does not substitute the billing team for an absent Stack selection", async () => { - currentUser = { - ...stackUser(), - selectedTeam: null, - listTeams: async () => [ - { id: "team-b", displayName: "Team B" }, - ], - }; - - const response = await teamsRoute.GET(request("/api/subrouter/teams")); - const body = await response.json() as { - selectedTeamId: string | null; - }; - - expect(response.status).toBe(200); - expect(body.selectedTeamId).toBeNull(); - }); - - test("looks up a requested team directly instead of paginating every team", async () => { - const listTeams = mock(async (...args: unknown[]) => { - const options = args[0] as { - readonly cursor?: string; - readonly query?: string; - } | undefined; - if (options?.query === "team-c") { - return Object.assign( - [{ id: "team-c", displayName: "Team C" }], - { nextCursor: "unused-page" }, - ); - } - return Object.assign( - [{ id: "team-b", displayName: "Team B" }], - { nextCursor: "page-2" }, - ); - }); - currentUser = { ...stackUser(), listTeams }; - - const response = await accountsRoute.GET( - request("/api/subrouter/accounts?teamId=team-c"), - ); - - expect(response.status).toBe(200); - expect(listTeams).toHaveBeenCalledTimes(1); - expect(listTeams).toHaveBeenCalledWith({ - query: "team-c", - limit: 100, - }); - }); - - test("follows Stack team pagination before resolving CLI permissions", async () => { - const firstPage = Object.assign( - [{ id: "team-a", displayName: "Team A" }], - { nextCursor: "page-2" }, - ); - const secondPage = Object.assign( - [{ id: "team-c", displayName: "Team C" }], - { nextCursor: null }, - ); - const listTeams = mock(async (...args: unknown[]) => { - const options = args[0] as { readonly cursor?: string } | undefined; - return options?.cursor === "page-2" ? secondPage : firstPage; - }); - currentUser = { ...stackUser(), listTeams }; - - const response = await teamsRoute.GET(request("/api/subrouter/teams")); - const body = await response.json() as { - teams: Array<{ id: string }>; - }; - - expect(response.status).toBe(200); - expect(body.teams.map((team) => team.id)).toEqual([ - "team-a", - "team-c", - "user-1", - ]); - expect(listTeams).toHaveBeenCalledTimes(2); - const listTeamCalls = ( - listTeams as unknown as { mock: { calls: unknown[][] } } - ).mock.calls; - expect(listTeamCalls[1]?.[0]).toMatchObject({ - cursor: "page-2", - limit: 100, - }); - }); - - test("times out Stack team pagination as one authorization operation", async () => { - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; - process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "20"; - let releaseFirstPage!: () => void; - const firstPageReady = new Promise((resolve) => { - releaseFirstPage = resolve; - }); - const listTeams = mock(async (...args: unknown[]) => { - const options = args[0] as { readonly cursor?: string } | undefined; - if (!options?.cursor) { - await firstPageReady; - return Object.assign( - [{ id: "team-a", displayName: "Team A" }], - { nextCursor: "page-2" }, - ); - } - return Object.assign([], { nextCursor: null }); - }); - currentUser = { ...stackUser(), listTeams }; - - const routePromise = teamsRoute.GET(request("/api/subrouter/teams")); - const result = await Promise.race([ - routePromise, - new Promise((resolve) => setTimeout(() => resolve(null), 100)), - ]); - releaseFirstPage(); - await routePromise; - - expect(result?.status).toBe(503); - expect(listTeams).toHaveBeenCalledTimes(1); - }); - - test("bounds timed-out Stack permission work across concurrent requests", async () => { - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; - process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "20"; - let releasePermissions!: () => void; - const permissionsReady = new Promise((resolve) => { - releasePermissions = resolve; - }); - let active = 0; - let maxActive = 0; - let reportPermissionsDrained!: () => void; - const permissionsDrained = new Promise((resolve) => { - reportPermissionsDrained = resolve; - }); - const listPermissions = mock(async () => { - active += 1; - maxActive = Math.max(maxActive, active); - await permissionsReady; - active -= 1; - if (active === 0) reportPermissionsDrained(); - return [{ id: "subrouter:use" }]; - }); - currentUser = { - ...stackUser(), - listPermissions, - }; - - const routes = Array.from( - { length: 12 }, - () => teamsRoute.GET(request("/api/subrouter/teams")), - ); - const responses = await Promise.all(routes); - releasePermissions(); - await permissionsDrained; - - expect(responses.every((response) => response.status === 503)).toBe(true); - expect(maxActive).toBeLessThanOrEqual(8); - }); - - test("opens a bounded circuit after every Stack authorization slot hangs", async () => { - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; - process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "20"; - let releasePermissions!: () => void; - const permissionsReady = new Promise((resolve) => { - releasePermissions = resolve; - }); - const listPermissions = mock(async () => { - await permissionsReady; - return [{ id: "subrouter:use" }]; - }); - currentUser = { - ...stackUser(), - listPermissions, - }; - - const saturated = Array.from( - { length: 8 }, - () => teamsRoute.GET(request("/api/subrouter/teams")), - ); - await Promise.all(saturated); - expect(listPermissions).toHaveBeenCalledTimes(8); - - process.env.SUBROUTER_STACK_AUTH_TIMEOUT_MS = "1000"; - const probe = teamsRoute.GET(request("/api/subrouter/teams")); - const result = await Promise.race([ - probe, - new Promise((resolve) => setTimeout(() => resolve(null), 100)), - ]); - - releasePermissions(); - await probe; - expect(result?.status).toBe(503); - expect(listPermissions).toHaveBeenCalledTimes(8); - }); - - test("resolves one permission snapshot per scope with bounded concurrency", async () => { - process.env.SUBROUTER_ENFORCE_STACK_PERMISSIONS = "1"; - process.env.SUBROUTER_ALLOWED_TEAM_IDS = "*"; - const teams = Array.from({ length: 12 }, (_, index) => ({ - id: `team-${index}`, - displayName: `Team ${index}`, - })); - let active = 0; - let maxActive = 0; - let releasePermissions!: () => void; - const permissionsReleased = new Promise((resolve) => { - releasePermissions = resolve; - }); - let reportOverlap!: () => void; - const overlapObserved = new Promise((resolve) => { - reportOverlap = resolve; - }); - const listPermissions = mock(async () => { - active += 1; - maxActive = Math.max(maxActive, active); - if (active >= 2) reportOverlap(); - await permissionsReleased; - active -= 1; - return [ - { id: "subrouter:use" }, - { id: "subrouter:manage_accounts" }, - ]; - }); - const hasPermission = mock(async () => { - throw new Error("hasPermission performs a duplicate permission request"); - }); - currentUser = { - ...stackUser(), - selectedTeam: teams[0], - listTeams: async () => teams, - listPermissions, - hasPermission, - }; - - const responsePromise = teamsRoute.GET(request("/api/subrouter/teams")); - await overlapObserved; - releasePermissions(); - const response = await responsePromise; - const body = await response.json() as { - teams: Array<{ id: string }>; - }; - - expect(response.status).toBe(200); - expect(body.teams).toHaveLength(teams.length + 1); - expect(listPermissions).toHaveBeenCalledTimes(teams.length + 1); - expect(hasPermission).not.toHaveBeenCalled(); - expect(maxActive).toBeGreaterThan(1); - expect(maxActive).toBeLessThanOrEqual(8); - }); -}); - -type TestRequestInit = RequestInit & { - readonly auth?: "bearer" | "cookie"; -}; - -function request(path: string, init: TestRequestInit = {}): Request { - const headers = new Headers(init.headers); - if (!headers.has("content-type")) headers.set("content-type", "application/json"); - if (init.auth !== "cookie") { - headers.set("authorization", "Bearer access-token"); - headers.set("x-stack-refresh-token", "refresh-token"); - } - return new Request(`https://cmux.test${path}`, { - method: init.method ?? "GET", - headers, - body: init.body, - }); -} - -async function textWithoutTenantKeys(response: Response): Promise { - const text = await response.text(); - expect(text).not.toContain("srt_"); - return text; -} - -function stackUser() { - return { - id: "user-1", - displayName: "User One", - primaryEmail: "user@example.com", - selectedTeam: { id: "team-a", displayName: "Team A" }, - listTeams: async () => [ - { id: "team-a", displayName: "Team A" }, - { id: "team-b", displayName: "Team B" }, - ], - }; -} - -function createMockSubrouter() { - const state = { - adminCreates: 0, - tenantListCalls: 0, - accounts: [] as Array>, - deletedAccountIds: [] as string[], - lastCreateAccountUrl: null as URL | null, - lastCreateAccountBody: null as unknown, - lastLeaseBody: null as unknown, - lastLeaseEvent: null as { - leaseId: string; - body: unknown; - } | null, - lastRepairAccount: null as { - accountId: string; - validate: string | null; - adopt: string | null; - body: unknown; - } | null, - fetch: undefined as unknown as ReturnType, - }; - - state.fetch = mock(async (...args: unknown[]): Promise => { - const input = args[0] as RequestInfo | URL; - const init = args[1] as RequestInit | undefined; - const url = new URL(String(input)); - const method = init?.method ?? "GET"; - const authorization = new Headers(init?.headers).get("authorization") ?? ""; - - if (url.pathname === "/admin/tenants" && method === "POST") { - expect(authorization).toBe("Bearer admin-test-token"); - state.adminCreates += 1; - const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string }; - return jsonResponse({ - id: "tenant-team-a", - name: body.name ?? "Team A", - key: "srt_1234567890abcdef1234567890abcdef", - }); - } - - if (url.pathname === "/tenant/accounts" && method === "GET") { - expect(authorization).toBe("Bearer srt_1234567890abcdef1234567890abcdef"); - state.tenantListCalls += 1; - return jsonResponse({ accounts: state.accounts }); - } - - if (url.pathname === "/tenant/accounts" && method === "POST") { - expect(authorization).toBe("Bearer srt_1234567890abcdef1234567890abcdef"); - state.lastCreateAccountUrl = url; - state.lastCreateAccountBody = JSON.parse(String(init?.body ?? "{}")); - const body = state.lastCreateAccountBody as { provider: string; label?: string }; - const account = { - id: "acct-created", - kind: body.provider, - label: body.label ?? null, - createdAt: "2026-07-02T00:00:00.000Z", - }; - state.accounts.push(account); - return jsonResponse(account); - } - - const repairMatch = url.pathname.match( - /^\/tenant\/accounts\/([^/]+)\/repair$/, - ); - if (repairMatch && method === "POST") { - expect(authorization).toBe("Bearer srt_1234567890abcdef1234567890abcdef"); - state.lastRepairAccount = { - accountId: decodeURIComponent(repairMatch[1]), - validate: url.searchParams.get("validate"), - adopt: url.searchParams.get("adopt"), - body: JSON.parse(String(init?.body ?? "{}")), - }; - return jsonResponse({ - id: state.lastRepairAccount.accountId, - provider: "codex", - auth_mode: "oauth", - label: "Alice", - accessToken: "must-not-leak", - refreshToken: "must-not-leak", - }); - } - - if (url.pathname.startsWith("/tenant/accounts/") && method === "DELETE") { - expect(authorization).toBe("Bearer srt_1234567890abcdef1234567890abcdef"); - state.deletedAccountIds.push(decodeURIComponent(url.pathname.slice("/tenant/accounts/".length))); - return jsonResponse({ ok: true }); - } - - if (url.pathname === "/tenant/leases" && method === "POST") { - expect(authorization).toBe("Bearer srt_1234567890abcdef1234567890abcdef"); - state.lastLeaseBody = JSON.parse(String(init?.body ?? "{}")); - return jsonResponse({ - leaseId: "lease-1", - accountId: "account-1", - provider: "codex", - authMode: "oauth", - token: "leased-access-token", - providerAccountId: "provider-account-1", - label: "Shared Codex", - credentialGeneration: 4, - issuedAt: "2026-07-28T00:00:00.000Z", - expiresAt: "2026-07-28T00:05:00.000Z", - credentialExpiresAt: "2026-07-28T01:00:00.000Z", - refreshToken: "refresh-token-that-must-not-leak", - }); - } - - const leaseEventMatch = url.pathname.match(/^\/tenant\/leases\/([^/]+)\/events$/); - if (leaseEventMatch && method === "POST") { - expect(authorization).toBe("Bearer srt_1234567890abcdef1234567890abcdef"); - state.lastLeaseEvent = { - leaseId: decodeURIComponent(leaseEventMatch[1]), - body: JSON.parse(String(init?.body ?? "{}")), - }; - return jsonResponse({ ok: true }); - } - - return jsonResponse({ error: "not found" }, 404); - }); - - return state; -} - -function seedTenantMapping(db: ReturnType) { - db.rows.push({ - teamId: "team-a", - tenantId: "tenant-team-a", - tenantName: "Team A", - encryptedTenantKey: encryptTenantKey("srt_1234567890abcdef1234567890abcdef", secret), - }); -} - -function createFakeRouteDb() { - const rows: Array<{ - teamId: string; - tenantId: string; - tenantName: string; - encryptedTenantKey: string; - }> = []; - let tail = Promise.resolve(); - - const db = { - rows, - insertCalls: 0, - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => rows.slice(0, 1), - }), - }), - }), - transaction: async (callback: (tx: unknown) => Promise): Promise => { - const run = tail.then(async () => { - const tx = { - execute: async () => [], - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => rows.slice(0, 1), - }), - }), - }), - insert: () => ({ - values: async (row: (typeof rows)[number]) => { - db.insertCalls += 1; - rows.push(row); - }, - }), - }; - return await callback(tx); - }); - tail = run.then(() => undefined, () => undefined); - return await run; - }, - }; - return db; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} diff --git a/web/tests/subrouter-crypto.test.ts b/web/tests/subrouter-crypto.test.ts deleted file mode 100644 index ec4fa858c6aa..000000000000 --- a/web/tests/subrouter-crypto.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - SubrouterTenantKeyDecryptionError, - SubrouterTenantKeySecretError, - decryptTenantKey, - encryptTenantKey, -} from "../services/subrouter/crypto"; - -const secret = Buffer.alloc(32, 7).toString("base64"); - -describe("subrouter tenant key crypto", () => { - test("round-trips a tenant key without storing plaintext", () => { - const tenantKey = "srt_0123456789abcdef0123456789abcdef"; - const encrypted = encryptTenantKey(tenantKey, secret); - - expect(encrypted.startsWith("v1:")).toBe(true); - expect(encrypted).not.toContain(tenantKey); - expect(decryptTenantKey(encrypted, secret)).toBe(tenantKey); - }); - - test("rejects tampered ciphertext", () => { - const encrypted = encryptTenantKey("srt_0123456789abcdef0123456789abcdef", secret); - const parts = encrypted.split(":"); - parts[2] = Buffer.alloc(16, 3).toString("base64"); - - expect(() => decryptTenantKey(parts.join(":"), secret)).toThrow(SubrouterTenantKeyDecryptionError); - }); - - test("rejects a non-256-bit secret", () => { - const shortSecret = Buffer.alloc(31, 1).toString("base64"); - - expect(() => encryptTenantKey("srt_0123456789abcdef0123456789abcdef", shortSecret)).toThrow( - SubrouterTenantKeySecretError, - ); - }); -}); diff --git a/web/tests/subrouter-cutover.test.ts b/web/tests/subrouter-cutover.test.ts new file mode 100644 index 000000000000..ebee1ee931bd --- /dev/null +++ b/web/tests/subrouter-cutover.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; + +import { hostedSubrouterCutoverReadyForTeam } from "../services/subrouter/cutover"; + +describe("hosted Subrouter cutover gate", () => { + test("allows new teams with no legacy mapping", async () => { + await expect( + hostedSubrouterCutoverReadyForTeam("team-new", async () => undefined), + ).resolves.toBe(true); + }); + + test("blocks a legacy mapping until the migration operator marks it ready", async () => { + await expect( + hostedSubrouterCutoverReadyForTeam( + "team-legacy", + async () => ({ hostedReadyAt: null }), + ), + ).resolves.toBe(false); + }); + + test("allows a legacy mapping after its hosted copy is verified", async () => { + await expect( + hostedSubrouterCutoverReadyForTeam( + "team-ready", + async () => ({ hostedReadyAt: new Date("2026-08-03T00:00:00Z") }), + ), + ).resolves.toBe(true); + }); +}); diff --git a/web/tests/subrouter-legacy-migration-script.test.ts b/web/tests/subrouter-legacy-migration-script.test.ts new file mode 100644 index 000000000000..258559b6c41a --- /dev/null +++ b/web/tests/subrouter-legacy-migration-script.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "bun:test"; + +import { + legacySubrouterRetirementConfigForTarget, + runLegacyTenantMigration, +} from "../scripts/subrouter/migrate-legacy-tenants"; + +const mappings = [ + { teamId: "team-b", tenantId: "legacy-b", tenantName: "Team B" }, + { teamId: "team-a", tenantId: "legacy-a", tenantName: "Team A" }, +]; + +describe("legacy Subrouter migration operator", () => { + test("derives the legacy source from the explicit migration target", () => { + expect(legacySubrouterRetirementConfigForTarget("production", { + SUBROUTER_ADMIN_TOKEN: "production-admin", + })).toEqual({ + baseUrl: "https://subrouter.cmux.dev", + adminToken: "production-admin", + }); + expect(legacySubrouterRetirementConfigForTarget("staging", { + VERCEL_ENV: "production", + SUBROUTER_ADMIN_TOKEN: "staging-admin", + })).toEqual({ + baseUrl: "https://subrouter-staging.cmux.dev", + adminToken: "staging-admin", + }); + }); + + test("requires apply before source finalization", async () => { + await expect(runLegacyTenantMigration({ + mappings, + apply: false, + finalizeSource: true, + destinationUrl: "https://sr.cmux.com", + openStackSession: async () => { + throw new Error("unexpected session"); + }, + exchangeHostedTenant: async () => { + throw new Error("unexpected exchange"); + }, + migrateLegacyTenant: async () => { + throw new Error("unexpected migration"); + }, + markFinalizationStarted: async () => { + throw new Error("unexpected finalization marker"); + }, + markHostedReady: async () => { + throw new Error("unexpected readiness mutation"); + }, + log: () => {}, + })).rejects.toThrow("--finalize-source requires --apply"); + }); + + test("dry-run reports database mappings without minting sessions or mutating either service", async () => { + let sessionsOpened = 0; + let exchanges = 0; + let migrations = 0; + let finalizationMarkers = 0; + let readinessMutations = 0; + const logged: unknown[] = []; + + await expect(runLegacyTenantMigration({ + mappings, + apply: false, + finalizeSource: false, + destinationUrl: "https://sr.cmux.com", + openStackSession: async () => { + sessionsOpened += 1; + throw new Error("unexpected session"); + }, + exchangeHostedTenant: async () => { + exchanges += 1; + throw new Error("unexpected exchange"); + }, + migrateLegacyTenant: async () => { + migrations += 1; + throw new Error("unexpected migration"); + }, + markFinalizationStarted: async () => { + finalizationMarkers += 1; + }, + markHostedReady: async () => { + readinessMutations += 1; + }, + log: (value) => logged.push(value), + })).resolves.toEqual({ planned: 2, migrated: 0, sourceFinalized: false }); + + expect(sessionsOpened).toBe(0); + expect(exchanges).toBe(0); + expect(migrations).toBe(0); + expect(finalizationMarkers).toBe(0); + expect(readinessMutations).toBe(0); + expect(logged).toEqual([{ + mode: "dry-run", + destinationUrl: "https://sr.cmux.com", + tenants: [ + { teamId: "team-a", legacyTenantId: "legacy-a" }, + { teamId: "team-b", legacyTenantId: "legacy-b" }, + ], + }]); + }); + + test("pre-copy keeps hosted cutover closed until the source is finalized", async () => { + const readyTeamIds: string[] = []; + + await expect(runLegacyTenantMigration({ + mappings: [mappings[0]!], + apply: true, + finalizeSource: false, + destinationUrl: "https://sr.cmux.com", + openStackSession: async () => ({ + accessToken: "access-secret", + close: async () => {}, + }), + exchangeHostedTenant: async () => ({ + tenantId: "team-b", + tenantKey: "srt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + migrateLegacyTenant: async () => ({ + migrated: 4, + sourceFinalized: false, + }), + markFinalizationStarted: async () => { + throw new Error("pre-copy must not persist a finalization marker"); + }, + markHostedReady: async (teamId) => { + readyTeamIds.push(teamId); + }, + log: () => {}, + })).resolves.toEqual({ planned: 1, migrated: 4, sourceFinalized: false }); + + expect(readyTeamIds).toEqual([]); + }); + + test("persists a resumable finalization before touching the legacy source", async () => { + const operations: string[] = []; + let readinessAttempts = 0; + const migrationOptions = { + mappings: [mappings[0]!], + apply: true, + finalizeSource: true, + destinationUrl: "https://sr.cmux.com", + openStackSession: async () => ({ + accessToken: "access-secret", + close: async () => {}, + }), + exchangeHostedTenant: async () => ({ + tenantId: "team-b", + tenantKey: "srt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + markFinalizationStarted: async () => { + operations.push("finalization-started"); + }, + migrateLegacyTenant: async () => { + operations.push("source-finalized"); + return { migrated: 4, sourceFinalized: true }; + }, + markHostedReady: async () => { + operations.push("hosted-ready"); + readinessAttempts += 1; + if (readinessAttempts === 1) throw new Error("readiness write failed"); + }, + log: () => {}, + }; + + await expect(runLegacyTenantMigration(migrationOptions)).rejects.toThrow( + "readiness write failed", + ); + expect(operations).toEqual([ + "finalization-started", + "source-finalized", + "hosted-ready", + ]); + + await expect(runLegacyTenantMigration(migrationOptions)).resolves.toEqual({ + planned: 1, + migrated: 4, + sourceFinalized: true, + }); + expect(operations.slice(3)).toEqual([ + "finalization-started", + "source-finalized", + "hosted-ready", + ]); + }); + + test("applies mappings by immutable ids and always closes impersonation sessions", async () => { + const openedTeamIds: string[] = []; + const closedTeamIds: string[] = []; + const migrationInputs: unknown[] = []; + const openStackSession = async (mapping: (typeof mappings)[number]) => { + openedTeamIds.push(mapping.teamId); + return { + accessToken: `access-${mapping.teamId}`, + close: async () => { + closedTeamIds.push(mapping.teamId); + }, + }; + }; + const exchangeHostedTenant = async (input: { + readonly teamId: string; + readonly accessToken: string; + }) => ({ + tenantId: input.teamId, + tenantKey: input.teamId === "team-a" + ? "srt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + : "srt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }); + const migrateLegacyTenant = async (input: { + readonly legacyTenantId: string; + readonly finalizeSource: boolean; + }) => { + migrationInputs.push(input); + return { + migrated: input.legacyTenantId === "legacy-a" ? 2 : 4, + sourceFinalized: input.finalizeSource, + }; + }; + const logged: unknown[] = []; + const finalizingTeamIds: string[] = []; + const readyTeamIds: string[] = []; + + await expect(runLegacyTenantMigration({ + mappings, + apply: true, + finalizeSource: true, + destinationUrl: "https://sr.cmux.com", + openStackSession, + exchangeHostedTenant, + migrateLegacyTenant, + markFinalizationStarted: async (teamId) => { + finalizingTeamIds.push(teamId); + }, + markHostedReady: async (teamId) => { + readyTeamIds.push(teamId); + }, + log: (value) => logged.push(value), + })).resolves.toEqual({ planned: 2, migrated: 6, sourceFinalized: true }); + + expect(openedTeamIds).toEqual([ + "team-a", + "team-b", + ]); + expect(migrationInputs[0]).toEqual({ + legacyTenantId: "legacy-a", + destinationUrl: "https://sr.cmux.com", + tenantKey: "srt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + finalizeSource: true, + }); + expect(closedTeamIds).toEqual(["team-a", "team-b"]); + expect(finalizingTeamIds).toEqual(["team-a", "team-b"]); + expect(readyTeamIds).toEqual(["team-a", "team-b"]); + expect(JSON.stringify(logged)).not.toContain("srt_"); + expect(JSON.stringify(logged)).not.toContain("access-"); + }); + + test("closes the current session before stopping after a migration failure", async () => { + let closeCount = 0; + let readinessMutations = 0; + + await expect(runLegacyTenantMigration({ + mappings: [mappings[0]!], + apply: true, + finalizeSource: false, + destinationUrl: "https://sr.cmux.com", + openStackSession: async () => ({ + accessToken: "access-secret", + close: async () => { + closeCount += 1; + }, + }), + exchangeHostedTenant: async () => ({ + tenantId: "team-b", + tenantKey: "srt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + migrateLegacyTenant: async () => { + throw new Error("source migration failed"); + }, + markFinalizationStarted: async () => { + throw new Error("pre-copy must not persist a finalization marker"); + }, + markHostedReady: async () => { + readinessMutations += 1; + }, + log: () => {}, + })).rejects.toThrow("source migration failed"); + + expect(closeCount).toBe(1); + expect(readinessMutations).toBe(0); + }); +}); diff --git a/web/tests/subrouter-tenants.test.ts b/web/tests/subrouter-tenants.test.ts deleted file mode 100644 index 9b6f1db2ab36..000000000000 --- a/web/tests/subrouter-tenants.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; - -import { - getTenantForTeam, - getOrCreateTenantForTeam, -} from "../services/subrouter/tenants"; - -const secret = Buffer.alloc(32, 9).toString("base64"); - -describe("subrouter tenants service", () => { - test("creates one tenant mapping and reuses it on later calls", async () => { - const db = createFakeTenantDb(); - const createTenant = mock(async (input: unknown) => ({ - id: "tenant-1", - name: (input as { name: string }).name, - key: "srt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - })); - const client = { - createTenant, - rotateTenant: mock(), - revokeTenant: mock(), - listAccounts: mock(), - createAccount: mock(), - deleteAccount: mock(), - }; - - const first = await getOrCreateTenantForTeam( - db as never, - "team-a", - "Team A", - { client: client as never, tenantKeySecret: secret }, - ); - const second = await getOrCreateTenantForTeam( - db as never, - "team-a", - "Team A", - { client: client as never, tenantKeySecret: secret }, - ); - - expect(first).toEqual(second); - expect(createTenant).toHaveBeenCalledTimes(1); - expect(db.rows[0].tenantId).toBe("tenant-1"); - expect(db.rows[0].tenantName).toBe("Team A"); - expect(db.rows[0].encryptedTenantKey).not.toContain("srt_"); - }); - - test("lookup returns the existing tenant mapping without provisioning", async () => { - const db = createFakeTenantDb(); - const createTenant = mock(async (input: unknown) => ({ - id: "tenant-1", - name: (input as { name: string }).name, - key: "srt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - })); - const client = { - createTenant, - rotateTenant: mock(), - revokeTenant: mock(), - listAccounts: mock(), - createAccount: mock(), - deleteAccount: mock(), - }; - - const created = await getOrCreateTenantForTeam( - db as never, - "team-a", - "Team A", - { client: client as never, tenantKeySecret: secret }, - ); - const lookedUp = await getTenantForTeam( - db as never, - "team-a", - { tenantKeySecret: secret }, - ); - - expect(lookedUp).toEqual(created); - expect(createTenant).toHaveBeenCalledTimes(1); - expect(db.rows).toHaveLength(1); - }); - - test("lookup returns null without provisioning when no tenant mapping exists", async () => { - const db = createFakeTenantDb(); - - const lookedUp = await getTenantForTeam( - db as never, - "team-a", - { tenantKeySecret: secret }, - ); - - expect(lookedUp).toBeNull(); - expect(db.rows).toHaveLength(0); - }); - - test("revokes the upstream tenant when the mapping insert fails", async () => { - const db = createFakeTenantDb(); - db.insertError = new Error("insert failed"); - const createTenant = mock(async () => ({ - id: "tenant-orphan", - name: "Team A", - key: "srt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - })); - const revokeTenant = mock(async () => {}); - const client = { - createTenant, - rotateTenant: mock(), - revokeTenant, - listAccounts: mock(), - createAccount: mock(), - deleteAccount: mock(), - }; - - await expect( - getOrCreateTenantForTeam( - db as never, - "team-a", - "Team A", - { client: client as never, tenantKeySecret: secret }, - ), - ).rejects.toThrow("insert failed"); - - expect(revokeTenant).toHaveBeenCalledTimes(1); - expect(revokeTenant).toHaveBeenCalledWith("tenant-orphan"); - expect(db.rows).toHaveLength(0); - }); - - test("validates tenant key encryption before provisioning upstream tenants", async () => { - const db = createFakeTenantDb(); - const createTenant = mock(async () => ({ - id: "tenant-orphan", - name: "Team A", - key: "srt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - })); - const revokeTenant = mock(async () => {}); - const client = { - createTenant, - rotateTenant: mock(), - revokeTenant, - listAccounts: mock(), - createAccount: mock(), - deleteAccount: mock(), - }; - - await expect( - getOrCreateTenantForTeam( - db as never, - "team-a", - "Team A", - { client: client as never, tenantKeySecret: "not-a-valid-secret" }, - ), - ).rejects.toThrow("subrouter tenant key secret is invalid"); - - expect(createTenant).not.toHaveBeenCalled(); - expect(revokeTenant).not.toHaveBeenCalled(); - expect(db.rows).toHaveLength(0); - }); -}); - -function createFakeTenantDb() { - const rows: Array<{ - teamId: string; - tenantId: string; - tenantName: string; - encryptedTenantKey: string; - }> = []; - - const db = { - rows, - insertError: null as Error | null, - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => rows.slice(0, 1), - }), - }), - }), - transaction: async (callback: (tx: unknown) => Promise): Promise => { - const tx = { - execute: async () => [], - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => rows.slice(0, 1), - }), - }), - }), - insert: () => ({ - values: async (row: (typeof rows)[number]) => { - if (db.insertError) throw db.insertError; - rows.push(row); - }, - }), - }; - return await callback(tx); - }, - }; - return db; -} diff --git a/web/tests/test-preload.ts b/web/tests/test-preload.ts index ee56be203a73..742fbb04167f 100644 --- a/web/tests/test-preload.ts +++ b/web/tests/test-preload.ts @@ -19,3 +19,5 @@ process.env.STACK_SECRET_SERVER_KEY ??= "stack-secret"; process.env.NEXT_PUBLIC_STACK_PROJECT_ID ??= "00000000-0000-4000-8000-000000000000"; process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ??= "test-publishable-client-key"; process.env.SLACK_ENTERPRISE_WEBHOOK_URL ??= "https://slack.test/enterprise"; +process.env.SUBROUTER_STACK_TENANT_DELETE_TOKEN ??= + "0123456789abcdef0123456789abcdef-test"; diff --git a/web/tests/web-test-runner-isolation.test.ts b/web/tests/web-test-runner-isolation.test.ts index 3f4755fd3944..81ea91909224 100644 --- a/web/tests/web-test-runner-isolation.test.ts +++ b/web/tests/web-test-runner-isolation.test.ts @@ -177,6 +177,35 @@ test("shared web test runner preserves recursive discovery", () => { } }); +test("shared web test runner handles discovery beyond a pipe buffer", () => { + const fixtureRoot = createRunnerFixture(); + try { + const bulkRoot = join(fixtureRoot, "tests", "bulk"); + mkdirSync(bulkRoot, { recursive: true }); + const expectedHeadings: string[] = []; + for (let index = 0; index < 96; index += 1) { + const fileName = `${String(index).padStart(3, "0")}-${"x".repeat(180)}.test.ts`; + writeFileSync(join(bulkRoot, fileName), fixtureTestSource); + expectedHeadings.push(`tests/bulk/${fileName}:`); + } + + const result = runChild( + "bash", + ["scripts/run-tests.sh"], + fixtureRoot, + 20_000, + ); + if (result.status !== 0) { + throw new Error( + `shared web test runner failed large discovery:\n${result.output}`, + ); + } + expectHeadings(result.output, expectedHeadings); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); + test("shared web test runner fails when default discovery finds no tests", () => { const fixtureRoot = createRunnerFixture(); try { @@ -215,6 +244,7 @@ function runChild( command: string, args: string[], cwd: string, + timeoutMs = 300_000, ): { status: number | null; output: string } { const environment = { ...process.env }; // Bun's agent reporter hides passing-file headings, which these discovery @@ -229,7 +259,7 @@ function runChild( env: environment, // This bounds only a non-terminating child; normal completion is asserted // causally below rather than against elapsed time. - timeout: 300_000, + timeout: timeoutMs, killSignal: "SIGKILL", }); const output = [result.stdout, result.stderr].join("\n");