Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
8e9f561
test: cover hosted Subrouter web flows
lawrencecchen Jul 31, 2026
fc68170
feat: use Stack Auth for hosted Subrouter
lawrencecchen Jul 31, 2026
132dbee
test: cover hosted auth fail-closed behavior
lawrencecchen Jul 31, 2026
08be52e
test: preserve hosted account health
lawrencecchen Jul 31, 2026
9703208
test: keep hosted auth mock type-safe
lawrencecchen Jul 31, 2026
1d0af02
fix: harden hosted auth configuration
lawrencecchen Jul 31, 2026
a164c01
test: keep CLI auth errors provider-neutral
lawrencecchen Jul 31, 2026
a6f73a8
fix: use provider-neutral CLI auth errors
lawrencecchen Jul 31, 2026
0cd2f20
test: publish canonical Subrouter hostname
lawrencecchen Aug 1, 2026
6e107e5
fix: publish sr.cmux.com to CLI clients
lawrencecchen Aug 1, 2026
dedd9b6
Merge remote-tracking branch 'origin/main' into feat-subrouter-user-o…
lawrencecchen Aug 1, 2026
f53b673
test: keep CLI auth on issuing origin
lawrencecchen Aug 1, 2026
e7a225e
fix: complete CLI auth on issuing origin
lawrencecchen Aug 1, 2026
d853b84
test: redact hosted account health details
lawrencecchen Aug 1, 2026
50a6bb7
fix: redact hosted account health details
lawrencecchen Aug 1, 2026
876de0f
test: isolate CLI config environment
lawrencecchen Aug 3, 2026
a32ae0a
test: keep tenant credentials out of URLs
lawrencecchen Aug 3, 2026
46eba34
fix: authorize hosted tenant requests by header
lawrencecchen Aug 3, 2026
601d67f
test: require hosted tenant retirement on account deletion
lawrencecchen Aug 3, 2026
ba95379
fix: retire hosted tenants before account deletion
lawrencecchen Aug 3, 2026
f6830ae
test: require trusted tenant retirement credential
lawrencecchen Aug 3, 2026
8843d8f
fix: authenticate hosted tenant retirement service
lawrencecchen Aug 3, 2026
92b1b66
test: configure hosted deletion credential
lawrencecchen Aug 3, 2026
771ee79
test: preserve hosted rollout compatibility
lawrencecchen Aug 3, 2026
774117c
fix: preserve hosted rollout compatibility
lawrencecchen Aug 3, 2026
afc0a1a
test: preserve shipped subrouter clients
lawrencecchen Aug 3, 2026
64afe3c
fix: preserve shipped subrouter clients
lawrencecchen Aug 3, 2026
09848ab
test: protect subrouter credential responses
lawrencecchen Aug 3, 2026
6bb3056
fix: harden subrouter compatibility responses
lawrencecchen Aug 3, 2026
5625278
test: preserve hosted account metadata
lawrencecchen Aug 3, 2026
88b1870
fix: preserve hosted account metadata
lawrencecchen Aug 3, 2026
674cd8c
test: keep hosted deletion retryable
lawrencecchen Aug 3, 2026
8019a00
fix: keep hosted tenant cleanup retryable
lawrencecchen Aug 3, 2026
882e509
test: preserve hosted protocol failures
lawrencecchen Aug 3, 2026
03ef412
fix: preserve hosted protocol semantics
lawrencecchen Aug 3, 2026
590b427
test: require legacy tenant cutover safety
lawrencecchen Aug 3, 2026
0284a98
fix: migrate and retire legacy tenants safely
lawrencecchen Aug 3, 2026
d16c68a
test: bind hosted credentials to deployment config
lawrencecchen Aug 3, 2026
b222603
fix: bind hosted credentials to team config
lawrencecchen Aug 3, 2026
9b30cd0
test: gate hosted tenant cutover errors
lawrencecchen Aug 3, 2026
febe683
fix: gate hosted tenant cutover safely
lawrencecchen Aug 3, 2026
0dd2f00
Merge remote-tracking branch 'origin/main' into feat-subrouter-user-o…
lawrencecchen Aug 3, 2026
2840272
fix: persist hosted cutover readiness
lawrencecchen Aug 3, 2026
bece65d
test: close hosted cutover gaps
lawrencecchen Aug 3, 2026
a44598a
fix: close hosted cutover gaps
lawrencecchen Aug 3, 2026
815e625
test: require resumable tenant finalization
lawrencecchen Aug 3, 2026
35cedac
fix: make tenant finalization resumable
lawrencecchen Aug 3, 2026
4c33226
Merge remote-tracking branch 'origin/main' into feat-subrouter-user-o…
lawrencecchen Aug 3, 2026
3d7d692
test: cover large web test discovery
lawrencecchen Aug 3, 2026
cbc2fd5
fix: avoid web test discovery deadlock
lawrencecchen Aug 3, 2026
ae7dbf1
Merge remote-tracking branch 'origin/main' into feat-subrouter-user-o…
lawrencecchen Aug 3, 2026
8840fa9
ci: pin current GhosttyKit artifact
lawrencecchen Aug 3, 2026
ac0a4bb
test: broker native hosted tenant exchange
lawrencecchen Aug 3, 2026
1471f59
fix: broker scoped hosted tenant credentials
lawrencecchen Aug 4, 2026
b691b0b
style: remove trailing blank lines
lawrencecchen Aug 4, 2026
17c351d
test: require secure exact hosted exchange
lawrencecchen Aug 4, 2026
c3cd71a
fix: validate hosted exchange boundaries
lawrencecchen Aug 4, 2026
4771583
test: preserve dashboard recovery states
lawrencecchen Aug 4, 2026
f71c609
fix: bound dashboard auth recovery
lawrencecchen Aug 4, 2026
c8bc8bd
test: preserve unconfigured service status
lawrencecchen Aug 4, 2026
f83c382
fix: preserve unconfigured service response
lawrencecchen Aug 4, 2026
a773bd2
test: fail closed on hosted cleanup outages
lawrencecchen Aug 4, 2026
bc8ab22
fix: fail closed on hosted cleanup uncertainty
lawrencecchen Aug 4, 2026
c93be06
test: checkpoint hosted tenant deletion
lawrencecchen Aug 4, 2026
51f9031
fix: checkpoint hosted tenant deletion
lawrencecchen Aug 4, 2026
410bb11
test: bound account deletion token refresh
lawrencecchen Aug 4, 2026
27a6039
fix: bound account deletion auth refresh
lawrencecchen Aug 4, 2026
22889ba
test: keep hosted deletion retries visible
lawrencecchen Aug 4, 2026
056fd7d
fix: serialize visible deletion retries
lawrencecchen Aug 4, 2026
fbf4d36
Merge origin/main into feat-subrouter-user-onboarding
lawrencecchen Aug 4, 2026
fc9c6c1
test: pin legacy migration source to target
lawrencecchen Aug 4, 2026
6f5d1a9
fix: bind legacy migration source to target
lawrencecchen Aug 4, 2026
f1a2bfe
test: cover account deletion without hosted Subrouter
lawrencecchen Aug 4, 2026
cd86855
fix: gate hosted cleanup by deployment state
lawrencecchen Aug 4, 2026
d0d8630
test: keep hosted deletion checkpoint owned in flight
lawrencecchen Aug 4, 2026
ab22eda
fix: serialize hosted deletion checkpoint ownership
lawrencecchen Aug 4, 2026
4f4bb61
test: checkpoint bounded legacy tenant retirement
lawrencecchen Aug 4, 2026
3f04cc4
fix: bound legacy tenant retirement during deletion
lawrencecchen Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions web/app/[locale]/dashboard/dashboard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
],
},
{
Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand Down
79 changes: 54 additions & 25 deletions web/app/[locale]/dashboard/subrouter/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" };

Expand Down Expand Up @@ -71,9 +70,15 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa
redirect("/");
}
const requestHeaders = await headers();
let authorized: Awaited<ReturnType<typeof authorizedSubrouterTeams>> | null;
const tokenStore = {
headers: { get: (name: string) => requestHeaders.get(name) },
};
let authenticated: {
readonly authorized: Awaited<ReturnType<typeof authorizedSubrouterTeams>>;
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", {
Expand All @@ -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<ReturnType<ReturnType<typeof getStackServerApp>["getAuthJson"]>>;
try {
authJson = await getStackServerApp().getAuthJson({ tokenStore });
} catch {
throw new SubrouterAuthorizationUnavailableError(
"Stack session refresh unavailable",
);
}
return {
authorized,
accessToken: authJson?.accessToken ?? null,
};
},
);
} catch (error) {
Expand All @@ -101,26 +119,30 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa
</div>
);
}
if (!authorized) {
if (!authenticated) {
redirect(vaultSignInHref(localizedVaultPath(locale, "/dashboard/subrouter")));
}
if (!authenticated.accessToken) {
redirect(vaultSignInHref(localizedVaultPath(locale, "/dashboard/subrouter")));
}

const [tPage, t] = await Promise.all([
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",
Expand Down Expand Up @@ -155,6 +177,8 @@ export default async function SubrouterOverviewPage({ params, searchParams }: Pa

{accountState.kind === "notConfigured" ? (
<StatusPanel title={t("notConfiguredTitle")} body={t("notConfiguredBody")} />
) : accountState.kind === "migrationPending" ? (
<StatusPanel title={t("migrationPendingTitle")} body={t("migrationPendingBody")} />
) : accountState.kind === "error" ? (
<StatusPanel title={t("loadErrorTitle")} body={t("loadErrorBody")} />
) : (
Expand Down Expand Up @@ -262,19 +286,24 @@ function selectTeam(teams: readonly DashboardTeam[], requestedTeamId: string | u
return teams[0];
}

async function loadAccounts(team: DashboardTeam): Promise<AccountState> {
const config = subrouterRuntimeConfig();
if (!config) return { kind: "notConfigured" };

async function loadAccounts(
team: DashboardTeam,
accessToken: string,
): Promise<AccountState> {
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 {
Expand Down
Loading