diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index ef607293..97d469b4 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -38,3 +38,6 @@ jobs: - name: Verify account-service URL configuration run: bash charts/brain-system/tests/account-api-env.sh + + - name: Verify Desktop URL configuration + run: bash charts/brain-system/tests/desktop-api-env.sh diff --git a/CONTEXT.md b/CONTEXT.md index b1764a12..91cc91ed 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,9 +22,15 @@ _Avoid_: Favorite Project, starred Project, recent Project. ### App Sidebar -The persistent left-edge product navigation surface containing product-level navigation, Project navigation (Pinned Projects and all other Projects), and app-level actions. It is outside the Project Canvas and is not a Side Pane or a Project list. It has exactly two user-controlled states, and these are their canonical names: **Expanded** (icons with text labels) and **Collapsed** (an icon rail with tooltips). Before the user has ever changed it, the App Sidebar is Collapsed; thereafter the user's last chosen state is remembered per browser. State changes only by explicit user action and is independent of viewport width. +The persistent left-edge product navigation surface containing product-level navigation, Project navigation (Pinned Projects and all other Projects), and app-level actions. It is outside the Project Canvas and is not a Side Pane or a Project list. It has exactly two user-controlled states, and these are their canonical names: **Expanded** (icons with text labels) and **Collapsed** (an icon rail with tooltips). Before the user has ever changed it, the App Sidebar is Collapsed; thereafter the user's last chosen state is remembered per browser. State changes only by explicit user action and is independent of viewport width. Its brand slot — the Sealos mark at the top — is the App Sidebar's only collapse/expand control: it shows the mark while the pointer is away and becomes the control (collapse when Expanded, expand when Collapsed) while the pointer is over the App Sidebar or the control has focus; there is no separate collapse button. -_Avoid_: Project list, left Side Pane, Project Shortcut (retired term), open/closed sidebar, full/mini sidebar, rail mode. +_Avoid_: Project list, left Side Pane, Project Shortcut (retired term), open/closed sidebar, full/mini sidebar, rail mode, collapse button (as a control apart from the brand slot). + +### Workspace Switcher + +The row under the App Sidebar's brand slot that names the current Workspace — its Workspace avatar, display name, and the plan of its Workspace Subscription (Pay-As-You-Go when it has none) — and the popover that row opens: a card for the current Workspace (avatar, Personal or the user's Workspace Role, plan), the other Workspaces the user belongs to in the current region with their Workspace Role and plan, a create-Workspace row, and the manage row that is the Workspace Area's single entry. The plan is a Workspace fact and shows here, not on the account row; when the Workspace Subscription needs attention (payment-due, cancelling) the row grows a second line carrying that hint. In the Collapsed rail only the avatar remains and still opens the popover. It carries no pending-invitation count and no Sealos wordmark. Choosing another Workspace here is the only action that switches; afterwards the user stays on the same page of the Billing Area or Workspace Area, and lands on the Project list from anywhere inside a Project. + +_Avoid_: team switcher, namespace switcher, workspace dropdown, workspace menu. ### Sealos Desktop Entry @@ -238,12 +244,48 @@ The condition where a domain's observed desired configuration changes to a value ## Authorization & Identity +### Workspace + +The user-visible collaboration boundary on the platform: one Kubernetes `ns-…` namespace and the Desktop "team" it corresponds to, holding Projects, workloads, and a Workspace Subscription. Every user has exactly one **Personal Workspace** — created with the account, never deletable, never transferable — and may own or belong to any number of **Team Workspaces**. A Workspace is identified by its stable uid; its display name is a label users may change. + +_Avoid_: team, namespace (as user-visible words), ns. + +### Workspace Role + +The membership level a Workspace Actor holds in one Workspace: Owner (exactly one per Workspace, the Workspace Owner), Manager, or Developer. It comes from the platform's membership record for that Workspace, not from the subscription record's role field, and a user's role differs per Workspace. + +_Avoid_: permission level, team role, subscription role. + +### Workspace Area + +The product area under the `/workspace` URL prefix where users manage Workspaces themselves — display name, members and their Workspace Roles, invitations, ownership, and deletion. It is entered from a single entry, the manage row in the App Sidebar's Workspace switcher, and presented as one surface: a list of every Workspace the user belongs to beside the detail of the Managed Workspace. Subscription and cost are not its business; those belong to the Billing Area — and so does Workspace Creation, which the list's create row merely opens. + +_Avoid_: team center, workspace settings, members page, manage dialog. + +### Managed Workspace + +The Workspace whose detail the Workspace Area is showing and operating on, chosen from the area's list or named in its URL and defaulting to the current Workspace. It is a selection local to the Workspace Area: changing it never switches the current Workspace that the rest of Brain works in, so a user can manage — or delete — a Workspace they are not currently in. + +_Avoid_: selected workspace, current Workspace (for the one being managed), target workspace. + +### Workspace Invite Link + +The only way a member joins a Team Workspace: a link an Owner or Manager generates in the Workspace Area for one Workspace Role, which the invitee opens and accepts on the Sealos Desktop. It is short-lived, and generating another for the same Workspace and role replaces it. Accepting adds the invitee to the Workspace at once; there is no pending state, so a Workspace's member list never shows someone who has not yet joined, and Brain holds no inbox of invitations awaiting the user. + +_Avoid_: invitation (as a pending object), invite by user ID, pending invite, invite request. + ### Workspace Actor The verified human identity acting within a workspace namespace, established by cross-checking the request kubeconfig's live workspace access against the desktop-minted proof binding it to the global user id. Actor verification and namespace authorization are separate checks: one establishes who is acting, the other where that actor may act. A Desktop session user id, an unverified app-token claim, or a namespace-authorized workload ServiceAccount is not a Workspace Actor. _Avoid_: Desktop user id, namespace member id. +### Brain Session + +The set of Desktop-issued credentials Brain holds in one browser tab — the regional token, the app token, and the kubeconfig — exchanged from Desktop's shared login cookie and kept only in page memory; Brain never persists it. The Workspace it points at is Desktop's current Workspace, which Brain follows rather than remembers. + +_Avoid_: login, Desktop session, SDK session, token (unqualified). + ## Deployment ### Deployment Task @@ -664,10 +706,16 @@ Account-level money and workspace subscriptions, owned by the platform's account ### Billing Area -The product area under the `/billing` URL prefix where users manage the current workspace's Workspace Subscription and inspect costs, usage quota, and pricing. It is entered from a single entry — the Billing row in the App Sidebar's account popover — and presented as one surface with Plan, Costs, Usage, and Pricing tabs; the Plan view is the area's index and the landing point of a Stripe Checkout Round-Trip. +The product area under the `/billing` URL prefix where users manage the current workspace's Workspace Subscription and inspect costs, usage quota, and pricing, and where Workspace Creation happens. It is entered from the Billing row in the App Sidebar's account popover, or in creation mode from a create row (Workspace Switcher popover, Workspace Area list), and presented as one surface with Plan, Costs, Usage, and Pricing tabs; the Plan view is the area's index and the landing point of a Stripe Checkout Round-Trip. _Avoid_: cost center, billing app, separate billing pages. +### Workspace Creation + +Bringing a new Team Workspace into being from Brain: the user names it and chooses its initial Subscription Plan in one step, in the Billing Area's creation mode, and pays through a Stripe Checkout Round-Trip that lands in the new Workspace. A Workspace is created the moment its name and plan are submitted — before payment — so an abandoned payment leaves a Workspace that exists without a Workspace Subscription; the platform reports it as Pay-As-You-Go and it subscribes like any other. Creation is open to every signed-in user and is never gated by the current Workspace's subscription state. Brain creates no Pay-As-You-Go Workspace: a plan is always chosen, which is why creation lives beside subscription rather than in the Workspace Area. + +_Avoid_: new team, add workspace, create mode (as the name of the concept), PAYG workspace creation. + ### Billing Region One entry in the platform's global region catalog served by account-service: a cluster identified durably by an opaque uid and addressably by a unique domain. account-service stores each Workspace Subscription under the workspace plus the Billing Region's domain, so every subscription query and payment action is region-addressed. The catalog's order carries no meaning — no position in it designates any particular region. diff --git a/apps/ui/.env.example b/apps/ui/.env.example index 3740001d..ba336452 100644 --- a/apps/ui/.env.example +++ b/apps/ui/.env.example @@ -1,5 +1,4 @@ API_URL= -NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG= # Optional canonical app origin for GitHub App install callbacks. Leave empty # to derive it from request headers at runtime. APP_URL= @@ -34,12 +33,22 @@ BILLING_LOCAL_REGION_DOMAIN= BILLING_GPU_ENABLED=false # App-token verification for personal-resource routes (ADR-0059). Required in -# production (startup fails fast without it). For local dev pick any secret, -# then mint a matching token for the dev kubeconfig with -# bun scripts/mint-dev-app-token.mjs -# and paste it into NEXT_PUBLIC_DEV_APP_TOKEN. +# production (startup fails fast without it). The Brain Session (ADR-0083) +# hands the page the app token that Desktop mints, so local development +# needs the real JWT_INTERNAL of the Desktop DESKTOP_API_BASE_URL points at +# (staging), not a made-up secret. JWT_INTERNAL= -NEXT_PUBLIC_DEV_APP_TOKEN= + +# Desktop upstream for the Brain Session (ADR-0083): POST /api/session +# exchanges the shared login cookie here. The Helm chart derives the +# in-cluster Service URL when its value is empty; local development points +# it at a staging Desktop, e.g. https://. +DESKTOP_API_BASE_URL= +# Local development only: stands in for the shared login cookie when the +# request carries none. Copy the `sealos_auth_token` cookie value from a +# browser signed in to the staging Desktop above. Ignored by production +# builds. +DEV_GLOBAL_TOKEN= # Platform-funded Chat Agent connection. Eligible Active Free Trial workspaces # use it for FREE_CHAT_TURNS successful turns, then use the caller's AI Proxy. @@ -54,6 +63,9 @@ ASSISTANT_GATEWAY_MODEL= DEVBOX_API_BASE_URL= TEMPLATE_PROVIDER_URL= +# Only for `bun scripts/devbox-api-smoke.mjs`: a URL-encoded kubeconfig the +# smoke script calls the Devbox API with directly (not the Brain Session). +DEVBOX_SMOKE_ENCODED_KUBECONFIG= DEVBOX_JWT_SIGNING_KEY= # Required for managed Skills: immutable sandbox/v1 image built with the offline Skill bundle. diff --git a/apps/ui/scripts/devbox-api-smoke.mjs b/apps/ui/scripts/devbox-api-smoke.mjs index 6a6c3c69..089e303b 100644 --- a/apps/ui/scripts/devbox-api-smoke.mjs +++ b/apps/ui/scripts/devbox-api-smoke.mjs @@ -25,8 +25,10 @@ function namespaceFromKubeconfig(kubeconfig) { } const env = readDotenv(".env"); +// This smoke script talks to the Devbox API directly with a kubeconfig of +// its own; it is not on the Brain Session path. const kubeconfig = decodeURIComponent( - env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG || "" + env.DEVBOX_SMOKE_ENCODED_KUBECONFIG || "" ); const namespace = namespaceFromKubeconfig(kubeconfig); const devboxApiBaseUrl = (env.DEVBOX_API_BASE_URL || "").replace(/\/+$/, ""); diff --git a/apps/ui/scripts/mint-dev-app-token.mjs b/apps/ui/scripts/mint-dev-app-token.mjs deleted file mode 100644 index 8fa1c0e5..00000000 --- a/apps/ui/scripts/mint-dev-app-token.mjs +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bun -/** - * Mints a local-dev App Token (ADR-0059) for the dev kubeconfig's crName, - * signed with the dev `JWT_INTERNAL`. The production verifier has zero - * development branches, so dev traffic needs a genuinely signed token; this - * script is how it gets minted. - * - * Usage (from apps/ui; Bun auto-loads .env.local): - * bun scripts/mint-dev-app-token.mjs - * - * Prints the token on stdout; paste it into NEXT_PUBLIC_DEV_APP_TOKEN in - * apps/ui/.env.local. Requires JWT_INTERNAL and - * NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG in the environment. - */ -import { createHash } from "node:crypto"; -import { SignJWT } from "jose"; -import { parse } from "yaml"; - -function fail(message) { - console.error(`mint-dev-app-token: ${message}`); - process.exit(1); -} - -function requiredEnv(name) { - const value = (process.env[name] ?? "").trim(); - if (value === "") { - fail(`${name} is required (set it in apps/ui/.env.local).`); - } - return value; -} - -function decodedKubeconfig(raw) { - try { - return decodeURIComponent(raw); - } catch { - return raw; - } -} - -/** The active kubeconfig user's ServiceAccount crName, as the server derives it. */ -function crNameFromKubeconfig(kubeconfigText) { - let cfg; - try { - cfg = parse(kubeconfigText); - } catch { - fail("NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG is not valid kubeconfig YAML."); - } - const context = cfg?.contexts?.find( - (entry) => entry.name === cfg?.["current-context"] - )?.context; - const token = cfg?.users - ?.find((entry) => entry.name === context?.user) - ?.user?.token?.trim(); - if (!token) { - fail("The dev kubeconfig has no active user bearer token."); - } - let subject; - try { - subject = JSON.parse( - Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8") - ).sub; - } catch { - fail("The dev kubeconfig bearer token is not a JWT."); - } - const prefix = "system:serviceaccount:user-system:"; - if (typeof subject !== "string" || !subject.startsWith(prefix)) { - fail( - `The dev kubeconfig subject is not a user-system ServiceAccount: ${subject}` - ); - } - return subject.slice(prefix.length); -} - -/** Stable dev-only userUid: a UUID-shaped digest of the crName. */ -function devUserUid(crName) { - const hex = createHash("sha256") - .update(`dev-app-token:${crName}`) - .digest("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`; -} - -const secret = requiredEnv("JWT_INTERNAL"); -const kubeconfigText = decodedKubeconfig( - requiredEnv("NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG") -); - -const crName = crNameFromKubeconfig(kubeconfigText); -const userUid = - (process.env.DEV_APP_TOKEN_USER_UID ?? "").trim() || devUserUid(crName); -// account-service resolves some endpoints (e.g. /account) by the `User.id` -// column, not the uid, so the claim must be overridable independently. -const userId = (process.env.DEV_APP_TOKEN_USER_ID ?? "").trim() || userUid; - -const token = await new SignJWT({ - userCrName: crName, - userId, - userUid, -}) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime("7d") - .sign(new TextEncoder().encode(secret)); - -console.error("Minted dev App Token."); -console.error( - "Paste it into apps/ui/.env.local as NEXT_PUBLIC_DEV_APP_TOKEN:\n" -); -console.log(token); diff --git a/apps/ui/src/app/api/billing/workspace-create/retry-payment/route.ts b/apps/ui/src/app/api/billing/workspace-create/retry-payment/route.ts new file mode 100644 index 00000000..4995dfe8 --- /dev/null +++ b/apps/ui/src/app/api/billing/workspace-create/retry-payment/route.ts @@ -0,0 +1,16 @@ +import { BILLING_ROUTES } from "@/features/billing/server/billing-route-table"; +import { withBillingDevMock } from "@/features/billing/server/create-billing-route"; +import { createBillingWorkspaceCreateRetryPaymentHandler } from "@/features/billing/server/workspace-creation-handlers"; +import { requestAccountService } from "@/lib/account-service/client"; +import { authorizeWorkspaceActor } from "@/lib/request-kubeconfig-auth"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withBillingDevMock( + BILLING_ROUTES.workspaceCreateRetryPayment, + createBillingWorkspaceCreateRetryPaymentHandler({ + authorizeWorkspaceActor, + requestAccountService, + }) +); diff --git a/apps/ui/src/app/api/billing/workspace-create/route.ts b/apps/ui/src/app/api/billing/workspace-create/route.ts new file mode 100644 index 00000000..f48c0039 --- /dev/null +++ b/apps/ui/src/app/api/billing/workspace-create/route.ts @@ -0,0 +1,16 @@ +import { BILLING_ROUTES } from "@/features/billing/server/billing-route-table"; +import { withBillingDevMock } from "@/features/billing/server/create-billing-route"; +import { createBillingWorkspaceCreateHandler } from "@/features/billing/server/workspace-creation-handlers"; +import { requestAccountService } from "@/lib/account-service/client"; +import { authorizeWorkspaceActor } from "@/lib/request-kubeconfig-auth"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withBillingDevMock( + BILLING_ROUTES.workspaceCreate, + createBillingWorkspaceCreateHandler({ + authorizeWorkspaceActor, + requestAccountService, + }) +); diff --git a/apps/ui/src/app/api/billing/workspace-plans/handler.test.ts b/apps/ui/src/app/api/billing/workspace-plans/handler.test.ts new file mode 100644 index 00000000..20a148b9 --- /dev/null +++ b/apps/ui/src/app/api/billing/workspace-plans/handler.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AccountServiceRequest } from "@/lib/account-service/client-core"; + +import { createWorkspacePlansHandler } from "./handler"; + +const ACTOR_OK = () => + Promise.resolve({ + actorBinding: { + crName: "alice-cr", + mintedAt: 1_753_600_000, + userId: "user-alice", + userUid: "uid-alice", + }, + namespace: "ns-alice", + ok: true as const, + workspaceActor: "alice-cr", + }); + +function plansRequest(workspaces: string[]) { + const url = new URL("https://brain.example.test/api/billing/workspace-plans"); + for (const workspace of workspaces) { + url.searchParams.append("workspace", workspace); + } + return new Request(url, { + headers: { + Authorization: "Bearer encoded-kubeconfig", + "X-Sealos-App-Token": "desktop-app-token", + }, + }); +} + +function subscriptionAnswers( + answers: Record +): (request: AccountServiceRequest) => Promise { + return (request) => { + const body = JSON.parse(String(request.init?.body)) as { + workspace: string; + }; + const answer = answers[body.workspace]; + if (answer instanceof Response) { + return Promise.resolve(answer); + } + return Promise.resolve(Response.json({ subscription: answer })); + }; +} + +test("reads each requested Workspace's plan name as the verified actor; no subscription reads as null", async () => { + const calls: AccountServiceRequest[] = []; + const handler = createWorkspacePlansHandler({ + authorizeWorkspaceActor: ACTOR_OK, + regionDomain: () => "region.test", + requestAccountService: (request) => { + calls.push(request); + return subscriptionAnswers({ + "ns-alice": { PlanName: "Pro", Status: "NORMAL", type: "SUBSCRIPTION" }, + "ns-deleted": { + PlanName: "Hobby", + Status: "DELETED", + type: "SUBSCRIPTION", + }, + "ns-payg": { type: "PAYG" }, + })(request); + }, + }); + + const response = await handler( + plansRequest(["ns-alice", "ns-payg", "ns-deleted"]) + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + plans: { "ns-alice": "Pro", "ns-deleted": null, "ns-payg": null }, + }); + assert.deepEqual( + calls.map((call) => [ + call.pathname, + call.actor, + JSON.parse(String(call.init?.body)), + ]), + ["ns-alice", "ns-payg", "ns-deleted"].map((workspace) => [ + "/account/v1alpha1/workspace-subscription/info", + { userId: "user-alice", userUid: "uid-alice" }, + { regionDomain: "region.test", workspace }, + ]) + ); +}); + +test("a Workspace whose read is refused or fails answers null alone", async () => { + const handler = createWorkspacePlansHandler({ + authorizeWorkspaceActor: ACTOR_OK, + regionDomain: () => "region.test", + requestAccountService: subscriptionAnswers({ + "ns-alice": { PlanName: "Pro", type: "SUBSCRIPTION" }, + // account-service answers 401 for a non-member's read. + "ns-other": Response.json({ error: "no permission" }, { status: 401 }), + }), + }); + const response = await handler(plansRequest(["ns-alice", "ns-other"])); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + plans: { "ns-alice": "Pro", "ns-other": null }, + }); +}); + +test("answers 400 without a Workspace to read and 401 without a proven binding", async () => { + const handler = createWorkspacePlansHandler({ + authorizeWorkspaceActor: ACTOR_OK, + regionDomain: () => "region.test", + requestAccountService: () => { + throw new Error("must not read"); + }, + }); + assert.equal((await handler(plansRequest([]))).status, 400); + + const unauthorized = createWorkspacePlansHandler({ + authorizeWorkspaceActor: () => + Promise.resolve({ + code: "app_token_required", + message: "Authentication is required.", + ok: false, + status: 401, + }), + regionDomain: () => "region.test", + requestAccountService: () => { + throw new Error("must not read"); + }, + }); + assert.equal((await unauthorized(plansRequest(["ns-alice"]))).status, 401); +}); + +test("answers 503 when the region domain is not configured", async () => { + const handler = createWorkspacePlansHandler({ + authorizeWorkspaceActor: ACTOR_OK, + regionDomain: () => "", + requestAccountService: () => { + throw new Error("must not read"); + }, + }); + assert.equal((await handler(plansRequest(["ns-alice"]))).status, 503); +}); diff --git a/apps/ui/src/app/api/billing/workspace-plans/handler.ts b/apps/ui/src/app/api/billing/workspace-plans/handler.ts new file mode 100644 index 00000000..59bb9e5a --- /dev/null +++ b/apps/ui/src/app/api/billing/workspace-plans/handler.ts @@ -0,0 +1,103 @@ +import { + type AuthorizeWorkspaceActor, + authorizeBillingActor, +} from "@/features/billing/server/authorized-proxy"; +import { BILLING_JUDGMENT_TIMEOUT_MS } from "@/features/billing/server/judgment-budget"; +import { workspacePlanNameFromSubscription } from "@/features/billing/workspace-plan-name"; +import type { AccountServiceClient } from "@/lib/account-service/client-core"; + +/** + * `GET /api/billing/workspace-plans?workspace=…&workspace=…` (spec §C.5): + * the plan name of each named Workspace's subscription, read one by one + * from account-service's `workspace-subscription/info` as the verified + * Workspace Actor (ADR-0060's self-signed JWT). No subscription, a deleted + * one, or a refused or failed read → null for that row (the Switcher shows + * PAYG for null); a row never fails the route. + * + * Verified against account-service (`authenticateWorkspaceSubscriptionRequest` + * with `isOwner=false`): any member of a Workspace may read its subscription + * info, so non-Owner rows carry their real plan name; a Workspace the actor + * is not a member of answers 401 and lands as null. + */ + +const SUBSCRIPTION_INFO_PATHNAME = + "/account/v1alpha1/workspace-subscription/info"; +/** + * One read per Workspace fans out to account-service; the cap keeps a + * pathological list from turning one Switcher open into hundreds of + * upstream calls. Desktop's own Workspace limit sits far below it. + */ +const MAX_WORKSPACES_PER_READ = 50; + +export interface WorkspacePlansHandlerDependencies { + authorizeWorkspaceActor: AuthorizeWorkspaceActor; + /** The cluster's region domain (`BILLING_LOCAL_REGION_DOMAIN`). */ + regionDomain: () => string; + requestAccountService: AccountServiceClient; +} + +function requestedWorkspaces(request: Request): string[] { + const workspaces = new URL(request.url).searchParams + .getAll("workspace") + .map((workspace) => workspace.trim()) + .filter((workspace) => workspace !== ""); + return [...new Set(workspaces)].slice(0, MAX_WORKSPACES_PER_READ); +} + +export function createWorkspacePlansHandler( + dependencies: WorkspacePlansHandlerDependencies +) { + return async function handler(request: Request): Promise { + const actor = await authorizeBillingActor( + request, + dependencies.authorizeWorkspaceActor + ); + if (!actor.ok) { + return actor.response; + } + const workspaces = requestedWorkspaces(request); + if (workspaces.length === 0) { + return Response.json( + { error: "At least one workspace is required." }, + { status: 400 } + ); + } + const regionDomain = dependencies.regionDomain().trim(); + if (regionDomain === "") { + return Response.json( + { error: "Billing region is not configured." }, + { status: 503 } + ); + } + const signal = AbortSignal.timeout(BILLING_JUDGMENT_TIMEOUT_MS); + const readPlan = async (workspace: string): Promise => { + try { + const response = await dependencies.requestAccountService({ + actor: { userId: actor.userId, userUid: actor.userUid }, + init: { + body: JSON.stringify({ regionDomain, workspace }), + method: "POST", + signal, + }, + pathname: SUBSCRIPTION_INFO_PATHNAME, + }); + if (!response.ok) { + await response.body?.cancel(); + return null; + } + return workspacePlanNameFromSubscription(await response.json()); + } catch { + return null; + } + }; + const names = await Promise.all(workspaces.map(readPlan)); + const plans: Record = {}; + workspaces.forEach((workspace, index) => { + plans[workspace] = names[index] ?? null; + }); + return Response.json( + { plans }, + { headers: { "cache-control": "no-store" } } + ); + }; +} diff --git a/apps/ui/src/app/api/billing/workspace-plans/route.ts b/apps/ui/src/app/api/billing/workspace-plans/route.ts new file mode 100644 index 00000000..dac07d71 --- /dev/null +++ b/apps/ui/src/app/api/billing/workspace-plans/route.ts @@ -0,0 +1,17 @@ +import { BILLING_ROUTES } from "@/features/billing/server/billing-route-table"; +import { withBillingDevMock } from "@/features/billing/server/create-billing-route"; +import { requestAccountService } from "@/lib/account-service/client"; +import { authorizeWorkspaceActor } from "@/lib/request-kubeconfig-auth"; +import { createWorkspacePlansHandler } from "./handler"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const GET = withBillingDevMock( + BILLING_ROUTES.workspacePlans, + createWorkspacePlansHandler({ + authorizeWorkspaceActor, + regionDomain: () => process.env.BILLING_LOCAL_REGION_DOMAIN ?? "", + requestAccountService, + }) +); diff --git a/apps/ui/src/app/api/project-canvas/layout/route.ts b/apps/ui/src/app/api/project-canvas/layout/route.ts index 78dc893e..542d2ce2 100644 --- a/apps/ui/src/app/api/project-canvas/layout/route.ts +++ b/apps/ui/src/app/api/project-canvas/layout/route.ts @@ -12,7 +12,6 @@ import { patchProjectCanvasLayout, } from "@/features/project-canvas/layout/repository"; import { authorizeRequestNamespace } from "@/lib/request-kubeconfig-auth"; -import { hasDevCredentialBypass } from "@/lib/server-credentials"; export const runtime = "nodejs"; @@ -24,10 +23,6 @@ async function authorizeNamespace( request: Request, namespace: string ): Promise { - if (hasDevCredentialBypass()) { - return null; - } - const authorization = await authorizeRequestNamespace(request, { namespace, subject: "Canvas layout", diff --git a/apps/ui/src/app/api/project-navigation-preferences/route.ts b/apps/ui/src/app/api/project-navigation-preferences/route.ts index dadf4682..7f27c159 100644 --- a/apps/ui/src/app/api/project-navigation-preferences/route.ts +++ b/apps/ui/src/app/api/project-navigation-preferences/route.ts @@ -7,7 +7,6 @@ import { updateProjectNavigationPreferences, } from "@/lib/project-persistence/navigation-preferences"; import { authorizeRequestNamespace } from "@/lib/request-kubeconfig-auth"; -import { hasDevCredentialBypass } from "@/lib/server-credentials"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -27,10 +26,6 @@ async function authorizeNamespace( request: Request, namespace: string ): Promise { - if (hasDevCredentialBypass()) { - return null; - } - const authorization = await authorizeRequestNamespace(request, { namespace, subject: "Project navigation preferences", diff --git a/apps/ui/src/app/api/projects/route.ts b/apps/ui/src/app/api/projects/route.ts index fd74389e..4df39707 100644 --- a/apps/ui/src/app/api/projects/route.ts +++ b/apps/ui/src/app/api/projects/route.ts @@ -14,7 +14,6 @@ import { updateProject, } from "@/lib/project-persistence/projects"; import { authorizeRequestNamespace } from "@/lib/request-kubeconfig-auth"; -import { hasDevCredentialBypass } from "@/lib/server-credentials"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -48,10 +47,6 @@ async function authorizeNamespace( | { denied: null; encodedKubeconfig: string } | { denied: Response; encodedKubeconfig?: never } > { - if (hasDevCredentialBypass()) { - return { denied: null, encodedKubeconfig: "" }; - } - const authorization = await authorizeRequestNamespace(request, { namespace, subject: "Project", diff --git a/apps/ui/src/app/api/session/route.ts b/apps/ui/src/app/api/session/route.ts new file mode 100644 index 00000000..43c5ba25 --- /dev/null +++ b/apps/ui/src/app/api/session/route.ts @@ -0,0 +1,7 @@ +import { withSessionDevMock } from "@/features/session/server/create-session-route"; +import { createSessionHandler } from "@/features/session/server/session-handler"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withSessionDevMock(createSessionHandler()); diff --git a/apps/ui/src/app/api/workspace/delete/route.ts b/apps/ui/src/app/api/workspace/delete/route.ts new file mode 100644 index 00000000..a7ae9447 --- /dev/null +++ b/apps/ui/src/app/api/workspace/delete/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceDeleteHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.delete, + createWorkspaceDeleteHandler() +); diff --git a/apps/ui/src/app/api/workspace/details/route.ts b/apps/ui/src/app/api/workspace/details/route.ts new file mode 100644 index 00000000..d335cf14 --- /dev/null +++ b/apps/ui/src/app/api/workspace/details/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { createWorkspaceDetailsHandler } from "@/features/workspace/server/workspace-details-handler"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.details, + createWorkspaceDetailsHandler() +); diff --git a/apps/ui/src/app/api/workspace/invite-link/route.ts b/apps/ui/src/app/api/workspace/invite-link/route.ts new file mode 100644 index 00000000..4c522f80 --- /dev/null +++ b/apps/ui/src/app/api/workspace/invite-link/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceInviteLinkHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.inviteLink, + createWorkspaceInviteLinkHandler() +); diff --git a/apps/ui/src/app/api/workspace/list/route.ts b/apps/ui/src/app/api/workspace/list/route.ts new file mode 100644 index 00000000..cab3f75a --- /dev/null +++ b/apps/ui/src/app/api/workspace/list/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { createWorkspaceListHandler } from "@/features/workspace/server/workspace-list-handler"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const GET = withWorkspaceDevMock( + WORKSPACE_ROUTES.list, + createWorkspaceListHandler() +); diff --git a/apps/ui/src/app/api/workspace/member/alias/route.ts b/apps/ui/src/app/api/workspace/member/alias/route.ts new file mode 100644 index 00000000..aa367419 --- /dev/null +++ b/apps/ui/src/app/api/workspace/member/alias/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceMemberAliasHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.memberAlias, + createWorkspaceMemberAliasHandler() +); diff --git a/apps/ui/src/app/api/workspace/member/remove/route.ts b/apps/ui/src/app/api/workspace/member/remove/route.ts new file mode 100644 index 00000000..171b7465 --- /dev/null +++ b/apps/ui/src/app/api/workspace/member/remove/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceMemberRemoveHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.memberRemove, + createWorkspaceMemberRemoveHandler() +); diff --git a/apps/ui/src/app/api/workspace/member/role/route.ts b/apps/ui/src/app/api/workspace/member/role/route.ts new file mode 100644 index 00000000..1c810be8 --- /dev/null +++ b/apps/ui/src/app/api/workspace/member/role/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceMemberRoleHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.memberRole, + createWorkspaceMemberRoleHandler() +); diff --git a/apps/ui/src/app/api/workspace/rename/route.ts b/apps/ui/src/app/api/workspace/rename/route.ts new file mode 100644 index 00000000..e3bbe4d3 --- /dev/null +++ b/apps/ui/src/app/api/workspace/rename/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceRenameHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.rename, + createWorkspaceRenameHandler() +); diff --git a/apps/ui/src/app/api/workspace/transfer/route.ts b/apps/ui/src/app/api/workspace/transfer/route.ts new file mode 100644 index 00000000..42ebb587 --- /dev/null +++ b/apps/ui/src/app/api/workspace/transfer/route.ts @@ -0,0 +1,11 @@ +import { withWorkspaceDevMock } from "@/features/workspace/server/create-workspace-route"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { createWorkspaceTransferHandler } from "@/features/workspace/server/workspace-write-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = withWorkspaceDevMock( + WORKSPACE_ROUTES.transfer, + createWorkspaceTransferHandler() +); diff --git a/apps/ui/src/app/billing/layout.test.tsx b/apps/ui/src/app/billing/layout.test.tsx index bba547e8..19a44127 100644 --- a/apps/ui/src/app/billing/layout.test.tsx +++ b/apps/ui/src/app/billing/layout.test.tsx @@ -4,11 +4,10 @@ import { isValidElement, type ReactNode } from "react"; mock.module("server-only", () => ({})); -const { - default: AuthBootstrap, - DevboxBootstrap, - SealosSdkBootstrap, -} = await import("@/features/shell/auth-bootstrap"); +const { SessionBootstrap } = await import( + "@/features/session/session-bootstrap" +); +const { DevboxBootstrap } = await import("@/features/shell/devbox-bootstrap"); const { default: ProjectWorkspaceLayout } = await import( "@/features/shell/project-workspace-layout" ); @@ -43,11 +42,10 @@ function mountedComponents( ); } -test("billing layout keeps one tab shell and shared auth chrome across tabs", () => { +test("billing layout keeps one tab shell and the session bootstrap across tabs", () => { const mounted = mountedComponents(BillingLayout({ children: null })); - assert.ok(mounted.has(AuthBootstrap), "AuthBootstrap is mounted"); - assert.ok(mounted.has(SealosSdkBootstrap), "SealosSdkBootstrap is mounted"); + assert.ok(mounted.has(SessionBootstrap), "SessionBootstrap is mounted"); assert.ok(mounted.has(BillingTabShell), "BillingTabShell is mounted"); assert.equal( mounted.has(DevboxBootstrap), diff --git a/apps/ui/src/app/billing/layout.tsx b/apps/ui/src/app/billing/layout.tsx index 6bade9aa..4ba01145 100644 --- a/apps/ui/src/app/billing/layout.tsx +++ b/apps/ui/src/app/billing/layout.tsx @@ -1,17 +1,15 @@ import BillingTabShell from "@/features/billing/billing-tab-shell"; import { BillingEscalationDialog } from "@/features/billing-escalation/billing-escalation-dialog"; +import { SessionBootstrap } from "@/features/session/session-bootstrap"; import { AppShellChrome, AppShellSidebar, AppShellView, } from "@/features/shell/app-shell"; import { AppSidebarCookieBridge } from "@/features/shell/app-sidebar-cookie-bridge"; -import AuthBootstrap, { - SealosSdkBootstrap, -} from "@/features/shell/auth-bootstrap"; import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; -/** Desktop iframe auth is resolved on the client through the Sealos SDK. */ +/** The Brain Session is established on the client from the shared login cookie (ADR-0083). */ export const dynamic = "force-dynamic"; export default function BillingLayout({ @@ -21,8 +19,7 @@ export default function BillingLayout({ }>) { return ( - - + diff --git a/apps/ui/src/app/billing/page.tsx b/apps/ui/src/app/billing/page.tsx index 87f4031a..91d04216 100644 --- a/apps/ui/src/app/billing/page.tsx +++ b/apps/ui/src/app/billing/page.tsx @@ -1,4 +1,5 @@ import BillingPlan, { + type BillingPlanMode, type BillingStripeReturn, } from "@/features/billing/billing-plan"; import { @@ -14,14 +15,18 @@ function firstSearchParam(value: string | string[] | undefined): string | null { return normalized ? normalized : null; } +/** `?mode=upgrade` opens the plan change, `?mode=create` Workspace Creation. */ +function billingPlanMode(value: string | null): BillingPlanMode | null { + return value === "upgrade" || value === "create" ? value : null; +} + export default async function BillingPlanPage({ searchParams, }: { searchParams: Promise; }) { const query = await searchParams; - const initialMode = - firstSearchParam(query.mode) === "upgrade" ? "upgrade" : null; + const initialMode = billingPlanMode(firstSearchParam(query.mode)); const stripeState = firstSearchParam(query.stripeState); const payId = firstSearchParam(query.payId); const workspaceId = firstSearchParam(query.workspaceId); @@ -29,12 +34,18 @@ export default async function BillingPlanPage({ stripeState === "success" && payId != null && workspaceId != null ? { payId, workspaceId } : null; + // A cancelled Checkout still ends the round-trip it belonged to: the page + // spends a pending Workspace Creation record for that Workspace so a + // later plan change is never reworded as a creation. + const stripeCancelWorkspaceId = + stripeState === "cancel" && workspaceId != null ? workspaceId : null; return ( ); diff --git a/apps/ui/src/app/project/[uid]/page.tsx b/apps/ui/src/app/project/[uid]/page.tsx index 29b754e2..62d4569c 100644 --- a/apps/ui/src/app/project/[uid]/page.tsx +++ b/apps/ui/src/app/project/[uid]/page.tsx @@ -4,6 +4,7 @@ import { useAtomValue } from "jotai"; import { BrainModuleView } from "@/features/analytics/brain-module-view"; import { useProjectId } from "@/features/panes/use-project-id"; import { ProjectCanvasWorkbench } from "@/features/project-canvas/workbench/project-canvas-workbench"; +import { ProjectWorkspaceGuard } from "@/features/projects/project-workspace-guard"; import { kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; export default function ProjectIdPage() { @@ -12,6 +13,7 @@ export default function ProjectIdPage() { const namespace = useAtomValue(namespaceAtom); return ( <> + { const mounted = mountedComponents(ProjectLayout({ children: null })); assert.ok(mounted.has(DevboxBootstrap), "DevboxBootstrap is mounted"); - assert.ok(mounted.has(SealosSdkBootstrap), "SealosSdkBootstrap is mounted"); +}); + +// The Brain Session (ADR-0083) is the layout's only credential source; an +// unmounted bootstrap leaves every credential atom empty forever. +test("project layout mounts the session bootstrap", () => { + const mounted = mountedComponents(ProjectLayout({ children: null })); + + assert.ok(mounted.has(SessionBootstrap), "SessionBootstrap is mounted"); }); // The Onboarding Gate covers the whole console surface from this layout diff --git a/apps/ui/src/app/project/layout.tsx b/apps/ui/src/app/project/layout.tsx index 72548fe1..a8ae128d 100644 --- a/apps/ui/src/app/project/layout.tsx +++ b/apps/ui/src/app/project/layout.tsx @@ -1,19 +1,17 @@ import { BillingEscalationDialog } from "@/features/billing-escalation/billing-escalation-dialog"; import { OnboardingGate } from "@/features/onboarding/onboarding-gate"; +import { SessionBootstrap } from "@/features/session/session-bootstrap"; import { AppShellChrome, AppShellSidebar, AppShellView, } from "@/features/shell/app-shell"; import { AppSidebarCookieBridge } from "@/features/shell/app-sidebar-cookie-bridge"; -import AuthBootstrap, { - DevboxBootstrap, - SealosSdkBootstrap, -} from "@/features/shell/auth-bootstrap"; +import { DevboxBootstrap } from "@/features/shell/devbox-bootstrap"; import ProjectWorkspaceLayout from "@/features/shell/project-workspace-layout"; import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; -/** Desktop iframe auth is resolved on the client through the Sealos SDK. */ +/** The Brain Session is established on the client from the shared login cookie (ADR-0083). */ export const dynamic = "force-dynamic"; export default function ProjectLayout({ @@ -23,8 +21,7 @@ export default function ProjectLayout({ }>) { return ( - - + diff --git a/apps/ui/src/app/workspace/[uid]/page.tsx b/apps/ui/src/app/workspace/[uid]/page.tsx new file mode 100644 index 00000000..a5db4da9 --- /dev/null +++ b/apps/ui/src/app/workspace/[uid]/page.tsx @@ -0,0 +1,4 @@ +/** `/workspace/`: the layout renders the area; it reads the uid from the route. */ +export default function WorkspaceDetailPage() { + return null; +} diff --git a/apps/ui/src/app/workspace/layout.test.tsx b/apps/ui/src/app/workspace/layout.test.tsx new file mode 100644 index 00000000..2ac6c8bd --- /dev/null +++ b/apps/ui/src/app/workspace/layout.test.tsx @@ -0,0 +1,56 @@ +import { mock, test } from "bun:test"; +import assert from "node:assert/strict"; +import { isValidElement, type ReactNode } from "react"; + +mock.module("server-only", () => ({})); + +const { SessionBootstrap } = await import( + "@/features/session/session-bootstrap" +); +const { DevboxBootstrap } = await import("@/features/shell/devbox-bootstrap"); +const { default: ProjectWorkspaceLayout } = await import( + "@/features/shell/project-workspace-layout" +); +const { StatusHintBanner } = await import( + "@/features/status-hint/status-hint-banner" +); +const { WorkspaceArea } = await import("@/features/workspace/workspace-area"); +const { default: WorkspaceLayout } = await import("./layout"); + +function mountedComponents( + node: ReactNode, + found: Set = new Set() +): Set { + if (Array.isArray(node)) { + for (const child of node) { + mountedComponents(child, found); + } + return found; + } + if (!isValidElement(node)) { + return found; + } + found.add(node.type); + return mountedComponents( + (node.props as { children?: ReactNode }).children, + found + ); +} + +test("workspace layout mounts the area once with the session bootstrap and the status hint", () => { + const mounted = mountedComponents(WorkspaceLayout({ children: null })); + + assert.ok(mounted.has(SessionBootstrap), "SessionBootstrap is mounted"); + assert.ok(mounted.has(WorkspaceArea), "WorkspaceArea is mounted"); + assert.ok(mounted.has(StatusHintBanner), "StatusHintBanner is mounted"); + assert.equal( + mounted.has(DevboxBootstrap), + false, + "DevboxBootstrap is absent" + ); + assert.equal( + mounted.has(ProjectWorkspaceLayout), + false, + "ProjectWorkspaceLayout is absent" + ); +}); diff --git a/apps/ui/src/app/workspace/layout.tsx b/apps/ui/src/app/workspace/layout.tsx new file mode 100644 index 00000000..ede5c9e6 --- /dev/null +++ b/apps/ui/src/app/workspace/layout.tsx @@ -0,0 +1,37 @@ +import { SessionBootstrap } from "@/features/session/session-bootstrap"; +import { + AppShellChrome, + AppShellSidebar, + AppShellView, +} from "@/features/shell/app-shell"; +import { AppSidebarCookieBridge } from "@/features/shell/app-sidebar-cookie-bridge"; +import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; +import { WorkspaceArea } from "@/features/workspace/workspace-area"; + +/** The Brain Session is established on the client from the shared login cookie (ADR-0083). */ +export const dynamic = "force-dynamic"; + +/** + * The Workspace Area's frame (spec §D): the area itself lives here so the + * list stays mounted while the Managed Workspace changes; the pages below + * only name the uid in the URL, which the area reads. + */ +export default function WorkspaceLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + + {children} + + + + ); +} diff --git a/apps/ui/src/app/workspace/page.tsx b/apps/ui/src/app/workspace/page.tsx new file mode 100644 index 00000000..baf030e4 --- /dev/null +++ b/apps/ui/src/app/workspace/page.tsx @@ -0,0 +1,7 @@ +/** + * `/workspace` (spec §D.1): the area in the layout replaces the route with + * the current Workspace's uid once the session names it; nothing to render. + */ +export default function WorkspaceIndexPage() { + return null; +} diff --git a/apps/ui/src/features/billing/billing-plan-congratulations-dialog.tsx b/apps/ui/src/features/billing/billing-plan-congratulations-dialog.tsx index dd80750e..688db0a5 100644 --- a/apps/ui/src/features/billing/billing-plan-congratulations-dialog.tsx +++ b/apps/ui/src/features/billing/billing-plan-congratulations-dialog.tsx @@ -18,6 +18,13 @@ import type { SettledPayment } from "@/features/billing/billing-plan-checkout-di import type { BillingPlanSnapshot } from "@/features/billing/billing-plan-data"; import type { BillingCurrency } from "@/features/billing/config-core"; +/** + * What a settled payment concluded: a plan change on the current Workspace, + * or Workspace Creation's first subscription — the Stripe return of a + * Workspace this tab created (spec §G.5), worded as such. + */ +export type SettledPaymentConclusion = "changed" | "created"; + /** * Holds the conclusion both checkout surfaces show, so the wiring between a * settled payment and the congratulations dialog exists once. The refresh @@ -27,19 +34,28 @@ import type { BillingCurrency } from "@/features/billing/config-core"; */ export function useSettledPaymentCongratulations() { const [congratulations, setCongratulations] = useState< - (SettledPayment & { snapshot: BillingPlanSnapshot }) | null + | (SettledPayment & { + conclusion: SettledPaymentConclusion; + snapshot: BillingPlanSnapshot; + }) + | null >(null); const settledSnapshotRef = useRef(null); const open = useCallback( - (snapshot: BillingPlanSnapshot, chargedMicroUnits: number | null) => { - setCongratulations({ chargedMicroUnits, snapshot }); + ( + snapshot: BillingPlanSnapshot, + chargedMicroUnits: number | null, + conclusion: SettledPaymentConclusion = "changed" + ) => { + setCongratulations({ chargedMicroUnits, conclusion, snapshot }); }, [] ); return { chargedMicroUnits: congratulations?.chargedMicroUnits ?? null, + conclusion: congratulations?.conclusion ?? "changed", dismiss: useCallback(() => setCongratulations(null), []), onPaymentSuccess: useCallback( ({ chargedMicroUnits }: SettledPayment) => { @@ -64,10 +80,16 @@ export function useSettledPaymentCongratulations() { interface BillingPlanCongratulationsDialogProps { /** See `SettledPayment`. A `null` amount drops the charged-today row. */ chargedMicroUnits?: number | null; + conclusion?: SettledPaymentConclusion; currency: BillingCurrency; onClose: () => void; /** The refreshed subscription. `null` keeps the dialog closed. */ snapshot: BillingPlanSnapshot | null; + /** + * The Workspace's display name, for a creation's conclusion; the + * subscription itself only knows the namespace. + */ + workspaceName?: string | null; } /** @@ -79,9 +101,11 @@ interface BillingPlanCongratulationsDialogProps { */ export function BillingPlanCongratulationsDialog({ chargedMicroUnits = null, + conclusion = "changed", currency, onClose, snapshot, + workspaceName = null, }: BillingPlanCongratulationsDialogProps) { const current = snapshot?.current ?? null; const recipe = current == null ? null : planCardRecipe(current.planName); @@ -107,6 +131,11 @@ export function BillingPlanCongratulationsDialog({ : cn("bg-linear-to-br", recipe.wash) )} > + {conclusion === "created" ? ( +

+ Workspace created +

+ ) : null} {current.planName} - {current.workspace} + + {conclusion === "created" && workspaceName + ? workspaceName + : current.workspace} + diff --git a/apps/ui/src/features/billing/billing-plan-data.test.ts b/apps/ui/src/features/billing/billing-plan-data.test.ts index 764d034c..d2fffefb 100644 --- a/apps/ui/src/features/billing/billing-plan-data.test.ts +++ b/apps/ui/src/features/billing/billing-plan-data.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import type { WorkspaceRole } from "@/features/session/session-schema"; + import { BillingRequestError } from "./billing-data-client"; import { cancelSubscriptionInvoice, @@ -359,7 +361,8 @@ test("keeps core Plan data available when auxiliary requests fail", async () => function loadSnapshotWithSubscription( overrides: Record, - extraResponses: Record = {} + extraResponses: Record = {}, + workspaceRole: WorkspaceRole | null = "Owner" ) { const responses: Record = { ...RESPONSES, @@ -381,6 +384,7 @@ function loadSnapshotWithSubscription( appToken: "desktop-app-token", kubeconfig: "apiVersion: v1", workspace: "workspace-a", + workspaceRole, }, { fetch: (input) => @@ -426,12 +430,14 @@ test("presents a deleted subscription as the subscribable-again PAYG shape", asy }); test("keeps payment authority role-gated for a deleted subscription", async () => { - // Subscription state and payment authority are orthogonal: the record's - // role survives normalization, so only the OWNER manages payments. - const snapshot = await loadSnapshotWithSubscription({ - Status: "DELETED", - role: "DEVELOPER", - }); + // Subscription state and payment authority are orthogonal: the session's + // Workspace Role decides (spec §J.1), so only the Owner manages payments + // — whatever role the subscription record itself still names. + const snapshot = await loadSnapshotWithSubscription( + { Status: "DELETED", role: "OWNER" }, + {}, + "Developer" + ); assert.equal(snapshot.current.lifecycle, "active"); assert.equal(snapshot.current.isPayg, true); @@ -615,6 +621,7 @@ test("a PAYG workspace treats every plan as a fresh subscription", async () => { appToken: "desktop-app-token", kubeconfig: "apiVersion: v1", workspace: "workspace-a", + workspaceRole: "Owner", }, { fetch: (input) => { @@ -1175,7 +1182,6 @@ test("loads the sidebar subscription summary with only region-addressed reads", lifecycle: "active", planName: "Pro", recoveryVoice: "renew", - role: "OWNER", warningDeadlineAt: null, warningStage: null, }); @@ -1202,7 +1208,6 @@ test("the sidebar summary reports an Active Free Trial and its period end", asyn lifecycle: "active", planName: "Free", recoveryVoice: "resubscribe", - role: "OWNER", warningDeadlineAt: null, warningStage: null, }); @@ -1229,7 +1234,6 @@ test("the sidebar summary presents a deleted subscription as PAYG", async () => lifecycle: "active", planName: "PAYG", recoveryVoice: "renew", - role: "OWNER", warningDeadlineAt: null, warningStage: null, }); diff --git a/apps/ui/src/features/billing/billing-plan-data.ts b/apps/ui/src/features/billing/billing-plan-data.ts index 3fef4b89..4ec50b23 100644 --- a/apps/ui/src/features/billing/billing-plan-data.ts +++ b/apps/ui/src/features/billing/billing-plan-data.ts @@ -1,6 +1,6 @@ import { Quantity } from "@workspace/shared"; import { z } from "zod"; - +import type { WorkspaceRole } from "@/features/session/session-schema"; import { isActiveFreeTrialSubscription } from "@/lib/account-service/free-trial-core"; import { type BillingCredentials, @@ -192,7 +192,8 @@ const subscriptionSchema = z.object({ // Absent for PAYG workspaces: the upstream embeds a nil subscription and // serializes only `{"type":"PAYG"}`. Workspace: z.string().default(""), - role: z.enum(["MANAGER", "DEVELOPER", "OWNER"]).optional(), + // The record also names a `role`; Brain no longer reads it (spec §J.1): + // the Workspace Role comes from the Brain Session's membership list. type: z.enum(["SUBSCRIPTION", "PAYG"]).optional(), }); const subscriptionResponseSchema = z.object({ @@ -308,7 +309,7 @@ export function isDeletedSubscriptionRecord(status: string): boolean { // Present a deleted subscription as the no-subscription PAYG shape: stale // plan, period, and invoice facts must not leak into a workspace that can -// simply subscribe again. The record's role survives for `canManage`. +// simply subscribe again. function normalizeDeletedSubscriptionRecord( subscription: z.infer ): z.infer { @@ -482,7 +483,15 @@ function availableWorkspaceData( } export async function loadBillingPlanSnapshot( - credentials: BillingCredentials & { workspace: string }, + credentials: BillingCredentials & { + workspace: string; + /** + * The caller's Workspace Role in `workspace` from the Brain Session + * (spec §J.1). Only the Workspace Owner manages payments — what + * account-service enforces — so an unknown role fails closed. + */ + workspaceRole?: WorkspaceRole | null; + }, dependencies: BillingPlanLoaderDependencies = {} ): Promise { const fetch = dependencies.fetch ?? globalThis.fetch; @@ -606,14 +615,12 @@ export async function loadBillingPlanSnapshot( last4: paymentMethod.card.last4, }, current: { - // Subscription state and payment authority are orthogonal: whenever the - // record names a role (including a normalized deleted one), only the - // OWNER manages payments; a roleless PAYG record has no membership - // facts, so managing stays open. - canManage: - subscription.role == null - ? subscription.type === "PAYG" - : subscription.role === "OWNER", + // Subscription state and payment authority are orthogonal: only the + // Workspace Owner manages payments, whatever the subscription's state. + // The role is the session's membership fact (spec §J.1) — the + // subscription record's own role field used to leave a PAYG Workspace + // open to every member, which account-service then refused. + canManage: credentials.workspaceRole === "Owner", cancelAtPeriodEnd: subscription.CancelAtPeriodEnd, currentPeriodEndAt: subscription.CurrentPeriodEndAt, invoiceId: subscription.InvoiceInfo?.ID ?? null, @@ -746,9 +753,6 @@ export async function loadBillingPlans( .sort((left, right) => left.order - right.order); } -/** The caller's membership role in the workspace as the subscription record names it. */ -export type WorkspaceSubscriptionRole = "DEVELOPER" | "MANAGER" | "OWNER"; - /** * The Workspace Subscription facts the App Sidebar account section needs — * a two-request read (region, then the region-addressed subscription route) @@ -761,8 +765,6 @@ export interface WorkspaceSubscriptionSummary { lifecycle: SubscriptionLifecycle; planName: string; recoveryVoice: RecoveryVoice; - /** Null when the record names no role (PAYG workspaces). */ - role: WorkspaceSubscriptionRole | null; /** * The Deletion Countdown's next deadline, derived client-side exactly as * the Plan view derives it (ADR-0063). Set only while `warningStage` is. @@ -820,7 +822,6 @@ export async function loadWorkspaceSubscriptionSummary( lifecycle, planName: subscription.PlanName, recoveryVoice: recoveryVoice(subscription.PlanName), - role: subscription.role ?? null, warningDeadlineAt: subscriptionWarningDeadline({ currentPeriodEndAt: subscription.CurrentPeriodEndAt, expireAt: subscription.ExpireAt ?? null, diff --git a/apps/ui/src/features/billing/billing-plan-surface.states.test.tsx b/apps/ui/src/features/billing/billing-plan-surface.states.test.tsx index df1403d5..7f856c92 100644 --- a/apps/ui/src/features/billing/billing-plan-surface.states.test.tsx +++ b/apps/ui/src/features/billing/billing-plan-surface.states.test.tsx @@ -45,9 +45,11 @@ const CREDENTIALS = { }; function loadSnapshotForScenario(scenario: string) { - return loadBillingPlanSnapshot(CREDENTIALS, { - fetch: scenarioTestFetch(scenario), - }); + // The viewer is the Workspace Owner (spec §J.1): payment actions render. + return loadBillingPlanSnapshot( + { ...CREDENTIALS, workspaceRole: "Owner" }, + { fetch: scenarioTestFetch(scenario) } + ); } async function renderScenario( diff --git a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx index 65065ff2..2472e80c 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -12,10 +12,23 @@ import { restoreGlobal, withTestDom, } from "@/features/project-canvas/react-test-harness"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; +import { + appTokenAtom, + currentWorkspaceAtom, + kubeconfigAtom, + namespaceAtom, +} from "@/lib/auth-store"; import { CANCEL_PLAN_PREVIEW_PENDING_MS } from "./billing-cancel-plan-dialog-tweaks"; import { formatBillingDate, formatBillingDateTime } from "./billing-datetime"; import type { BillingPlanSnapshot } from "./billing-plan-data"; +import { + readBillingReturnRoute, + recordBillingReturnRoute, +} from "./billing-return-route"; +import { + consumePendingWorkspaceCreation, + recordPendingWorkspaceCreation, +} from "./workspace-creation-return"; const SNAPSHOT: BillingPlanSnapshot = { availability: { @@ -234,6 +247,302 @@ test("Free payment-due renewal opens the paid plan picker", async () => { }); }); +test("create mode opens Workspace Creation regardless of role or lifecycle and is consumed from the URL", async () => { + await withTestDom(async (act) => { + const { BillingPlanWorkflow } = await import("./billing-plan"); + const replacements: string[] = []; + let rendered: ReturnType | undefined; + + window.history.replaceState({}, "", "/billing?mode=create&source=switcher"); + + // Creation is never gated by the current Workspace (CONTEXT: Workspace + // Creation): a Developer in a locked Workspace still creates their own. + const snapshot: BillingPlanSnapshot = { + ...SNAPSHOT, + current: { + ...SNAPSHOT.current, + canManage: false, + lifecycle: "unavailable", + }, + }; + + try { + await act(() => { + rendered = render( + $3.00} + credentials={{ + appToken: "desktop-app-token", + kubeconfig: "apiVersion: v1", + }} + currency="usd" + existingWorkspaceNames={["private team", "Acme"]} + gpuEnabled + initialMode="create" + onRefreshSnapshot={() => Promise.resolve(snapshot)} + replaceUrl={(url) => replacements.push(url)} + snapshot={snapshot} + /> + ); + }); + + const dialog = rendered?.getByRole("dialog", { name: "New Workspace" }); + assert.ok(dialog); + assert.ok( + within(dialog).getByRole("textbox", { name: "Workspace name" }) + ); + assert.ok( + within(dialog).getAllByRole("button", { name: "Subscribe" }).length > 0 + ); + assert.equal( + rendered?.queryByRole("dialog", { name: "Choose Your Workspace Plan" }), + null + ); + assert.deepEqual(replacements, ["/billing?source=switcher"]); + } finally { + await act(() => rendered?.unmount()); + } + }); +}); + +test("a Stripe return for the Workspace this tab created concludes as a creation and forgets the return route", async () => { + await withTestDom(async (act) => { + const { BillingPlanWorkflow } = await import("./billing-plan"); + const replacements: string[] = []; + const refreshedSnapshot: BillingPlanSnapshot = { + ...SNAPSHOT, + current: { + ...SNAPSHOT.current, + planName: "Team", + priceMicroUnits: 50_000_000, + resources: [{ label: "CPU", value: "12" }], + workspace: "ns-new00001", + }, + }; + let rendered: ReturnType | undefined; + + // Entered the Billing Area from a Project of the old Workspace, created + // a Workspace, and came back through Desktop's Stripe callback. + window.history.replaceState({}, "", "/project/abc"); + recordBillingReturnRoute(); + recordPendingWorkspaceCreation("ns-new00001", "payment-1"); + window.history.replaceState( + {}, + "", + "/billing?stripeState=success&payId=payment-1&workspaceId=ns-new00001" + ); + + try { + await act(() => { + rendered = render( + $3.00} + credentials={{ + appToken: "desktop-app-token", + kubeconfig: "apiVersion: v1", + }} + currency="usd" + gpuEnabled + onRefreshSnapshot={() => Promise.resolve(refreshedSnapshot)} + replaceUrl={(url) => replacements.push(url)} + snapshot={SNAPSHOT} + stripeReturn={{ payId: "payment-1", workspaceId: "ns-new00001" }} + workspaceName="Robotics" + /> + ); + }); + + const dialog = rendered?.getByRole("dialog", { name: "Team" }); + const text = dialog?.textContent ?? ""; + assert.ok(text.includes("Workspace created")); + assert.ok(text.includes("Robotics")); + assert.ok(text.includes("12")); + assert.equal(text.includes("Charged today"), false); + + // The recorded entry point named the old Workspace's route; close + // returns home instead, and the creation record is spent. + assert.equal(readBillingReturnRoute(), "/"); + assert.equal(window.sessionStorage.getItem("billing-return-route"), null); + assert.equal(consumePendingWorkspaceCreation("ns-new00001"), false); + + await act(() => { + const done = rendered?.getByRole("button", { name: "Done" }); + if (done != null) { + fireEvent.click(done); + } + }); + assert.deepEqual(replacements, ["/billing"]); + } finally { + await act(() => rendered?.unmount()); + } + }); +}); + +test("a cancelled Checkout spends the creation record but keeps the entry point and draws no conclusion", async () => { + await withTestDom(async (act) => { + const { BillingPlanWorkflow } = await import("./billing-plan"); + let rendered: ReturnType | undefined; + + window.history.replaceState({}, "", "/project/abc"); + recordBillingReturnRoute(); + recordPendingWorkspaceCreation("ns-new00001", "pay-1"); + window.history.replaceState( + {}, + "", + "/billing?stripeState=cancel&workspaceId=ns-new00001" + ); + + try { + await act(() => { + rendered = render( + $3.00} + credentials={{ + appToken: "desktop-app-token", + kubeconfig: "apiVersion: v1", + }} + currency="usd" + gpuEnabled + onRefreshSnapshot={() => Promise.resolve(SNAPSHOT)} + replaceUrl={() => undefined} + snapshot={SNAPSHOT} + stripeCancelWorkspaceId="ns-new00001" + /> + ); + }); + + // The round-trip is over: the record is spent, so a later plan change + // for this Workspace can never be reworded as a creation. + assert.equal(consumePendingWorkspaceCreation("ns-new00001"), false); + // The user continues where they were: the entry point survives. + assert.equal(readBillingReturnRoute(), "/project/abc"); + // A cancel concludes nothing. + assert.equal( + (rendered?.baseElement.textContent ?? "").includes("Workspace created"), + false + ); + } finally { + await act(() => rendered?.unmount()); + } + }); +}); + +test("a later plan change for an abandoned creation pays under its own id and reads as a plan change", async () => { + await withTestDom(async (act) => { + const { BillingPlanWorkflow } = await import("./billing-plan"); + const refreshedSnapshot: BillingPlanSnapshot = { + ...SNAPSHOT, + current: { + ...SNAPSHOT.current, + planName: "Team", + priceMicroUnits: 50_000_000, + resources: [{ label: "CPU", value: "12" }], + }, + }; + let rendered: ReturnType | undefined; + + window.history.replaceState({}, "", "/project/abc"); + recordBillingReturnRoute(); + // The creation's Checkout was abandoned; the plan change for that same + // Workspace returns under its own, different pay id. + recordPendingWorkspaceCreation("workspace-a", "pay-creation"); + window.history.replaceState( + {}, + "", + "/billing?stripeState=success&payId=payment-1&workspaceId=workspace-a" + ); + + try { + await act(() => { + rendered = render( + $3.00} + credentials={{ + appToken: "desktop-app-token", + kubeconfig: "apiVersion: v1", + }} + currency="usd" + gpuEnabled + onRefreshSnapshot={() => Promise.resolve(refreshedSnapshot)} + replaceUrl={() => undefined} + snapshot={SNAPSHOT} + stripeReturn={{ payId: "payment-1", workspaceId: "workspace-a" }} + /> + ); + }); + + assert.ok(rendered?.getByRole("dialog", { name: "Team" })); + assert.equal( + (rendered?.baseElement.textContent ?? "").includes("Workspace created"), + false + ); + // The entry point survives — this was a plan change — and the stale + // creation record is spent. + assert.equal(readBillingReturnRoute(), "/project/abc"); + assert.equal(consumePendingWorkspaceCreation("workspace-a"), false); + } finally { + await act(() => rendered?.unmount()); + } + }); +}); + +test("a record without a pay id concludes as a plan change: fail closed", async () => { + await withTestDom(async (act) => { + const { BillingPlanWorkflow } = await import("./billing-plan"); + const refreshedSnapshot: BillingPlanSnapshot = { + ...SNAPSHOT, + current: { + ...SNAPSHOT.current, + planName: "Team", + priceMicroUnits: 50_000_000, + resources: [{ label: "CPU", value: "12" }], + }, + }; + let rendered: ReturnType | undefined; + + window.history.replaceState({}, "", "/project/abc"); + recordBillingReturnRoute(); + // Desktop's checkout answer carried no pay id (or a legacy record): + // never reword a return as a creation on a wildcard. + recordPendingWorkspaceCreation("workspace-a"); + window.history.replaceState( + {}, + "", + "/billing?stripeState=success&payId=payment-1&workspaceId=workspace-a" + ); + + try { + await act(() => { + rendered = render( + $3.00} + credentials={{ + appToken: "desktop-app-token", + kubeconfig: "apiVersion: v1", + }} + currency="usd" + gpuEnabled + onRefreshSnapshot={() => Promise.resolve(refreshedSnapshot)} + replaceUrl={() => undefined} + snapshot={SNAPSHOT} + stripeReturn={{ payId: "payment-1", workspaceId: "workspace-a" }} + /> + ); + }); + + assert.ok(rendered?.getByRole("dialog", { name: "Team" })); + assert.equal( + (rendered?.baseElement.textContent ?? "").includes("Workspace created"), + false + ); + assert.equal(readBillingReturnRoute(), "/project/abc"); + assert.equal(consumePendingWorkspaceCreation("workspace-a"), false); + } finally { + await act(() => rendered?.unmount()); + } + }); +}); + test("Stripe return refreshes before congratulations and clears on close", async () => { await withTestDom(async (act) => { const { BillingPlanWorkflow } = await import("./billing-plan"); @@ -250,6 +559,8 @@ test("Stripe return refreshes before congratulations and clears on close", async }; let rendered: ReturnType | undefined; + window.history.replaceState({}, "", "/project/abc"); + recordBillingReturnRoute(); window.history.replaceState( {}, "", @@ -296,6 +607,9 @@ test("Stripe return refreshes before congratulations and clears on close", async ); assert.ok(congratulations.includes("$50.00")); assert.equal(congratulations.includes("Charged today"), false); + assert.equal(congratulations.includes("Workspace created"), false); + // The same Workspace: close still returns where the user came from. + assert.equal(readBillingReturnRoute(), "/project/abc"); assert.deepEqual(replacements, []); await act(() => { @@ -553,6 +867,16 @@ async function renderPlanPage( store.set(appTokenAtom, "desktop-app-token"); store.set(kubeconfigAtom, "apiVersion: v1"); store.set(namespaceAtom, "workspace-a"); + // Payment authority is the session's Workspace Role (spec §J.1): the + // viewer is the Workspace Owner here. + store.set(currentWorkspaceAtom, { + createdAt: "2026-01-01T00:00:00.000Z", + id: "workspace-a", + isPersonal: false, + name: "Workspace A", + role: "Owner", + uid: "uid-workspace-a", + }); let rendered: ReturnType | undefined; await act(() => { rendered = render( diff --git a/apps/ui/src/features/billing/billing-plan.tsx b/apps/ui/src/features/billing/billing-plan.tsx index bf486c1b..26f53df5 100644 --- a/apps/ui/src/features/billing/billing-plan.tsx +++ b/apps/ui/src/features/billing/billing-plan.tsx @@ -8,6 +8,7 @@ import { type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -32,6 +33,7 @@ import { } from "@/features/billing/billing-plan-change-dialog"; import { BillingPlanCongratulationsDialog, + type SettledPaymentConclusion, useSettledPaymentCongratulations, } from "@/features/billing/billing-plan-congratulations-dialog"; import { @@ -49,22 +51,37 @@ import { BillingBalanceValue, BillingPlanSurface, } from "@/features/billing/billing-plan-surface"; +import { clearBillingReturnRoute } from "@/features/billing/billing-return-route"; import { accountBalanceSwrKey, accountCreditsSwrKey, aiCreditsSwrKey, settleSubscriptionChange, } from "@/features/billing/billing-subscription-settlement"; +import { + BillingWorkspaceCreationDialog, + type BillingWorkspaceCreationServices, +} from "@/features/billing/billing-workspace-creation-dialog"; import { submitCancellationSurvey } from "@/features/billing/cancellation-survey/client"; import { EMPTY_CANCELLATION_SURVEY_ANSWERS } from "@/features/billing/cancellation-survey/reasons"; import type { BillingCurrency } from "@/features/billing/config-core"; import { useWorkspaceOwnerStanding } from "@/features/billing/use-workspace-owner-standing"; +import { + consumePendingWorkspaceCreation, + readPendingWorkspaceCreation, +} from "@/features/billing/workspace-creation-return"; import { type FreeChatTurnsUsage, fetchFreeChatTurnsUsage, } from "@/features/chat/persistence/client"; import { observeSubscriptionChangeQuietly } from "@/features/notifications/subscription-change-observer"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; +import { + appTokenAtom, + currentWorkspaceAtom, + kubeconfigAtom, + namespaceAtom, + workspacesAtom, +} from "@/lib/auth-store"; import { errorDescription, toastErrorDetail } from "@/lib/toast-utils"; export interface BillingStripeReturn { @@ -72,17 +89,28 @@ export interface BillingStripeReturn { workspaceId: string; } +/** + * What `/billing?mode=` opens on arrival: the plan-change dialog for the + * current Workspace, or Workspace Creation (spec §G.1) — the latter never + * gated by the current Workspace's role or subscription state. + */ +export type BillingPlanMode = "create" | "upgrade"; + interface BillingPlanWorkflowProps { actionPending?: SubscriptionLifecycleAction | null; balance: ReactNode; cardManagementPending?: boolean; /** Injected by tests; production uses the checkout dialog's own defaults. */ checkoutServices?: BillingPlanChangeServices; + /** Injected by tests; production uses the creation dialog's own defaults. */ + creationServices?: BillingWorkspaceCreationServices; credentials: BillingCredentials; credits?: ReactNode; currency: BillingCurrency; + /** The session's Workspace names, for creation's inline duplicate check. */ + existingWorkspaceNames?: readonly string[]; gpuEnabled: boolean; - initialMode?: "upgrade" | null; + initialMode?: BillingPlanMode | null; invoiceCancellationPending?: boolean; onCancelInvoice?: (invoiceId: string) => void; onLifecycleAction?: SubscriptionLifecycleHandler; @@ -91,9 +119,12 @@ interface BillingPlanWorkflowProps { replaceUrl: (url: string) => void; schedulePoll?: (callback: () => void, delay: number) => () => void; snapshot: BillingPlanSnapshot; + stripeCancelWorkspaceId?: string | null; stripeReturn?: BillingStripeReturn | null; /** Whether the viewer is proven to be the Workspace Owner (ADR-0082). */ viewerIsOwner?: boolean; + /** The current Workspace's display name, for a creation's conclusion. */ + workspaceName?: string | null; } function currentUrlWithout(parameters: readonly string[]): string { @@ -110,9 +141,11 @@ export function BillingPlanWorkflow({ balance, cardManagementPending = false, checkoutServices, + creationServices, credentials, credits = null, currency, + existingWorkspaceNames = [], gpuEnabled, initialMode = null, invoiceCancellationPending = false, @@ -123,11 +156,14 @@ export function BillingPlanWorkflow({ replaceUrl, schedulePoll, snapshot, + stripeCancelWorkspaceId = null, stripeReturn = null, viewerIsOwner = false, + workspaceName = null, }: BillingPlanWorkflowProps) { const stripeAcknowledgedKeyRef = useRef(null); const stripeRefreshRef = useRef<{ + conclusion: SettledPaymentConclusion; key: string; request: Promise; } | null>(null); @@ -140,6 +176,10 @@ export function BillingPlanWorkflow({ const [planDialogOpen, setPlanDialogOpen] = useState( initialMode === "upgrade" && planDialogActionable ); + // Workspace Creation is open to every signed-in user (CONTEXT): no gate. + const [creationDialogOpen, setCreationDialogOpen] = useState( + initialMode === "create" + ); const [selectedPlanId, setSelectedPlanId] = useState(null); const congratulations = useSettledPaymentCongratulations(); const { @@ -150,9 +190,10 @@ export function BillingPlanWorkflow({ // The mode parameter is consumed once per arrival: the state above handles // the deep-link mount, and this handles a later client-side navigation back - // to ?mode=upgrade. Opening during render keeps the picker from painting a - // frame without it. Stripping the parameter is a URL side effect, so it - // stays in an effect — and runs whether or not the picker opened. + // to ?mode=upgrade or ?mode=create. Opening during render keeps the dialog + // from painting a frame without it. Stripping the parameter is a URL side + // effect, so it stays in an effect — and runs whether or not a dialog + // opened. const [consumedMode, setConsumedMode] = useState(initialMode); if (consumedMode !== initialMode) { setConsumedMode(initialMode); @@ -160,13 +201,27 @@ export function BillingPlanWorkflow({ setSelectedPlanId(null); setPlanDialogOpen(true); } + if (initialMode === "create") { + setCreationDialogOpen(true); + } } useEffect(() => { - if (initialMode === "upgrade") { + if (initialMode != null) { replaceUrl(currentUrlWithout(["mode"])); } }, [initialMode, replaceUrl]); + useEffect(() => { + if (stripeCancelWorkspaceId == null) { + return; + } + // A cancelled Checkout still ends the creation's round-trip: spend the + // record, so a later plan change for that Workspace reads as a plan + // change. The recorded entry point stays — the user continues where + // they were — and a cancel opens no conclusion dialog. + consumePendingWorkspaceCreation(stripeCancelWorkspaceId); + }, [stripeCancelWorkspaceId]); + useEffect(() => { if (stripeReturn == null) { return; @@ -178,12 +233,37 @@ export function BillingPlanWorkflow({ } let refresh = stripeRefreshRef.current; if (refresh?.key !== key) { + // Read once per arrival, alongside the refresh: a creation's record + // is spent on the first read — any return for the recorded Workspace + // spends it, so an abandoned creation Checkout never rewords a later + // plan change for the same Workspace. The conclusion is a creation + // only when the recorded pay id is this return's — fail closed, so a + // record without one (Desktop omitted it, or a legacy record) reads + // as the safer plan-change wording rather than rewording later + // returns as creations. A creation's recorded return route belongs to + // the Workspace the creation left (spec §G.5), so close returns home; + // a plan change came back to the same Workspace and keeps its own. + const pending = readPendingWorkspaceCreation(); + const recordedHere = + pending != null && pending.workspaceId === stripeReturn.workspaceId; + const created = + recordedHere && + pending?.payId != null && + pending.payId === stripeReturn.payId; + if (recordedHere) { + consumePendingWorkspaceCreation(stripeReturn.workspaceId); + } + if (created) { + clearBillingReturnRoute(); + } refresh = { + conclusion: created ? "created" : "changed", key, request: onRefreshSnapshot(stripeReturn.workspaceId), }; stripeRefreshRef.current = refresh; } + const { conclusion } = refresh; let active = true; refresh.request @@ -193,10 +273,11 @@ export function BillingPlanWorkflow({ // Close and open in the same commit: the plan dialog's backdrop // hands off to the congratulations one without a bright gap. setPlanDialogOpen(false); + setCreationDialogOpen(false); setSelectedPlanId(null); // The redirect leg carries no quote, so it concludes without a // charged-today row. - openCongratulations(nextSnapshot, null); + openCongratulations(nextSnapshot, null, conclusion); } }) .catch((error: unknown) => { @@ -266,11 +347,24 @@ export function BillingPlanWorkflow({ services={checkoutServices} snapshot={snapshot} /> + ); @@ -374,17 +468,27 @@ export function BillingPlan({ gpuEnabled, initialMode = null, replaceUrl, + stripeCancelWorkspaceId = null, stripeReturn = null, }: { currency: BillingCurrency; gpuEnabled: boolean; - initialMode?: "upgrade" | null; + initialMode?: BillingPlanMode | null; replaceUrl: (url: string) => void; + stripeCancelWorkspaceId?: string | null; stripeReturn?: BillingStripeReturn | null; }) { const appToken = useAtomValue(appTokenAtom); const kubeconfig = useAtomValue(kubeconfigAtom); const workspace = useAtomValue(namespaceAtom).trim(); + const currentWorkspace = useAtomValue(currentWorkspaceAtom); + // Payment authority is the session's Workspace Role (spec §J.1). + const workspaceRole = currentWorkspace?.role ?? null; + const sessionWorkspaces = useAtomValue(workspacesAtom); + const existingWorkspaceNames = useMemo( + () => sessionWorkspaces.map((entry) => entry.name), + [sessionWorkspaces] + ); const [actionPending, setActionPending] = useState(null); const [cardManagementPending, setCardManagementPending] = useState(false); @@ -422,7 +526,13 @@ export function BillingPlan({ credentialsReady ? (["billing-plan-snapshot", workspace, kubeconfig, appToken] as const) : null, - () => loadBillingPlanSnapshot({ appToken, kubeconfig, workspace }), + () => + loadBillingPlanSnapshot({ + appToken, + kubeconfig, + workspace, + workspaceRole, + }), { revalidateOnFocus: false, shouldRetryOnError: false } ); const creditsKey = @@ -644,6 +754,7 @@ export function BillingPlan({ appToken, kubeconfig, workspace: targetWorkspace, + workspaceRole, }); if (nextSnapshot == null) { throw new Error("The refreshed subscription is unavailable."); @@ -674,7 +785,7 @@ export function BillingPlan({ } return nextSnapshot; }, - [appToken, currency, kubeconfig, refreshSnapshot, workspace] + [appToken, currency, kubeconfig, refreshSnapshot, workspace, workspaceRole] ); if (!credentialsReady || snapshotLoading) { @@ -736,6 +847,7 @@ export function BillingPlan({ /> } currency={currency} + existingWorkspaceNames={existingWorkspaceNames} gpuEnabled={gpuEnabled} initialMode={initialMode} invoiceCancellationPending={invoiceCancellationPending} @@ -745,8 +857,18 @@ export function BillingPlan({ onRefreshSnapshot={refreshPlanSnapshot} replaceUrl={replaceUrl} snapshot={snapshot} + stripeCancelWorkspaceId={stripeCancelWorkspaceId} stripeReturn={stripeReturn} viewerIsOwner={viewerIsOwner} + // Desktop switches to the created Workspace before calling back, so + // the session's current Workspace is the created one; anything else + // (a stale session) falls back to the namespace rather than misnaming. + workspaceName={ + currentWorkspace != null && + currentWorkspace.id === stripeReturn?.workspaceId + ? currentWorkspace.name + : null + } /> ); } @@ -755,11 +877,13 @@ export default function BillingPlanRoute({ currency, gpuEnabled, initialMode = null, + stripeCancelWorkspaceId = null, stripeReturn = null, }: { currency: BillingCurrency; gpuEnabled: boolean; - initialMode?: "upgrade" | null; + initialMode?: BillingPlanMode | null; + stripeCancelWorkspaceId?: string | null; stripeReturn?: BillingStripeReturn | null; }) { const router = useRouter(); @@ -771,6 +895,7 @@ export default function BillingPlanRoute({ replaceUrl={(url) => { router.replace(url, { scroll: false }); }} + stripeCancelWorkspaceId={stripeCancelWorkspaceId} stripeReturn={stripeReturn} /> ); diff --git a/apps/ui/src/features/billing/billing-pricing.tsx b/apps/ui/src/features/billing/billing-pricing.tsx index ac243eec..cfaf90b1 100644 --- a/apps/ui/src/features/billing/billing-pricing.tsx +++ b/apps/ui/src/features/billing/billing-pricing.tsx @@ -63,7 +63,12 @@ import { } from "@/features/billing/billing-pricing-data"; import { settleSubscriptionChange } from "@/features/billing/billing-subscription-settlement"; import type { BillingCurrency } from "@/features/billing/config-core"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; +import { + appTokenAtom, + currentWorkspaceAtom, + kubeconfigAtom, + namespaceAtom, +} from "@/lib/auth-store"; import { errorDescription } from "@/lib/toast-utils"; export const PRICING_CYCLES = [ @@ -842,6 +847,8 @@ export default function BillingPricing({ const appToken = useAtomValue(appTokenAtom); const kubeconfig = useAtomValue(kubeconfigAtom); const workspace = useAtomValue(namespaceAtom).trim(); + // Payment authority is the session's Workspace Role (spec §J.1). + const workspaceRole = useAtomValue(currentWorkspaceAtom)?.role ?? null; const credentialsReady = appToken.trim() !== "" && kubeconfig.trim() !== "" && workspace !== ""; const { @@ -865,7 +872,13 @@ export default function BillingPricing({ credentialsReady ? (["billing-plan-snapshot", workspace, kubeconfig, appToken] as const) : null, - () => loadBillingPlanSnapshot({ appToken, kubeconfig, workspace }), + () => + loadBillingPlanSnapshot({ + appToken, + kubeconfig, + workspace, + workspaceRole, + }), { revalidateOnFocus: false, shouldRetryOnError: false } ); const settlementCancelRef = useRef<(() => void) | null>(null); diff --git a/apps/ui/src/features/billing/billing-return-route.test.ts b/apps/ui/src/features/billing/billing-return-route.test.ts index e985ceee..d9bd60cd 100644 --- a/apps/ui/src/features/billing/billing-return-route.test.ts +++ b/apps/ui/src/features/billing/billing-return-route.test.ts @@ -1,7 +1,40 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { sanitizeBillingReturnRoute } from "./billing-return-route"; +import { + readBillingReturnRoute, + recordBillingReturnRoute, + sanitizeBillingReturnRoute, +} from "./billing-return-route"; +import { recordPendingWorkspaceCreation } from "./workspace-creation-return"; + +function withWindow( + location: { pathname: string; search: string }, + run: (storage: Map) => void +) { + const storage = new Map(); + const previous = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, value), + }, + }, + }); + try { + run(storage); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Object.defineProperty(globalThis, "window", previous); + } + } +} test("sanitizeBillingReturnRoute accepts an in-app route outside /billing", () => { assert.equal(sanitizeBillingReturnRoute("/project"), "/project"); @@ -20,3 +53,69 @@ test("sanitizeBillingReturnRoute falls back to home for unusable values", () => assert.equal(sanitizeBillingReturnRoute("/billing/costs"), "/"); assert.equal(sanitizeBillingReturnRoute("/billing?mode=upgrade"), "/"); }); + +test("a creation's Stripe return reads as home — a pure read; the page's effect voids the entry point", () => { + withWindow({ pathname: "/project/abc", search: "" }, (storage) => { + recordBillingReturnRoute(); + recordPendingWorkspaceCreation("ns-new", "p1"); + assert.equal(readBillingReturnRoute(), "/project/abc"); + + window.location.pathname = "/billing"; + window.location.search = "?stripeState=success&payId=p1&workspaceId=ns-new"; + assert.equal(readBillingReturnRoute(), "/"); + // Pure: deciding never mutates — the workflow's return effect clears. + assert.equal(storage.has("billing-return-route"), true); + + // Once the return parameters are stripped, nothing creation-flavored + // remains, but the entry point itself still stands until the effect. + window.location.search = ""; + assert.equal(readBillingReturnRoute(), "/project/abc"); + }); +}); + +test("a cancelled Checkout keeps the entry point: the user continues where they were", () => { + withWindow({ pathname: "/project/abc", search: "" }, () => { + recordBillingReturnRoute(); + recordPendingWorkspaceCreation("ns-new", "p1"); + window.location.pathname = "/billing"; + window.location.search = "?stripeState=cancel&workspaceId=ns-new"; + assert.equal(readBillingReturnRoute(), "/project/abc"); + }); +}); + +test("a later plan change for an abandoned creation pays under its own id: it reads as a plan change", () => { + withWindow({ pathname: "/project/abc", search: "" }, () => { + recordBillingReturnRoute(); + // The creation's Checkout was abandoned; a plan change for that same + // Workspace returns with its own, different pay id. + recordPendingWorkspaceCreation("ns-new", "p-creation"); + window.location.pathname = "/billing"; + window.location.search = + "?stripeState=success&payId=p-plan&workspaceId=ns-new"; + assert.equal(readBillingReturnRoute(), "/project/abc"); + }); +}); + +test("a record without a pay id never reads as a creation landing: fail closed", () => { + withWindow({ pathname: "/project/abc", search: "" }, () => { + recordBillingReturnRoute(); + // Desktop's checkout answer carried no pay id (or a legacy record). + recordPendingWorkspaceCreation("ns-new"); + window.location.pathname = "/billing"; + window.location.search = "?stripeState=success&payId=p1&workspaceId=ns-new"; + assert.equal(readBillingReturnRoute(), "/project/abc"); + }); +}); + +test("a plan change's Stripe return keeps the entry point: it is the same Workspace", () => { + withWindow({ pathname: "/project/abc", search: "" }, () => { + recordBillingReturnRoute(); + window.location.pathname = "/billing"; + window.location.search = "?stripeState=success&payId=p1&workspaceId=ns-abc"; + assert.equal(readBillingReturnRoute(), "/project/abc"); + + // Another tab's creation record names a different Workspace: kept too. + recordPendingWorkspaceCreation("ns-other"); + assert.equal(readBillingReturnRoute(), "/project/abc"); + }); +}); diff --git a/apps/ui/src/features/billing/billing-return-route.ts b/apps/ui/src/features/billing/billing-return-route.ts index c137e4d7..1386459d 100644 --- a/apps/ui/src/features/billing/billing-return-route.ts +++ b/apps/ui/src/features/billing/billing-return-route.ts @@ -1,50 +1,70 @@ -const BILLING_RETURN_ROUTE_STORAGE_KEY = "billing-return-route"; +import { createAreaReturnRoute } from "@/features/shell/area-return-route"; + +import { readPendingWorkspaceCreation } from "./workspace-creation-return"; /** - * The Billing Area close button returns to the in-app route the user entered - * from. Only an internal path outside /billing qualifies; anything else - * (deep link entry, cleared storage, tampered value) falls back to home. + * The Billing Area's return address: the close button returns to the in-app + * route the user entered from. Only an internal path outside /billing + * qualifies; anything else falls back to home. Wire `record` onto links that + * navigate into /billing (the App Sidebar entries); a click while already + * inside the Billing Area keeps the original entry point. + * + * Workspace Creation's Stripe Checkout Round-Trip voids the record: the + * page arrives on `?stripeState=…&workspaceId=…` in the created Workspace, + * and the recorded route belongs to the one the user left (spec §G.5), so + * close lands on home rather than on a route from another Workspace. A + * plan change's return stays in the same Workspace and keeps its entry + * point. `read` is pure — it decides, but never mutates: the billing + * workflow's Stripe-return effect spends the creation record and voids the + * entry point once per arrival, and a cancel return keeps the entry point + * (the user continues where they were) while still spending the record, so + * a later plan change for that Workspace is never reworded as a creation. */ +const billingReturnRoute = createAreaReturnRoute({ + prefix: "/billing", + storageKey: "billing-return-route", +}); + export function sanitizeBillingReturnRoute(raw: string | null): string { - if ( - raw?.startsWith("/") && - !raw.startsWith("//") && - !raw.startsWith("/billing") - ) { - return raw; - } - return "/"; + return billingReturnRoute.sanitize(raw); +} + +export function recordBillingReturnRoute(): void { + billingReturnRoute.record(); +} + +export function clearBillingReturnRoute(): void { + billingReturnRoute.clear(); } /** - * Records the current route as the Billing Area entry point. Wire this onto - * links that navigate into /billing (the App Sidebar entries); a click while - * already inside the Billing Area keeps the original entry point. + * Whether the page is a Stripe *success* return of a Workspace this tab was + * creating: the pending record's Workspace matches the URL's, and its pay + * id is a non-empty match for the URL's — fail closed, so a record without + * one (Desktop omitted it, or a legacy record) never rewords a later return + * as a creation landing. */ -export function recordBillingReturnRoute(): void { +function arrivedFromCreationLanding(): boolean { if (typeof window === "undefined") { - return; + return false; } - const route = `${window.location.pathname}${window.location.search}`; - if (route.startsWith("/billing")) { - return; + const query = new URLSearchParams(window.location.search); + if (query.get("stripeState") !== "success") { + return false; } - try { - window.sessionStorage.setItem(BILLING_RETURN_ROUTE_STORAGE_KEY, route); - } catch { - // Storage can be unavailable (private browsing); close falls back to home. + const pending = readPendingWorkspaceCreation(); + const workspaceId = query.get("workspaceId"); + if ( + pending == null || + workspaceId == null || + pending.workspaceId !== workspaceId + ) { + return false; } + const payId = query.get("payId"); + return pending.payId != null && pending.payId === payId; } export function readBillingReturnRoute(): string { - if (typeof window === "undefined") { - return "/"; - } - try { - return sanitizeBillingReturnRoute( - window.sessionStorage.getItem(BILLING_RETURN_ROUTE_STORAGE_KEY) - ); - } catch { - return "/"; - } + return arrivedFromCreationLanding() ? "/" : billingReturnRoute.read(); } diff --git a/apps/ui/src/features/billing/billing-tab-shell.tsx b/apps/ui/src/features/billing/billing-tab-shell.tsx index caee7277..15be714d 100644 --- a/apps/ui/src/features/billing/billing-tab-shell.tsx +++ b/apps/ui/src/features/billing/billing-tab-shell.tsx @@ -1,11 +1,12 @@ "use client"; -import { AppIconButton } from "@workspace/ui/components/app-icon-button"; import { cn } from "@workspace/ui/lib/utils"; -import { Calculator, ChartPie, Dock, ReceiptText, X } from "lucide-react"; +import { Calculator, ChartPie, Dock, ReceiptText } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { type ReactNode, useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; + +import { AreaShell } from "@/features/shell/area-shell"; import { readBillingReturnRoute } from "./billing-return-route"; @@ -35,38 +36,6 @@ export function billingTabFromPathname(pathname: string): BillingTab | null { return tab?.value ?? null; } -// The entry point is recorded once per navigation into /billing and never -// changes while the Billing Area is mounted, so the store has nothing to -// publish after the initial read. -const subscribeToNothing = () => () => { - // no-op unsubscribe -}; - -/** - * Close returns to the in-app route the user entered the Billing Area from. - * The server snapshot is the home fallback so server and client render the - * same href; the recorded entry point only exists in the browser and lands - * on the first client render after hydration. - */ -function BillingCloseButton() { - const returnHref = useSyncExternalStore( - subscribeToNothing, - readBillingReturnRoute, - () => "/" - ); - return ( - } - size="lg" - variant="quiet" - > - - - ); -} - export function BillingNavigationFrame({ activeTab, children, @@ -75,60 +44,55 @@ export function BillingNavigationFrame({ children: ReactNode; }) { return ( -
-
-
- -

- Billing -

-
- -
-
- -
-
{children}
-
+ {BILLING_TABS.map((tab) => { + const active = tab.value === activeTab; + const Icon = BILLING_TAB_ICONS[tab.value]; + return ( + + + {tab.label} + + ); + })} + + } + asideClassName="lg:w-50" + closeLabel="Close billing" + icon={ + + } + readReturnRoute={readBillingReturnRoute} + slot="billing-tab-shell" + title="Billing" + > +
+
{children}
-
+ ); } diff --git a/apps/ui/src/features/billing/billing-workspace-creation-dialog.interaction.test.tsx b/apps/ui/src/features/billing/billing-workspace-creation-dialog.interaction.test.tsx new file mode 100644 index 00000000..37c55520 --- /dev/null +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.interaction.test.tsx @@ -0,0 +1,642 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fireEvent, render, within } from "@testing-library/react/pure"; +import { getDefaultStore } from "jotai"; + +import { withTestDom } from "@/features/project-canvas/react-test-harness"; +import { workspacesAtom } from "@/lib/auth-store"; +import type { BillingPlanSnapshot } from "./billing-plan-data"; +import type { BillingWorkspaceCreationServices } from "./billing-workspace-creation-dialog"; +import { WorkspaceNameConflictError } from "./workspace-creation-client"; +import { consumePendingWorkspaceCreation } from "./workspace-creation-return"; + +const PLANS: BillingPlanSnapshot["plans"] = [ + { + changeKind: null, + description: "Free workspace plan", + hasMonthlyPrice: false, + id: "free", + isCurrent: true, + name: "Free", + order: 0, + priceMicroUnits: 0, + resources: [{ label: "CPU", value: "1" }], + }, + { + changeKind: "upgrade", + description: "For growing workloads", + id: "pro", + isCurrent: false, + name: "Pro", + order: 2, + priceMicroUnits: 20_000_000, + resources: [{ label: "CPU", value: "4" }], + }, + { + changeKind: "upgrade", + description: "For larger teams", + id: "team", + isCurrent: false, + name: "Team", + order: 3, + priceMicroUnits: 50_000_000, + resources: [{ label: "CPU", value: "12" }], + }, +]; + +const EXISTING_NAMES = ["private team", "Acme"]; +const CREDENTIALS = { appToken: "desktop-app-token", kubeconfig: "kc" }; +const STARTED = { + invoiceId: "inv-1", + payId: "pay-1", + redirectUrl: "https://checkout.stripe.test/inv-1", + status: "started" as const, +}; +const CREATED = { id: "ns-new00001", name: "Robotics", uid: "uid-new" }; + +function fakeServices( + overrides: Partial = {} +) { + const calls: { kind: string; input: unknown }[] = []; + const services: BillingWorkspaceCreationServices = { + createWorkspace: (input) => { + calls.push({ input, kind: "create" }); + return Promise.resolve({ payment: STARTED, workspace: CREATED }); + }, + openUrl: () => undefined, + redirectTop: (url) => { + calls.push({ input: url, kind: "redirect" }); + }, + retryPayment: (input) => { + calls.push({ input, kind: "retry" }); + return Promise.resolve(STARTED); + }, + ...overrides, + }; + return { calls, services }; +} + +async function mountDialog( + act: Parameters[0]>[0], + services: BillingWorkspaceCreationServices, + onOpenChange: (open: boolean) => void = () => undefined +) { + const { BillingWorkspaceCreationDialog } = await import( + "./billing-workspace-creation-dialog" + ); + let rendered: ReturnType | undefined; + await act(() => { + rendered = render( + + ); + }); + if (rendered == null) { + throw new Error("dialog did not render"); + } + return rendered; +} + +function nameInput(rendered: ReturnType): HTMLInputElement { + return rendered.getByRole("textbox", { + name: "Workspace name", + }) as HTMLInputElement; +} + +/** + * A real focus plus a keyUp flush after the input: React falls back to + * keystroke polling for change detection when react-dom was first loaded + * without a DOM, as happens mid-suite, and drops a bare input event. + */ +async function typeName( + act: Parameters[0]>[0], + rendered: ReturnType, + value: string +) { + await act(() => { + const field = nameInput(rendered); + field.focus(); + fireEvent.input(field, { target: { value } }); + fireEvent.keyUp(field, { key: value.at(-1) ?? "" }); + }); +} + +async function pickPro( + act: Parameters[0]>[0], + rendered: ReturnType +) { + await act(() => { + // Every paid plan reads "Subscribe" in creation; Pro sorts first. + const [subscribe] = rendered.getAllByRole("button", { name: "Subscribe" }); + if (subscribe != null) { + fireEvent.click(subscribe); + } + }); +} + +test("the creation dialog names the Workspace beside the paid plans only", async () => { + await withTestDom(async (act) => { + const { services } = fakeServices(); + const rendered = await mountDialog(act, services); + try { + const dialog = rendered.getByRole("dialog", { name: "New Workspace" }); + assert.ok( + within(dialog).getByRole("textbox", { name: "Workspace name" }) + ); + assert.equal(nameInput(rendered).value, ""); + const text = dialog.textContent ?? ""; + assert.ok(text.includes("Pro")); + assert.ok(text.includes("Team")); + assert.equal(text.includes("Free"), false); + assert.equal( + rendered.getAllByRole("button", { name: "Subscribe" }).length, + 2 + ); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("picking a plan with a bad name reports it inline and never submits", async () => { + await withTestDom(async (act) => { + const { calls, services } = fakeServices(); + const rendered = await mountDialog(act, services); + try { + for (const [value, message] of [ + [" ", "Enter a name for the Workspace."], + ["x".repeat(33), "Use at most 32 characters."], + [" acme ", "A Workspace with this name already exists."], + ] as const) { + await typeName(act, rendered, value); + await pickPro(act, rendered); + assert.equal(rendered.getByRole("alert").textContent, message); + assert.equal(nameInput(rendered).getAttribute("aria-invalid"), "true"); + assert.equal( + rendered.queryByRole("dialog", { name: "Create Workspace" }), + null + ); + } + assert.deepEqual(calls, []); + + // Typing again clears the verdict until the next attempt. + await typeName(act, rendered, "Robotics"); + assert.equal(rendered.queryByRole("alert"), null); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("a valid name and plan confirm, create, and hand the top window to Stripe", async () => { + await withTestDom(async (act) => { + const { calls, services } = fakeServices(); + const rendered = await mountDialog(act, services); + try { + await typeName(act, rendered, " Robotics "); + await pickPro(act, rendered); + + const confirm = rendered.getByRole("dialog", { + name: "Create Workspace", + }); + const summary = confirm.textContent ?? ""; + assert.ok(summary.includes("Robotics")); + assert.ok(summary.includes("Pro")); + assert.ok(summary.includes("$20.00")); + assert.deepEqual(calls, []); + + await act(() => { + fireEvent.click( + within(confirm).getByRole("button", { name: "Create & Pay" }) + ); + }); + + assert.deepEqual(calls, [ + { + input: { + ...CREDENTIALS, + name: "Robotics", + planName: "Pro", + regionDomain: "us.example.test", + }, + kind: "create", + }, + { input: STARTED.redirectUrl, kind: "redirect" }, + ]); + // The return leg tells a creation from a plan change by this record. + assert.equal(consumePendingWorkspaceCreation(CREATED.id), true); + // The page is unloading; a second press must not create a second one. + const creating = within(confirm).getByRole("button", { + name: "Creating…", + }); + assert.equal(creating.hasAttribute("disabled"), true); + assert.equal( + within(confirm).queryByRole("button", { name: "Create & Pay" }), + null + ); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("a taken name Desktop reports lands inline on the field", async () => { + await withTestDom(async (act) => { + const { calls, services } = fakeServices({ + createWorkspace: () => Promise.reject(new WorkspaceNameConflictError()), + }); + const rendered = await mountDialog(act, services); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + + assert.equal( + rendered.queryByRole("dialog", { name: "Create Workspace" }), + null + ); + assert.equal( + rendered.getByRole("alert").textContent, + "A Workspace with this name already exists." + ); + assert.equal( + calls.some((call) => call.kind === "redirect"), + false + ); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("a failed first payment offers to retry it or leave the created Workspace as is", async () => { + await withTestDom(async (act) => { + const closes: boolean[] = []; + const { calls, services } = fakeServices({ + createWorkspace: () => + Promise.resolve({ + payment: { error: "card declined", status: "failed" as const }, + workspace: CREATED, + }), + }); + const rendered = await mountDialog(act, services, (open) => + closes.push(open) + ); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + + const failed = rendered.getByRole("dialog", { + name: "Workspace created", + }); + const text = failed.textContent ?? ""; + assert.ok(text.includes("Robotics")); + assert.ok(text.includes("payment could not be started")); + assert.ok(text.includes("card declined")); + assert.equal(consumePendingWorkspaceCreation(CREATED.id), false); + + await act(() => { + fireEvent.click( + within(failed).getByRole("button", { name: "Retry payment" }) + ); + }); + assert.deepEqual(calls, [ + { + input: { + ...CREDENTIALS, + planName: "Pro", + regionDomain: "us.example.test", + workspaceId: CREATED.id, + }, + kind: "retry", + }, + { input: STARTED.redirectUrl, kind: "redirect" }, + ]); + assert.equal(consumePendingWorkspaceCreation(CREATED.id), true); + assert.equal( + within(failed) + .getByRole("button", { name: "Starting payment…" }) + .hasAttribute("disabled"), + true + ); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("Later closes the whole dialog without touching the created Workspace", async () => { + await withTestDom(async (act) => { + const closes: boolean[] = []; + const { calls, services } = fakeServices({ + createWorkspace: () => + Promise.resolve({ + payment: { error: "card declined", status: "failed" as const }, + workspace: CREATED, + }), + }); + const rendered = await mountDialog(act, services, (open) => + closes.push(open) + ); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Later" })); + }); + assert.deepEqual(closes, [false]); + assert.deepEqual(calls, []); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("a 409 on a name the actor already owns is a created Workspace awaiting its payment, not a taken name", async () => { + await withTestDom(async (act) => { + // Desktop created the Workspace but its answer never landed; the + // refreshed session list now shows the actor owning the name. + const store = getDefaultStore(); + store.set(workspacesAtom, [ + { + createdAt: "2026-09-15T00:00:00.000Z", + id: CREATED.id, + isPersonal: false, + name: "Robotics", + role: "Owner" as const, + uid: CREATED.uid, + }, + ]); + const { calls, services } = fakeServices({ + createWorkspace: () => Promise.reject(new WorkspaceNameConflictError()), + }); + const rendered = await mountDialog(act, services); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + + // Not the duplicate field verdict — the payment-failed offer. + assert.equal(rendered.getByRole("alert") == null, false); + const failed = rendered.getByRole("dialog", { + name: "Workspace created", + }); + assert.ok((failed.textContent ?? "").includes("Robotics")); + + await act(() => { + fireEvent.click( + within(failed).getByRole("button", { name: "Retry payment" }) + ); + }); + assert.deepEqual(calls, [ + { + input: { + ...CREDENTIALS, + planName: "Pro", + regionDomain: "us.example.test", + workspaceId: CREATED.id, + }, + kind: "retry", + }, + { input: STARTED.redirectUrl, kind: "redirect" }, + ]); + } finally { + await act(() => rendered.unmount()); + store.set(workspacesAtom, []); + } + }); +}); + +test("a payment that settles without a checkout hop closes the dialog as done", async () => { + await withTestDom(async (act) => { + const closes: boolean[] = []; + const { calls, services } = fakeServices({ + createWorkspace: () => + Promise.resolve({ + payment: { + invoiceId: "inv-1", + payId: "pay-1", + status: "settled" as const, + }, + workspace: CREATED, + }), + }); + const rendered = await mountDialog(act, services, (open) => + closes.push(open) + ); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + + assert.deepEqual(closes, [false]); + // No Stripe hand-off, no failed-payment offer. + assert.equal( + calls.some((call) => call.kind === "redirect"), + false + ); + assert.equal( + rendered.queryByRole("dialog", { name: "Workspace created" }), + null + ); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +/** Mounts the dialog the way the workflow does — `open` only hides it. */ +async function mountReopenableDialog( + act: Parameters[0]>[0], + services: BillingWorkspaceCreationServices, + onOpenChange: (open: boolean) => void +) { + const { BillingWorkspaceCreationDialog } = await import( + "./billing-workspace-creation-dialog" + ); + const element = (open: boolean) => ( + + ); + let rendered: ReturnType | undefined; + await act(() => { + rendered = render(element(true)); + }); + if (rendered == null) { + throw new Error("dialog did not render"); + } + const setOpen = async (open: boolean) => { + await act(() => { + rendered?.rerender(element(open)); + }); + }; + return { rendered, setOpen }; +} + +test("reopening after Later starts fresh: no stacked offer, no leftover name", async () => { + await withTestDom(async (act) => { + const closes: boolean[] = []; + const { services } = fakeServices({ + createWorkspace: () => + Promise.resolve({ + payment: { error: "card declined", status: "failed" as const }, + workspace: CREATED, + }), + }); + const { rendered, setOpen } = await mountReopenableDialog( + act, + services, + (open) => closes.push(open) + ); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + assert.ok(rendered.getByRole("dialog", { name: "Workspace created" })); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Later" })); + }); + assert.deepEqual(closes, [false]); + + await setOpen(false); + await setOpen(true); + + assert.equal( + rendered.queryByRole("dialog", { name: "Workspace created" }), + null, + "the failed-payment offer does not survive the close" + ); + assert.equal(nameInput(rendered).value, ""); + assert.equal(rendered.queryByRole("alert"), null); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("reopening after a settled creation is not stuck submitting", async () => { + await withTestDom(async (act) => { + const closes: boolean[] = []; + const { services } = fakeServices({ + createWorkspace: () => + Promise.resolve({ + payment: { + invoiceId: "inv-1", + payId: "pay-1", + status: "settled" as const, + }, + workspace: CREATED, + }), + }); + const { rendered, setOpen } = await mountReopenableDialog( + act, + services, + (open) => closes.push(open) + ); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + assert.deepEqual(closes, [false]); + + await setOpen(false); + await setOpen(true); + + // A fresh picker: the name is empty and a new attempt can confirm. + assert.equal(nameInput(rendered).value, ""); + assert.equal( + rendered.queryByRole("button", { name: "Creating…" }), + null, + "not stuck submitting" + ); + await typeName(act, rendered, "Second try"); + await pickPro(act, rendered); + assert.ok(rendered.getByRole("dialog", { name: "Create Workspace" })); + assert.ok( + rendered.getByRole("button", { name: "Create & Pay" }), + "the confirm action is reachable again" + ); + } finally { + await act(() => rendered.unmount()); + } + }); +}); + +test("a name the actor's Personal Workspace carries is a taken name, never a Retry-payment offer", async () => { + await withTestDom(async (act) => { + const store = getDefaultStore(); + store.set(workspacesAtom, [ + { + createdAt: "2026-09-15T00:00:00.000Z", + id: "ns-personal", + isPersonal: true, + name: "Robotics", + role: "Owner" as const, + uid: "uid-personal", + }, + ]); + const { calls, services } = fakeServices({ + createWorkspace: () => Promise.reject(new WorkspaceNameConflictError()), + }); + const rendered = await mountDialog(act, services); + try { + await typeName(act, rendered, "Robotics"); + await pickPro(act, rendered); + await act(() => { + fireEvent.click(rendered.getByRole("button", { name: "Create & Pay" })); + }); + + assert.equal( + rendered.getByRole("alert").textContent, + "A Workspace with this name already exists." + ); + assert.equal( + rendered.queryByRole("dialog", { name: "Workspace created" }), + null, + "the Personal Workspace is never offered a creation payment retry" + ); + assert.equal( + calls.some((call) => call.kind === "retry"), + false + ); + } finally { + await act(() => rendered.unmount()); + store.set(workspacesAtom, []); + } + }); +}); diff --git a/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx new file mode 100644 index 00000000..a5a27810 --- /dev/null +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { AppDialog } from "@workspace/ui/components/app-dialog"; +import { AppInputField } from "@workspace/ui/components/app-input-field"; +import { DialogClose } from "@workspace/ui/components/dialog"; +import { useStore } from "jotai"; +import { X } from "lucide-react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; + +import { formatBillingAmount } from "@/features/billing/billing-amount"; +import type { BillingCredentials } from "@/features/billing/billing-data-client"; +import type { BillingPlanSnapshot } from "@/features/billing/billing-plan-data"; +import { BillingPlanPicker } from "@/features/billing/billing-plan-picker"; +import type { BillingCurrency } from "@/features/billing/config-core"; +import { useWorkspaceRefresh } from "@/features/workspace/use-workspace-refresh"; +import { WORKSPACE_NAME_MAX_LENGTH } from "@/features/workspace/workspace-write-schema"; +import { workspacesAtom } from "@/lib/auth-store"; +import { errorDescription } from "@/lib/toast-utils"; + +import { + createWorkspaceWithSubscription, + retryWorkspaceCreationPayment, + WorkspaceNameConflictError, +} from "./workspace-creation-client"; +import { + WORKSPACE_NAME_ISSUE_MESSAGES, + type WorkspaceNameIssue, + workspaceNameIssue, +} from "./workspace-creation-core"; +import { recordPendingWorkspaceCreation } from "./workspace-creation-return"; +import type { + CreatedWorkspace, + WorkspaceCreationPayment, +} from "./workspace-creation-schema"; + +/** + * The Billing Area's creation mode (spec §G, CONTEXT "Workspace Creation"): + * the plan-change dialog's sibling for a Workspace that does not exist yet. + * The name field and the Plan Picker share one screen; picking a paid plan + * checks the name and stacks a confirmation over the picker; confirming + * runs Brain's two steps and hands the top window to Stripe Checkout — + * a whole-page hop, since the page it returns to belongs to another + * Workspace. Nothing here is gated by the current Workspace's role or + * subscription state: anyone signed in may create. + * + * A failed second step is an outcome the dialog owns: the Workspace exists, + * and the confirmation becomes the offer to retry its first payment or + * leave it Pay-As-You-Go for now. + */ + +export interface BillingWorkspaceCreationServices { + createWorkspace: typeof createWorkspaceWithSubscription; + /** Where the picker's contact plans send the user (a new tab). */ + openUrl: (url: string) => void; + /** The whole-page hop to Stripe Checkout (`window.top`, never a new tab). */ + redirectTop: (url: string) => void; + retryPayment: typeof retryWorkspaceCreationPayment; +} + +const DEFAULT_WORKSPACE_CREATION_SERVICES: BillingWorkspaceCreationServices = { + createWorkspace: createWorkspaceWithSubscription, + openUrl: (url) => { + window.open(url, "_blank", "noopener,noreferrer"); + }, + redirectTop: (url) => { + const top = window.top ?? window; + top.location.href = url; + }, + retryPayment: retryWorkspaceCreationPayment, +}; + +// When the confirmation stacks over the picker's dialog, the root backdrop +// already dims the page and the receded picker (see the checkout dialog). +const NESTED_DIALOG_OVERLAY = + "bg-transparent backdrop-blur-none supports-backdrop-filter:backdrop-blur-none"; + +type SnapshotPlan = BillingPlanSnapshot["plans"][number]; + +/** + * Every plan is new to a Workspace that does not exist: no current plan, no + * pending change, every paid card reads "Subscribe". Contact plans keep + * their sales pointer. + */ +function creationPlans(plans: BillingPlanSnapshot["plans"]): SnapshotPlan[] { + return plans.map((plan) => ({ + ...plan, + changeKind: plan.changeKind === "contact" ? "contact" : "subscribe", + isCurrent: false, + })); +} + +interface BillingWorkspaceCreationDialogProps { + credentials: BillingCredentials; + currency: BillingCurrency; + /** The session's Workspace names, for the inline duplicate check. */ + existingWorkspaceNames: readonly string[]; + gpuEnabled: boolean; + onOpenChange: (open: boolean) => void; + open: boolean; + plans: BillingPlanSnapshot["plans"]; + regionDomain: string; + services?: BillingWorkspaceCreationServices; +} + +type CreationStage = + | { kind: "pick" } + | { kind: "confirm"; plan: SnapshotPlan } + | { + error: string; + kind: "payment-failed"; + plan: SnapshotPlan; + workspace: CreatedWorkspace; + }; + +export function BillingWorkspaceCreationDialog({ + credentials, + currency, + existingWorkspaceNames, + gpuEnabled, + onOpenChange, + open, + plans, + regionDomain, + services = DEFAULT_WORKSPACE_CREATION_SERVICES, +}: BillingWorkspaceCreationDialogProps) { + const inputId = useId(); + const inputRef = useRef(null); + const store = useStore(); + const refreshWorkspaces = useWorkspaceRefresh(); + const [name, setName] = useState(""); + const [nameIssue, setNameIssue] = useState(null); + const [stage, setStage] = useState({ kind: "pick" }); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const pickerPlans = useMemo(() => creationPlans(plans), [plans]); + const trimmedName = name.trim(); + + // A closed dialog is a finished attempt: the workflow keeps this dialog + // mounted (`open` only hides it), so reopening must not inherit the last + // attempt's stage, name, or a submitting state frozen for a Stripe hop + // that never came. `handedOff`'s freeze is for a real top-window hand-off + // — the page is leaving — never for a plain close. + useEffect(() => { + if (open) { + return; + } + setStage({ kind: "pick" }); + setSubmitting(false); + setError(null); + setName(""); + setNameIssue(null); + }, [open]); + + const selectPlan = (planId: string) => { + const issue = workspaceNameIssue(name, existingWorkspaceNames); + setNameIssue(issue); + if (issue != null) { + inputRef.current?.focus(); + return; + } + const plan = pickerPlans.find((candidate) => candidate.id === planId); + if (plan == null) { + return; + } + setError(null); + setStage({ kind: "confirm", plan }); + }; + + const handOffToStripe = ( + workspace: CreatedWorkspace, + payment: Extract + ) => { + recordPendingWorkspaceCreation(workspace.id, payment.payId); + services.redirectTop(payment.redirectUrl); + }; + + const confirm = async (plan: SnapshotPlan) => { + if (submitting) { + return; + } + setSubmitting(true); + setError(null); + // The top-level navigation takes a moment to unload the page; the + // button stays in its submitting state so a second press cannot create + // a second Workspace in the meantime. + let handedOff = false; + try { + const { payment, workspace } = await services.createWorkspace({ + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + name: trimmedName, + planName: plan.name, + regionDomain, + }); + // A Workspace now exists whatever the payment did; the session list + // (the duplicate-name check and the Switcher both read it) must show + // it at once, or a second create under a new name would go through. + refreshWorkspaces({ details: false }).catch(() => undefined); + if (payment.status === "started") { + handedOff = true; + handOffToStripe(workspace, payment); + return; + } + if (payment.status === "settled") { + // Paid without a checkout hop (the terms rule the path out; read + // it as done): close rather than offer a payment that exists. + handedOff = true; + onOpenChange(false); + return; + } + setStage({ + error: payment.error, + kind: "payment-failed", + plan, + workspace, + }); + } catch (cause) { + if (cause instanceof WorkspaceNameConflictError) { + // A 409 on a name this dialog just submitted can be Desktop having + // created the Workspace while its answer never landed (a timeout, + // a malformed envelope). Re-read the list: if the actor now owns a + // *Team* Workspace of that name — the creation's subject, never the + // Personal one a name may share with its user — offer the payment + // retry instead of "taken". The name stays retriable either way: + // a list that has not caught up shows the taken verdict, and the + // next attempt re-runs this recovery. + await refreshWorkspaces({ details: false }).catch(() => undefined); + const owned = store + .get(workspacesAtom) + .find( + (candidate) => + !candidate.isPersonal && + candidate.role === "Owner" && + candidate.name.trim().toLowerCase() === trimmedName.toLowerCase() + ); + if (owned != null) { + setStage({ + error: + "The Workspace was created, but its payment was never started.", + kind: "payment-failed", + plan, + workspace: { + id: owned.id, + name: owned.name, + uid: owned.uid, + }, + }); + return; + } + setNameIssue("duplicate"); + setStage({ kind: "pick" }); + return; + } + setError(errorDescription(cause, "The Workspace could not be created.")); + } finally { + if (!handedOff) { + setSubmitting(false); + } + } + }; + + const retry = async (plan: SnapshotPlan, workspace: CreatedWorkspace) => { + if (submitting) { + return; + } + setSubmitting(true); + setError(null); + let handedOff = false; + try { + const payment = await services.retryPayment({ + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + planName: plan.name, + regionDomain, + workspaceId: workspace.id, + }); + if (payment.status === "started") { + handedOff = true; + handOffToStripe(workspace, payment); + return; + } + if (payment.status === "settled") { + handedOff = true; + onOpenChange(false); + return; + } + setError(payment.error); + } catch (cause) { + setError( + errorDescription( + cause, + "The subscription payment could not be started." + ) + ); + } finally { + if (!handedOff) { + setSubmitting(false); + } + } + }; + + const closeStage = () => { + if (submitting) { + return; + } + // Leaving the failed-payment offer leaves the created Workspace as is + // (spec §G.6): the whole dialog closes rather than returning to a + // picker that would create a second one. + if (stage.kind === "payment-failed") { + onOpenChange(false); + return; + } + setError(null); + setStage({ kind: "pick" }); + }; + + return ( + { + if (!(nextOpen || submitting)) { + onOpenChange(false); + } + }} + open={open} + > + {/* Carries the Canvas Glow material like the plan-change dialog. */} + +
+ + New Workspace + + + Name the Workspace and choose its plan. + + + + +
+ + { + setName(event.target.value); + setNameIssue(null); + }} + ref={inputRef} + value={name} + /> + + + + { + if (!nextOpen) { + closeStage(); + } + }} + open={open && stage.kind !== "pick"} + > + + {stage.kind === "confirm" ? ( + { + confirm(stage.plan).catch(() => undefined); + }} + plan={stage.plan} + submitting={submitting} + /> + ) : null} + {stage.kind === "payment-failed" ? ( + { + retry(stage.plan, stage.workspace).catch(() => undefined); + }} + submitting={submitting} + workspace={stage.workspace} + /> + ) : null} + + +
+
+ ); +} + +function ConfirmStage({ + currency, + error, + name, + onConfirm, + plan, + submitting, +}: { + currency: BillingCurrency; + error: string | null; + name: string; + onConfirm: () => void; + plan: SnapshotPlan; + submitting: boolean; +}) { + return ( + <> + + Create Workspace + + The Workspace is created now; its plan starts once the payment + completes. + + + +
+
Name
+
{name}
+
Plan
+
{plan.name}
+
Price
+
+ {formatBillingAmount(plan.priceMicroUnits, currency)}/month +
+
+ {error == null ? null : ( +

+ {error} +

+ )} +
+ + + + Create & Pay + + + + ); +} + +function PaymentFailedStage({ + error, + onRetry, + submitting, + workspace, +}: { + error: string; + onRetry: () => void; + submitting: boolean; + workspace: CreatedWorkspace; +}) { + return ( + <> + + Workspace created + + “{workspace.name}” has been created, but its payment could not be + started. Until it subscribes, it runs Pay-As-You-Go. + + + +

+ {error} +

+
+ + {/* Closing this stage closes the whole dialog; see `closeStage`. */} + Later + + Retry payment + + + + ); +} + +export type { BillingWorkspaceCreationDialogProps }; diff --git a/apps/ui/src/features/billing/server/billing-route-table.ts b/apps/ui/src/features/billing/server/billing-route-table.ts index 5c03fc66..554aa150 100644 --- a/apps/ui/src/features/billing/server/billing-route-table.ts +++ b/apps/ui/src/features/billing/server/billing-route-table.ts @@ -109,12 +109,29 @@ export const BILLING_ROUTES = { apiPath: "/api/billing/workspace-consumption", upstreamPathname: "/account/v1alpha1/costs/workspace/consumption", }, + // Brain's own two-step write (spec §G.3): Desktop `namespace/create`, + // then account-service's pay — no single upstream, so a Brain dispatch key. + workspaceCreate: { + apiPath: "/api/billing/workspace-create", + upstreamPathname: "brain:workspace/create", + }, + // Step 2 alone, for a Workspace whose first payment could not start (§G.7). + workspaceCreateRetryPayment: { + apiPath: "/api/billing/workspace-create/retry-payment", + upstreamPathname: "brain:workspace/create/retry-payment", + }, // Brain's own read (ADR-0082): the Workspace Owner standing off the // namespace, judged with the verified crName — no account-service upstream. workspaceOwner: { apiPath: "/api/billing/workspace-owner", upstreamPathname: "brain:workspace/owner", }, + // Brain's own read (spec §C.5): the plan name per Workspace for the + // Workspace Switcher, assembled from per-Workspace subscription reads. + workspacePlans: { + apiPath: "/api/billing/workspace-plans", + upstreamPathname: "brain:workspace/plans", + }, workspaceQuota: { apiPath: "/api/billing/workspace-quota", upstreamPathname: "/account/v1alpha1/workspace/get-resource-quota", diff --git a/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts b/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts index 15c680cf..fff68023 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts @@ -18,7 +18,13 @@ import { BILLING_DEV_SCENARIOS, formatBillingDevMockCookie, } from "../../dev-mock-cookie"; +import { + WORKSPACE_NAME_CONFLICT_CODE, + workspaceCreationResponseSchema, + workspaceCreationRetryResponseSchema, +} from "../../workspace-creation-schema"; import { parseWorkspaceOwnerStanding } from "../../workspace-owner"; +import { loadWorkspacePlans } from "../../workspace-plans-data"; import { BILLING_ROUTES } from "../billing-route-table"; import { judgeWorkspaceBillingStanding } from "../billing-standing-core"; import { billingDevMockResponse, freeChatTurnsFixture } from "./index"; @@ -89,9 +95,10 @@ const CREDITLESS_SCENARIOS = new Set([ ]); function loadPlanForScenario(scenario: string) { - return loadBillingPlanSnapshot(CREDENTIALS, { - fetch: mockFetchFor(scenario), - }); + return loadBillingPlanSnapshot( + { ...CREDENTIALS, workspaceRole: "Owner" }, + { fetch: mockFetchFor(scenario) } + ); } test("the free-turns fixture spends the trial's allowance with the scenario (ADR-0073)", () => { @@ -647,3 +654,101 @@ test("unknown scenarios fail loud instead of falling through", async () => { ); assert.equal(response?.status, 500); }); + +test("every scenario answers the Switcher's plan read: the scenario's plan everywhere, PAYG for the Sandbox", async () => { + for (const scenario of BILLING_DEV_SCENARIOS) { + const plans = await loadWorkspacePlans( + CREDENTIALS, + ["ns-test", "ns-mocksand"], + mockFetchFor(scenario) + ); + const snapshot = await loadPlanForScenario(scenario); + assert.equal( + plans["ns-test"], + snapshot.current.isPayg ? null : snapshot.current.planName, + `${scenario}: the current Workspace's badge matches the Plan view` + ); + assert.equal(plans["ns-mocksand"], null, `${scenario}: Sandbox is PAYG`); + } +}); + +function workspaceCreateRequest( + entry: { apiPath: string }, + body: Record +): Request { + return mockRequest(entry.apiPath, "active", { + body: JSON.stringify({ + payMethod: "stripe", + period: "1m", + planName: "Pro", + regionDomain: "mock.sealos.run", + ...body, + }), + method: "POST", + }); +} + +test("workspace creation answers a created Workspace and a checkout URL back into Billing", async () => { + const entry = BILLING_ROUTES.workspaceCreate; + const response = await billingDevMockResponse( + entry.upstreamPathname, + workspaceCreateRequest(entry, { name: " Robotics " }) + ); + assert.equal(response?.status, 200); + assert.equal(scenarioFromSetCookie(response as Response), null); + const payload = workspaceCreationResponseSchema.parse(await response?.json()); + assert.equal(payload.workspace.name, "Robotics"); + assert.ok(payload.workspace.id.startsWith("ns-")); + assert.equal(payload.payment.status, "started"); + if (payload.payment.status !== "started") { + return; + } + // The mock skips Stripe: the top-level redirect lands straight on the + // Billing Area's Stripe return for the new Workspace, on this origin. + const landing = new URL(payload.payment.redirectUrl); + assert.equal(landing.origin, "http://localhost"); + assert.equal(landing.pathname, "/billing"); + assert.equal(landing.searchParams.get("stripeState"), "success"); + assert.equal(landing.searchParams.get("workspaceId"), payload.workspace.id); + assert.ok(landing.searchParams.get("payId")); +}); + +test("workspace creation mocks the taken name, the failed first payment, and its retry", async () => { + const entry = BILLING_ROUTES.workspaceCreate; + const conflict = await billingDevMockResponse( + entry.upstreamPathname, + workspaceCreateRequest(entry, { name: "Conflict" }) + ); + assert.equal(conflict?.status, 409); + const conflictPayload = (await conflict?.json()) as { code: string }; + assert.equal(conflictPayload.code, WORKSPACE_NAME_CONFLICT_CODE); + + const failed = await billingDevMockResponse( + entry.upstreamPathname, + workspaceCreateRequest(entry, { name: "Payfail Labs" }) + ); + assert.equal(failed?.status, 200); + const failedPayload = workspaceCreationResponseSchema.parse( + await failed?.json() + ); + assert.equal(failedPayload.payment.status, "failed"); + + const retryEntry = BILLING_ROUTES.workspaceCreateRetryPayment; + const retried = await billingDevMockResponse( + retryEntry.upstreamPathname, + workspaceCreateRequest(retryEntry, { + workspaceId: failedPayload.workspace.id, + }) + ); + assert.equal(retried?.status, 200); + const retriedPayload = workspaceCreationRetryResponseSchema.parse( + await retried?.json() + ); + assert.equal(retriedPayload.payment.status, "started"); + + const invalid = await billingDevMockResponse( + entry.upstreamPathname, + workspaceCreateRequest(entry, { name: "" }) + ); + assert.equal(invalid?.status, 400); +}); diff --git a/apps/ui/src/features/billing/server/dev-fixtures/index.ts b/apps/ui/src/features/billing/server/dev-fixtures/index.ts index 0baeecda..8c1e84cb 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -7,8 +7,15 @@ import { type DevMockResolution, resolveDevMock, } from "@/features/dev-mock/server/resolve"; -import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; +import { + WORKSPACE_NAME_CONFLICT_CODE, + WORKSPACE_NAME_CONFLICT_MESSAGE, + type WorkspaceCreationPayment, + workspaceCreationRequestSchema, + workspaceCreationRetryRequestSchema, +} from "../../workspace-creation-schema"; +import { workspacePlanNameFromSubscription } from "../../workspace-plan-name"; import { WORKSPACE_OWNER_FIXTURE_PATHNAME } from "./pathnames"; /** @@ -35,7 +42,11 @@ import { WORKSPACE_OWNER_FIXTURE_PATHNAME } from "./pathnames"; interface FixtureContext { body: Record; + /** The request's origin, for answers that carry a URL back into Brain. */ + origin: string; scenario: BillingDevScenario; + /** The request's query string, for the GET routes that read it. */ + searchParams: URLSearchParams; workspace: string; } @@ -165,16 +176,8 @@ function daysFromNow(days: number): string { return new Date(Date.now() + days * DAY_IN_MILLISECONDS).toISOString(); } -function defaultWorkspace(): string { - try { - const decoded = decodeURIComponent( - process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG ?? "" - ).trim(); - return namespaceFromKubeconfigText(decoded) ?? "ns-mock"; - } catch { - return "ns-mock"; - } -} +/** The workspace fixtures address when the request names none. */ +const DEFAULT_MOCK_WORKSPACE = "ns-mock"; const MOCK_INVOICE_INFO = { ID: "inv-mock-1", @@ -639,7 +642,32 @@ function appCostsPayload(context: FixtureContext): unknown { }; } +/** + * The session Dev Mock's Sandbox Workspace (`features/session/server/ + * dev-fixtures.ts`): the one Switcher row that always reads PAYG, so a + * mock session shows a badge-less row beside the scenario's plan. + */ +const PAYG_SWITCHER_WORKSPACE = "ns-mocksand"; + const FIXTURES: Record unknown> = { + // Brain's own read (spec §C.5): the plan per Workspace for the Switcher. + // Every requested Workspace carries the scenario's plan (so the current + // Workspace agrees with the Plan view), except the Sandbox, which is PAYG. + [BILLING_ROUTES.workspacePlans.upstreamPathname]: ({ + scenario, + searchParams, + }) => { + const plans: Record = {}; + for (const workspace of searchParams.getAll("workspace")) { + plans[workspace] = + workspace === PAYG_SWITCHER_WORKSPACE + ? null + : workspacePlanNameFromSubscription({ + subscription: subscriptionPayload(scenario, workspace), + }); + } + return { plans }; + }, // Brain's own read (ADR-0082): the Workspace Owner standing off the // namespace's platform marks, answered under a Brain dispatch key. [WORKSPACE_OWNER_FIXTURE_PATHNAME]: ({ scenario }) => @@ -888,6 +916,47 @@ interface WriteFixtureResult { /** Scenario the successful write moves the session to. */ nextScenario: BillingDevScenario; payload: unknown; + /** Answers other than 200 (a 409 for a taken name, a 400 for a bad body). */ + status?: number; +} + +const MOCK_CREATED_WORKSPACE_ID = "ns-mock-created"; +const MOCK_CREATED_WORKSPACE_UID = "mock-created-0000-4000-8000-000000000000"; +/** A creation name containing this fails Step 2, so the retry dialog can be clicked through. */ +const MOCK_PAYMENT_FAILURE_MARK = "payfail"; +/** This creation name (case-insensitively) is "already taken", like Desktop's 409. */ +const MOCK_TAKEN_WORKSPACE_NAME = "conflict"; + +/** + * Workspace Creation's Step 2 as the mock answers it: no Stripe hop — the + * "checkout URL" is the Billing Area's own Stripe return for the new + * Workspace on this origin, so the top-level redirect lands where the real + * round-trip would. + */ +function mockWorkspaceCreationPayment( + context: FixtureContext, + input: { + /** Text whose "payfail" fails the payment: the creation's name; nothing on a retry. */ + failWhenMarked: string; + workspaceId: string; + } +): WorkspaceCreationPayment { + if (input.failWhenMarked.toLowerCase().includes(MOCK_PAYMENT_FAILURE_MARK)) { + return { + error: "Mock payment refused (the name says so).", + status: "failed", + }; + } + const landing = new URL("/billing", context.origin); + landing.searchParams.set("stripeState", "success"); + landing.searchParams.set("payId", MOCK_CHECKOUT_PAY_ID); + landing.searchParams.set("workspaceId", input.workspaceId); + return { + invoiceId: MOCK_CHECKOUT_INVOICE_ID, + payId: MOCK_CHECKOUT_PAY_ID, + redirectUrl: landing.toString(), + status: "started", + }; } /** @@ -920,6 +989,65 @@ const WRITE_FIXTURES: Record< nextScenario: context.scenario, payload: { id: "mock-cancellation-survey", ok: true }, }), + // Workspace Creation (spec §G): the two-step write answers in place — + // creation is not a subscription state of the current Workspace, so the + // scenario stays. The name picks the branch: "conflict" is taken, a name + // with "payfail" creates the Workspace but fails its first payment. + [BILLING_ROUTES.workspaceCreate.upstreamPathname]: (context) => { + const parsed = workspaceCreationRequestSchema.safeParse(context.body); + if (!parsed.success) { + return { + nextScenario: context.scenario, + payload: { error: "Invalid workspace creation request." }, + status: 400, + }; + } + const { name } = parsed.data; + if (name.toLowerCase() === MOCK_TAKEN_WORKSPACE_NAME) { + return { + nextScenario: context.scenario, + payload: { + code: WORKSPACE_NAME_CONFLICT_CODE, + error: WORKSPACE_NAME_CONFLICT_MESSAGE, + }, + status: 409, + }; + } + return { + nextScenario: context.scenario, + payload: { + payment: mockWorkspaceCreationPayment(context, { + failWhenMarked: name, + workspaceId: MOCK_CREATED_WORKSPACE_ID, + }), + workspace: { + id: MOCK_CREATED_WORKSPACE_ID, + name, + uid: MOCK_CREATED_WORKSPACE_UID, + }, + }, + }; + }, + [BILLING_ROUTES.workspaceCreateRetryPayment.upstreamPathname]: (context) => { + const parsed = workspaceCreationRetryRequestSchema.safeParse(context.body); + if (!parsed.success) { + return { + nextScenario: context.scenario, + payload: { error: "Invalid workspace payment retry request." }, + status: 400, + }; + } + // A retry always starts: the failed first payment was the name's doing. + return { + nextScenario: context.scenario, + payload: { + payment: mockWorkspaceCreationPayment(context, { + failWhenMarked: "", + workspaceId: parsed.data.workspaceId, + }), + }, + }; + }, "/account/v1alpha1/workspace-subscription/pay": (context) => { const operator = typeof context.body.operator === "string" ? context.body.operator : ""; @@ -1016,7 +1144,7 @@ export function resolveBillingDevMock( export function billingDevMockWorkspace(requested: unknown): string { return typeof requested === "string" && requested.trim() !== "" ? requested - : defaultWorkspace(); + : DEFAULT_MOCK_WORKSPACE; } export async function billingDevMockResponse( @@ -1037,9 +1165,12 @@ export async function billingDevMockResponse( typeof payload === "object" && payload != null ? (payload as Record) : {}; + const requestUrl = new URL(request.url); const context: FixtureContext = { body, + origin: requestUrl.origin, scenario, + searchParams: requestUrl.searchParams, workspace: billingDevMockWorkspace(body.workspace), }; @@ -1052,6 +1183,7 @@ export async function billingDevMockResponse( result.nextScenario === scenario ? undefined : transitionHeaders(result.nextScenario), + status: result.status ?? 200, }); } } diff --git a/apps/ui/src/features/billing/server/dev-fixtures/scenario-test-fetch.ts b/apps/ui/src/features/billing/server/dev-fixtures/scenario-test-fetch.ts index a8e2bc21..2acefca7 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/scenario-test-fetch.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/scenario-test-fetch.ts @@ -17,27 +17,25 @@ const ROUTE_TO_UPSTREAM = new Map( ]) ); -function requestPathname(input: Parameters[0]): string { +function requestUrl(input: Parameters[0]): URL { if (typeof input === "string") { - return input; + return new URL(input, "http://localhost"); } if (input instanceof URL) { - return input.pathname; + return input; } - return new URL(input.url, "http://localhost").pathname; + return new URL(input.url, "http://localhost"); } export function scenarioTestFetch(scenario: string): BillingFetch { return async (input, init) => { - const pathname = requestPathname(input); - const upstream = ROUTE_TO_UPSTREAM.get(pathname); + const url = requestUrl(input); + const upstream = ROUTE_TO_UPSTREAM.get(url.pathname); if (upstream == null) { - throw new Error(`route ${pathname} has no upstream mapping`); + throw new Error(`route ${url.pathname} has no upstream mapping`); } - const request = new Request( - new URL(pathname, "http://localhost"), - init ?? undefined - ); + // The query rides along: the GET routes read it (workspace-plans). + const request = new Request(url, init ?? undefined); request.headers.set("cookie", `${BILLING_DEV_MOCK_COOKIE}=${scenario}`); const response = await billingDevMockResponse(upstream, request); if (response == null) { diff --git a/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts b/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts new file mode 100644 index 00000000..d22c1fc1 --- /dev/null +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { FakeDesktopOptions } from "@/features/session/server/desktop-test-double"; +import type { AccountServiceRequest } from "@/lib/account-service/client-core"; +import type { WorkspaceActorAuthorization } from "@/lib/request-kubeconfig-auth"; +import { + WORKSPACE_NAME_CONFLICT_CODE, + WORKSPACE_NAME_CONFLICT_MESSAGE, +} from "../workspace-creation-schema"; +import type { WorkspaceCreationRouteDependencies } from "./workspace-creation-handlers"; + +mock.module("server-only", () => ({})); +const { + createBillingWorkspaceCreateHandler, + createBillingWorkspaceCreateRetryPaymentHandler, +} = await import("./workspace-creation-handlers"); +const { BILLING_ROUTES } = await import("./billing-route-table"); +const { createFakeDesktop } = await import( + "@/features/session/server/desktop-test-double" +); + +const DEV_ENV = { + DESKTOP_API_BASE_URL: "http://sealos-desktop.sealos.svc:3000", + NODE_ENV: "development", +}; +const APP_TOKEN = "app.token/with+chars"; +const DESKTOP_CREATE_PATH = "/api/auth/namespace/create"; +const PAY_PATH = BILLING_ROUTES.subscriptionPay.upstreamPathname; +const CREATED = { + createTime: "2026-09-15T00:00:00.000Z", + id: "ns-new00001", + nstype: 0, + role: 0, + teamName: "Robotics", + uid: "33333333-3333-4333-8333-333333333333", +}; + +const VERIFIED_ACTOR = { + actorBinding: { + crName: "alice-cr", + mintedAt: 1_753_600_000, + userId: "user-alice", + userUid: "uid-alice", + }, + namespace: "ns-abc12345", + ok: true, + workspaceActor: "alice-cr", +} satisfies WorkspaceActorAuthorization; + +const VALID_CREATE_BODY = { + name: " Robotics ", + payMethod: "stripe", + period: "1m", + planName: "Pro", + promotionCode: "SAVE20", + regionDomain: "us.example.test", +}; + +const VALID_RETRY_BODY = { + payMethod: "stripe", + period: "1m", + planName: "Pro", + regionDomain: "us.example.test", + workspaceId: CREATED.id, +}; + +interface LogEntry { + fields: Record; + message: string; +} + +function billingRequest( + apiPath: string, + input: { body?: unknown; rawBody?: string; token?: string | null } = {} +): Request { + const headers: Record = { + Authorization: "Bearer encoded-kubeconfig", + "Content-Type": "application/json", + }; + if (input.token !== null) { + headers["X-Sealos-App-Token"] = input.token ?? APP_TOKEN; + } + return new Request(`https://brain.example.test${apiPath}`, { + body: input.rawBody ?? JSON.stringify(input.body ?? {}), + headers, + method: "POST", + }); +} + +function paymentAnswer(): Response { + return Response.json({ + invoiceID: "invoice-1", + payID: "pay-1", + redirectUrl: "https://checkout.stripe.test/invoice-1", + success: true, + }); +} + +const CREATE_PATH = BILLING_ROUTES.workspaceCreate.apiPath; +const RETRY_PATH = BILLING_ROUTES.workspaceCreateRetryPayment.apiPath; +const PLANS_PATH = BILLING_ROUTES.plans.upstreamPathname; + +/** The priced-plan catalog answer: Pro priced, Free not. */ +function planListAnswer(): Response { + return Response.json({ + plans: [ + { + ID: "plan-free", + MaxResources: {}, + Name: "Free", + Prices: [{ BillingCycle: "1m", Price: 0 }], + }, + { + ID: "plan-pro", + MaxResources: {}, + Name: "Pro", + Prices: [ + { BillingCycle: "1m", Price: 999 }, + { BillingCycle: "1y", Price: 9999 }, + ], + }, + ], + }); +} + +function harness( + input: { + answers?: FakeDesktopOptions["answers"]; + authorize?: () => Promise; + env?: Record; + pay?: (request: AccountServiceRequest) => Response; + planList?: (request: AccountServiceRequest) => Response; + } = {} +) { + const desktop = createFakeDesktop({ + answers: input.answers ?? { + [DESKTOP_CREATE_PATH]: { code: 200, data: { namespace: CREATED } }, + }, + }); + const accountRequests: AccountServiceRequest[] = []; + const logs: LogEntry[] = []; + const dependencies: WorkspaceCreationRouteDependencies = { + authorizeWorkspaceActor: + input.authorize ?? (() => Promise.resolve(VERIFIED_ACTOR)), + env: input.env ?? DEV_ENV, + fetchDesktop: desktop.fetch, + log: (message, fields) => logs.push({ fields, message }), + requestAccountService: (request) => { + accountRequests.push(request); + if (request.pathname === PLANS_PATH) { + return Promise.resolve((input.planList ?? planListAnswer)(request)); + } + return Promise.resolve((input.pay ?? paymentAnswer)(request)); + }, + }; + return { + accountRequests, + create: createBillingWorkspaceCreateHandler(dependencies), + desktopCalls: desktop.calls, + logs, + retry: createBillingWorkspaceCreateRetryPaymentHandler(dependencies), + }; +} + +describe(`POST ${CREATE_PATH}`, () => { + it("creates the Workspace with the raw app token, then starts the payment as Brain", async () => { + const { accountRequests, create, desktopCalls } = harness(); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + payment: { + invoiceId: "invoice-1", + payId: "pay-1", + redirectUrl: "https://checkout.stripe.test/invoice-1", + status: "started", + }, + workspace: { id: CREATED.id, name: "Robotics", uid: CREATED.uid }, + }); + expect(desktopCalls).toEqual([ + { + authorization: APP_TOKEN, + body: { teamName: "Robotics", userType: "subscription" }, + method: "POST", + path: DESKTOP_CREATE_PATH, + }, + ]); + expect(accountRequests.map((request) => request.pathname)).toEqual([ + PLANS_PATH, + PAY_PATH, + ]); + const pay = accountRequests[1]; + expect(pay?.pathname).toBe(PAY_PATH); + expect(pay?.actor).toEqual({ userId: "user-alice", userUid: "uid-alice" }); + expect(pay?.init?.method).toBe("POST"); + expect(JSON.parse(String(pay?.init?.body))).toEqual({ + operator: "created", + payApp: "system-brain", + payMethod: "stripe", + period: "1m", + planName: "Pro", + promotionCode: "SAVE20", + regionDomain: "us.example.test", + workspace: CREATED.id, + }); + }); + + it("answers 409 with the name-conflict code when Desktop refuses the name, never paying", async () => { + const { accountRequests, create } = harness({ + answers: { + [DESKTOP_CREATE_PATH]: { + code: 409, + message: "The team is already exist", + }, + }, + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: WORKSPACE_NAME_CONFLICT_CODE, + error: WORKSPACE_NAME_CONFLICT_MESSAGE, + }); + expect(accountRequests.map((request) => request.pathname)).toEqual([ + PLANS_PATH, + ]); + }); + + it("refuses a plan the catalog does not price, before Desktop creates anything", async () => { + const { accountRequests, create, desktopCalls } = harness(); + const response = await create( + billingRequest(CREATE_PATH, { + body: { ...VALID_CREATE_BODY, planName: "Free" }, + }) + ); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain("priced"); + expect(desktopCalls).toEqual([]); + expect(accountRequests.map((request) => request.pathname)).toEqual([ + PLANS_PATH, + ]); + }); + + it("refuses creation when the plan catalog cannot be read, before Desktop creates anything", async () => { + const { create, desktopCalls } = harness({ + planList: () => Response.json({ error: "down" }, { status: 500 }), + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + + expect(response.status).toBe(502); + expect(desktopCalls).toEqual([]); + }); + + it("reads a paid answer without a checkout URL as settled, not failed", async () => { + const { create } = harness({ + pay: () => + Response.json({ + invoiceID: "invoice-1", + payID: "pay-1", + success: true, + }), + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + payment: { invoiceId: "invoice-1", payId: "pay-1", status: "settled" }, + workspace: { id: CREATED.id, name: "Robotics", uid: CREATED.uid }, + }); + }); + + it("answers 200 with a failed payment — never an error that hides the Workspace — when the pay call throws", async () => { + const { create } = harness({ + pay: () => { + throw new Error("connection reset"); + }, + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + payment: { status: string }; + workspace: { id: string }; + }; + expect(payload.workspace.id).toBe(CREATED.id); + expect(payload.payment.status).toBe("failed"); + }); + + it("translates Desktop's other refusals and transport failures without its message text", async () => { + for (const [answer, status] of [ + [{ code: 403, message: "max workspaces" }, 403], + [{ code: 401, message: "token verify error" }, 401], + [{ code: 500, message: "failed to create team" }, 502], + [new Response("bad gateway", { status: 502 }), 502], + [Object.assign(new Error("timed out"), { name: "TimeoutError" }), 504], + [new Error("connection refused"), 502], + ] as const) { + const { accountRequests, create, logs } = harness({ + answers: { [DESKTOP_CREATE_PATH]: answer }, + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + expect(response.status).toBe(status); + const payload = (await response.json()) as { error: string }; + expect(payload.error).not.toContain("max workspaces"); + expect(payload.error).not.toContain("failed to create team"); + expect( + accountRequests.filter((request) => request.pathname === PAY_PATH) + ).toEqual([]); + expect(JSON.stringify(logs)).not.toContain(APP_TOKEN); + expect(JSON.stringify(logs)).not.toContain("encoded-kubeconfig"); + } + }); + + it("answers 200 with a failed payment when Step 2 fails after the Workspace exists", async () => { + for (const pay of [ + () => Response.json({ error: "card declined" }, { status: 402 }), + () => + Response.json( + { error: "Account service is unavailable." }, + { + status: 502, + } + ), + () => Response.json({ success: false }), + () => new Response("not json"), + ]) { + const { create, logs } = harness({ pay }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + payment: { error: string; status: string }; + workspace: { id: string }; + }; + expect(payload.workspace.id).toBe(CREATED.id); + expect(payload.payment.status).toBe("failed"); + expect(payload.payment.error.length).toBeGreaterThan(0); + expect(JSON.stringify(logs)).not.toContain(APP_TOKEN); + } + }); + + it("answers 400 for an invalid body, never calling Desktop", async () => { + const { accountRequests, create, desktopCalls } = harness(); + for (const body of [ + {}, + { ...VALID_CREATE_BODY, name: " " }, + { ...VALID_CREATE_BODY, name: "x".repeat(33) }, + { ...VALID_CREATE_BODY, planName: "" }, + { ...VALID_CREATE_BODY, period: "2m" }, + { ...VALID_CREATE_BODY, payMethod: "cash" }, + // A balance payment would settle without a redirect (spec §G.4). + { ...VALID_CREATE_BODY, payMethod: "balance" }, + { ...VALID_CREATE_BODY, regionDomain: undefined }, + ]) { + const response = await create(billingRequest(CREATE_PATH, { body })); + expect(response.status).toBe(400); + } + const nonJson = await create( + billingRequest(CREATE_PATH, { rawBody: "not json" }) + ); + expect(nonJson.status).toBe(400); + expect(desktopCalls).toEqual([]); + expect(accountRequests).toEqual([]); + }); + + it("refuses a failed actor binding with 401 before touching Desktop", async () => { + const { accountRequests, create, desktopCalls } = harness({ + authorize: () => + Promise.resolve({ + code: "app_token_mismatch", + message: "App token does not match the authenticated actor.", + ok: false, + status: 403, + }), + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: "Authentication is required.", + }); + expect(desktopCalls).toEqual([]); + expect(accountRequests).toEqual([]); + }); + + it("answers 502 when Desktop is not configured", async () => { + const { create, desktopCalls } = harness({ + env: { NODE_ENV: "development" }, + }); + const response = await create( + billingRequest(CREATE_PATH, { body: VALID_CREATE_BODY }) + ); + expect(response.status).toBe(502); + expect(desktopCalls).toEqual([]); + }); +}); + +describe(`POST ${RETRY_PATH}`, () => { + it("redoes only Step 2 for the created Workspace", async () => { + const { accountRequests, desktopCalls, retry } = harness(); + const response = await retry( + billingRequest(RETRY_PATH, { body: VALID_RETRY_BODY }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + payment: { + invoiceId: "invoice-1", + payId: "pay-1", + redirectUrl: "https://checkout.stripe.test/invoice-1", + status: "started", + }, + }); + expect(desktopCalls).toEqual([]); + expect(accountRequests.map((request) => request.pathname)).toEqual([ + PLANS_PATH, + PAY_PATH, + ]); + expect(JSON.parse(String(accountRequests[1]?.init?.body))).toEqual({ + operator: "created", + payApp: "system-brain", + payMethod: "stripe", + period: "1m", + planName: "Pro", + regionDomain: "us.example.test", + workspace: CREATED.id, + }); + }); + + it("answers 200 with a failed payment when account-service refuses again", async () => { + const { retry } = harness({ + pay: () => Response.json({ error: "card declined" }, { status: 402 }), + }); + const response = await retry( + billingRequest(RETRY_PATH, { body: VALID_RETRY_BODY }) + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + payment: { error: "card declined", status: "failed" }, + }); + }); + + it("answers 400 for an invalid body and 401 for a failed binding", async () => { + const { accountRequests, retry } = harness(); + for (const body of [ + {}, + { ...VALID_RETRY_BODY, workspaceId: " " }, + { ...VALID_RETRY_BODY, period: "1w" }, + ]) { + const response = await retry(billingRequest(RETRY_PATH, { body })); + expect(response.status).toBe(400); + } + expect(accountRequests).toEqual([]); + + const unauthorized = harness({ + authorize: () => + Promise.resolve({ + code: "workspace_actor_required", + message: "A verified Workspace Actor is required.", + ok: false, + status: 403, + }), + }); + const response = await unauthorized.retry( + billingRequest(RETRY_PATH, { body: VALID_RETRY_BODY }) + ); + expect(response.status).toBe(401); + expect(unauthorized.accountRequests).toEqual([]); + }); +}); diff --git a/apps/ui/src/features/billing/server/workspace-creation-handlers.ts b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts new file mode 100644 index 00000000..f26ee833 --- /dev/null +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts @@ -0,0 +1,403 @@ +import "server-only"; + +import type { z } from "zod"; + +import { + createDesktopAuthApi, + type DesktopAuthApi, +} from "@/features/session/server/desktop-auth-api"; +import { + createDesktopClient, + type DesktopCallFailure, + type DesktopFetch, + desktopApiBaseUrlFromEnv, +} from "@/features/session/server/desktop-client"; +import { desktopFailureLogFields } from "@/features/workspace/server/workspace-route-context"; +import { appTokenFromRequest } from "@/lib/app-token"; + +import { billingPlansResponseSchema } from "../billing-plan-catalog"; +import { + type CreatedWorkspace, + WORKSPACE_NAME_CONFLICT_CODE, + WORKSPACE_NAME_CONFLICT_MESSAGE, + type WorkspaceCreationPayment, + type WorkspacePaymentTerms, + workspaceCreationRequestSchema, + workspaceCreationRetryRequestSchema, +} from "../workspace-creation-schema"; +import { + authorizeBillingActor, + type BillingProxyDependencies, +} from "./authorized-proxy"; +import { BILLING_ROUTES } from "./billing-route-table"; + +/** + * Workspace Creation's routes (spec §G.3, §G.7), the costcenter's two steps + * replayed by Brain: Step 1 asks Desktop for a Team Workspace with the + * request's app token — raw, the form Desktop's `create` verifies — and + * Step 2 asks account-service to start the first subscription payment as + * Brain (`operator: created`, `payApp: system-brain`). A taken name is the + * one failure the page must tell apart (409); every other Desktop failure + * is translated without its message text. Once the Workspace exists, a + * failed Step 2 is an outcome, not an error: the route answers 200 with + * `payment.status = failed` and the retry route redoes Step 2 alone. + * Nothing here logs a token. + */ + +export type WorkspaceCreationRouteLog = ( + message: string, + fields: Record +) => void; + +export interface WorkspaceCreationRouteDependencies + extends BillingProxyDependencies { + env?: Record; + fetchDesktop?: DesktopFetch; + log?: WorkspaceCreationRouteLog; +} + +type RouteHandler = (request: Request) => Promise; + +/** The account-service actor a Billing write runs as. */ +interface PayingActor { + userId: string; + userUid: string; +} + +function errorResponse(error: string, status: number, code?: string): Response { + return Response.json(code == null ? { error } : { code, error }, { + headers: { "cache-control": "no-store" }, + status, + }); +} + +function jsonResponse(payload: unknown): Response { + return Response.json(payload, { headers: { "cache-control": "no-store" } }); +} + +/** + * The preamble both routes share: the Workspace Actor proven the way every + * Billing write proves it (a missing binding or legacy id → 401), then the + * route's zod body (→ 400). account-service still addresses the actor by + * the legacy id as well. + */ +async function authorizedCreationRequest( + request: Request, + dependencies: WorkspaceCreationRouteDependencies, + schema: z.ZodType, + invalidMessage: string +): Promise< + | { actor: PayingActor; body: T; response?: never } + | { actor?: never; body?: never; response: Response } +> { + const actor = await authorizeBillingActor( + request, + dependencies.authorizeWorkspaceActor + ); + if (!actor.ok) { + return { response: actor.response }; + } + if (actor.userId === "") { + return { response: errorResponse("Authentication is required.", 401) }; + } + const payload: unknown = await request.json().catch(() => null); + const parsed = schema.safeParse(payload); + if (!parsed.success) { + return { response: errorResponse(invalidMessage, 400) }; + } + return { + actor: { userId: actor.userId, userUid: actor.userUid }, + body: parsed.data, + }; +} + +const DESKTOP_CODE_STATUSES: Record = { + 400: 400, + 401: 401, + 403: 403, +}; + +/** Desktop's envelope code or transport failure → Brain's own answer (§B.1). */ +function desktopCreateFailureResponse(failure: DesktopCallFailure): Response { + if (failure.kind === "desktop_code") { + if (failure.code === 409) { + return errorResponse( + WORKSPACE_NAME_CONFLICT_MESSAGE, + 409, + WORKSPACE_NAME_CONFLICT_CODE + ); + } + const status = DESKTOP_CODE_STATUSES[failure.code]; + return status == null + ? errorResponse("Desktop could not create the Workspace.", 502) + : errorResponse("Desktop refused to create the Workspace.", status); + } + if (failure.kind === "timeout") { + return errorResponse("Desktop did not answer in time.", 504); + } + return errorResponse("Desktop is unavailable.", 502); +} + +function upstreamErrorText(payload: unknown, fallback: string): string { + if ( + typeof payload === "object" && + payload != null && + "error" in payload && + typeof payload.error === "string" && + payload.error.trim() !== "" + ) { + return payload.error.trim(); + } + return fallback; +} + +const PAYMENT_FAILED_FALLBACK = + "The subscription payment could not be started."; + +/** + * The priced plan names account-service's catalog answers with: creation + * always chooses a priced plan (spec §G.2), and the picker's list is not + * authority — a crafted POST naming "Free" must be refused before Desktop + * creates anything. Null when the catalog could not be read: creation then + * fails closed rather than create a Workspace it cannot bill. + */ +async function pricedPlanNames( + dependencies: WorkspaceCreationRouteDependencies, + actor: PayingActor +): Promise | null> { + try { + const response = await dependencies.requestAccountService({ + actor, + init: { body: JSON.stringify({}), method: "POST" }, + pathname: BILLING_ROUTES.plans.upstreamPathname, + }); + if (!response.ok) { + await response.body?.cancel(); + return null; + } + const parsed = billingPlansResponseSchema.safeParse(await response.json()); + if (!parsed.success) { + return null; + } + const names = new Set(); + for (const plan of parsed.data.plans) { + if (plan.Prices.some((price) => price.Price > 0)) { + names.add(plan.Name.trim()); + } + } + return names; + } catch { + return null; + } +} + +/** + * Step 2: account-service's pay for the created Workspace. Any refusal — + * an upstream error status, a non-JSON body, `success: false`, no checkout + * URL — is a failed payment the page can retry, never a route error. + */ +async function startWorkspacePayment( + dependencies: WorkspaceCreationRouteDependencies, + actor: PayingActor, + workspaceId: string, + fields: WorkspacePaymentTerms +): Promise { + const body = { + ...(fields.cardId == null ? {} : { cardId: fields.cardId }), + operator: "created", + payApp: "system-brain", + payMethod: fields.payMethod, + period: fields.period, + planName: fields.planName, + ...(fields.promotionCode == null + ? {} + : { promotionCode: fields.promotionCode }), + regionDomain: fields.regionDomain, + workspace: workspaceId, + }; + const response = await dependencies.requestAccountService({ + actor, + init: { body: JSON.stringify(body), method: "POST" }, + pathname: BILLING_ROUTES.subscriptionPay.upstreamPathname, + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) { + return { + error: upstreamErrorText(payload, PAYMENT_FAILED_FALLBACK), + status: "failed", + }; + } + const checkout = + typeof payload === "object" && payload != null + ? (payload as Record) + : {}; + const redirectUrl = + typeof checkout.redirectUrl === "string" ? checkout.redirectUrl.trim() : ""; + if (checkout.success !== true) { + return { error: PAYMENT_FAILED_FALLBACK, status: "failed" }; + } + if (redirectUrl === "") { + // account-service's balance-style path settles without a checkout URL. + // The terms admit Stripe only, but a paid answer must be read as paid, + // never as a failed payment the page would offer to retry. + return { + invoiceId: + typeof checkout.invoiceID === "string" ? checkout.invoiceID : null, + payId: typeof checkout.payID === "string" ? checkout.payID : null, + status: "settled", + }; + } + return { + invoiceId: + typeof checkout.invoiceID === "string" ? checkout.invoiceID : null, + payId: typeof checkout.payID === "string" ? checkout.payID : null, + redirectUrl, + status: "started", + }; +} + +type DesktopForCreation = + | { desktop: DesktopAuthApi; ok: true } + | { ok: false; response: Response }; + +function desktopForCreation( + dependencies: WorkspaceCreationRouteDependencies, + log: WorkspaceCreationRouteLog +): DesktopForCreation { + const baseUrl = desktopApiBaseUrlFromEnv(dependencies.env ?? process.env); + if (baseUrl == null) { + log("DESKTOP_API_BASE_URL is not configured", {}); + return { + ok: false, + response: errorResponse("Desktop is unavailable.", 502), + }; + } + return { + desktop: createDesktopAuthApi( + createDesktopClient({ baseUrl, fetch: dependencies.fetchDesktop }) + ), + ok: true, + }; +} + +function routeLog( + dependencies: WorkspaceCreationRouteDependencies, + routeLabel: string +): WorkspaceCreationRouteLog { + return ( + dependencies.log ?? + ((message, fields) => console.warn(`[${routeLabel}] ${message}`, fields)) + ); +} + +/** `POST /api/billing/workspace-create`: Step 1 at Desktop, then Step 2. */ +export function createBillingWorkspaceCreateHandler( + dependencies: WorkspaceCreationRouteDependencies +): RouteHandler { + const entry = BILLING_ROUTES.workspaceCreate; + return async function handler(request: Request): Promise { + const log = routeLog(dependencies, entry.apiPath); + const { actor, body, response } = await authorizedCreationRequest( + request, + dependencies, + workspaceCreationRequestSchema, + "Invalid workspace creation request." + ); + if (response != null) { + return response; + } + const priced = await pricedPlanNames(dependencies, actor); + if (priced == null) { + log("plan catalog unreadable; refusing to create", {}); + return errorResponse("The plan catalog is unavailable. Try again.", 502); + } + if (!priced.has(body.planName.trim())) { + return errorResponse("Choose a priced Subscription Plan.", 400); + } + const desktop = desktopForCreation(dependencies, log); + if (!desktop.ok) { + return desktop.response; + } + + const created = await desktop.desktop.namespaceCreate( + appTokenFromRequest(request), + body.name + ); + if (!created.ok) { + log( + "Desktop workspace creation failed", + desktopFailureLogFields(created) + ); + return desktopCreateFailureResponse(created); + } + const workspace: CreatedWorkspace = created.data; + // The Workspace exists now, so a pay call that throws is an outcome, + // never an error that would hide its id from the page. + const payment = await startWorkspacePayment( + dependencies, + actor, + workspace.id, + body + ).catch( + () => + ({ + error: PAYMENT_FAILED_FALLBACK, + status: "failed", + }) as const + ); + if (payment.status === "failed") { + log("Workspace created but its first payment did not start", { + workspaceId: workspace.id, + }); + } + return jsonResponse({ payment, workspace }); + }; +} + +/** + * `POST /api/billing/workspace-create/retry-payment`: Step 2 alone. The + * Workspace named is not the one the actor's kubeconfig proves — it was + * just created — so Brain cannot vouch for it; account-service's pay is + * the authority that the actor owns the Workspace it subscribes, as it is + * for every Billing write. + */ +export function createBillingWorkspaceCreateRetryPaymentHandler( + dependencies: WorkspaceCreationRouteDependencies +): RouteHandler { + return async function handler(request: Request): Promise { + const log = routeLog( + dependencies, + BILLING_ROUTES.workspaceCreateRetryPayment.apiPath + ); + const { actor, body, response } = await authorizedCreationRequest( + request, + dependencies, + workspaceCreationRetryRequestSchema, + "Invalid workspace payment retry request." + ); + if (response != null) { + return response; + } + const priced = await pricedPlanNames(dependencies, actor); + if (priced == null) { + log("plan catalog unreadable; refusing to retry", {}); + return errorResponse("The plan catalog is unavailable. Try again.", 502); + } + if (!priced.has(body.planName.trim())) { + return errorResponse("Choose a priced Subscription Plan.", 400); + } + const payment = await startWorkspacePayment( + dependencies, + actor, + body.workspaceId, + body + ).catch( + () => + ({ + error: PAYMENT_FAILED_FALLBACK, + status: "failed", + }) as const + ); + return jsonResponse({ payment }); + }; +} diff --git a/apps/ui/src/features/billing/use-workspace-owner-standing.ts b/apps/ui/src/features/billing/use-workspace-owner-standing.ts index c07f9475..6512dd94 100644 --- a/apps/ui/src/features/billing/use-workspace-owner-standing.ts +++ b/apps/ui/src/features/billing/use-workspace-owner-standing.ts @@ -1,10 +1,9 @@ "use client"; -import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; -import { useAtomValue } from "jotai"; import useSWR from "swr"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; import { loadWorkspaceOwnerStanding } from "./workspace-owner-data"; @@ -18,20 +17,10 @@ import { loadWorkspaceOwnerStanding } from "./workspace-owner-data"; export function useWorkspaceOwnerStanding( options: { refreshInterval?: number } = {} ) { - const appToken = useAtomValue(appTokenAtom).trim(); - const kubeconfig = useAtomValue(kubeconfigAtom).trim(); - const workspace = useAtomValue(namespaceAtom).trim(); - const credentialsReady = - appToken !== "" && kubeconfig !== "" && workspace !== ""; + const credentials = useSessionCredentials(); + const { appToken, kubeconfig } = credentials; return useSWR( - credentialsReady - ? ([ - "workspace-owner", - workspace, - kubeconfigCredentialKey(kubeconfig), - appToken, - ] as const) - : null, + credentials.ready ? SESSION_SWR_KEYS.workspaceOwner(credentials) : null, () => loadWorkspaceOwnerStanding({ appToken, kubeconfig }), { refreshInterval: options.refreshInterval, diff --git a/apps/ui/src/features/billing/workspace-creation-client.test.ts b/apps/ui/src/features/billing/workspace-creation-client.test.ts new file mode 100644 index 00000000..fb242939 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-client.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { BillingFetch } from "./billing-data-client"; +import { + createWorkspaceWithSubscription, + retryWorkspaceCreationPayment, + WorkspaceNameConflictError, +} from "./workspace-creation-client"; +import { WORKSPACE_NAME_CONFLICT_CODE } from "./workspace-creation-schema"; + +const CREDENTIALS = { appToken: "desktop-app-token", kubeconfig: "kc" }; + +function recordingFetch(respond: () => Response) { + const calls: { + body: unknown; + headers: Headers; + method: string; + url: string; + }[] = []; + const fetch: BillingFetch = (input, init) => { + calls.push({ + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + headers: new Headers(init?.headers), + method: init?.method ?? "GET", + url: String(input), + }); + return Promise.resolve(respond()); + }; + return { calls, fetch }; +} + +test("createWorkspaceWithSubscription posts the name and plan with the credentials and parses the answer", async () => { + const { calls, fetch } = recordingFetch(() => + Response.json({ + payment: { + invoiceId: "inv-1", + payId: "pay-1", + redirectUrl: "https://checkout.stripe.test/inv-1", + status: "started", + }, + workspace: { id: "ns-new", name: "Robotics", uid: "uid-new" }, + }) + ); + const result = await createWorkspaceWithSubscription( + { + ...CREDENTIALS, + name: "Robotics", + planName: "Pro", + regionDomain: "us.example.test", + }, + { fetch } + ); + assert.deepEqual(result, { + payment: { + invoiceId: "inv-1", + payId: "pay-1", + redirectUrl: "https://checkout.stripe.test/inv-1", + status: "started", + }, + workspace: { id: "ns-new", name: "Robotics", uid: "uid-new" }, + }); + assert.equal(calls[0]?.url, "/api/billing/workspace-create"); + assert.equal(calls[0]?.method, "POST"); + assert.equal( + calls[0]?.headers.get("X-Sealos-App-Token"), + "desktop-app-token" + ); + assert.deepEqual(calls[0]?.body, { + name: "Robotics", + payMethod: "stripe", + period: "1m", + planName: "Pro", + regionDomain: "us.example.test", + }); +}); + +test("a 409 from the route is the name conflict, distinguishable from other failures", async () => { + const { fetch } = recordingFetch(() => + Response.json( + { code: WORKSPACE_NAME_CONFLICT_CODE, error: "taken" }, + { status: 409 } + ) + ); + await assert.rejects( + createWorkspaceWithSubscription( + { + ...CREDENTIALS, + name: "Robotics", + planName: "Pro", + regionDomain: "us.example.test", + }, + { fetch } + ), + WorkspaceNameConflictError + ); + + const refused = recordingFetch(() => + Response.json( + { error: "Desktop refused to create the Workspace." }, + { + status: 403, + } + ) + ); + await assert.rejects( + createWorkspaceWithSubscription( + { + ...CREDENTIALS, + name: "Robotics", + planName: "Pro", + regionDomain: "us.example.test", + }, + { fetch: refused.fetch } + ), + (error: unknown) => + !(error instanceof WorkspaceNameConflictError) && + error instanceof Error && + error.message === "Desktop refused to create the Workspace." + ); +}); + +test("retryWorkspaceCreationPayment posts the created Workspace's id and parses the payment", async () => { + const { calls, fetch } = recordingFetch(() => + Response.json({ payment: { error: "card declined", status: "failed" } }) + ); + const result = await retryWorkspaceCreationPayment( + { + ...CREDENTIALS, + planName: "Pro", + regionDomain: "us.example.test", + workspaceId: "ns-new", + }, + { fetch } + ); + assert.deepEqual(result, { error: "card declined", status: "failed" }); + assert.equal(calls[0]?.url, "/api/billing/workspace-create/retry-payment"); + assert.deepEqual(calls[0]?.body, { + payMethod: "stripe", + period: "1m", + planName: "Pro", + regionDomain: "us.example.test", + workspaceId: "ns-new", + }); +}); diff --git a/apps/ui/src/features/billing/workspace-creation-client.ts b/apps/ui/src/features/billing/workspace-creation-client.ts new file mode 100644 index 00000000..ebbae080 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-client.ts @@ -0,0 +1,131 @@ +import { + type BillingCredentials, + type BillingFetch, + BillingRequestError, + createBillingJsonRequester, +} from "@/features/billing/billing-data-client"; + +import { BILLING_ROUTES } from "./server/billing-route-table"; +import { + WORKSPACE_NAME_CONFLICT_MESSAGE, + type WorkspaceCreationPayment, + type WorkspaceCreationResponse, + workspaceCreationResponseSchema, + workspaceCreationRetryResponseSchema, +} from "./workspace-creation-schema"; + +/** + * The page's side of Workspace Creation (spec §G): the two routes as + * functions, credentials attached the way every billing fetcher does. The + * server's 409 becomes the one error the dialog tells apart — the name is + * taken and the field says so inline; every other failure surfaces its + * message. Brain always pays by Stripe for a month: the dialog offers no + * other terms. + */ + +export class WorkspaceNameConflictError extends Error { + constructor() { + super(WORKSPACE_NAME_CONFLICT_MESSAGE); + this.name = "WorkspaceNameConflictError"; + } +} + +interface WorkspaceCreationDependencies { + fetch?: BillingFetch; +} + +interface WorkspaceCreationInput extends BillingCredentials { + name: string; + planName: string; + regionDomain: string; +} + +const PAYMENT_TERMS = { payMethod: "stripe", period: "1m" } as const; + +function routeErrorMessage(payload: unknown): string | null { + if ( + typeof payload === "object" && + payload != null && + "error" in payload && + typeof payload.error === "string" && + payload.error.trim() !== "" + ) { + return payload.error.trim(); + } + return null; +} + +function creationRequester( + credentials: BillingCredentials, + fallbackErrorMessage: string, + dependencies: WorkspaceCreationDependencies +) { + return createBillingJsonRequester({ + credentials, + fallbackErrorMessage, + fetch: dependencies.fetch ?? globalThis.fetch, + }); +} + +/** Both steps: the Workspace, then its first payment. */ +export async function createWorkspaceWithSubscription( + input: WorkspaceCreationInput, + dependencies: WorkspaceCreationDependencies = {} +): Promise { + const requestBillingJson = creationRequester( + input, + "The Workspace could not be created.", + dependencies + ); + let payload: unknown; + try { + payload = await requestBillingJson(BILLING_ROUTES.workspaceCreate.apiPath, { + name: input.name, + ...PAYMENT_TERMS, + planName: input.planName, + regionDomain: input.regionDomain, + }); + } catch (error) { + if (error instanceof BillingRequestError && error.status === 409) { + throw new WorkspaceNameConflictError(); + } + // A 403 here is Desktop refusing the creation (a Workspace limit), not + // the billing-permission verdict the shared requester words every 403 + // as; the route's own message is the truthful one. + if (error instanceof BillingRequestError && error.status === 403) { + throw new BillingRequestError( + routeErrorMessage(error.payload) ?? error.message, + error.status, + error.payload + ); + } + throw error; + } + return workspaceCreationResponseSchema.parse(payload); +} + +/** Step 2 again, for a Workspace that exists without its subscription. */ +export async function retryWorkspaceCreationPayment( + input: BillingCredentials & { + planName: string; + regionDomain: string; + workspaceId: string; + }, + dependencies: WorkspaceCreationDependencies = {} +): Promise { + const requestBillingJson = creationRequester( + input, + "The subscription payment could not be started.", + dependencies + ); + const payload = await requestBillingJson( + BILLING_ROUTES.workspaceCreateRetryPayment.apiPath, + { + ...PAYMENT_TERMS, + planName: input.planName, + regionDomain: input.regionDomain, + workspaceId: input.workspaceId, + } + ); + return workspaceCreationRetryResponseSchema.parse(payload).payment; +} diff --git a/apps/ui/src/features/billing/workspace-creation-core.test.ts b/apps/ui/src/features/billing/workspace-creation-core.test.ts new file mode 100644 index 00000000..e49f7fb7 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-core.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + WORKSPACE_NAME_ISSUE_MESSAGES, + workspaceNameIssue, +} from "./workspace-creation-core"; + +const EXISTING = ["private team", "Acme", "Sandbox"]; + +test("a trimmed, unique name within the cap has no issue", () => { + assert.equal(workspaceNameIssue(" Robotics ", EXISTING), null); + assert.equal(workspaceNameIssue("x".repeat(32), EXISTING), null); +}); + +test("a blank name is required", () => { + assert.equal(workspaceNameIssue("", EXISTING), "required"); + assert.equal(workspaceNameIssue(" ", EXISTING), "required"); +}); + +test("a name over 32 characters after trimming is too long", () => { + assert.equal(workspaceNameIssue(`${"x".repeat(33)}`, EXISTING), "too-long"); + assert.equal(workspaceNameIssue(` ${"x".repeat(32)} `, EXISTING), null); +}); + +test("a name matching a loaded Workspace case-insensitively is a duplicate", () => { + assert.equal(workspaceNameIssue("acme", EXISTING), "duplicate"); + assert.equal(workspaceNameIssue(" ACME ", EXISTING), "duplicate"); + assert.equal(workspaceNameIssue("Acme Robotics", EXISTING), null); +}); + +test("every issue has a message for the field", () => { + for (const issue of ["required", "too-long", "duplicate"] as const) { + assert.ok(WORKSPACE_NAME_ISSUE_MESSAGES[issue].length > 0); + } + assert.ok(WORKSPACE_NAME_ISSUE_MESSAGES["too-long"].includes("32")); +}); diff --git a/apps/ui/src/features/billing/workspace-creation-core.ts b/apps/ui/src/features/billing/workspace-creation-core.ts new file mode 100644 index 00000000..12848d3b --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-core.ts @@ -0,0 +1,39 @@ +import { WORKSPACE_NAME_MAX_LENGTH } from "@/features/workspace/workspace-write-schema"; + +/** + * Workspace Creation's name rules on the client (spec §G.2): the name is + * trimmed, required, at most 32 characters, and checked against the + * Workspaces the session already lists — case-insensitively, since + * Desktop's own check is exact and would let "acme" past "Acme" only to + * confuse the Switcher. The server repeats none of this beyond the schema; + * Desktop's 409 remains the authority on duplicates. + */ + +export type WorkspaceNameIssue = "duplicate" | "required" | "too-long"; + +export const WORKSPACE_NAME_ISSUE_MESSAGES: Record = + { + duplicate: "A Workspace with this name already exists.", + required: "Enter a name for the Workspace.", + "too-long": `Use at most ${WORKSPACE_NAME_MAX_LENGTH} characters.`, + }; + +export function workspaceNameIssue( + name: string, + existingNames: readonly string[] +): WorkspaceNameIssue | null { + const trimmed = name.trim(); + if (trimmed === "") { + return "required"; + } + if (trimmed.length > WORKSPACE_NAME_MAX_LENGTH) { + return "too-long"; + } + const folded = trimmed.toLowerCase(); + if ( + existingNames.some((existing) => existing.trim().toLowerCase() === folded) + ) { + return "duplicate"; + } + return null; +} diff --git a/apps/ui/src/features/billing/workspace-creation-return.ts b/apps/ui/src/features/billing/workspace-creation-return.ts new file mode 100644 index 00000000..698319e4 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-return.ts @@ -0,0 +1,105 @@ +/** + * What tells a Workspace Creation's Stripe return from a plan change's: + * Desktop's callback lands both on the same `/billing?stripeState=success` + * (spec §G.5), so before handing the top window to Stripe the page records + * which Workspace it is creating, and the return leg reads and forgets the + * record. Per tab, like the area return routes: a creation begun in one + * tab never rewords another's conclusion. Storage that is unavailable + * makes the return read as a plan change — a wording, never a lost payment. + * + * The record carries the checkout's `payId`: a later plan change for the + * same Workspace pays under a different one, so an abandoned creation + * Checkout can never reword that return. Any Stripe return for the + * recorded Workspace — success or cancel — spends the record; one for a + * different Workspace leaves it. + */ + +const STORAGE_KEY = "billing-workspace-creation"; + +export interface PendingWorkspaceCreation { + /** The checkout's pay id, when Desktop's answer carried one. */ + payId: string | null; + workspaceId: string; +} + +function readStored(): string | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.sessionStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export function recordPendingWorkspaceCreation( + workspaceId: string, + payId: string | null = null +): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ payId, workspaceId } satisfies PendingWorkspaceCreation) + ); + } catch { + // See above: the conclusion reads as a plan change instead. + } +} + +export function readPendingWorkspaceCreation(): PendingWorkspaceCreation | null { + const stored = readStored(); + if (stored == null) { + return null; + } + try { + const parsed: unknown = JSON.parse(stored); + if ( + typeof parsed === "object" && + parsed != null && + "workspaceId" in parsed && + typeof (parsed as { workspaceId: unknown }).workspaceId === "string" + ) { + const record = parsed as { payId?: unknown; workspaceId: string }; + return { + payId: + typeof record.payId === "string" && record.payId !== "" + ? record.payId + : null, + workspaceId: record.workspaceId, + }; + } + } catch { + // A legacy plain-string record names the Workspace; its pay id is lost. + return { payId: null, workspaceId: stored }; + } + return null; +} + +/** Whether `workspaceId` is the Workspace this tab was creating; the record stays. */ +export function isPendingWorkspaceCreation(workspaceId: string): boolean { + return readPendingWorkspaceCreation()?.workspaceId === workspaceId; +} + +/** + * Whether `workspaceId` is the Workspace this tab was creating; forgets the + * record when it matched. A record for another Workspace stays. + */ +export function consumePendingWorkspaceCreation(workspaceId: string): boolean { + if (typeof window === "undefined") { + return false; + } + const recorded = readPendingWorkspaceCreation(); + if (recorded == null || recorded.workspaceId !== workspaceId) { + return false; + } + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch { + // See above: the conclusion reads as a plan change instead. + } + return true; +} diff --git a/apps/ui/src/features/billing/workspace-creation-schema.ts b/apps/ui/src/features/billing/workspace-creation-schema.ts new file mode 100644 index 00000000..5193157c --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-schema.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +import { workspaceNameSchema } from "@/features/workspace/workspace-write-schema"; + +/** + * The request and response shapes of Workspace Creation's two routes (spec + * §G.3, §G.7). Client-safe: the creation dialog builds its requests from + * these, the route handlers validate bodies with them, and the dev-mock + * fixtures answer in them. The payment fields mirror the subscription pay + * route's paid-change branch; `operator` and `payApp` are the server's to + * add, never the client's to choose. + */ + +/** + * The first payment's terms. Brain's creation always goes to Stripe + * Checkout (spec §G.4): a balance payment would settle without a redirect, + * and the routes read "no redirect" as a failed payment, so the schema + * admits no other method. + */ +export const workspacePaymentTermsSchema = z.object({ + cardId: z.string().trim().min(1).optional(), + payMethod: z.literal("stripe"), + period: z.enum(["1m", "1y"]), + planName: z.string().trim().min(1), + promotionCode: z.string().trim().min(1).optional(), + regionDomain: z.string().trim().min(1), +}); + +export type WorkspacePaymentTerms = z.infer; + +/** `POST /api/billing/workspace-create`: name the Workspace and its first plan. */ +export const workspaceCreationRequestSchema = + workspacePaymentTermsSchema.extend({ name: workspaceNameSchema }); + +export type WorkspaceCreationRequest = z.infer< + typeof workspaceCreationRequestSchema +>; + +/** `POST /api/billing/workspace-create/retry-payment`: Step 2 again for a created Workspace. */ +export const workspaceCreationRetryRequestSchema = + workspacePaymentTermsSchema.extend({ + workspaceId: z.string().trim().min(1), + }); + +export type WorkspaceCreationRetryRequest = z.infer< + typeof workspaceCreationRetryRequestSchema +>; + +export const createdWorkspaceSchema = z.object({ + /** The Kubernetes namespace name, `ns-…` — what account-service calls `workspace`. */ + id: z.string().min(1), + name: z.string(), + uid: z.string().min(1), +}); + +export type CreatedWorkspace = z.infer; + +/** + * Step 2's outcome. `started` carries the Stripe Checkout URL the page + * hands the top window; `failed` means the Workspace exists without a + * subscription and the page offers to retry. + */ +export const workspaceCreationPaymentSchema = z.discriminatedUnion("status", [ + z.object({ + invoiceId: z.string().nullable(), + payId: z.string().nullable(), + redirectUrl: z.string().min(1), + status: z.literal("started"), + }), + z.object({ + error: z.string(), + status: z.literal("failed"), + }), + z.object({ + invoiceId: z.string().nullable(), + payId: z.string().nullable(), + /** + * The payment settled without a checkout URL — account-service's + * balance-style path, which the terms rule out but must still be read + * as paid, never as a failed payment the page would offer to retry. + */ + status: z.literal("settled"), + }), +]); + +export type WorkspaceCreationPayment = z.infer< + typeof workspaceCreationPaymentSchema +>; + +export const workspaceCreationResponseSchema = z.object({ + payment: workspaceCreationPaymentSchema, + workspace: createdWorkspaceSchema, +}); + +export type WorkspaceCreationResponse = z.infer< + typeof workspaceCreationResponseSchema +>; + +export const workspaceCreationRetryResponseSchema = z.object({ + payment: workspaceCreationPaymentSchema, +}); + +export type WorkspaceCreationRetryResponse = z.infer< + typeof workspaceCreationRetryResponseSchema +>; + +/** The one creation failure the page keys on: Desktop's 409 for a taken name. */ +export const WORKSPACE_NAME_CONFLICT_CODE = "workspace_name_conflict"; + +export const WORKSPACE_NAME_CONFLICT_MESSAGE = + "A Workspace with this name already exists."; diff --git a/apps/ui/src/features/billing/workspace-plan-name.ts b/apps/ui/src/features/billing/workspace-plan-name.ts new file mode 100644 index 00000000..73d72500 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-plan-name.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { isDeletedSubscriptionRecord } from "./billing-plan-data"; + +const workspacePlanSubscriptionSchema = z.object({ + subscription: z.object({ + PlanName: z.string().optional(), + Status: z.string().optional(), + type: z.string().optional(), + }), +}); + +/** + * The plan name the Workspace Switcher shows for one + * `workspace-subscription/info` payload (spec §C.5): the subscription's + * plan, or null — shown as PAYG — when there is no subscription, the + * record is deleted, or the payload is not a subscription at all. The + * route handler and the billing Dev Mock share this one rule. + */ +export function workspacePlanNameFromSubscription( + payload: unknown +): string | null { + const parsed = workspacePlanSubscriptionSchema.safeParse(payload); + if (!parsed.success) { + return null; + } + const { PlanName, Status, type } = parsed.data.subscription; + const planName = PlanName?.trim() ?? ""; + if ( + type === "PAYG" || + planName === "" || + isDeletedSubscriptionRecord(Status ?? "") + ) { + return null; + } + return planName; +} diff --git a/apps/ui/src/features/billing/workspace-plans-data.ts b/apps/ui/src/features/billing/workspace-plans-data.ts new file mode 100644 index 00000000..3587b738 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-plans-data.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { + type BillingCredentials, + type BillingFetch, + createBillingJsonRequester, +} from "./billing-data-client"; + +const workspacePlansResponseSchema = z.object({ + plans: z.record(z.string(), z.string().nullable()), +}); + +/** Plan name per Workspace namespace id; null = no subscription (PAYG). */ +export type WorkspacePlans = Record; + +/** + * The Workspace Switcher's plan badges (spec §C.5): one read of + * `GET /api/billing/workspace-plans` for the session's Workspace list. + * The route answers null per Workspace it cannot read; a failed route + * rejects, and the Switcher then shows no badge on any row. + */ +export async function loadWorkspacePlans( + credentials: BillingCredentials, + workspaceIds: readonly string[], + fetch: BillingFetch = globalThis.fetch +): Promise { + const requestBillingJson = createBillingJsonRequester({ + credentials, + fallbackErrorMessage: "Could not load the workspace plans.", + fetch, + }); + const query = new URLSearchParams(); + for (const id of workspaceIds) { + query.append("workspace", id); + } + return workspacePlansResponseSchema.parse( + await requestBillingJson(`/api/billing/workspace-plans?${query}`) + ).plans; +} diff --git a/apps/ui/src/features/deploy/deploy-billing-notice.test.ts b/apps/ui/src/features/deploy/deploy-billing-notice.test.ts index da6f30b6..d3f9d14c 100644 --- a/apps/ui/src/features/deploy/deploy-billing-notice.test.ts +++ b/apps/ui/src/features/deploy/deploy-billing-notice.test.ts @@ -41,7 +41,6 @@ const PAYG: WorkspaceSubscriptionSummary = { lifecycle: "active", planName: "PAYG", recoveryVoice: "renew", - role: null, warningDeadlineAt: null, warningStage: null, }; @@ -49,7 +48,6 @@ const HOBBY: WorkspaceSubscriptionSummary = { ...PAYG, isPayg: false, planName: "Hobby", - role: "OWNER", }; const QUIET: StatusHintInputs = { diff --git a/apps/ui/src/features/deploy/github/use-github-auth.ts b/apps/ui/src/features/deploy/github/use-github-auth.ts index 46ee1850..12dc9508 100644 --- a/apps/ui/src/features/deploy/github/use-github-auth.ts +++ b/apps/ui/src/features/deploy/github/use-github-auth.ts @@ -1,6 +1,5 @@ "use client"; -import { useAtomValue } from "jotai"; import { useCallback, useEffect, useRef } from "react"; import useSWR, { useSWRConfig } from "swr"; import { @@ -11,8 +10,9 @@ import { parseInstallReturnPathParam, } from "@/features/deploy/github/types"; import { githubReposSWRKey } from "@/features/deploy/github/use-github-repos"; +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; import { appTokenRequestHeaders } from "@/lib/app-token-header"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; import { personalResourceAuthHeaders } from "@/lib/personal-resource-headers"; const GITHUB_APP_INSTALL_POPUP_NAME = "brain-github-app-install"; @@ -196,13 +196,12 @@ export function useGithubAuth(options?: { enabled?: boolean; }): UseGithubAuthResult { const enabled = options?.enabled ?? true; - const appToken = useAtomValue(appTokenAtom); - const kubeconfig = useAtomValue(kubeconfigAtom); - const namespace = useAtomValue(namespaceAtom).trim(); - const canCheck = enabled && namespace !== "" && kubeconfig.trim() !== ""; + const credentials = useSessionCredentials(); + const { appToken, kubeconfig, namespace } = credentials; + const canCheck = enabled && namespace !== "" && kubeconfig !== ""; const { mutate: mutateCache } = useSWRConfig(); const swrKey = canCheck - ? (["github-connection", namespace, kubeconfig, appToken] as const) + ? SESSION_SWR_KEYS.githubConnection(credentials) : null; const { data, error, isLoading, mutate } = useSWR( @@ -231,11 +230,11 @@ export function useGithubAuth(options?: { return; } mutate().catch(() => undefined); - const reposKey = githubReposSWRKey({ appToken, kubeconfig, namespace }); + const reposKey = githubReposSWRKey(credentials); if (reposKey != null) { mutateCache(reposKey).catch(() => undefined); } - }, [appToken, canCheck, kubeconfig, mutate, mutateCache, namespace]); + }, [canCheck, credentials, mutate, mutateCache]); const handleInstallComplete = useCallback( (data: unknown, options?: { applyReturnPath?: boolean }) => { diff --git a/apps/ui/src/features/deploy/github/use-github-repos.test.ts b/apps/ui/src/features/deploy/github/use-github-repos.test.ts index d11be0f0..aa5a2ee0 100644 --- a/apps/ui/src/features/deploy/github/use-github-repos.test.ts +++ b/apps/ui/src/features/deploy/github/use-github-repos.test.ts @@ -1,34 +1,26 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; + import { githubReposSWRKey } from "./use-github-repos"; -test("githubReposSWRKey matches the GitHub repos cache key", () => { +const CREDENTIALS = { + appToken: "app-token", + kubeconfig: "kubeconfig", + namespace: " ns-demo ", + regionalToken: "regional-token", +}; + +test("githubReposSWRKey is the session-keyed GitHub repos cache key", () => { assert.deepEqual( - githubReposSWRKey({ - appToken: "app-token", - kubeconfig: "kubeconfig", - namespace: " ns-demo ", - }), - ["github-user-repos", "ns-demo", "kubeconfig", "app-token"] + githubReposSWRKey(CREDENTIALS), + SESSION_SWR_KEYS.githubUserRepos(CREDENTIALS) ); + assert.equal(githubReposSWRKey(CREDENTIALS)?.[0], "github-user-repos"); }); test("githubReposSWRKey returns null without namespace or kubeconfig", () => { - assert.equal( - githubReposSWRKey({ - appToken: "app-token", - kubeconfig: "kubeconfig", - namespace: "", - }), - null - ); - assert.equal( - githubReposSWRKey({ - appToken: "app-token", - kubeconfig: "", - namespace: "ns-demo", - }), - null - ); + assert.equal(githubReposSWRKey({ ...CREDENTIALS, namespace: "" }), null); + assert.equal(githubReposSWRKey({ ...CREDENTIALS, kubeconfig: "" }), null); }); diff --git a/apps/ui/src/features/deploy/github/use-github-repos.ts b/apps/ui/src/features/deploy/github/use-github-repos.ts index c82a821d..45c1e716 100644 --- a/apps/ui/src/features/deploy/github/use-github-repos.ts +++ b/apps/ui/src/features/deploy/github/use-github-repos.ts @@ -1,29 +1,23 @@ "use client"; -import { useAtomValue } from "jotai"; import useSWR from "swr"; import type { GithubDeployerRepo } from "@/features/deploy/github-deployer/github-deployer.types"; -import { appTokenAtom, kubeconfigAtom } from "@/lib/auth-store"; +import { + SESSION_SWR_KEYS, + type SessionCredentials, +} from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; import { personalResourceAuthHeaders } from "@/lib/personal-resource-headers"; interface GithubReposResponse { repos: GithubDeployerRepo[]; } -export function githubReposSWRKey(input: { - appToken: string; - kubeconfig: string; - namespace: string; -}) { +export function githubReposSWRKey(input: SessionCredentials) { const namespace = input.namespace.trim(); const kubeconfig = input.kubeconfig.trim(); return namespace !== "" && kubeconfig !== "" - ? ([ - "github-user-repos", - namespace, - input.kubeconfig, - input.appToken, - ] as const) + ? SESSION_SWR_KEYS.githubUserRepos(input) : null; } @@ -52,11 +46,11 @@ export function useGithubRepos(input: { isAuthorized: boolean; namespace: string | undefined; }) { - const appToken = useAtomValue(appTokenAtom); - const kubeconfig = useAtomValue(kubeconfigAtom); + const session = useSessionCredentials(); + const { appToken, kubeconfig } = session; const namespace = input.namespace?.trim() ?? ""; const swrKey = input.isAuthorized - ? githubReposSWRKey({ appToken, kubeconfig, namespace }) + ? githubReposSWRKey({ ...session, namespace }) : null; const { data, error, isLoading, mutate } = useSWR( diff --git a/apps/ui/src/features/dev-mock/cookie.ts b/apps/ui/src/features/dev-mock/cookie.ts index 2c47b007..9a916b72 100644 --- a/apps/ui/src/features/dev-mock/cookie.ts +++ b/apps/ui/src/features/dev-mock/cookie.ts @@ -12,6 +12,8 @@ * typos fail loud instead of silently serving real data. */ +import { cookieValueFromHeader } from "@/lib/cookie-header"; + const OFF_PREFIX = "off:"; export interface DevMockState { @@ -50,26 +52,6 @@ export interface DevMockCookie extends DevMockCookieDef { setCookieHeader(state: DevMockState): string; } -function cookieValue(header: string | null, name: string): string | undefined { - for (const pair of (header ?? "").split(";")) { - const separator = pair.indexOf("="); - if (separator === -1) { - continue; - } - if (pair.slice(0, separator).trim() === name) { - const raw = pair.slice(separator + 1).trim(); - try { - return decodeURIComponent(raw); - } catch { - // A malformed %-sequence (some other cookie's doing) must surface as - // an invalid value, not throw out of every load(). - return raw; - } - } - } - return undefined; -} - export function defineDevMockCookie( def: DevMockCookieDef ): DevMockCookie { @@ -82,9 +64,9 @@ export function defineDevMockCookie( documentCookie: (state) => `${def.name}=${format(state)}; path=/; samesite=lax`, format, - fromCookieHeader: (header) => cookieValue(header, def.name), + fromCookieHeader: (header) => cookieValueFromHeader(header, def.name), fromRequest: (request) => - cookieValue(request.headers.get("cookie"), def.name), + cookieValueFromHeader(request.headers.get("cookie"), def.name), is, parse: (raw) => { const value = raw?.trim() ?? ""; diff --git a/apps/ui/src/features/dev-mock/dev-mocks.tsx b/apps/ui/src/features/dev-mock/dev-mocks.tsx index 0c2f8c0b..56a80d8a 100644 --- a/apps/ui/src/features/dev-mock/dev-mocks.tsx +++ b/apps/ui/src/features/dev-mock/dev-mocks.tsx @@ -5,6 +5,7 @@ import { ChatDevMockTweaks } from "@/features/chat/dev-mock"; import { DeployTaskDevMockTweaks } from "@/features/deploy/task/dev-mock"; import { NotificationsDevMockTweaks } from "@/features/notifications/dev-mock"; import { ProjectsExplorerDevMock } from "@/features/projects/explorer/projects-dev-mock"; +import { SessionDevMockTweaks } from "@/features/session/dev-mock"; /** * The app-global Dev Mock registry: every Dev Mock registers here, once, next @@ -20,6 +21,7 @@ import { ProjectsExplorerDevMock } from "@/features/projects/explorer/projects-d export function DevMocks() { return ( <> + diff --git a/apps/ui/src/features/notifications/read-dispatch.test.ts b/apps/ui/src/features/notifications/read-dispatch.test.ts index d3445183..7b0276ee 100644 --- a/apps/ui/src/features/notifications/read-dispatch.test.ts +++ b/apps/ui/src/features/notifications/read-dispatch.test.ts @@ -32,7 +32,7 @@ const MIXED = [ ]; test("every id gets a receipt; Owners and Managers also patch the CRs once each", () => { - for (const role of ["OWNER", "MANAGER"] as const) { + for (const role of ["Owner", "Manager"] as const) { const plan = planReadDispatch(MIXED, role); assert.deepEqual(plan.receiptIds, [ "db:m1", @@ -44,10 +44,10 @@ test("every id gets a receipt; Owners and Managers also patch the CRs once each" }); test("Developers skip the CR patch but still get the receipt", () => { - const plan = planReadDispatch(MIXED, "DEVELOPER"); + const plan = planReadDispatch(MIXED, "Developer"); assert.equal(plan.receiptIds.length, 3); assert.deepEqual(plan.crNames, []); - assert.equal(shouldSyncCRReadLabel("DEVELOPER"), false); + assert.equal(shouldSyncCRReadLabel("Developer"), false); }); test("an unknown role tries the patch (the cluster decides)", () => { diff --git a/apps/ui/src/features/notifications/read-dispatch.ts b/apps/ui/src/features/notifications/read-dispatch.ts index 940fba39..96a84a32 100644 --- a/apps/ui/src/features/notifications/read-dispatch.ts +++ b/apps/ui/src/features/notifications/read-dispatch.ts @@ -1,12 +1,13 @@ -import type { WorkspaceSubscriptionRole } from "@/features/billing/billing-plan-data"; +import type { WorkspaceRole } from "@/features/session/session-schema"; import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; /** * Per-source mark-read dispatch. Any role always writes a Brain receipt for * every id; platform items additionally patch the CR's `isRead` label so the * desktop bell follows — but only for roles the cluster lets patch. Owners - * and Managers hold that permission, Developers do not, and an unknown role - * (PAYG workspaces carry none) tries and lets a 403 fall through silently. + * and Managers hold that permission, Developers do not. The role is the + * Brain Session's Workspace Role for the current Workspace (spec §J.1); + * an unknown role (no session yet) tries and lets a 403 fall through. */ export interface ReadDispatch { /** CR names to patch best-effort. */ @@ -16,14 +17,14 @@ export interface ReadDispatch { } export function shouldSyncCRReadLabel( - role: WorkspaceSubscriptionRole | null | undefined + role: WorkspaceRole | null | undefined ): boolean { - return role !== "DEVELOPER"; + return role !== "Developer"; } export function planReadDispatch( items: readonly AppNotification[], - role: WorkspaceSubscriptionRole | null | undefined + role: WorkspaceRole | null | undefined ): ReadDispatch { const receiptIds = [...new Set(items.map((item) => item.id))]; const crNames = shouldSyncCRReadLabel(role) diff --git a/apps/ui/src/features/notifications/use-notification-feed.ts b/apps/ui/src/features/notifications/use-notification-feed.ts index 511b184b..a2fb555a 100644 --- a/apps/ui/src/features/notifications/use-notification-feed.ts +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -10,17 +10,17 @@ import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { toast } from "sonner"; import useSWR from "swr"; - import { loadAccountCredits } from "@/features/billing/account-credits"; import { loadHasToppedUp } from "@/features/billing/account-top-up"; +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; import { type AppNotification, countUnreadNotifications, isNotificationUnread, } from "@/features/shell/app-sidebar-notifications-model"; import { notificationReadIdsAtom } from "@/features/shell/app-sidebar-notifications-store"; -import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; +import { currentWorkspaceAtom } from "@/lib/auth-store"; import { fetchNotificationFeed, @@ -57,20 +57,22 @@ export interface NotificationFeed { * fixture CRs replace the cluster poll. */ export function useNotificationFeed(): NotificationFeed { - const appToken = useAtomValue(appTokenAtom).trim(); - const kubeconfig = useAtomValue(kubeconfigAtom).trim(); - const namespace = useAtomValue(namespaceAtom).trim(); + const credentials = useSessionCredentials(); + const { + appToken, + kubeconfig, + namespace, + ready: credentialsReady, + } = credentials; const [readIds, setReadIds] = useAtom(notificationReadIdsAtom); - const { data: subscription } = useWorkspaceSubscriptionSummary(); + // The Workspace Role is the session's membership fact (spec §J.1), not + // the subscription record's role field, which PAYG Workspaces leave empty. + const workspaceRole = useAtomValue(currentWorkspaceAtom)?.role ?? null; - const credentialsReady = - appToken !== "" && kubeconfig !== "" && namespace !== ""; const credentialKey = kubeconfigCredentialKey(kubeconfig); const brainFeed = useSWR( - credentialsReady - ? (["notifications-feed", namespace, credentialKey, appToken] as const) - : null, + credentialsReady ? SESSION_SWR_KEYS.notificationsFeed(credentials) : null, () => fetchNotificationFeed({ appToken, kubeconfig, namespace }), { refreshInterval: NOTIFICATION_CR_REFRESH_INTERVAL_MS, @@ -135,7 +137,7 @@ export function useNotificationFeed(): NotificationFeed { // for the session. const credits = useSWR( credentialsReady - ? (["notifications-credits", credentialKey, appToken] as const) + ? SESSION_SWR_KEYS.notificationsCredits(credentials) : null, () => loadAccountCredits({ appToken, kubeconfig }), { @@ -147,7 +149,7 @@ export function useNotificationFeed(): NotificationFeed { ); const toppedUp = useSWR( credentialsReady - ? (["notifications-topped-up", credentialKey, appToken] as const) + ? SESSION_SWR_KEYS.notificationsToppedUp(credentials) : null, () => loadHasToppedUp({ appToken, kubeconfig }), { revalidateOnFocus: false, shouldRetryOnError: false } @@ -195,7 +197,7 @@ export function useNotificationFeed(): NotificationFeed { if (!credentialsReady) { return; } - const plan = planReadDispatch(targets, subscription?.role); + const plan = planReadDispatch(targets, workspaceRole); const credentials = { appToken, kubeconfig, namespace }; // The receipt is the read state's source of truth (`readIds` is // session-only): a failed write rolls the optimistic ids back so the @@ -236,7 +238,7 @@ export function useNotificationFeed(): NotificationFeed { refreshBrainFeed, refreshCRList, setReadIds, - subscription?.role, + workspaceRole, ] ); diff --git a/apps/ui/src/features/projects/explorer/use-projects-explorer.test.ts b/apps/ui/src/features/projects/explorer/use-projects-explorer.test.ts index 3120a8d8..1aeef098 100644 --- a/apps/ui/src/features/projects/explorer/use-projects-explorer.test.ts +++ b/apps/ui/src/features/projects/explorer/use-projects-explorer.test.ts @@ -10,7 +10,7 @@ test("project history empty state separates missing credentials from database fa ), { description: - "Project history is waiting for workspace credentials. Open Brain inside Sealos Desktop or configure NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG for local development.", + "Project history is waiting for workspace credentials. Open Brain inside Sealos Desktop, or set DEV_GLOBAL_TOKEN for local development.", title: "Workspace credentials unavailable", } ); diff --git a/apps/ui/src/features/projects/explorer/use-projects-explorer.ts b/apps/ui/src/features/projects/explorer/use-projects-explorer.ts index 30ba1a1e..d9fab483 100644 --- a/apps/ui/src/features/projects/explorer/use-projects-explorer.ts +++ b/apps/ui/src/features/projects/explorer/use-projects-explorer.ts @@ -88,7 +88,7 @@ export function projectHistoryErrorEmptyState(error: unknown): if (message.startsWith("API 401:")) { return { description: - "Project history is waiting for workspace credentials. Open Brain inside Sealos Desktop or configure NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG for local development.", + "Project history is waiting for workspace credentials. Open Brain inside Sealos Desktop, or set DEV_GLOBAL_TOKEN for local development.", title: "Workspace credentials unavailable", }; } @@ -128,6 +128,11 @@ interface ProjectsExplorerReadModel { * consumers render those rows inert (the generated Projects do not exist). */ devMockActive: boolean; + /** + * True once the Project list answered (or the Dev Mock stands in for it): + * an empty `states.projects` is then a fact, not a pending read. + */ + projectsLoaded: boolean; /** Revalidate the projects list (e.g. after creating a project). */ refreshProjects: () => Promise; states: ProjectExplorerStates; @@ -279,6 +284,7 @@ function useProjectsExplorerModel(options: ProjectsExplorerReadModelOptions) { ns, pinnedProjectLimit, projects, + projectsLoaded: devMock !== null || rawProjects !== undefined, states, togglePinnedProject, }; @@ -287,11 +293,17 @@ function useProjectsExplorerModel(options: ProjectsExplorerReadModelOptions) { export function useProjectsExplorerReadModel( options: ProjectsExplorerReadModelOptions ): ProjectsExplorerReadModel { - const { data, devMockActive, mutate, states } = + const { data, devMockActive, mutate, projectsLoaded, states } = useProjectsExplorerModel(options); return useMemo( - () => ({ data, devMockActive, refreshProjects: mutate, states }), - [data, devMockActive, mutate, states] + () => ({ + data, + devMockActive, + projectsLoaded, + refreshProjects: mutate, + states, + }), + [data, devMockActive, mutate, projectsLoaded, states] ); } @@ -309,6 +321,7 @@ export function useProjectsExplorer( ns, pinnedProjectLimit, projects, + projectsLoaded, states, togglePinnedProject, } = useProjectsExplorerModel(options); @@ -475,5 +488,12 @@ export function useProjectsExplorer( ] ); - return { actions, data, devMockActive, states, refreshProjects: mutate }; + return { + actions, + data, + devMockActive, + projectsLoaded, + refreshProjects: mutate, + states, + }; } diff --git a/apps/ui/src/features/projects/project-workspace-guard-core.test.ts b/apps/ui/src/features/projects/project-workspace-guard-core.test.ts new file mode 100644 index 00000000..7bdb37a9 --- /dev/null +++ b/apps/ui/src/features/projects/project-workspace-guard-core.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectWorkspaceGuardDecision } from "./project-workspace-guard-core"; + +test("a loaded list that lacks the Project sends the page to the Project list", () => { + assert.equal( + projectWorkspaceGuardDecision({ + loaded: true, + projectId: "elsewhere", + projectIds: ["alpha", "beta"], + }), + "leave" + ); +}); + +test("a loaded list that has the Project keeps the page", () => { + assert.equal( + projectWorkspaceGuardDecision({ + loaded: true, + projectId: "beta", + projectIds: ["alpha", "beta"], + }), + "stay" + ); +}); + +test("nothing is judged while the list is loading or off a Project route", () => { + assert.equal( + projectWorkspaceGuardDecision({ + loaded: false, + projectId: "elsewhere", + projectIds: [], + }), + "stay" + ); + assert.equal( + projectWorkspaceGuardDecision({ + loaded: true, + projectId: "", + projectIds: ["alpha"], + }), + "stay" + ); +}); diff --git a/apps/ui/src/features/projects/project-workspace-guard-core.ts b/apps/ui/src/features/projects/project-workspace-guard-core.ts new file mode 100644 index 00000000..7d0d1e1b --- /dev/null +++ b/apps/ui/src/features/projects/project-workspace-guard-core.ts @@ -0,0 +1,20 @@ +/** + * The `/project/` guard's judgment (spec §H.2): once the Project list + * of the current Workspace is loaded and does not contain the Project the + * URL names — after a Workspace switch landed on a stale deep link, or a + * link into a Project of another Workspace — the page leaves for the + * Project list instead of showing an empty canvas. While the list is still + * loading nothing is judged, so a slow list never bounces a valid Project. + */ +export type ProjectWorkspaceGuardDecision = "leave" | "stay"; + +export function projectWorkspaceGuardDecision(input: { + loaded: boolean; + projectId: string; + projectIds: readonly string[]; +}): ProjectWorkspaceGuardDecision { + if (!input.loaded || input.projectId === "") { + return "stay"; + } + return input.projectIds.includes(input.projectId) ? "stay" : "leave"; +} diff --git a/apps/ui/src/features/projects/project-workspace-guard.test.tsx b/apps/ui/src/features/projects/project-workspace-guard.test.tsx new file mode 100644 index 00000000..9ce63d14 --- /dev/null +++ b/apps/ui/src/features/projects/project-workspace-guard.test.tsx @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, mock, test } from "bun:test"; +import assert from "node:assert/strict"; +import { getDefaultStore } from "jotai"; +import { useState } from "react"; + +import { + actAndDrain, + installTestDom, + restoreActEnvironment, + setActEnvironment, + type TestDom, +} from "@/features/project-canvas/react-test-harness"; +import type { ProjectExplorerProject } from "@/features/projects/explorer/project-explorer.types"; +import { kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +// The guard's two external facts — the route and the Project list — are +// stood in for here; the assertions are the router call and the toast. +const route = { pathname: "/project/elsewhere", replaced: [] as string[] }; +const explorer = { + devMockActive: false, + /** What the next revalidation answers with; null keeps the list as is. */ + freshProjects: null as ProjectExplorerProject[] | null, + /** + * When set, the revalidation resolves this payload verbatim without + * touching the rendered list — the snapshot has not painted yet. + */ + freshPayloadOnly: null as { projects: ProjectExplorerProject[] } | null, + projects: [] as ProjectExplorerProject[], + projectsLoaded: false, + /** When set, the revalidation rejects — the SWR verdict never lands. */ + refreshFails: false, + refreshes: 0, +}; +const toasts: string[] = []; + +mock.module("next/navigation", () => ({ + usePathname: () => route.pathname, + useRouter: () => ({ + replace: (href: string) => { + route.replaced.push(href); + }, + }), +})); +mock.module("@/features/projects/explorer/use-projects-explorer", () => ({ + useProjectsExplorerReadModel: () => { + const [, rerender] = useState(0); + return { + data: { aps: undefined, dbs: undefined }, + devMockActive: explorer.devMockActive, + projectsLoaded: explorer.projectsLoaded, + refreshProjects: () => { + explorer.refreshes += 1; + if (explorer.refreshFails) { + return Promise.reject(new Error("offline")); + } + if (explorer.freshPayloadOnly != null) { + return Promise.resolve(explorer.freshPayloadOnly); + } + if (explorer.freshProjects != null) { + explorer.projects = explorer.freshProjects; + rerender((n) => n + 1); + } + // SWR's mutate resolves with the raw `/api/projects` payload. + return Promise.resolve({ projects: explorer.projects }); + }, + states: { pinnedProjectIds: [], projects: explorer.projects }, + }; + }, +})); +mock.module("sonner", () => ({ + toast: (message: string) => { + toasts.push(message); + }, +})); + +const moduleDom = installTestDom(); +const { render } = await import("@testing-library/react/pure"); +const { ProjectIdProvider } = await import("@/features/panes/use-project-id"); +const { PROJECT_NOT_IN_WORKSPACE_NOTICE, ProjectWorkspaceGuard } = await import( + "./project-workspace-guard" +); +await moduleDom.restore(); + +const project = (id: string): ProjectExplorerProject => ({ + createdAt: "2026-05-26T00:00:00.000Z", + id, + name: id, +}); + +let dom: TestDom; +let actEnvironment: boolean | undefined; +let rendered: ReturnType | undefined; + +beforeEach(() => { + dom = installTestDom(); + actEnvironment = setActEnvironment(true); + const store = getDefaultStore(); + store.set(kubeconfigAtom, "apiVersion: v1"); + store.set(namespaceAtom, "ns-a"); + route.pathname = "/project/elsewhere"; + route.replaced = []; + explorer.devMockActive = false; + explorer.freshProjects = null; + explorer.freshPayloadOnly = null; + explorer.projects = []; + explorer.projectsLoaded = false; + explorer.refreshFails = false; + explorer.refreshes = 0; + toasts.length = 0; +}); + +afterEach(async () => { + await actAndDrain(() => { + rendered?.unmount(); + rendered = undefined; + }).catch(() => undefined); + restoreActEnvironment(actEnvironment); + await dom.restore(); +}); + +async function mountGuard() { + await actAndDrain(() => { + rendered = render( + + + + ); + }); +} + +test("a loaded list without the Project replaces the route with the Project list and says so", async () => { + explorer.projects = [project("alpha"), project("beta")]; + explorer.projectsLoaded = true; + await mountGuard(); + // One revalidation first, so a stale cache never bounces a real Project. + assert.equal(explorer.refreshes, 1); + assert.deepEqual(route.replaced, ["/project"]); + assert.deepEqual(toasts, [PROJECT_NOT_IN_WORKSPACE_NOTICE]); +}); + +test("a stale cached list that misses a Project created elsewhere stays once the refresh finds it", async () => { + route.pathname = "/project/created-elsewhere"; + explorer.projects = [project("alpha")]; + explorer.projectsLoaded = true; + explorer.freshProjects = [project("alpha"), project("created-elsewhere")]; + await mountGuard(); + assert.equal(explorer.refreshes, 1); + assert.deepEqual(route.replaced, []); + assert.deepEqual(toasts, []); +}); + +test("a refresh that fails confirms nothing: the page stays and no toast is served", async () => { + explorer.projects = [project("alpha")]; + explorer.projectsLoaded = true; + explorer.refreshFails = true; + await mountGuard(); + assert.equal(explorer.refreshes, 1); + assert.deepEqual(route.replaced, []); + assert.deepEqual(toasts, []); +}); + +test("a payload that carries the Project holds the guard even before the rendered list paints", async () => { + route.pathname = "/project/created-elsewhere"; + explorer.projects = [project("alpha")]; + explorer.projectsLoaded = true; + // The revalidation found it, but the hook's snapshot still lacks it. + explorer.freshPayloadOnly = { + projects: [project("alpha"), project("created-elsewhere")], + }; + await mountGuard(); + assert.equal(explorer.refreshes, 1); + assert.deepEqual(route.replaced, []); + assert.deepEqual(toasts, []); +}); + +test("a loaded list with the Project leaves the page alone", async () => { + route.pathname = "/project/beta"; + explorer.projects = [project("alpha"), project("beta")]; + explorer.projectsLoaded = true; + await mountGuard(); + assert.deepEqual(route.replaced, []); + assert.deepEqual(toasts, []); +}); + +test("nothing is judged while the list is loading, or under the Projects Dev Mock", async () => { + await mountGuard(); + assert.deepEqual(route.replaced, []); + + explorer.devMockActive = true; + explorer.projects = [project("fixture")]; + explorer.projectsLoaded = true; + await actAndDrain(() => { + rendered?.unmount(); + rendered = undefined; + }); + await mountGuard(); + assert.deepEqual(route.replaced, []); + assert.deepEqual(toasts, []); +}); diff --git a/apps/ui/src/features/projects/project-workspace-guard.tsx b/apps/ui/src/features/projects/project-workspace-guard.tsx new file mode 100644 index 00000000..307e671d --- /dev/null +++ b/apps/ui/src/features/projects/project-workspace-guard.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { useProjectId } from "@/features/panes/use-project-id"; +import { useProjectsExplorerReadModel } from "@/features/projects/explorer/use-projects-explorer"; +import { kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +import { projectWorkspaceGuardDecision } from "./project-workspace-guard-core"; + +export const PROJECT_NOT_IN_WORKSPACE_NOTICE = + "That Project is not in the current Workspace."; + +/** + * The `/project/` guard (spec §H.2): once the current Workspace's + * Project list is loaded and lacks the Project in the URL, replace the + * route with the Project list and say so. Renders nothing. The Projects + * Dev Mock's fixture rows are not real Projects, so the guard stands down + * while it is on. + * + * The list is an SWR cache that does not revalidate on focus, so a Project + * created in another tab is absent from it until something refreshes. A + * first "leave" verdict therefore revalidates once and only acts when the + * revalidation's *payload* came back as a list that still lacks the + * Project — a real Project is never bounced by a stale cache or by a + * rendered snapshot that has not painted yet, and a refresh that failed + * (401, 5xx, offline) or answered an unusable shape confirms nothing, so + * the guard keeps standing rather than judging from the stale verdict. + */ +/** + * The revalidation's payload — the raw `/api/projects` answer — or null + * when it did not come back as a usable list. The guard judges only the + * payload: the hook's rendered snapshot may not have painted yet. + */ +function freshProjectIdsOf(fresh: unknown): string[] | null { + if (typeof fresh !== "object" || fresh == null) { + return null; + } + const projects = (fresh as { projects?: unknown }).projects; + if (!Array.isArray(projects)) { + return null; + } + const ids: string[] = []; + for (const project of projects) { + if ( + typeof project !== "object" || + project == null || + !("id" in project) || + typeof (project as { id: unknown }).id !== "string" + ) { + return null; + } + ids.push((project as { id: string }).id); + } + return ids; +} + +export function ProjectWorkspaceGuard() { + const projectId = useProjectId(); + const router = useRouter(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const namespace = useAtomValue(namespaceAtom); + const { devMockActive, projectsLoaded, refreshProjects, states } = + useProjectsExplorerReadModel({ kubeconfig, ns: namespace }); + const decision = devMockActive + ? "stay" + : projectWorkspaceGuardDecision({ + loaded: projectsLoaded, + projectId, + projectIds: states.projects.map((project) => project.id), + }); + // The Project id whose absence a completed revalidation has re-judged. + const [revalidatedFor, setRevalidatedFor] = useState(null); + + useEffect(() => { + if (decision !== "leave") { + setRevalidatedFor((current) => (current == null ? current : null)); + return; + } + if (revalidatedFor === projectId) { + router.replace("/project"); + toast(PROJECT_NOT_IN_WORKSPACE_NOTICE); + return; + } + let cancelled = false; + refreshProjects() + .then((fresh) => { + if (cancelled) { + return; + } + const freshIds = freshProjectIdsOf(fresh); + // Only a payload that came back as a list may confirm the verdict; + // one that carries the Project stays the guard's hand until the + // rendered snapshot flips the decision. + if (freshIds == null || freshIds.includes(projectId)) { + return; + } + setRevalidatedFor(projectId); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [decision, projectId, refreshProjects, revalidatedFor, router]); + + return null; +} diff --git a/apps/ui/src/features/session/desktop-sdk.ts b/apps/ui/src/features/session/desktop-sdk.ts new file mode 100644 index 00000000..d70f1ce7 --- /dev/null +++ b/apps/ui/src/features/session/desktop-sdk.ts @@ -0,0 +1,123 @@ +"use client"; + +import { EVENT_NAME } from "@labring/sealos-desktop-sdk"; +import { createSealosApp, sealosApp } from "@labring/sealos-desktop-sdk/app"; + +/** + * The SDK boundary (ADR-0083, spec §A.6): Brain keeps the Desktop SDK for + * Desktop *state* — the handshake, the current `nsid`, the host domain, the + * language and its change event, `openApp` — and never for credentials. + * The return types below carry no kubeconfig, token, or user-display + * fields, so no fallback to SDK-delivered credentials can be added without + * changing a type here. + */ + +/** What Brain reads from Desktop's session: the current Workspace only. */ +export interface DesktopShellState { + /** Desktop's current namespace id (`ns-…`). */ + nsid: string; +} + +/** Whether this page runs inside a Desktop iframe (or any parent frame). */ +export function isInsideDesktopIframe(): boolean { + try { + return typeof window !== "undefined" && window.top !== window; + } catch { + // A cross-origin `window.top` throws on access in some browsers; that + // still means there is a parent. + return true; + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + () => { + clearTimeout(timer); + resolve(null); + } + ); + }); +} + +/** + * The SDK answers within its own 10 s budget inside the Desktop iframe. + * Outside one (local development), only the Sealos App Dev Bridge extension + * can answer, so an unanswered read is given up quickly instead of holding + * the session for the SDK's full timeout. + */ +const OUTSIDE_IFRAME_SDK_TIMEOUT_MS = 2000; +const INSIDE_IFRAME_SDK_TIMEOUT_MS = 12_000; + +function sdkTimeoutMs(): number { + return isInsideDesktopIframe() + ? INSIDE_IFRAME_SDK_TIMEOUT_MS + : OUTSIDE_IFRAME_SDK_TIMEOUT_MS; +} + +/** Desktop's current Workspace, or null when no shell (or bridge) answers. */ +export async function readDesktopShellState(): Promise { + const session = await withTimeout(sealosApp.getSession(), sdkTimeoutMs()); + const nsid = session?.user?.nsid?.trim() ?? ""; + return nsid === "" ? null : { nsid }; +} + +/** Desktop's language, or null when nothing answers. */ +export async function readDesktopLanguage(): Promise { + const language = await withTimeout(sealosApp.getLanguage(), sdkTimeoutMs()); + const lng = language?.lng?.trim() ?? ""; + return lng === "" ? null : lng; +} + +/** Desktop's cloud domain from the host config, or null when nothing answers. */ +export async function readDesktopDomain(): Promise { + const hostConfig = await withTimeout( + sealosApp.getHostConfig(), + sdkTimeoutMs() + ); + const domain = hostConfig?.cloud?.domain?.trim() ?? ""; + return domain === "" ? null : domain; +} + +function eventLanguage(event: unknown): string { + if (typeof event === "string") { + return event.trim(); + } + if ( + typeof event === "object" && + event !== null && + "lng" in event && + typeof event.lng === "string" + ) { + return event.lng.trim(); + } + return ""; +} + +/** + * Runs the SDK handshake for the page's lifetime and forwards Desktop's + * language changes. Returns the teardown. + */ +export function connectDesktopSdk(handlers: { + onLanguageChange: (language: string) => void; +}): () => void { + const cleanup = createSealosApp(); + const unsubscribe = sealosApp.addAppEventListen( + EVENT_NAME.CHANGE_I18N, + (event) => { + const language = eventLanguage(event); + if (language !== "") { + handlers.onLanguageChange(language); + } + } + ); + return () => { + unsubscribe?.(); + cleanup?.(); + }; +} diff --git a/apps/ui/src/features/session/dev-mock-cookie.ts b/apps/ui/src/features/session/dev-mock-cookie.ts new file mode 100644 index 00000000..22a73d49 --- /dev/null +++ b/apps/ui/src/features/session/dev-mock-cookie.ts @@ -0,0 +1,28 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Session Dev Mock's cookie (grammar in `features/dev-mock/cookie.ts`): + * while it names a scenario, `POST /api/session` answers from fixtures + * instead of exchanging the login cookie with Desktop, so the shell can be + * exercised in each Workspace Role without a staging Desktop. The + * credentials it hands out are fakes; pair it with the other Dev Mocks for + * a fully offline page. Off by default: the real staging path runs unless a + * scenario is explicitly selected. + */ + +export const SESSION_DEV_SCENARIOS = [ + "owner-team", + "manager", + "developer", + "personal-only", +] as const; + +export type SessionDevScenario = (typeof SESSION_DEV_SCENARIOS)[number]; + +export const DEFAULT_SESSION_DEV_SCENARIO: SessionDevScenario = "owner-team"; + +export const sessionDevMockCookie = defineDevMockCookie({ + defaultScenario: DEFAULT_SESSION_DEV_SCENARIO, + name: "sealai-session-dev-mock", + scenarios: SESSION_DEV_SCENARIOS, +}); diff --git a/apps/ui/src/features/session/dev-mock.tsx b/apps/ui/src/features/session/dev-mock.tsx new file mode 100644 index 00000000..8f505d39 --- /dev/null +++ b/apps/ui/src/features/session/dev-mock.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; + +import { reloadForDevMock } from "@/features/dev-mock/reload"; +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + DEFAULT_SESSION_DEV_SCENARIO, + SESSION_DEV_SCENARIOS, + sessionDevMockCookie, +} from "./dev-mock-cookie"; + +export const SESSION_DEV_MOCK_KEY = "session-mock"; + +const sessionDevMockSource = createDevMockCookieSource(sessionDevMockCookie); + +/** Registers the mock with the app-global registry; renders nothing. */ +export function SessionDevMockTweaks() { + useDevTweaksMock(SESSION_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_SESSION_DEV_SCENARIO, + note: "Serves POST /api/session and /api/workspace/* from fixtures (fake credentials, one scenario per Workspace Role); toggling reloads the page", + // The session is established once at mount; a reload is the one honest + // way to re-establish it from (or off) the fixtures. + revalidate: reloadForDevMock, + scenarios: SESSION_DEV_SCENARIOS, + source: sessionDevMockSource, + title: "Session mock", + }); + return null; +} diff --git a/apps/ui/src/features/session/server/create-session-route.ts b/apps/ui/src/features/session/server/create-session-route.ts new file mode 100644 index 00000000..19e2da83 --- /dev/null +++ b/apps/ui/src/features/session/server/create-session-route.ts @@ -0,0 +1,26 @@ +type SessionRouteHandler = (request: Request) => Promise; + +/** + * Lets the session dev-mock dispatcher answer `POST /api/session` first in + * dev and demo builds (same gate as the billing routes: `NEXT_PUBLIC_DEV_TWEAKS=1` + * marks a demo image). The build-time-guarded dynamic import keeps the + * fixtures out of real production bundles; by default the mock is off and + * the real Desktop path runs. The gate is inlined here, as in + * `withBillingDevMock`, because a shared helper call would not be + * statically dropped from the bundle. + */ +export function withSessionDevMock( + handler: SessionRouteHandler +): SessionRouteHandler { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return handler; + } + return async (request) => { + const { sessionDevMockResponse } = await import("./dev-fixtures"); + const mocked = await sessionDevMockResponse(request); + return mocked ?? handler(request); + }; +} diff --git a/apps/ui/src/features/session/server/desktop-auth-api.ts b/apps/ui/src/features/session/server/desktop-auth-api.ts new file mode 100644 index 00000000..e3cbf033 --- /dev/null +++ b/apps/ui/src/features/session/server/desktop-auth-api.ts @@ -0,0 +1,417 @@ +import "server-only"; + +import { z } from "zod"; + +import type { WorkspaceMember } from "@/features/workspace/workspace-details-schema"; + +import type { SessionWorkspace, WorkspaceRole } from "../session-schema"; +import { + type DesktopCallResult, + type DesktopClient, + encodedTokenAuthorization, +} from "./desktop-client"; + +/** + * The Desktop `/api/auth/*` calls Brain makes: the four the Brain Session + * needs (spec §A.1) and the Workspace-management reads (spec §B.2), typed + * against the Desktop DTOs they answer with. Each takes the token in the + * form Desktop's verifier expects — the global token for `regionToken`, the + * regional token everywhere else — and returns the raw DTO, or, where the + * shape is Brain's own, the transformed one. + */ + +export const DESKTOP_AUTH_PATHS = { + info: "/api/auth/info", + namespaceAbdicate: "/api/auth/namespace/abdicate", + namespaceCreate: "/api/auth/namespace/create", + namespaceDelete: "/api/auth/namespace/delete", + namespaceDetails: "/api/auth/namespace/details", + namespaceInviteCode: "/api/auth/namespace/getInviteCode", + namespaceList: "/api/auth/namespace/list", + namespaceModifyRole: "/api/auth/namespace/modifyRole", + namespaceRemoveUser: "/api/auth/namespace/removeUser", + namespaceRename: "/api/auth/namespace/rename", + namespaceSetAlias: "/api/auth/namespace/setAlias", + namespaceSwitch: "/api/auth/namespace/switch", + regionToken: "/api/auth/regionToken", +} as const; + +const regionTokenDataSchema = z.object({ + appToken: z.string().min(1), + kubeconfig: z.string().min(1), + token: z.string().min(1), +}); + +export type RegionTokenData = z.infer; + +/** `UserRole { Owner = 0, Manager = 1, Developer = 2 }` in Desktop. */ +const DESKTOP_ROLES: Record = { + 0: "Owner", + 1: "Manager", + 2: "Developer", +}; + +/** The role code Desktop's write routes take (`role`, `tRole`). */ +const DESKTOP_ROLE_CODES: Record = { + Developer: 2, + Manager: 1, + Owner: 0, +}; + +/** Desktop answers the write routes with `data: null`; nothing is read from it. */ +const voidDataSchema = z.unknown().transform((): null => null); + +const inviteCodeDataSchema = z.object({ code: z.string().min(1) }); + +export type InviteCodeData = z.infer; + +/** `NSType { Team = 0, Private = 1 }` in Desktop. */ +const DESKTOP_NSTYPE_PRIVATE = 1; + +const namespaceDtoSchema = z.object({ + createTime: z.union([z.string(), z.number()]), + id: z.string().min(1), + nstype: z.number(), + role: z.number(), + teamName: z.string(), + uid: z.string().min(1), +}); + +const namespaceListDataSchema = z.object({ + namespaces: z.array(namespaceDtoSchema), +}); + +const switchDataSchema = z.object({ + appToken: z.string().min(1), + token: z.string().min(1), +}); + +export type SwitchData = z.infer; + +const authInfoDataSchema = z.object({ + info: z.object({ + avatarUri: z.string().nullish(), + id: z.string().nullish(), + name: z.string().nullish(), + nickname: z.string().nullish(), + uid: z.string().nullish(), + }), +}); + +export type AuthInfoData = z.infer; + +function workspaceFromDto( + dto: z.infer +): SessionWorkspace | null { + const role = DESKTOP_ROLES[dto.role]; + if (role == null) { + return null; + } + return { + createdAt: String(dto.createTime), + id: dto.id, + isPersonal: dto.nstype === DESKTOP_NSTYPE_PRIVATE, + name: dto.teamName, + role, + uid: dto.uid, + }; +} + +/** + * `namespace/create`'s answer: the new Team Workspace as Desktop describes + * it. Only the identifiers and the name matter to Workspace Creation — the + * subscription payment addresses it by `id`, the Switcher by `uid`. + */ +export interface DesktopCreatedWorkspace { + id: string; + name: string; + uid: string; +} + +export const desktopCreatedWorkspaceSchema = z + .object({ namespace: namespaceDtoSchema }) + .transform( + (data): DesktopCreatedWorkspace => ({ + id: data.namespace.id, + name: data.namespace.teamName, + uid: data.namespace.uid, + }) + ); + +/** The user's Workspaces in Brain's shape, in Desktop's order (Personal first). */ +export const desktopWorkspaceListSchema = namespaceListDataSchema.transform( + (data, ctx): SessionWorkspace[] => { + const workspaces: SessionWorkspace[] = []; + for (const dto of data.namespaces) { + const workspace = workspaceFromDto(dto); + if (workspace == null) { + ctx.addIssue({ code: "custom", message: "unknown workspace role" }); + return z.NEVER; + } + workspaces.push(workspace); + } + return workspaces; + } +); + +/** `TeamUserDto`: one IN_WORKSPACE member as Desktop's `details` lists it. */ +const teamUserDtoSchema = z.object({ + alias: z.string().nullish(), + avatarUrl: z.string().nullish(), + crUid: z.string().min(1), + createdTime: z.union([z.string(), z.number()]).nullish(), + joinTime: z.union([z.string(), z.number()]).nullish(), + k8s_username: z.string(), + nickname: z.string().nullish(), + role: z.number(), + uid: z.string().nullish(), +}); + +/** A Desktop timestamp — ISO text or an epoch number — as ISO text; "" when absent. */ +function isoTimestamp(value: string | number | null | undefined): string { + if (value == null) { + return ""; + } + if (typeof value === "string") { + return value; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toISOString(); +} + +function memberFromDto( + dto: z.infer +): WorkspaceMember | null { + const role = DESKTOP_ROLES[dto.role]; + if (role == null) { + return null; + } + // Desktop leaves `joinTime` optional; the User CR's creation time is the + // closest fact when it is missing. + const joined = dto.joinTime ?? dto.createdTime; + return { + alias: dto.alias == null || dto.alias === "" ? null : dto.alias, + avatarUrl: dto.avatarUrl ?? "", + crName: dto.k8s_username, + crUid: dto.crUid, + joinedAt: isoTimestamp(joined), + nickname: dto.nickname ?? "", + role, + userUid: dto.uid ?? "", + }; +} + +export interface DesktopWorkspaceDetails { + members: WorkspaceMember[]; + workspace: SessionWorkspace; +} + +/** + * Desktop's `details` answer in Brain's shape. Desktop judges `nstype` here + * by `id === 'ns-' + userCrName` rather than by the membership row's + * `isPrivate` as `list` does; the Workspace Area keeps the list's verdict + * and reads only the members from this answer. + */ +export const desktopWorkspaceDetailsSchema = z + .object({ + namespace: namespaceDtoSchema, + users: z.array(teamUserDtoSchema), + }) + .transform((data, ctx): DesktopWorkspaceDetails => { + const workspace = workspaceFromDto(data.namespace); + if (workspace == null) { + ctx.addIssue({ code: "custom", message: "unknown workspace role" }); + return z.NEVER; + } + const members: WorkspaceMember[] = []; + for (const dto of data.users) { + const member = memberFromDto(dto); + if (member == null) { + ctx.addIssue({ code: "custom", message: "unknown member role" }); + return z.NEVER; + } + members.push(member); + } + return { members, workspace }; + }); + +export interface DesktopAuthApi { + authInfo(regionalToken: string): Promise>; + /** Transfers ownership to `targetCrUid`; the caller becomes a Developer. */ + namespaceAbdicate( + regionalToken: string, + workspaceUid: string, + targetCrUid: string + ): Promise>; + /** + * Creates a Team Workspace for a subscription (spec §G.3). Desktop's + * `create` verifies the app token too, so the call carries it raw — no + * encoding, no scheme — and needs no regional token. + */ + namespaceCreate( + appToken: string, + name: string + ): Promise>; + namespaceDelete( + regionalToken: string, + workspaceUid: string + ): Promise>; + namespaceDetails( + regionalToken: string, + workspaceUid: string + ): Promise>; + /** A Workspace Invite Link code for `role` (never Owner; the schema forbids it). */ + namespaceInviteCode( + regionalToken: string, + workspaceUid: string, + role: WorkspaceRole + ): Promise>; + namespaceList( + regionalToken: string + ): Promise>; + namespaceModifyRole( + regionalToken: string, + workspaceUid: string, + targetCrUid: string, + role: WorkspaceRole + ): Promise>; + /** Removes a member; the caller's own crUid means leaving. */ + namespaceRemoveUser( + regionalToken: string, + workspaceUid: string, + targetCrUid: string + ): Promise>; + namespaceRename( + regionalToken: string, + workspaceUid: string, + name: string + ): Promise>; + /** Sets a member's alias in this Workspace; null clears it. */ + namespaceSetAlias( + regionalToken: string, + workspaceUid: string, + targetCrUid: string, + alias: string | null + ): Promise>; + namespaceSwitch( + regionalToken: string, + workspaceUid: string + ): Promise>; + regionToken(globalToken: string): Promise>; +} + +export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { + const post = ( + regionalToken: string, + path: string, + body: unknown, + dataSchema: z.ZodType + ) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + body, + dataSchema, + method: "POST", + path, + }); + return { + authInfo: (regionalToken) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + dataSchema: authInfoDataSchema, + method: "GET", + path: DESKTOP_AUTH_PATHS.info, + }), + namespaceAbdicate: (regionalToken, workspaceUid, targetCrUid) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceAbdicate, + { ns_uid: workspaceUid, targetUserCrUid: targetCrUid }, + voidDataSchema + ), + namespaceCreate: (appToken, name) => + client.call({ + authorization: appToken, + body: { teamName: name, userType: "subscription" }, + dataSchema: desktopCreatedWorkspaceSchema, + method: "POST", + path: DESKTOP_AUTH_PATHS.namespaceCreate, + }), + namespaceDelete: (regionalToken, workspaceUid) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceDelete, + { ns_uid: workspaceUid }, + voidDataSchema + ), + namespaceDetails: (regionalToken, workspaceUid) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + body: { ns_uid: workspaceUid }, + dataSchema: desktopWorkspaceDetailsSchema, + method: "POST", + path: DESKTOP_AUTH_PATHS.namespaceDetails, + }), + namespaceInviteCode: (regionalToken, workspaceUid, role) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceInviteCode, + { ns_uid: workspaceUid, role: DESKTOP_ROLE_CODES[role] }, + inviteCodeDataSchema + ), + namespaceList: (regionalToken) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + dataSchema: desktopWorkspaceListSchema, + method: "GET", + path: DESKTOP_AUTH_PATHS.namespaceList, + }), + namespaceModifyRole: (regionalToken, workspaceUid, targetCrUid, role) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceModifyRole, + { + ns_uid: workspaceUid, + tRole: DESKTOP_ROLE_CODES[role], + targetUserCrUid: targetCrUid, + }, + voidDataSchema + ), + namespaceRemoveUser: (regionalToken, workspaceUid, targetCrUid) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceRemoveUser, + { ns_uid: workspaceUid, targetUserCrUid: targetCrUid }, + voidDataSchema + ), + namespaceRename: (regionalToken, workspaceUid, name) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceRename, + { ns_uid: workspaceUid, teamName: name }, + voidDataSchema + ), + namespaceSetAlias: (regionalToken, workspaceUid, targetCrUid, alias) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceSetAlias, + { alias, ns_uid: workspaceUid, targetUserCrUid: targetCrUid }, + voidDataSchema + ), + namespaceSwitch: (regionalToken, workspaceUid) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + body: { ns_uid: workspaceUid }, + dataSchema: switchDataSchema, + method: "POST", + path: DESKTOP_AUTH_PATHS.namespaceSwitch, + }), + regionToken: (globalToken) => + client.call({ + authorization: encodedTokenAuthorization(globalToken), + dataSchema: regionTokenDataSchema, + method: "POST", + path: DESKTOP_AUTH_PATHS.regionToken, + }), + }; +} diff --git a/apps/ui/src/features/session/server/desktop-client.test.ts b/apps/ui/src/features/session/server/desktop-client.test.ts new file mode 100644 index 00000000..a1f02460 --- /dev/null +++ b/apps/ui/src/features/session/server/desktop-client.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, mock } from "bun:test"; +import { z } from "zod"; + +mock.module("server-only", () => ({})); +const { + createDesktopClient, + desktopApiBaseUrlFromEnv, + encodedTokenAuthorization, +} = await import("./desktop-client"); + +const dataSchema = z.object({ token: z.string() }); + +function envelope(code: number, data: unknown, message = ""): Response { + return Response.json({ code, data, message }); +} + +describe("createDesktopClient", () => { + it("posts JSON with the verbatim Authorization header and unpacks a 200 envelope", async () => { + let seen: { init: RequestInit; url: URL } | undefined; + const client = createDesktopClient({ + baseUrl: "http://sealos-desktop.sealos.svc:3000/", + fetch: (url, init) => { + seen = { init, url }; + return Promise.resolve(envelope(200, { token: "regional" })); + }, + }); + + const result = await client.call({ + authorization: encodedTokenAuthorization("glo/bal+token"), + body: { ns_uid: "uuid" }, + dataSchema, + method: "POST", + path: "/api/auth/namespace/switch", + }); + + expect(result).toEqual({ data: { token: "regional" }, ok: true }); + expect(seen?.url.toString()).toBe( + "http://sealos-desktop.sealos.svc:3000/api/auth/namespace/switch" + ); + expect(seen?.init.method).toBe("POST"); + expect(seen?.init.body).toBe('{"ns_uid":"uuid"}'); + const headers = seen?.init.headers as Record; + expect(headers.Authorization).toBe("glo%2Fbal%2Btoken"); + expect(headers.Authorization?.startsWith("Bearer")).toBe(false); + expect(headers["Content-Type"]).toBe("application/json"); + expect(seen?.init.signal).toBeInstanceOf(AbortSignal); + }); + + it("sends no body or content type on GET", async () => { + let seen: RequestInit | undefined; + const client = createDesktopClient({ + baseUrl: "http://desktop.test", + fetch: (_url, init) => { + seen = init; + return Promise.resolve(envelope(200, { token: "t" })); + }, + }); + await client.call({ + authorization: "x", + dataSchema, + method: "GET", + path: "/api/auth/namespace/list", + }); + expect(seen?.body).toBeUndefined(); + expect( + (seen?.headers as Record)["Content-Type"] + ).toBeUndefined(); + }); + + it("surfaces a non-200 business code from an HTTP 200 envelope", async () => { + const client = createDesktopClient({ + baseUrl: "http://desktop.test", + fetch: () => Promise.resolve(envelope(401, null, "invalid token")), + }); + expect( + await client.call({ + authorization: "x", + dataSchema, + method: "POST", + path: "/api/auth/regionToken", + }) + ).toEqual({ + code: 401, + kind: "desktop_code", + message: "invalid token", + ok: false, + }); + }); + + it("treats a bad envelope, a bad data shape, and non-JSON as malformed", async () => { + const call = (response: Response) => + createDesktopClient({ + baseUrl: "http://desktop.test", + fetch: () => Promise.resolve(response), + }).call({ + authorization: "x", + dataSchema, + method: "GET", + path: "/p", + }); + expect(await call(Response.json({ hello: "world" }))).toEqual({ + kind: "malformed", + ok: false, + }); + expect(await call(envelope(200, { token: 5 }))).toEqual({ + kind: "malformed", + ok: false, + }); + expect(await call(new Response("", { status: 200 }))).toEqual({ + kind: "malformed", + ok: false, + }); + }); + + it("reports non-2xx HTTP statuses, timeouts, and network failures distinctly", async () => { + const call = (respond: () => Promise) => + createDesktopClient({ + baseUrl: "http://desktop.test", + fetch: respond, + }).call({ authorization: "x", dataSchema, method: "GET", path: "/p" }); + expect( + await call(() => Promise.resolve(new Response("nope", { status: 502 }))) + ).toEqual({ kind: "http", ok: false, status: 502 }); + expect( + await call(() => { + const error = new Error("timed out"); + error.name = "TimeoutError"; + return Promise.reject(error); + }) + ).toEqual({ kind: "timeout", ok: false }); + expect(await call(() => Promise.reject(new Error("ECONNREFUSED")))).toEqual( + { kind: "unreachable", ok: false } + ); + }); +}); + +describe("desktopApiBaseUrlFromEnv", () => { + it("reads DESKTOP_API_BASE_URL and drops trailing slashes", () => { + expect( + desktopApiBaseUrlFromEnv({ DESKTOP_API_BASE_URL: " http://d.test// " }) + ).toBe("http://d.test"); + expect(desktopApiBaseUrlFromEnv({ DESKTOP_API_BASE_URL: "" })).toBeNull(); + expect(desktopApiBaseUrlFromEnv({})).toBeNull(); + }); +}); diff --git a/apps/ui/src/features/session/server/desktop-client.ts b/apps/ui/src/features/session/server/desktop-client.ts new file mode 100644 index 00000000..c062c5d5 --- /dev/null +++ b/apps/ui/src/features/session/server/desktop-client.ts @@ -0,0 +1,160 @@ +import "server-only"; + +import type { z } from "zod"; + +/** + * The Desktop HTTP boundary (ADR-0083, spec §B.3): one place that calls a + * Desktop route and unpacks its envelope. Desktop answers HTTP 200 for + * everything and puts the business code in `body.code`; this client turns + * that into a discriminated result the route handlers translate into real + * HTTP statuses. `fetch` is injectable — the test seam — and every call + * carries a timeout so a stuck Desktop cannot hang a Brain request. + * + * Authorization forms differ per token and are the caller's business + * (`authorization` is passed verbatim): the global and regional tokens go + * URL-encoded without a scheme, the app token goes raw. + */ + +export const DESKTOP_REQUEST_TIMEOUT_MS = 30_000; + +export type DesktopFetch = (input: URL, init: RequestInit) => Promise; + +export type DesktopCallFailure = + /** Desktop answered with a non-200 business code. */ + | { code: number; kind: "desktop_code"; message: string } + /** Desktop answered, but not with a well-formed envelope or data shape. */ + | { kind: "malformed" } + /** Desktop answered with a non-2xx HTTP status (an ingress or 404 page). */ + | { kind: "http"; status: number } + | { kind: "timeout" } + | { kind: "unreachable" }; + +export type DesktopCallResult = + | { data: T; ok: true } + | ({ ok: false } & DesktopCallFailure); + +export interface DesktopCallInput { + authorization: string; + body?: unknown; + dataSchema: z.ZodType; + method: "GET" | "POST"; + /** Desktop route path, e.g. `/api/auth/regionToken`. */ + path: string; +} + +export interface DesktopClient { + call(input: DesktopCallInput): Promise>; +} + +const TRAILING_SLASHES_RE = /\/+$/; + +/** `DESKTOP_API_BASE_URL`, trailing slashes dropped; null when unset. */ +export function desktopApiBaseUrlFromEnv( + env: Record = process.env +): string | null { + const raw = env.DESKTOP_API_BASE_URL?.trim() ?? ""; + if (raw === "") { + return null; + } + return raw.replace(TRAILING_SLASHES_RE, ""); +} + +/** Global and regional tokens travel URL-encoded with no auth scheme. */ +export function encodedTokenAuthorization(token: string): string { + return encodeURIComponent(token); +} + +function isTimeout(error: unknown): boolean { + return ( + typeof error === "object" && + error != null && + "name" in error && + (error.name === "TimeoutError" || error.name === "AbortError") + ); +} + +function envelopeOf( + payload: unknown +): { code: number; data: unknown; message: string } | null { + if ( + typeof payload !== "object" || + payload == null || + Array.isArray(payload) + ) { + return null; + } + const record = payload as Record; + if (typeof record.code !== "number") { + return null; + } + return { + code: record.code, + data: record.data, + message: typeof record.message === "string" ? record.message : "", + }; +} + +export function createDesktopClient(options: { + baseUrl: string; + fetch?: DesktopFetch; + timeoutMs?: number; +}): DesktopClient { + const fetchDesktop: DesktopFetch = + options.fetch ?? ((url, init) => fetch(url, init)); + const timeoutMs = options.timeoutMs ?? DESKTOP_REQUEST_TIMEOUT_MS; + const baseUrl = options.baseUrl.replace(TRAILING_SLASHES_RE, ""); + + return { + async call(input: DesktopCallInput): Promise> { + const headers: Record = { + Accept: "application/json", + Authorization: input.authorization, + }; + const init: RequestInit = { + headers, + method: input.method, + signal: AbortSignal.timeout(timeoutMs), + }; + if (input.body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(input.body); + } + + let response: Response; + try { + response = await fetchDesktop(new URL(`${baseUrl}${input.path}`), init); + } catch (error) { + return isTimeout(error) + ? { kind: "timeout", ok: false } + : { kind: "unreachable", ok: false }; + } + if (!response.ok) { + await response.body?.cancel(); + return { kind: "http", ok: false, status: response.status }; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return { kind: "malformed", ok: false }; + } + const envelope = envelopeOf(payload); + if (envelope == null) { + return { kind: "malformed", ok: false }; + } + if (envelope.code !== 200) { + return { + code: envelope.code, + kind: "desktop_code", + message: envelope.message, + ok: false, + }; + } + const parsed = input.dataSchema.safeParse(envelope.data); + return parsed.success + ? { data: parsed.data, ok: true } + : { kind: "malformed", ok: false }; + }, + }; +} diff --git a/apps/ui/src/features/session/server/desktop-test-double.ts b/apps/ui/src/features/session/server/desktop-test-double.ts new file mode 100644 index 00000000..204d52dd --- /dev/null +++ b/apps/ui/src/features/session/server/desktop-test-double.ts @@ -0,0 +1,178 @@ +import type { DesktopFetch } from "./desktop-client"; + +/** + * A fake Desktop for the server tests (spec "Testing Decisions", seam 1): + * answers each `/api/auth/*` path with Desktop's "HTTP 200 + body.code" + * envelope and records every call so tests can assert order, headers, and + * bodies. Tokens are opaque strings except the regional ones, which carry a + * decodable payload the way Desktop's do. + */ + +export interface RecordedDesktopCall { + authorization: string | null; + body: unknown; + method: string; + path: string; +} + +export type DesktopAnswer = + | { code: number; data?: unknown; message?: string } + | Response + | Error; + +export interface FakeDesktopOptions { + answers: Partial< + Record< + string, + DesktopAnswer | ((call: RecordedDesktopCall) => DesktopAnswer) + > + >; +} + +export function fakeRegionalToken(claims: { + userCrName?: string; + userId?: string; + userUid?: string; + workspaceId: string; + workspaceUid: string; +}): string { + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }) + ).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + iat: 1_700_000_000, + regionUid: "region-1", + userCrName: claims.userCrName ?? "abc12345", + userCrUid: "cr-uid-1", + userId: claims.userId ?? "user-id-1", + userUid: claims.userUid ?? "user-uid-1", + workspaceId: claims.workspaceId, + workspaceUid: claims.workspaceUid, + }) + ).toString("base64url"); + return `${header}.${payload}.sig-${claims.workspaceId}`; +} + +export const FAKE_KUBECONFIG = `apiVersion: v1 +kind: Config +current-context: abc12345 +contexts: + - name: abc12345 + context: + cluster: sealos + user: abc12345 + namespace: ns-abc12345 +clusters: + - name: sealos + cluster: + server: https://apiserver.test +users: + - name: abc12345 + user: + token: sa-token-abc12345 +`; + +export const PERSONAL = { + createTime: "2026-01-01T00:00:00.000Z", + id: "ns-abc12345", + nstype: 1, + role: 0, + teamName: "private team", + uid: "11111111-1111-4111-8111-111111111111", +}; + +export const TEAM = { + createTime: "2026-02-01T00:00:00.000Z", + id: "ns-team0001", + nstype: 0, + role: 1, + teamName: "Acme", + uid: "22222222-2222-4222-8222-222222222222", +}; + +export const GLOBAL_TOKEN = "global.token.value"; +export const PERSONAL_REGIONAL_TOKEN = fakeRegionalToken({ + workspaceId: PERSONAL.id, + workspaceUid: PERSONAL.uid, +}); +export const TEAM_REGIONAL_TOKEN = fakeRegionalToken({ + workspaceId: TEAM.id, + workspaceUid: TEAM.uid, +}); +export const PERSONAL_APP_TOKEN = "app.token.personal"; +export const TEAM_APP_TOKEN = "app.token.team"; + +/** The happy-path answers; override per test. */ +export function defaultDesktopAnswers(): FakeDesktopOptions["answers"] { + return { + "/api/auth/info": { + code: 200, + data: { + info: { + avatarUri: "https://desktop.test/avatar.png", + id: "user-id-1", + name: "ada", + nickname: "Ada", + uid: "user-uid-1", + }, + }, + }, + "/api/auth/namespace/list": { + code: 200, + data: { namespaces: [PERSONAL, TEAM] }, + }, + "/api/auth/namespace/switch": { + code: 200, + data: { appToken: TEAM_APP_TOKEN, token: TEAM_REGIONAL_TOKEN }, + }, + "/api/auth/regionToken": { + code: 200, + data: { + appToken: PERSONAL_APP_TOKEN, + kubeconfig: FAKE_KUBECONFIG, + token: PERSONAL_REGIONAL_TOKEN, + }, + }, + }; +} + +export function createFakeDesktop( + options: FakeDesktopOptions = { answers: defaultDesktopAnswers() } +): { + calls: RecordedDesktopCall[]; + fetch: DesktopFetch; +} { + const calls: RecordedDesktopCall[] = []; + const fetchDesktop: DesktopFetch = (url, init) => { + const headers = new Headers(init.headers); + const call: RecordedDesktopCall = { + authorization: headers.get("authorization"), + body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, + method: init.method ?? "GET", + path: url.pathname, + }; + calls.push(call); + const configured = options.answers[url.pathname]; + const answer = + typeof configured === "function" ? configured(call) : configured; + if (answer == null) { + return Promise.resolve(new Response("not found", { status: 404 })); + } + if (answer instanceof Error) { + return Promise.reject(answer); + } + if (answer instanceof Response) { + return Promise.resolve(answer); + } + return Promise.resolve( + Response.json({ + code: answer.code, + data: answer.data ?? null, + message: + answer.message ?? (answer.code === 200 ? "Successfully" : "error"), + }) + ); + }; + return { calls, fetch: fetchDesktop }; +} diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts new file mode 100644 index 00000000..f92bd345 --- /dev/null +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -0,0 +1,443 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + WORKSPACE_ROUTES, + type WorkspaceRouteEntry, +} from "@/features/workspace/server/workspace-route-table"; +import { workspaceDetailsResponseSchema } from "@/features/workspace/workspace-details-schema"; +import { WORKSPACE_ERROR_CODES } from "@/features/workspace/workspace-errors"; +import { workspaceListResponseSchema } from "@/features/workspace/workspace-list-schema"; +import { workspaceInviteLinkResponseSchema } from "@/features/workspace/workspace-write-schema"; +import { + SESSION_DEV_SCENARIOS, + sessionDevMockCookie, +} from "../dev-mock-cookie"; + +import { brainSessionSchema } from "../session-schema"; +import { + resetWorkspaceDevMockState, + sessionDevMockResponse, + workspaceDevMockResponse, +} from "./dev-fixtures"; + +function request(input: { body?: unknown; cookie?: string }): Request { + return new Request("https://brain.test/api/session", { + body: JSON.stringify(input.body ?? {}), + headers: { + "content-type": "application/json", + ...(input.cookie == null ? {} : { cookie: input.cookie }), + }, + method: "POST", + }); +} + +test("the session mock stays out of the way without its cookie or while off", async () => { + assert.equal(await sessionDevMockResponse(request({})), null); + assert.equal( + await sessionDevMockResponse( + request({ + cookie: `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: false, scenario: "manager" })}`, + }) + ), + null + ); +}); + +// Every scenario must answer with a session the client's own schema accepts, +// staging the Workspace Role its name promises. +test("every scenario answers a valid Brain Session in the promised role", async () => { + const expectedRole = { + developer: "Developer", + manager: "Manager", + "owner-team": "Owner", + "personal-only": "Owner", + } as const; + for (const scenario of SESSION_DEV_SCENARIOS) { + const response = await sessionDevMockResponse( + request({ + cookie: `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: true, scenario })}`, + }) + ); + assert.notEqual(response, null, scenario); + assert.equal(response?.status, 200, scenario); + const session = brainSessionSchema.parse(await response?.json()); + assert.equal(session.workspace.role, expectedRole[scenario], scenario); + assert.equal( + session.workspace.isPersonal, + scenario === "personal-only", + scenario + ); + assert.equal(session.workspaces[0]?.isPersonal, true, scenario); + assert.equal(session.namespace, session.workspace.id, scenario); + assert.equal( + session.kubeconfig.includes(session.namespace), + true, + scenario + ); + assert.equal(session.fallback, undefined, scenario); + } +}); + +test("a requested nsid that the scenario knows is honoured; an unknown one falls back to the default with a notice", async () => { + const cookie = `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: true, scenario: "owner-team" })}`; + const personal = brainSessionSchema.parse( + await ( + await sessionDevMockResponse( + request({ body: { nsid: "ns-mock" }, cookie }) + ) + )?.json() + ); + assert.equal(personal.workspace.isPersonal, true); + assert.equal(personal.fallback, undefined); + + const unknown = brainSessionSchema.parse( + await ( + await sessionDevMockResponse( + request({ body: { nsid: "ns-elsewhere" }, cookie }) + ) + )?.json() + ); + assert.equal(unknown.fallback, "not_member"); + assert.equal(unknown.workspace.isPersonal, true); +}); + +// Spec §B.4: the same scenario answers every Workspace route, with the list +// the session itself staged, so the Switcher's refresh never disagrees +// with the session it started from. +test("every scenario answers every Workspace route with the session's own list", async () => { + for (const scenario of SESSION_DEV_SCENARIOS) { + const cookie = `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: true, scenario })}`; + const session = brainSessionSchema.parse( + await (await sessionDevMockResponse(request({ cookie })))?.json() + ); + for (const entry of Object.values(WORKSPACE_ROUTES)) { + // A write that lands (delete, say) must not shape the next route's read. + resetWorkspaceDevMockState(); + const response = await workspaceDevMockResponse( + entry.desktopPath, + new Request(`https://brain.test${entry.apiPath}`, { + body: JSON.stringify({ uid: session.workspace.uid }), + headers: { "content-type": "application/json", cookie }, + method: "POST", + }) + ); + // The read routes answer 200; a write route with a body this bare + // answers its own 400, never the 501 of a missing fixture. + assert.notEqual(response?.status, 501, `${scenario} ${entry.apiPath}`); + if ( + entry === WORKSPACE_ROUTES.list || + entry === WORKSPACE_ROUTES.details + ) { + assert.equal(response?.status, 200, `${scenario} ${entry.apiPath}`); + } + if (entry === WORKSPACE_ROUTES.list) { + assert.deepEqual( + workspaceListResponseSchema.parse(await response?.json()), + session.workspaces, + scenario + ); + } + if (entry === WORKSPACE_ROUTES.details) { + // The staged Workspace's members, with the mock user in the role + // the session gave them, so the Workspace Area gates as staged. + const details = workspaceDetailsResponseSchema.parse( + await response?.json() + ); + assert.deepEqual(details.workspace, session.workspace, scenario); + const me = details.members.find( + (candidate) => candidate.crName === session.user.crName + ); + assert.equal(me?.role, session.workspace.role, scenario); + assert.equal( + details.members.filter((candidate) => candidate.role === "Owner") + .length, + 1, + `${scenario} has exactly one Owner` + ); + } + } + // Every Workspace in the list has a details answer, not just the staged one. + for (const workspace of session.workspaces) { + const response = await workspaceDevMockResponse( + WORKSPACE_ROUTES.details.desktopPath, + new Request("https://brain.test/api/workspace/details", { + body: JSON.stringify({ uid: workspace.uid }), + headers: { "content-type": "application/json", cookie }, + method: "POST", + }) + ); + const details = workspaceDetailsResponseSchema.parse( + await response?.json() + ); + assert.equal(details.workspace.uid, workspace.uid, scenario); + assert.ok( + details.members.some( + (candidate) => candidate.crName === session.user.crName + ), + `${scenario} ${workspace.name} lists the mock user` + ); + } + } + // A uid outside the scenario's list is Desktop's 404, translated. + const unknown = await workspaceDevMockResponse( + WORKSPACE_ROUTES.details.desktopPath, + new Request("https://brain.test/api/workspace/details", { + body: JSON.stringify({ uid: "00000000-0000-4000-8000-0000000000ff" }), + headers: { + "content-type": "application/json", + cookie: `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: true, scenario: "owner-team" })}`, + }, + method: "POST", + }) + ); + assert.equal(unknown?.status, 404); + assert.deepEqual(await unknown?.json(), { + error: WORKSPACE_ERROR_CODES.notFound, + }); + assert.equal( + await workspaceDevMockResponse( + WORKSPACE_ROUTES.list.desktopPath, + new Request("https://brain.test/api/workspace/list") + ), + null + ); +}); + +// Spec §B.4: the writes take effect in memory, so the Workspace Area can be +// driven end to end against the mock — and the session and list agree with +// what was written. +const OWNER_COOKIE = `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: true, scenario: "owner-team" })}`; +const ACME_UID = "00000000-0000-4000-8000-000000000002"; + +async function write( + entry: WorkspaceRouteEntry, + body: unknown, + cookie = OWNER_COOKIE +): Promise { + const response = await workspaceDevMockResponse( + entry.desktopPath, + new Request(`https://brain.test${entry.apiPath}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json", cookie }, + method: "POST", + }) + ); + assert.ok(response); + return response; +} + +async function readDetails(uid: string, cookie = OWNER_COOKIE) { + return workspaceDetailsResponseSchema.parse( + await (await write(WORKSPACE_ROUTES.details, { uid }, cookie)).json() + ); +} + +async function readList(cookie = OWNER_COOKIE) { + return workspaceListResponseSchema.parse( + await (await write(WORKSPACE_ROUTES.list, {}, cookie)).json() + ); +} + +test("dev-mock writes: rename, role, alias, and remove take effect for the next read", async () => { + resetWorkspaceDevMockState(); + assert.equal( + ( + await write(WORKSPACE_ROUTES.rename, { + name: "Acme Robotics", + uid: ACME_UID, + }) + ).status, + 200 + ); + assert.equal( + (await readList()).find((w) => w.uid === ACME_UID)?.name, + "Acme Robotics" + ); + const session = brainSessionSchema.parse( + await ( + await sessionDevMockResponse(request({ cookie: OWNER_COOKIE })) + )?.json() + ); + assert.equal(session.workspace.name, "Acme Robotics"); + + assert.equal( + ( + await write(WORKSPACE_ROUTES.memberRole, { + crUid: "cr-chen", + role: "Manager", + uid: ACME_UID, + }) + ).status, + 200 + ); + assert.equal( + (await readDetails(ACME_UID)).members.find((m) => m.crUid === "cr-chen") + ?.role, + "Manager" + ); + + await write(WORKSPACE_ROUTES.memberAlias, { + alias: " Platform ", + crUid: "cr-chen", + uid: ACME_UID, + }); + assert.equal( + (await readDetails(ACME_UID)).members.find((m) => m.crUid === "cr-chen") + ?.alias, + "Platform" + ); + await write(WORKSPACE_ROUTES.memberAlias, { + alias: "", + crUid: "cr-chen", + uid: ACME_UID, + }); + assert.equal( + (await readDetails(ACME_UID)).members.find((m) => m.crUid === "cr-chen") + ?.alias, + null + ); + + assert.equal( + ( + await write(WORKSPACE_ROUTES.memberRemove, { + crUid: "cr-chen", + uid: ACME_UID, + }) + ).status, + 200 + ); + assert.equal( + (await readDetails(ACME_UID)).members.some((m) => m.crUid === "cr-chen"), + false + ); + resetWorkspaceDevMockState(); +}); + +test("dev-mock writes: transfer demotes the mock user, delete and leave drop the Workspace from the list", async () => { + resetWorkspaceDevMockState(); + assert.equal( + (await write(WORKSPACE_ROUTES.transfer, { crUid: "cr-lin", uid: ACME_UID })) + .status, + 200 + ); + const afterTransfer = await readDetails(ACME_UID); + assert.equal( + afterTransfer.members.find((m) => m.crUid === "cr-lin")?.role, + "Owner" + ); + assert.equal( + afterTransfer.members.find((m) => m.crUid === "cr-mock")?.role, + "Developer" + ); + assert.equal( + (await readList()).find((w) => w.uid === ACME_UID)?.role, + "Developer" + ); + // No longer the Owner: delete is Desktop's 403, translated. + assert.equal( + (await write(WORKSPACE_ROUTES.delete, { uid: ACME_UID })).status, + 403 + ); + // Leaving is removing yourself. + assert.equal( + ( + await write(WORKSPACE_ROUTES.memberRemove, { + crUid: "cr-mock", + uid: ACME_UID, + }) + ).status, + 200 + ); + assert.equal( + (await readList()).some((w) => w.uid === ACME_UID), + false + ); + assert.equal( + (await write(WORKSPACE_ROUTES.details, { uid: ACME_UID })).status, + 404 + ); + + resetWorkspaceDevMockState(); + const SANDBOX_UID = "00000000-0000-4000-8000-000000000003"; + // The mock user is a Developer in Sandbox: no delete. + assert.equal( + (await write(WORKSPACE_ROUTES.delete, { uid: SANDBOX_UID })).status, + 403 + ); + assert.equal( + (await write(WORKSPACE_ROUTES.delete, { uid: ACME_UID })).status, + 200 + ); + assert.deepEqual( + (await readList()).map((w) => w.uid), + ["00000000-0000-4000-8000-000000000001", SANDBOX_UID] + ); + resetWorkspaceDevMockState(); +}); + +test("dev-mock writes: an invite link answers a code, and the role matrix holds", async () => { + resetWorkspaceDevMockState(); + const link = await write(WORKSPACE_ROUTES.inviteLink, { + role: "Manager", + uid: ACME_UID, + }); + assert.equal(link.status, 200); + const { code } = workspaceInviteLinkResponseSchema.parse(await link.json()); + assert.ok(code.length > 8); + + const manager = `${sessionDevMockCookie.name}=${sessionDevMockCookie.format({ enabled: true, scenario: "manager" })}`; + assert.equal( + ( + await write( + WORKSPACE_ROUTES.rename, + { name: "X", uid: ACME_UID }, + manager + ) + ).status, + 403 + ); + assert.equal( + ( + await write( + WORKSPACE_ROUTES.inviteLink, + { role: "Developer", uid: ACME_UID }, + manager + ) + ).status, + 200 + ); + // A Manager never mints a Manager link — the same Owner proof the route + // handler runs against `namespace/list`. + assert.equal( + ( + await write( + WORKSPACE_ROUTES.inviteLink, + { role: "Manager", uid: ACME_UID }, + manager + ) + ).status, + 403 + ); + // A Manager removes Developers only. + assert.equal( + ( + await write( + WORKSPACE_ROUTES.memberRemove, + { crUid: "cr-yu", uid: ACME_UID }, + manager + ) + ).status, + 403 + ); + assert.equal( + ( + await write( + WORKSPACE_ROUTES.memberRemove, + { crUid: "cr-qi", uid: ACME_UID }, + manager + ) + ).status, + 200 + ); + resetWorkspaceDevMockState(); +}); diff --git a/apps/ui/src/features/session/server/dev-fixtures.ts b/apps/ui/src/features/session/server/dev-fixtures.ts new file mode 100644 index 00000000..00b85c00 --- /dev/null +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -0,0 +1,657 @@ +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { + type WorkspaceDetailsResponse, + type WorkspaceMember, + workspaceDetailsRequestSchema, +} from "@/features/workspace/workspace-details-schema"; +import { WORKSPACE_ERROR_CODES } from "@/features/workspace/workspace-errors"; +import { + gateMemberActions, + gateWorkspaceActions, +} from "@/features/workspace/workspace-gating-core"; +import { + WORKSPACE_WRITE_OK, + type WorkspaceInviteLinkResponse, + workspaceDeleteRequestSchema, + workspaceInviteLinkRequestSchema, + workspaceMemberAliasRequestSchema, + workspaceMemberRemoveRequestSchema, + workspaceMemberRoleRequestSchema, + workspaceRenameRequestSchema, + workspaceTransferRequestSchema, +} from "@/features/workspace/workspace-write-schema"; +import { + type SessionDevScenario, + sessionDevMockCookie, +} from "../dev-mock-cookie"; +import type { + BrainSession, + SessionWorkspace, + WorkspaceRole, +} from "../session-schema"; + +/** + * Session dev-mock fixtures (dev and demo builds only): one Brain Session + * per scenario, each staging a Workspace Role the shell must gate on. The + * credentials are inert fakes — a kubeconfig no apiserver accepts, tokens + * no verifier signs — so a mock session can never reach a real cluster or + * account; the other Dev Mocks answer the routes that would consume them. + * The same scenario answers the `/api/workspace/*` routes (spec §B.4), so + * the Switcher's list refresh agrees with the session it was staged from; + * the write routes change the scenario's state in memory (for the process's + * lifetime), so a rename, a role change, a removal, a transfer, or a delete + * shows on the next read exactly as it would against Desktop. + */ + +const MOCK_KUBECONFIG = (namespace: string) => `apiVersion: v1 +kind: Config +current-context: mock +contexts: + - name: mock + context: + cluster: mock + user: mock + namespace: ${namespace} +clusters: + - name: mock + cluster: + server: https://mock.invalid +users: + - name: mock + user: + token: mock-token +`; + +const PERSONAL: SessionWorkspace = { + createdAt: "2026-01-05T09:00:00.000Z", + id: "ns-mock", + isPersonal: true, + name: "private team", + role: "Owner", + uid: "00000000-0000-4000-8000-000000000001", +}; + +const ACME = (role: SessionWorkspace["role"]): SessionWorkspace => ({ + createdAt: "2026-02-14T09:00:00.000Z", + id: "ns-mockacme", + isPersonal: false, + name: "Acme", + role, + uid: "00000000-0000-4000-8000-000000000002", +}); + +const SANDBOX: SessionWorkspace = { + createdAt: "2026-03-01T09:00:00.000Z", + id: "ns-mocksand", + isPersonal: false, + name: "Sandbox", + role: "Developer", + uid: "00000000-0000-4000-8000-000000000003", +}; + +const MOCK_USER = { + avatar: "", + crName: "mock", + name: "Mock User", + userId: "mock-user", + userUid: "00000000-0000-4000-8000-00000000aaaa", +}; + +function member( + crUid: string, + nickname: string, + role: WorkspaceMember["role"], + joinedAt: string, + alias: string | null = null +): WorkspaceMember { + return { + alias, + avatarUrl: "", + crName: crUid === "cr-mock" ? MOCK_USER.crName : crUid.replace("cr-", ""), + crUid, + joinedAt, + nickname, + role, + userUid: crUid === "cr-mock" ? MOCK_USER.userUid : `uid-${crUid}`, + }; +} + +const ME = (role: WorkspaceMember["role"], joinedAt: string) => + member("cr-mock", MOCK_USER.name, role, joinedAt); + +/** + * The members of each Workspace per scenario (spec §B.4): the mock user + * holds the role the scenario names, beside enough other members that + * every gate in the Workspace Area has a row to act on — an Owner to + * protect, a Manager, Developers with and without an alias. + */ +function seedMembersFor( + scenario: SessionDevScenario, + workspaceUid: string +): WorkspaceMember[] { + if (workspaceUid === PERSONAL.uid) { + return [ME("Owner", PERSONAL.createdAt)]; + } + if (workspaceUid === SANDBOX.uid) { + return [ + member("cr-kai", "Kai", "Owner", "2026-03-01T09:00:00.000Z"), + member( + "cr-ming", + "Ming", + "Manager", + "2026-03-02T09:00:00.000Z", + "Docs PM" + ), + ME("Developer", "2026-03-05T09:00:00.000Z"), + member("cr-su", "Su Lan", "Developer", "2026-05-18T09:00:00.000Z"), + ]; + } + switch (scenario) { + case "owner-team": + return [ + ME("Owner", ACME("Owner").createdAt), + member( + "cr-lin", + "Lin Wei", + "Manager", + "2026-02-20T09:00:00.000Z", + "Frontend lead" + ), + member("cr-chen", "Chen Jie", "Developer", "2026-04-11T09:00:00.000Z"), + member( + "cr-zhao", + "zhao.xiaoming", + "Developer", + "2026-07-01T09:00:00.000Z", + "Summer intern" + ), + ]; + case "manager": + return [ + member("cr-rui", "Rui", "Owner", ACME("Owner").createdAt), + ME("Manager", "2026-02-15T09:00:00.000Z"), + member("cr-yu", "Yu", "Manager", "2026-02-25T09:00:00.000Z"), + member("cr-qi", "Qi", "Developer", "2026-07-30T09:00:00.000Z"), + ]; + default: + return [ + member("cr-kai", "Kai", "Owner", ACME("Owner").createdAt), + member( + "cr-ming", + "Ming", + "Manager", + "2026-02-15T09:00:00.000Z", + "Docs PM" + ), + ME("Developer", "2026-03-05T09:00:00.000Z"), + member("cr-su", "Su Lan", "Developer", "2026-05-18T09:00:00.000Z"), + ]; + } +} + +function seedWorkspacesFor(scenario: SessionDevScenario): SessionWorkspace[] { + switch (scenario) { + case "personal-only": + return [PERSONAL]; + case "owner-team": + return [PERSONAL, ACME("Owner"), SANDBOX]; + case "manager": + return [PERSONAL, ACME("Manager")]; + default: + return [PERSONAL, ACME("Developer")]; + } +} + +/** + * A scenario's mutable state: the list and each Workspace's members, seeded + * from the fixtures on first use and changed by the write fixtures. Lives + * for the dev server's process; `resetWorkspaceDevMockState` puts every + * scenario back to its seed (tests, and a dev tweak if one is ever wanted). + */ +interface ScenarioState { + members: Map; + workspaces: SessionWorkspace[]; +} + +const scenarioStates = new Map(); + +function stateFor(scenario: SessionDevScenario): ScenarioState { + let state = scenarioStates.get(scenario); + if (state == null) { + const workspaces = seedWorkspacesFor(scenario); + state = { + members: new Map( + workspaces.map((workspace) => [ + workspace.uid, + seedMembersFor(scenario, workspace.uid), + ]) + ), + workspaces, + }; + scenarioStates.set(scenario, state); + } + return state; +} + +export function resetWorkspaceDevMockState(): void { + scenarioStates.clear(); +} + +function workspacesFor(scenario: SessionDevScenario): SessionWorkspace[] { + return stateFor(scenario).workspaces; +} + +function membersFor( + scenario: SessionDevScenario, + workspaceUid: string +): WorkspaceMember[] { + return stateFor(scenario).members.get(workspaceUid) ?? []; +} + +function sessionFor( + scenario: SessionDevScenario, + requestedNsid: string | null +): BrainSession { + const workspaces = workspacesFor(scenario); + const requested = + requestedNsid == null + ? null + : workspaces.find((workspace) => workspace.id === requestedNsid); + // Without a nsid (no Desktop shell, no Dev Bridge) the mock stages the + // scenario's Team Workspace so its role can be seen; a nsid it does not + // know lands in Personal with the notice, exactly like the real path. + const staged = workspaces[1] ?? PERSONAL; + const current = requested ?? (requestedNsid == null ? staged : PERSONAL); + return { + appToken: `mock-app-token-${scenario}`, + ...(requestedNsid != null && requested == null + ? { fallback: "not_member" as const } + : {}), + kubeconfig: MOCK_KUBECONFIG(current.id), + namespace: current.id, + regionalToken: `mock-regional-token-${scenario}`, + user: MOCK_USER, + workspace: current, + workspaces, + }; +} + +export async function sessionDevMockResponse( + request: Request +): Promise { + const resolution = resolveDevMock(sessionDevMockCookie, request, "session"); + if (resolution.kind === "off") { + return null; + } + if (resolution.kind === "invalid") { + return resolution.response; + } + const payload: unknown = await request.json().catch(() => null); + const nsid = + typeof payload === "object" && + payload != null && + "nsid" in payload && + typeof payload.nsid === "string" && + payload.nsid.trim() !== "" + ? payload.nsid.trim() + : null; + return Response.json(sessionFor(resolution.scenario, nsid), { + headers: { "cache-control": "no-store" }, + }); +} + +function mockJson(payload: unknown, status = 200): Response { + return Response.json(payload, { + headers: { "cache-control": "no-store" }, + status, + }); +} + +async function detailsFixture( + scenario: SessionDevScenario, + request: Request +): Promise { + const payload: unknown = await request.json().catch(() => null); + const parsed = workspaceDetailsRequestSchema.safeParse(payload ?? {}); + if (!parsed.success) { + return mockJson({ error: WORKSPACE_ERROR_CODES.invalidRequest }, 400); + } + const workspace = workspacesFor(scenario).find( + (candidate) => candidate.uid === parsed.data.uid + ); + if (workspace == null) { + // Desktop answers 404 for a Workspace the caller is not in. + return mockJson({ error: WORKSPACE_ERROR_CODES.notFound }, 404); + } + const details: WorkspaceDetailsResponse = { + members: membersFor(scenario, workspace.uid), + workspace, + }; + return mockJson(details); +} + +type WriteOutcome = + | { kind: "ok"; body?: unknown } + | { kind: "error"; code: string; status: number }; + +const FORBIDDEN: WriteOutcome = { + code: WORKSPACE_ERROR_CODES.forbidden, + kind: "error", + status: 403, +}; +const NOT_FOUND: WriteOutcome = { + code: WORKSPACE_ERROR_CODES.notFound, + kind: "error", + status: 404, +}; + +function outcomeResponse(outcome: WriteOutcome): Response { + return outcome.kind === "ok" + ? mockJson(outcome.body ?? WORKSPACE_WRITE_OK) + : mockJson({ error: outcome.code }, outcome.status); +} + +/** The mock user's own membership row and role in a Workspace of the scenario. */ +function actorIn( + scenario: SessionDevScenario, + workspaceUid: string +): { role: WorkspaceRole; workspace: SessionWorkspace } | null { + const workspace = workspacesFor(scenario).find( + (candidate) => candidate.uid === workspaceUid + ); + return workspace == null ? null : { role: workspace.role, workspace }; +} + +function replaceWorkspace( + state: ScenarioState, + uid: string, + patch: Partial +): void { + state.workspaces = state.workspaces.map((workspace) => + workspace.uid === uid ? { ...workspace, ...patch } : workspace + ); +} + +function patchMember( + state: ScenarioState, + workspaceUid: string, + crUid: string, + patch: Partial +): void { + state.members.set( + workspaceUid, + membersOf(state, workspaceUid).map((member) => + member.crUid === crUid ? { ...member, ...patch } : member + ) + ); +} + +function membersOf(state: ScenarioState, workspaceUid: string) { + return state.members.get(workspaceUid) ?? []; +} + +function dropWorkspace(state: ScenarioState, uid: string): void { + state.workspaces = state.workspaces.filter( + (workspace) => workspace.uid !== uid + ); + state.members.delete(uid); +} + +/** + * The write fixtures apply Desktop's own rules (spec §E, the same gating + * module the page reads) to the scenario's state: what the page hides or + * disables, Desktop refuses, so a stale page meets the same 403 / 404 here + * as in staging. Bodies are validated with the routes' own schemas. + */ +function writeFixture( + schema: { + safeParse: ( + payload: unknown + ) => { success: true; data: TBody } | { success: false }; + }, + apply: ( + scenario: SessionDevScenario, + state: ScenarioState, + body: TBody + ) => WriteOutcome +) { + return async ( + scenario: SessionDevScenario, + request: Request + ): Promise => { + const payload: unknown = await request.json().catch(() => null); + const parsed = schema.safeParse(payload ?? {}); + if (!parsed.success) { + return mockJson({ error: WORKSPACE_ERROR_CODES.invalidRequest }, 400); + } + return outcomeResponse(apply(scenario, stateFor(scenario), parsed.data)); + }; +} + +/** The Workspace-level gates as Desktop judges them (no "current" here: Desktop's own check is UI-gated). */ +function workspaceGates(scenario: SessionDevScenario, uid: string) { + const actor = actorIn(scenario, uid); + if (actor == null) { + return null; + } + return gateWorkspaceActions({ + actorRole: actor.role, + isCurrent: false, + isPersonal: actor.workspace.isPersonal, + memberCount: membersFor(scenario, uid).length, + }); +} + +const WORKSPACE_FIXTURES: Record< + string, + (scenario: SessionDevScenario, request: Request) => Promise +> = { + [WORKSPACE_ROUTES.details.desktopPath]: detailsFixture, + [WORKSPACE_ROUTES.list.desktopPath]: (scenario) => + Promise.resolve(mockJson(workspacesFor(scenario))), + [WORKSPACE_ROUTES.rename.desktopPath]: writeFixture( + workspaceRenameRequestSchema, + (scenario, state, body) => { + const gates = workspaceGates(scenario, body.uid); + if (gates == null) { + return NOT_FOUND; + } + if (gates.rename.kind !== "enabled") { + return FORBIDDEN; + } + replaceWorkspace(state, body.uid, { name: body.name }); + return { kind: "ok" }; + } + ), + [WORKSPACE_ROUTES.delete.desktopPath]: writeFixture( + workspaceDeleteRequestSchema, + (scenario, state, body) => { + const gates = workspaceGates(scenario, body.uid); + if (gates == null) { + return NOT_FOUND; + } + if (gates.delete.kind !== "enabled") { + return FORBIDDEN; + } + dropWorkspace(state, body.uid); + return { kind: "ok" }; + } + ), + [WORKSPACE_ROUTES.inviteLink.desktopPath]: writeFixture( + workspaceInviteLinkRequestSchema, + (scenario, _state, body) => { + const gates = workspaceGates(scenario, body.uid); + if (gates == null) { + return NOT_FOUND; + } + if (gates.invite.kind !== "enabled") { + return FORBIDDEN; + } + const actor = actorIn(scenario, body.uid); + if (body.role === "Manager" && actor?.role !== "Owner") { + // The rule the route handler proves against `namespace/list`: a + // Manager link requires an Owner actor — Desktop leaves this hole + // open, Brain closes it (spec §E.4). + return FORBIDDEN; + } + const response: WorkspaceInviteLinkResponse = { + code: `mock-${body.role.toLowerCase()}-${crypto.randomUUID()}`, + }; + return { body: response, kind: "ok" }; + } + ), + [WORKSPACE_ROUTES.memberRemove.desktopPath]: writeFixture( + workspaceMemberRemoveRequestSchema, + (scenario, state, body) => { + const actor = actorIn(scenario, body.uid); + if (actor == null) { + return NOT_FOUND; + } + const target = membersOf(state, body.uid).find( + (member) => member.crUid === body.crUid + ); + if (target == null) { + return NOT_FOUND; + } + const isSelf = target.crName === MOCK_USER.crName; + if (isSelf) { + // Leaving: any non-Owner may; the Owner leaves only by transferring. + if (actor.role === "Owner") { + return FORBIDDEN; + } + dropWorkspace(state, body.uid); + return { kind: "ok" }; + } + const gates = gateMemberActions( + { + actorRole: actor.role, + isCurrent: false, + isPersonal: actor.workspace.isPersonal, + memberCount: membersOf(state, body.uid).length, + }, + { isSelf, targetRole: target.role } + ); + if (gates.remove.kind !== "enabled") { + return FORBIDDEN; + } + state.members.set( + body.uid, + membersOf(state, body.uid).filter( + (member) => member.crUid !== body.crUid + ) + ); + return { kind: "ok" }; + } + ), + [WORKSPACE_ROUTES.memberRole.desktopPath]: writeFixture( + workspaceMemberRoleRequestSchema, + (scenario, state, body) => { + const actor = actorIn(scenario, body.uid); + if (actor == null) { + return NOT_FOUND; + } + const target = membersOf(state, body.uid).find( + (member) => member.crUid === body.crUid + ); + if (target == null) { + return NOT_FOUND; + } + const gates = gateMemberActions( + { + actorRole: actor.role, + isCurrent: false, + isPersonal: actor.workspace.isPersonal, + memberCount: membersOf(state, body.uid).length, + }, + { isSelf: target.crName === MOCK_USER.crName, targetRole: target.role } + ); + if (gates.changeRole.kind !== "enabled") { + return FORBIDDEN; + } + patchMember(state, body.uid, body.crUid, { role: body.role }); + return { kind: "ok" }; + } + ), + [WORKSPACE_ROUTES.memberAlias.desktopPath]: writeFixture( + workspaceMemberAliasRequestSchema, + (scenario, state, body) => { + const actor = actorIn(scenario, body.uid); + if (actor == null) { + return NOT_FOUND; + } + if (actor.role === "Developer") { + return FORBIDDEN; + } + const target = membersOf(state, body.uid).find( + (member) => member.crUid === body.crUid + ); + if (target == null) { + return NOT_FOUND; + } + patchMember(state, body.uid, body.crUid, { alias: body.alias }); + return { kind: "ok" }; + } + ), + [WORKSPACE_ROUTES.transfer.desktopPath]: writeFixture( + workspaceTransferRequestSchema, + (scenario, state, body) => { + const gates = workspaceGates(scenario, body.uid); + if (gates == null) { + return NOT_FOUND; + } + if (gates.transfer.kind !== "enabled") { + return FORBIDDEN; + } + const target = membersOf(state, body.uid).find( + (member) => member.crUid === body.crUid + ); + if (target == null) { + return NOT_FOUND; + } + if (target.crName === MOCK_USER.crName) { + return { + code: WORKSPACE_ERROR_CODES.conflict, + kind: "error", + status: 409, + }; + } + // Desktop's `abdicate`: the target becomes Owner, the old Owner a Developer. + const me = membersOf(state, body.uid).find( + (member) => member.crName === MOCK_USER.crName + ); + patchMember(state, body.uid, body.crUid, { role: "Owner" }); + if (me != null) { + patchMember(state, body.uid, me.crUid, { role: "Developer" }); + } + replaceWorkspace(state, body.uid, { role: "Developer" }); + return { kind: "ok" }; + } + ), +}; + +/** Answers a `/api/workspace/*` route by its Desktop path from the scenario. */ +export function workspaceDevMockResponse( + desktopPath: string, + request: Request +): Promise { + const resolution = resolveDevMock(sessionDevMockCookie, request, "session"); + if (resolution.kind === "off") { + return Promise.resolve(null); + } + if (resolution.kind === "invalid") { + return Promise.resolve(resolution.response); + } + const fixture = WORKSPACE_FIXTURES[desktopPath]; + if (fixture == null) { + return Promise.resolve( + Response.json( + { + error: `Session mock mode does not support this operation (${desktopPath} has no fixture).`, + }, + { status: 501 } + ) + ); + } + return fixture(resolution.scenario, request); +} diff --git a/apps/ui/src/features/session/server/jwt-payload.ts b/apps/ui/src/features/session/server/jwt-payload.ts new file mode 100644 index 00000000..fc3c8d9e --- /dev/null +++ b/apps/ui/src/features/session/server/jwt-payload.ts @@ -0,0 +1,55 @@ +import "server-only"; + +/** + * Decodes a JWT payload without verifying it. Brain holds no Desktop + * regional key (ADR-0083), so a token Desktop just returned is trusted for + * its claims exactly as Desktop's own frontend trusts it (`jwtDecode`). + * Never use this for a token that arrived from the browser. + */ +export function decodeJwtPayload( + token: string +): Record | null { + const parts = token.split("."); + if (parts.length !== 3 || parts[1] == null || parts[1] === "") { + return null; + } + try { + const json = Buffer.from(parts[1], "base64url").toString("utf8"); + const payload: unknown = JSON.parse(json); + return typeof payload === "object" && + payload != null && + !Array.isArray(payload) + ? (payload as Record) + : null; + } catch { + return null; + } +} + +function claimString(payload: Record, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value.trim() : ""; +} + +/** The Desktop `AccessTokenPayload` claims Brain reads off a regional token. */ +export interface RegionalTokenClaims { + userCrName: string; + userId: string; + userUid: string; + workspaceId: string; + workspaceUid: string; +} + +export function regionalTokenClaims(token: string): RegionalTokenClaims | null { + const payload = decodeJwtPayload(token); + if (payload == null) { + return null; + } + return { + userCrName: claimString(payload, "userCrName"), + userId: claimString(payload, "userId"), + userUid: claimString(payload, "userUid"), + workspaceId: claimString(payload, "workspaceId"), + workspaceUid: claimString(payload, "workspaceUid"), + }; +} diff --git a/apps/ui/src/features/session/server/login-cookie.ts b/apps/ui/src/features/session/server/login-cookie.ts new file mode 100644 index 00000000..7d4e49f6 --- /dev/null +++ b/apps/ui/src/features/session/server/login-cookie.ts @@ -0,0 +1,35 @@ +import "server-only"; + +import { cookieValueFromHeader } from "@/lib/cookie-header"; + +/** + * Desktop writes its global token into the shared login cookie on the parent + * domain (`.`, not HttpOnly), so the browser attaches it + * to Brain's own same-origin requests (ADR-0083). Brain forwards its value + * to exactly one place — Desktop's `regionToken` — and never logs it. + */ +export const SEALOS_AUTH_COOKIE = "sealos_auth_token"; + +/** + * The global token for this request: the shared login cookie, or, when the + * cookie is absent in a non-production build, `DEV_GLOBAL_TOKEN` — the + * developer's stand-in for a Desktop shell (spec §I.1). A production build + * never reads the variable, so a stray value cannot become a session. + */ +export function globalTokenFromRequest( + request: Request, + env: Record = process.env +): string { + const fromCookie = + cookieValueFromHeader( + request.headers.get("cookie"), + SEALOS_AUTH_COOKIE + )?.trim() ?? ""; + if (fromCookie !== "") { + return fromCookie; + } + if (env.NODE_ENV === "production") { + return ""; + } + return env.DEV_GLOBAL_TOKEN?.trim() ?? ""; +} diff --git a/apps/ui/src/features/session/server/session-handler.test.ts b/apps/ui/src/features/session/server/session-handler.test.ts new file mode 100644 index 00000000..9b9aa113 --- /dev/null +++ b/apps/ui/src/features/session/server/session-handler.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; + +import { brainSessionSchema } from "../session-schema"; + +mock.module("server-only", () => ({})); +const { createSessionHandler } = await import("./session-handler"); +const { + createFakeDesktop, + defaultDesktopAnswers, + GLOBAL_TOKEN, + PERSONAL, + PERSONAL_APP_TOKEN, + PERSONAL_REGIONAL_TOKEN, + TEAM, + TEAM_APP_TOKEN, + TEAM_REGIONAL_TOKEN, +} = await import("./desktop-test-double"); + +const DEV_ENV = { + DESKTOP_API_BASE_URL: "http://sealos-desktop.sealos.svc:3000", + NODE_ENV: "development", +}; + +function sessionRequest(input: { + body?: unknown; + contentType?: string; + cookie?: string | null; + origin?: string | null; +}): Request { + const headers: Record = { + "content-type": input.contentType ?? "application/json", + }; + if (input.origin != null) { + headers.origin = input.origin; + } + if (input.cookie !== null) { + headers.cookie = + input.cookie ?? `other=1; sealos_auth_token=${GLOBAL_TOKEN}; theme=dark`; + } + return new Request("https://brain.test/api/session", { + body: JSON.stringify(input.body ?? {}), + headers, + method: "POST", + }); +} + +interface LogEntry { + fields: Record; + message: string; +} + +function handlerWith( + answers = defaultDesktopAnswers(), + env: Record = DEV_ENV +) { + const desktop = createFakeDesktop({ answers }); + const logs: LogEntry[] = []; + const handler = createSessionHandler({ + env, + fetchDesktop: desktop.fetch, + log: (message, fields) => logs.push({ fields, message }), + }); + return { calls: desktop.calls, handler, logs }; +} + +const SECRET_VALUES = [ + GLOBAL_TOKEN, + PERSONAL_REGIONAL_TOKEN, + TEAM_REGIONAL_TOKEN, + PERSONAL_APP_TOKEN, + TEAM_APP_TOKEN, + "sa-token-abc12345", +]; + +function expectNoTokenInLogs(logs: LogEntry[]) { + const serialized = JSON.stringify(logs); + for (const secret of SECRET_VALUES) { + expect(serialized.includes(secret)).toBe(false); + } +} + +describe("POST /api/session", () => { + it("refuses a foreign Origin before anything else (CSRF)", async () => { + const { calls, handler, logs } = handlerWith(); + const response = await handler( + sessionRequest({ body: { nsid: TEAM.id }, origin: "https://evil.test" }) + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "session_forbidden" }); + expect(calls.length).toBe(0); + expectNoTokenInLogs(logs); + }); + + it("accepts an Origin naming this app's own origin", async () => { + const { handler } = handlerWith(); + const response = await handler( + sessionRequest({ body: {}, origin: "https://brain.test" }) + ); + + expect(response.status).toBe(200); + }); + + it("refuses Origin: null — a sandboxed browser frame, not a non-browser client", async () => { + const { calls, handler } = handlerWith(); + const response = await handler( + sessionRequest({ body: {}, origin: "null" }) + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "session_forbidden" }); + expect(calls.length).toBe(0); + }); + + it("refuses an HTTP Origin against the HTTPS app in production", async () => { + const { handler } = handlerWith(defaultDesktopAnswers(), { + ...DEV_ENV, + NODE_ENV: "production", + }); + const response = await handler( + sessionRequest({ body: {}, origin: "http://brain.test" }) + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "session_forbidden" }); + }); + + it("refuses a JSON body that does not travel as application/json (CSRF)", async () => { + const { calls, handler } = handlerWith(); + const response = await handler( + sessionRequest({ + body: { nsid: TEAM.id }, + contentType: "text/plain", + }) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_session_request" }); + expect(calls.length).toBe(0); + }); + + it("lands a Team nsid through regionToken → list → switch ∥ info with the kubeconfig namespace rewritten", async () => { + const { calls, handler, logs } = handlerWith(); + const response = await handler(sessionRequest({ body: { nsid: TEAM.id } })); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const session = brainSessionSchema.parse(await response.json()); + expect(session.regionalToken).toBe(TEAM_REGIONAL_TOKEN); + expect(session.appToken).toBe(TEAM_APP_TOKEN); + expect(session.namespace).toBe(TEAM.id); + expect(namespaceFromKubeconfigText(session.kubeconfig)).toBe(TEAM.id); + expect(session.kubeconfig).toContain("sa-token-abc12345"); + expect(session.fallback).toBeUndefined(); + expect(session.workspace).toEqual({ + createdAt: TEAM.createTime, + id: TEAM.id, + isPersonal: false, + name: "Acme", + role: "Manager", + uid: TEAM.uid, + }); + expect(session.workspaces.map((workspace) => workspace.id)).toEqual([ + PERSONAL.id, + TEAM.id, + ]); + expect(session.workspaces[0]?.isPersonal).toBe(true); + expect(session.workspaces[0]?.role).toBe("Owner"); + // user identity decoded from the regional token, display data from info + expect(session.user).toEqual({ + avatar: "https://desktop.test/avatar.png", + crName: "abc12345", + name: "Ada", + userId: "user-id-1", + userUid: "user-uid-1", + }); + + expect(calls.map((call) => call.path)).toEqual([ + "/api/auth/regionToken", + "/api/auth/namespace/list", + "/api/auth/namespace/switch", + "/api/auth/info", + ]); + // global token: URL-encoded, no Bearer; regional token: URL-encoded + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.authorization).toBe(encodeURIComponent(GLOBAL_TOKEN)); + expect(calls[0]?.authorization?.startsWith("Bearer")).toBe(false); + expect(calls[1]?.method).toBe("GET"); + expect(calls[1]?.authorization).toBe( + encodeURIComponent(PERSONAL_REGIONAL_TOKEN) + ); + expect(calls[2]?.body).toEqual({ ns_uid: TEAM.uid }); + expect(calls[3]?.authorization).toBe( + encodeURIComponent(PERSONAL_REGIONAL_TOKEN) + ); + expect(logs).toEqual([]); + }); + + it("does not call switch for the Personal nsid or when nsid is omitted", async () => { + for (const body of [{ nsid: PERSONAL.id }, {}]) { + const { calls, handler } = handlerWith(); + const response = await handler(sessionRequest({ body })); + expect(response.status).toBe(200); + const session = brainSessionSchema.parse(await response.json()); + expect(session.workspace.id).toBe(PERSONAL.id); + expect(session.regionalToken).toBe(PERSONAL_REGIONAL_TOKEN); + expect(session.appToken).toBe(PERSONAL_APP_TOKEN); + expect(namespaceFromKubeconfigText(session.kubeconfig)).toBe(PERSONAL.id); + expect(session.fallback).toBeUndefined(); + expect(calls.map((call) => call.path)).toEqual([ + "/api/auth/regionToken", + "/api/auth/namespace/list", + "/api/auth/info", + ]); + } + }); + + it("lands in Personal with fallback not_member when nsid is not in the list", async () => { + const { calls, handler, logs } = handlerWith(); + const response = await handler( + sessionRequest({ body: { nsid: "ns-gone" } }) + ); + expect(response.status).toBe(200); + const session = brainSessionSchema.parse(await response.json()); + expect(session.fallback).toBe("not_member"); + expect(session.workspace.id).toBe(PERSONAL.id); + expect(calls.some((call) => call.path.endsWith("/switch"))).toBe(false); + expectNoTokenInLogs(logs); + }); + + it("answers 401 without a login cookie, and 401 when Desktop rejects the global token", async () => { + const missing = handlerWith(); + const withoutCookie = await missing.handler( + sessionRequest({ cookie: null }) + ); + expect(withoutCookie.status).toBe(401); + expect(await withoutCookie.json()).toEqual({ error: "session_expired" }); + expect(missing.calls).toEqual([]); + + const rejected = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/regionToken": { code: 401, message: "invalid token" }, + }); + const response = await rejected.handler(sessionRequest({})); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "session_expired" }); + expect(rejected.calls.map((call) => call.path)).toEqual([ + "/api/auth/regionToken", + ]); + expectNoTokenInLogs(rejected.logs); + }); + + it("uses DEV_GLOBAL_TOKEN without a cookie in development, never in production", async () => { + const dev = handlerWith(defaultDesktopAnswers(), { + ...DEV_ENV, + DEV_GLOBAL_TOKEN: "dev.global.token", + }); + const devResponse = await dev.handler(sessionRequest({ cookie: null })); + expect(devResponse.status).toBe(200); + expect(dev.calls[0]?.authorization).toBe( + encodeURIComponent("dev.global.token") + ); + + const cookieWins = handlerWith(defaultDesktopAnswers(), { + ...DEV_ENV, + DEV_GLOBAL_TOKEN: "dev.global.token", + }); + await cookieWins.handler(sessionRequest({})); + expect(cookieWins.calls[0]?.authorization).toBe( + encodeURIComponent(GLOBAL_TOKEN) + ); + + const production = handlerWith(defaultDesktopAnswers(), { + ...DEV_ENV, + DEV_GLOBAL_TOKEN: "dev.global.token", + NODE_ENV: "production", + }); + const productionResponse = await production.handler( + sessionRequest({ cookie: null }) + ); + expect(productionResponse.status).toBe(401); + expect(production.calls).toEqual([]); + expect(JSON.stringify(production.logs).includes("dev.global.token")).toBe( + false + ); + }); + + it("answers 409 for 'workspace is not inited' and never calls autoInitRegionToken", async () => { + const { calls, handler, logs } = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/regionToken": { + code: 409, + message: "workspace is not inited", + }, + }); + const response = await handler(sessionRequest({})); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "workspace_not_inited" }); + expect(calls.map((call) => call.path)).toEqual(["/api/auth/regionToken"]); + expect(logs.length).toBe(1); + expect(logs[0]?.fields).toMatchObject({ kind: "not_inited" }); + expectNoTokenInLogs(logs); + }); + + it("answers 502 when Desktop is unreachable, misconfigured, or malformed, and 504 on timeout", async () => { + const unreachable = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/regionToken": new Error("ECONNREFUSED"), + }); + expect((await unreachable.handler(sessionRequest({}))).status).toBe(502); + expect(unreachable.logs[0]?.fields).toMatchObject({ + kind: "unreachable", + step: "regionToken", + }); + + const malformed = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/namespace/list": { code: 200, data: { namespaces: "nope" } }, + }); + const malformedResponse = await malformed.handler(sessionRequest({})); + expect(malformedResponse.status).toBe(502); + expect(await malformedResponse.json()).toEqual({ + error: "desktop_unavailable", + }); + expect(malformed.logs[0]?.fields).toMatchObject({ + kind: "malformed", + step: "list", + }); + + const otherCode = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/namespace/switch": { + code: 403, + message: "You are not in this workspace", + }, + }); + const otherResponse = await otherCode.handler( + sessionRequest({ body: { nsid: TEAM.id } }) + ); + expect(otherResponse.status).toBe(502); + expect(otherCode.logs[0]?.fields).toMatchObject({ + code: 403, + kind: "desktop_error", + step: "switch", + }); + + const timeoutError = new Error("timeout"); + timeoutError.name = "TimeoutError"; + const timedOut = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/info": timeoutError, + }); + const timeoutResponse = await timedOut.handler(sessionRequest({})); + expect(timeoutResponse.status).toBe(504); + expect(await timeoutResponse.json()).toEqual({ error: "desktop_timeout" }); + + const unconfigured = handlerWith(defaultDesktopAnswers(), { + NODE_ENV: "development", + }); + expect((await unconfigured.handler(sessionRequest({}))).status).toBe(502); + expect(unconfigured.calls).toEqual([]); + + expectNoTokenInLogs([ + ...unreachable.logs, + ...malformed.logs, + ...otherCode.logs, + ...timedOut.logs, + ...unconfigured.logs, + ]); + }); + + it("rejects a body that is not the session request shape, and one that is not JSON", async () => { + const { calls, handler } = handlerWith(); + const response = await handler(sessionRequest({ body: { nsid: 42 } })); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_session_request" }); + + const notJson = await handler( + new Request("https://brain.test/api/session", { + body: "nsid=ns-team", + headers: { cookie: `sealos_auth_token=${GLOBAL_TOKEN}` }, + method: "POST", + }) + ); + expect(notJson.status).toBe(400); + expect(calls).toEqual([]); + }); + + it("treats a 401 on a token Desktop just minted as a Desktop anomaly, not a logout", async () => { + const { handler, logs } = handlerWith({ + ...defaultDesktopAnswers(), + "/api/auth/namespace/switch": { + code: 401, + message: "token verify error", + }, + }); + const response = await handler(sessionRequest({ body: { nsid: TEAM.id } })); + expect(response.status).toBe(502); + expect(logs[0]?.fields).toMatchObject({ + code: 401, + kind: "desktop_error", + step: "switch", + }); + expectNoTokenInLogs(logs); + }); + + it("accepts an empty body", async () => { + const { handler } = handlerWith(); + const response = await handler( + new Request("https://brain.test/api/session", { + headers: { cookie: `sealos_auth_token=${GLOBAL_TOKEN}` }, + method: "POST", + }) + ); + expect(response.status).toBe(200); + }); +}); diff --git a/apps/ui/src/features/session/server/session-handler.ts b/apps/ui/src/features/session/server/session-handler.ts new file mode 100644 index 00000000..c7cd93e7 --- /dev/null +++ b/apps/ui/src/features/session/server/session-handler.ts @@ -0,0 +1,176 @@ +import "server-only"; + +import { + type BrainSession, + SESSION_ERROR_CODES, + sessionRequestSchema, +} from "../session-schema"; +import { createDesktopAuthApi } from "./desktop-auth-api"; +import { + createDesktopClient, + type DesktopFetch, + desktopApiBaseUrlFromEnv, +} from "./desktop-client"; +import { globalTokenFromRequest } from "./login-cookie"; +import { establishBrainSession, type SessionFailure } from "./session-service"; + +/** + * `POST /api/session` (ADR-0083, spec §A): the single entry that establishes + * the Brain Session for start, reload, and the silent 401 re-exchange. The + * body carries only an optional `nsid`; the global token comes off the + * request (login cookie, or `DEV_GLOBAL_TOKEN` in development). Failures map + * to real HTTP statuses (§A.3) and are logged structurally — kind, step, + * Desktop code — never with a token value: the response body is the one + * place credentials travel, and only to the page that asked. + */ + +export type SessionLog = ( + message: string, + fields: Record +) => void; + +export interface SessionHandlerDependencies { + env?: Record; + fetchDesktop?: DesktopFetch; + log?: SessionLog; +} + +function errorResponse(code: string, status: number): Response { + return Response.json( + { error: code }, + { headers: { "cache-control": "no-store" }, status } + ); +} + +/** Spec §A.3: the HTTP status and error code each failure answers with. */ +export function sessionFailureResponse(failure: SessionFailure): Response { + switch (failure.kind) { + case "unauthorized": + return errorResponse(SESSION_ERROR_CODES.sessionExpired, 401); + case "not_inited": + return errorResponse(SESSION_ERROR_CODES.workspaceNotInited, 409); + case "timeout": + return errorResponse(SESSION_ERROR_CODES.desktopTimeout, 504); + default: + return errorResponse(SESSION_ERROR_CODES.desktopUnavailable, 502); + } +} + +function sessionResponse(session: BrainSession): Response { + return Response.json(session, { headers: { "cache-control": "no-store" } }); +} + +const JSON_CONTENT_TYPE_RE = /^application\/json\b/i; + +/** The request body: absent or blank means `{}`; anything else must be JSON. */ +async function requestPayload( + request: Request +): Promise<{ payload: unknown } | { invalid: true }> { + const text = (await request.text().catch(() => null))?.trim() ?? ""; + if (text === "") { + return { payload: {} }; + } + if (!JSON_CONTENT_TYPE_RE.test(request.headers.get("content-type") ?? "")) { + return { invalid: true }; + } + try { + return { payload: JSON.parse(text) }; + } catch { + return { invalid: true }; + } +} + +/** + * Whether the request's `Origin` names this app. The route is + * cookie-authenticated and can trigger Desktop's `namespace/switch`, so a + * sibling page on the shared cloud domain must not reach it: a present + * `Origin` must name this app's origin over HTTPS (HTTP origins pass only + * outside production, for local development), matching the `Host` header — + * which wins over `request.url` behind an ingress that rewrites the + * internal host. `Origin: null` is a browser (a sandboxed frame, some + * redirects), not a missing header, and is refused; an absent `Origin` — + * a non-browser client such as the smoke script — still passes the + * content-type gate below. + */ +function originAllowed( + request: Request, + env: Record +): boolean { + const origin = request.headers.get("origin")?.trim() ?? ""; + if (origin === "") { + return true; + } + try { + // `Origin: null` and any malformed value fail the parse and are refused. + const parsed = new URL(origin); + const schemeAllowed = + parsed.protocol === "https:" || + (env.NODE_ENV !== "production" && parsed.protocol === "http:"); + if (!schemeAllowed) { + return false; + } + const host = request.headers.get("host")?.trim() ?? ""; + if (host !== "" && parsed.host === host) { + return true; + } + return parsed.origin === new URL(request.url).origin; + } catch { + return false; + } +} + +export function createSessionHandler( + dependencies: SessionHandlerDependencies = {} +): (request: Request) => Promise { + const env = dependencies.env ?? process.env; + const log: SessionLog = + dependencies.log ?? + ((message, fields) => console.warn(`[session] ${message}`, fields)); + + return async function handler(request: Request): Promise { + if (!originAllowed(request, env)) { + log("session request from a foreign origin", {}); + return errorResponse(SESSION_ERROR_CODES.forbidden, 403); + } + const body = await requestPayload(request); + const parsed = + "invalid" in body + ? null + : sessionRequestSchema.safeParse(body.payload ?? {}); + if (parsed == null || !parsed.success) { + return errorResponse(SESSION_ERROR_CODES.invalidRequest, 400); + } + const nsid = parsed.data.nsid?.trim() ?? ""; + const requestedNsid = nsid !== ""; + + const globalToken = globalTokenFromRequest(request, env); + if (globalToken === "") { + log("no login cookie on the request", { requestedNsid }); + return errorResponse(SESSION_ERROR_CODES.sessionExpired, 401); + } + + const baseUrl = desktopApiBaseUrlFromEnv(env); + if (baseUrl == null) { + log("DESKTOP_API_BASE_URL is not configured", {}); + return errorResponse(SESSION_ERROR_CODES.desktopUnavailable, 502); + } + const desktop = createDesktopAuthApi( + createDesktopClient({ baseUrl, fetch: dependencies.fetchDesktop }) + ); + + const outcome = await establishBrainSession( + { globalToken, nsid: requestedNsid ? nsid : null }, + desktop + ); + if (!outcome.ok) { + log("establish failed", { ...outcome.failure, requestedNsid }); + return sessionFailureResponse(outcome.failure); + } + if (outcome.session.fallback != null) { + log("requested workspace not in list; landed in Personal", { + fallback: outcome.session.fallback, + }); + } + return sessionResponse(outcome.session); + }; +} diff --git a/apps/ui/src/features/session/server/session-service.ts b/apps/ui/src/features/session/server/session-service.ts new file mode 100644 index 00000000..c8f80e6e --- /dev/null +++ b/apps/ui/src/features/session/server/session-service.ts @@ -0,0 +1,200 @@ +import "server-only"; + +import { rewriteKubeconfigContextNamespace } from "@/lib/kubeconfig-namespace-core"; + +import type { + BrainSession, + SessionUser, + SessionWorkspace, +} from "../session-schema"; +import type { AuthInfoData, DesktopAuthApi } from "./desktop-auth-api"; +import type { DesktopCallFailure } from "./desktop-client"; +import { type RegionalTokenClaims, regionalTokenClaims } from "./jwt-payload"; + +/** + * Establishes the Brain Session (ADR-0083, spec §A.1) from a global token: + * `regionToken` (always lands in the Personal Workspace) → `namespace/list` + * → `namespace/switch` when the requested `nsid` names a Team Workspace the + * user belongs to, in parallel with `auth/info` → the kubeconfig's context + * namespace rewritten locally to the target Workspace. A `nsid` outside the + * list lands in the Personal Workspace with `fallback: "not_member"`, as + * Desktop's own home page does. A `409 workspace is not inited` is an + * anomaly, never repaired: establishing a session must not create a + * Workspace as a side effect, so `autoInitRegionToken` is never called. + */ + +export type SessionStep = "info" | "list" | "regionToken" | "switch"; + +export type SessionFailure = + /** The global token was rejected — the shared login cookie is stale. */ + | { kind: "unauthorized"; step: SessionStep } + /** Desktop's `409 workspace is not inited` on `regionToken`. */ + | { kind: "not_inited" } + /** Any other Desktop business code or HTTP status. */ + | { code: number; kind: "desktop_error"; step: SessionStep } + /** A response, token, or kubeconfig Brain could not make sense of. */ + | { kind: "malformed"; step: SessionStep } + | { kind: "timeout"; step: SessionStep } + | { kind: "unreachable"; step: SessionStep }; + +export type EstablishSessionOutcome = + | { ok: true; session: BrainSession } + | { failure: SessionFailure; ok: false }; + +export interface EstablishSessionInput { + globalToken: string; + /** Desktop's current namespace id from the SDK; null lands in Personal. */ + nsid: string | null; +} + +function failed(failure: SessionFailure): EstablishSessionOutcome { + return { failure, ok: false }; +} + +function failureOf( + step: SessionStep, + failure: DesktopCallFailure +): SessionFailure { + switch (failure.kind) { + case "desktop_code": + // Only the global token's rejection means the login is stale; a 401 + // on a token Desktop just minted is a Desktop anomaly, not a logout. + if (step === "regionToken" && failure.code === 401) { + return { kind: "unauthorized", step }; + } + if (step === "regionToken" && failure.code === 409) { + return { kind: "not_inited" }; + } + return { code: failure.code, kind: "desktop_error", step }; + case "http": + return { code: failure.status, kind: "desktop_error", step }; + case "malformed": + return { kind: "malformed", step }; + case "timeout": + return { kind: "timeout", step }; + default: + return { kind: "unreachable", step }; + } +} + +/** + * Where the session lands (spec §A.1): the requested `nsid` when it is in + * the list, else the Personal Workspace — flagged as a fallback when a + * `nsid` was asked for and not found. + */ +export function resolveTargetWorkspace(input: { + nsid: string | null; + personal: SessionWorkspace; + workspaces: SessionWorkspace[]; +}): { fallback: "not_member" | undefined; target: SessionWorkspace } { + const requestedNsid = input.nsid?.trim() ?? ""; + if (requestedNsid === "") { + return { fallback: undefined, target: input.personal }; + } + const requested = input.workspaces.find( + (workspace) => workspace.id === requestedNsid + ); + return requested == null + ? { fallback: "not_member", target: input.personal } + : { fallback: undefined, target: requested }; +} + +/** The list's `nstype` decides Personal; the token's claim is the fallback. */ +function personalWorkspace( + workspaces: SessionWorkspace[], + claimedUid: string +): SessionWorkspace | null { + return ( + workspaces.find((workspace) => workspace.isPersonal) ?? + workspaces.find((workspace) => workspace.uid === claimedUid) ?? + null + ); +} + +/** Identity from the token Desktop just returned, display data from `info`. */ +function sessionUser( + claims: RegionalTokenClaims, + info: AuthInfoData["info"] +): SessionUser { + return { + avatar: info.avatarUri?.trim() ?? "", + crName: claims.userCrName, + name: info.nickname?.trim() || info.name?.trim() || "", + userId: claims.userId || (info.id?.trim() ?? ""), + userUid: claims.userUid || (info.uid?.trim() ?? ""), + }; +} + +export async function establishBrainSession( + input: EstablishSessionInput, + desktop: DesktopAuthApi +): Promise { + const minted = await desktop.regionToken(input.globalToken); + if (!minted.ok) { + return failed(failureOf("regionToken", minted)); + } + const personalClaims = regionalTokenClaims(minted.data.token); + if (personalClaims == null) { + return failed({ kind: "malformed", step: "regionToken" }); + } + + const listed = await desktop.namespaceList(minted.data.token); + if (!listed.ok) { + return failed(failureOf("list", listed)); + } + const workspaces = listed.data; + const personal = personalWorkspace(workspaces, personalClaims.workspaceUid); + if (personal == null) { + return failed({ kind: "malformed", step: "list" }); + } + const { fallback, target } = resolveTargetWorkspace({ + nsid: input.nsid, + personal, + workspaces, + }); + + const [switched, info] = await Promise.all([ + target.uid === personal.uid + ? Promise.resolve(null) + : desktop.namespaceSwitch(minted.data.token, target.uid), + desktop.authInfo(minted.data.token), + ]); + if (switched != null && !switched.ok) { + return failed(failureOf("switch", switched)); + } + if (!info.ok) { + return failed(failureOf("info", info)); + } + + const tokens = switched?.ok ? switched.data : minted.data; + const claims = switched?.ok + ? regionalTokenClaims(tokens.token) + : personalClaims; + if (claims == null) { + return failed({ kind: "malformed", step: "switch" }); + } + + // Desktop's regionToken kubeconfig is not namespace-patched, so even the + // Personal Workspace gets the rewrite. + const kubeconfig = rewriteKubeconfigContextNamespace( + minted.data.kubeconfig, + target.id + ); + if (kubeconfig == null) { + return failed({ kind: "malformed", step: "regionToken" }); + } + + return { + ok: true, + session: { + appToken: tokens.appToken, + ...(fallback == null ? {} : { fallback }), + kubeconfig, + namespace: target.id, + regionalToken: tokens.token, + user: sessionUser(claims, info.data.info), + workspace: target, + workspaces, + }, + }; +} diff --git a/apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx b/apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx new file mode 100644 index 00000000..db06cbc9 --- /dev/null +++ b/apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, mock, test } from "bun:test"; +import assert from "node:assert/strict"; +import { getDefaultStore } from "jotai"; + +import { + actAndDrain, + defineGlobal, + type GlobalOverride, + installTestDom, + requestUrl, + restoreActEnvironment, + restoreGlobal, + setActEnvironment, + type TestDom, +} from "@/features/project-canvas/react-test-harness"; +import { + appTokenAtom, + kubeconfigAtom, + sessionStatusAtom, +} from "@/lib/auth-store"; + +import type { BrainSession } from "./session-schema"; + +// The Desktop SDK double: `nsid` is switchable so one file covers both the +// missed handshake (empty `nsid`) and the no-shell local-dev landing. The +// component's own `desktop-sdk` reading layer stays real. +const sdkState = { nsid: "" }; + +mock.module("@labring/sealos-desktop-sdk", () => ({ + EVENT_NAME: { CHANGE_I18N: "change_i18n", GET_APPS: "get-apps" }, +})); +mock.module("@labring/sealos-desktop-sdk/app", () => ({ + createSealosApp: () => () => undefined, + sealosApp: { + addAppEventListen: () => () => undefined, + getHostConfig: async () => ({ + cloud: { domain: "cloud.test", port: "", regionUid: "r" }, + }), + getLanguage: async () => ({ lng: "en" }), + getSession: async () => ({ + kubeconfig: "never-read", + token: "never-read", + user: { + avatar: "", + id: "x", + k8sUsername: "x", + name: "x", + nsid: sdkState.nsid, + }, + }), + }, +})); +mock.module("sonner", () => ({ + toast: () => undefined, +})); + +const PERSONAL_WORKSPACE = { + createdAt: "2026-02-01T00:00:00.000Z", + id: "ns-personal", + isPersonal: true, + name: "Ada", + role: "Owner" as const, + uid: "uid-personal", +}; + +const PERSONAL: BrainSession = { + appToken: "app-1", + kubeconfig: "apiVersion: v1\ncurrent-context: c\n", + namespace: "ns-personal", + regionalToken: "regional-1", + user: { + avatar: "", + crName: "abc", + name: "Ada", + userId: "u", + userUid: "uu", + }, + workspace: PERSONAL_WORKSPACE, + workspaces: [PERSONAL_WORKSPACE], +}; + +const sessionRequests: unknown[] = []; + +function fetchStub(input: unknown, init?: RequestInit): Promise { + const url = requestUrl(input); + if (url === "/api/session") { + sessionRequests.push(JSON.parse(String(init?.body))); + return Promise.resolve(Response.json(PERSONAL)); + } + return Promise.resolve(new Response("{}", { status: 404 })); +} + +let dom: TestDom; +let actEnvironment: boolean | undefined; +let fetchOverride: GlobalOverride; +let topDescriptor: PropertyDescriptor | undefined; + +/** Stands a parent frame over the window, as the Desktop iframe would. */ +function pretendInsideIframe() { + topDescriptor = Object.getOwnPropertyDescriptor(window, "top"); + Object.defineProperty(window, "top", { + configurable: true, + value: { location: { href: "about:blank" } }, + }); +} + +function restoreTop() { + if (topDescriptor == null) { + Reflect.deleteProperty(window, "top"); + return; + } + Object.defineProperty(window, "top", topDescriptor); +} + +beforeEach(() => { + dom = installTestDom(); + actEnvironment = setActEnvironment(true); + fetchOverride = defineGlobal("fetch", fetchStub); + sessionRequests.length = 0; + sdkState.nsid = ""; + const store = getDefaultStore(); + store.set(sessionStatusAtom, { kind: "idle" }); + store.set(kubeconfigAtom, ""); + store.set(appTokenAtom, ""); +}); + +afterEach(async () => { + restoreTop(); + restoreGlobal(fetchOverride); + restoreActEnvironment(actEnvironment); + await dom.restore(); +}); + +async function withBootstrap(run: () => void) { + const { render } = await import("@testing-library/react/pure"); + const { JotaiProvider } = await import("@/features/shell/jotai-provider"); + const { SessionBootstrap } = await import("./session-bootstrap"); + let rendered: ReturnType | undefined; + try { + await actAndDrain(() => { + rendered = render( + + + + ); + }, 50); + run(); + } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); + } +} + +test("inside the iframe a missed SDK handshake is an error, never a guess at Personal", async () => { + pretendInsideIframe(); + await withBootstrap(() => { + const store = getDefaultStore(); + assert.deepEqual(store.get(sessionStatusAtom), { + code: "desktop_unavailable", + kind: "error", + }); + assert.deepEqual(sessionRequests, [], "no session was established"); + assert.equal(store.get(kubeconfigAtom), ""); + assert.notEqual( + document.querySelector('[data-slot="session-error"]'), + null, + "error overlay is up" + ); + }); +}); + +test("outside an iframe a missing shell still lands in the Personal Workspace", async () => { + await withBootstrap(() => { + const store = getDefaultStore(); + assert.deepEqual(sessionRequests, [{}], "posted without an nsid"); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "ready" }); + assert.equal(store.get(kubeconfigAtom), PERSONAL.kubeconfig); + }); +}); diff --git a/apps/ui/src/features/session/session-bootstrap.test.tsx b/apps/ui/src/features/session/session-bootstrap.test.tsx new file mode 100644 index 00000000..bafc92ee --- /dev/null +++ b/apps/ui/src/features/session/session-bootstrap.test.tsx @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, mock, test } from "bun:test"; +import assert from "node:assert/strict"; +import { getDefaultStore } from "jotai"; + +import { + actAndDrain, + defineGlobal, + type GlobalOverride, + installTestDom, + requestUrl, + restoreActEnvironment, + restoreGlobal, + setActEnvironment, + type TestDom, +} from "@/features/project-canvas/react-test-harness"; +import { + appTokenAtom, + currentWorkspaceAtom, + desktopLanguageAtom, + kubeconfigAtom, + namespaceAtom, + regionalTokenAtom, + sessionStatusAtom, + sessionUserAtom, + workspacesAtom, +} from "@/lib/auth-store"; + +import type { BrainSession } from "./session-schema"; + +// The SDK double: answers Desktop's current Workspace and language the way +// the Desktop shell (or the local Dev Bridge) does. It never hands out +// credentials — the reading layer's type would not accept them anyway. +const desktopShell = { nsid: "ns-team" }; +const toasts: string[] = []; + +mock.module("@labring/sealos-desktop-sdk", () => ({ + EVENT_NAME: { CHANGE_I18N: "change_i18n", GET_APPS: "get-apps" }, +})); +mock.module("@labring/sealos-desktop-sdk/app", () => ({ + createSealosApp: () => () => undefined, + sealosApp: { + addAppEventListen: () => () => undefined, + getHostConfig: async () => ({ + cloud: { domain: "cloud.test", port: "", regionUid: "r" }, + features: { subscription: true }, + }), + getLanguage: async () => ({ lng: "zh" }), + getSession: async () => ({ + kubeconfig: "never-read", + token: "never-read", + user: { + avatar: "", + id: "x", + k8sUsername: "x", + name: "x", + nsid: desktopShell.nsid, + }, + }), + }, +})); +mock.module("sonner", () => ({ + toast: (message: string) => { + toasts.push(message); + }, +})); + +const moduleDom = installTestDom(); +const { render } = await import("@testing-library/react/pure"); +const { JotaiProvider } = await import("@/features/shell/jotai-provider"); +const { NOT_MEMBER_NOTICE, SessionBootstrap } = await import( + "./session-bootstrap" +); +const { desktopSigninUrl } = await import("./session-expired-overlay"); +await moduleDom.restore(); + +const TEAM = { + createdAt: "2026-02-01T00:00:00.000Z", + id: "ns-team", + isPersonal: false, + name: "Acme", + role: "Manager" as const, + uid: "uid-team", +}; + +function session(overrides: Partial = {}): BrainSession { + return { + appToken: "app-1", + kubeconfig: "apiVersion: v1\ncurrent-context: c\n", + namespace: "ns-team", + regionalToken: "regional-1", + user: { + avatar: "", + crName: "abc", + name: "Ada", + userId: "u", + userUid: "uu", + }, + workspace: TEAM, + workspaces: [TEAM], + ...overrides, + }; +} + +const sessionRoute = { + requests: [] as unknown[], + respond: (): Response => Response.json(session()), +}; + +function fetchStub(input: unknown, init?: RequestInit): Promise { + const url = requestUrl(input); + if (url === "/api/session") { + sessionRoute.requests.push(JSON.parse(String(init?.body))); + return Promise.resolve(sessionRoute.respond()); + } + return Promise.resolve(new Response("{}", { status: 404 })); +} + +let dom: TestDom; +let actEnvironment: boolean | undefined; +let fetchOverride: GlobalOverride; + +beforeEach(() => { + dom = installTestDom(); + actEnvironment = setActEnvironment(true); + fetchOverride = defineGlobal("fetch", fetchStub); + sessionRoute.requests = []; + sessionRoute.respond = () => Response.json(session()); + toasts.length = 0; + const store = getDefaultStore(); + store.set(sessionStatusAtom, { kind: "idle" }); + store.set(kubeconfigAtom, ""); + store.set(appTokenAtom, ""); + store.set(regionalTokenAtom, ""); +}); + +afterEach(async () => { + restoreGlobal(fetchOverride); + restoreActEnvironment(actEnvironment); + await dom.restore(); +}); + +async function withBootstrap(run: () => void) { + let rendered: ReturnType | undefined; + try { + await actAndDrain(() => { + rendered = render( + + + + ); + }, 50); + run(); + } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); + } +} + +test("reads Desktop's nsid through the SDK, posts it to /api/session, and lands the session in the atoms", async () => { + await withBootstrap(() => { + const store = getDefaultStore(); + assert.deepEqual(sessionRoute.requests, [{ nsid: "ns-team" }]); + assert.equal(store.get(regionalTokenAtom), "regional-1"); + assert.equal(store.get(appTokenAtom), "app-1"); + assert.equal(store.get(namespaceAtom), "ns-team"); + assert.deepEqual(store.get(currentWorkspaceAtom), TEAM); + assert.deepEqual(store.get(workspacesAtom), [TEAM]); + assert.equal(store.get(sessionUserAtom)?.name, "Ada"); + assert.equal(store.get(desktopLanguageAtom), "zh"); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "ready" }); + assert.deepEqual(toasts, []); + assert.equal(document.querySelector('[data-slot="session-expired"]'), null); + }); +}); + +test("a not_member fallback lands in Personal and tells the user", async () => { + sessionRoute.respond = () => + Response.json(session({ fallback: "not_member" })); + await withBootstrap(() => { + assert.deepEqual(toasts, [NOT_MEMBER_NOTICE]); + assert.deepEqual(getDefaultStore().get(sessionStatusAtom), { + kind: "ready", + }); + }); +}); + +test("a 401 from /api/session raises the session-expired overlay and holds no credentials", async () => { + sessionRoute.respond = () => + Response.json({ error: "session_expired" }, { status: 401 }); + await withBootstrap(() => { + const store = getDefaultStore(); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "expired" }); + assert.equal(store.get(kubeconfigAtom), ""); + assert.equal(store.get(appTokenAtom), ""); + assert.notEqual( + document.querySelector('[data-slot="session-expired"]'), + null, + "overlay is up" + ); + const button = document.querySelector( + '[data-slot="session-expired"] button' + ); + assert.notEqual(button, null); + }); +}); + +test("any other establish failure raises the generic session error with a reload", async () => { + sessionRoute.respond = () => + Response.json({ error: "desktop_unavailable" }, { status: 502 }); + await withBootstrap(() => { + assert.deepEqual(getDefaultStore().get(sessionStatusAtom), { + code: "desktop_unavailable", + kind: "error", + }); + assert.notEqual( + document.querySelector('[data-slot="session-error"]'), + null, + "error overlay is up" + ); + assert.equal(document.querySelector('[data-slot="session-expired"]'), null); + }); +}); + +test("desktopSigninUrl points at the Desktop sign-in page for the deployment", () => { + assert.equal(desktopSigninUrl("cloud.test"), "https://cloud.test/signin"); + assert.equal( + desktopSigninUrl("https://cloud.test/"), + "https://cloud.test/signin" + ); + assert.equal(desktopSigninUrl(" "), null); +}); diff --git a/apps/ui/src/features/session/session-bootstrap.tsx b/apps/ui/src/features/session/session-bootstrap.tsx new file mode 100644 index 00000000..32cb302c --- /dev/null +++ b/apps/ui/src/features/session/session-bootstrap.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useSetAtom, useStore } from "jotai"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +import { + desktopDomainAtom, + desktopLanguageAtom, + sessionStatusAtom, +} from "@/lib/auth-store"; + +import { + connectDesktopSdk, + isInsideDesktopIframe, + readDesktopDomain, + readDesktopLanguage, + readDesktopShellState, +} from "./desktop-sdk"; +import { SessionExpiredOverlay } from "./session-expired-overlay"; +import { SESSION_ERROR_CODES } from "./session-schema"; +import { establishSession } from "./session-store"; + +export const NOT_MEMBER_NOTICE = + "The Workspace Desktop had open is no longer in your list. You are in your Personal Workspace."; + +/** + * Establishes the Brain Session after mount (ADR-0083, spec §A.5): inside + * the Desktop iframe it first reads Desktop's current `nsid` through the + * SDK, then `POST /api/session { nsid }`; outside one it posts without a + * `nsid` and lands in the Personal Workspace. Inside the iframe a missed + * SDK handshake raises the generic session error instead of guessing + * Personal — the shell is the only source of the current Workspace. Until + * the session lands the shell keeps its existing empty-credentials state; + * a 401 raises the "session expired" overlay this component also mounts. + */ +type ShellFacts = { error: "shell-miss" } | { nsid: string | null }; + +/** + * Reads the SDK's shell facts and applies the language and domain to the + * atoms. Inside the iframe a missed handshake returns an error rather + * than "no shell": Desktop's shell is the only source of the current + * Workspace, and guessing Personal here would mint credentials for the + * wrong Workspace while Desktop's chrome still shows a Team one — the + * overlay's reload retries the handshake. Outside an iframe (local + * development) Personal remains the honest landing. + */ +async function readShellFacts( + setDesktopLanguage: (language: string) => void, + setDesktopDomain: (domain: string) => void +): Promise { + const insideIframe = isInsideDesktopIframe(); + const [shell, language, domain] = await Promise.all([ + readDesktopShellState(), + readDesktopLanguage(), + insideIframe ? readDesktopDomain() : Promise.resolve(null), + ]); + setDesktopLanguage(language ?? "en"); + if (domain != null) { + setDesktopDomain(domain); + } + if (insideIframe && shell == null) { + return { error: "shell-miss" }; + } + return { nsid: shell?.nsid ?? null }; +} + +export function SessionBootstrap() { + const store = useStore(); + const setDesktopLanguage = useSetAtom(desktopLanguageAtom); + const setDesktopDomain = useSetAtom(desktopDomainAtom); + + useEffect(() => { + let cancelled = false; + const disconnect = connectDesktopSdk({ + onLanguageChange: setDesktopLanguage, + }); + + const run = async () => { + const facts = await readShellFacts(setDesktopLanguage, setDesktopDomain); + if (cancelled) { + return; + } + if ("error" in facts) { + store.set(sessionStatusAtom, { + code: SESSION_ERROR_CODES.desktopUnavailable, + kind: "error", + }); + return; + } + const result = await establishSession(store, { nsid: facts.nsid }); + if (cancelled) { + return; + } + if (result.kind === "ok" && result.session.fallback === "not_member") { + toast(NOT_MEMBER_NOTICE); + } + }; + + run().catch((error: unknown) => { + if (!cancelled) { + console.warn("[SessionBootstrap] establish failed:", error); + } + }); + + return () => { + cancelled = true; + disconnect(); + }; + }, [setDesktopDomain, setDesktopLanguage, store]); + + return ; +} diff --git a/apps/ui/src/features/session/session-client.ts b/apps/ui/src/features/session/session-client.ts new file mode 100644 index 00000000..ea2032e2 --- /dev/null +++ b/apps/ui/src/features/session/session-client.ts @@ -0,0 +1,61 @@ +import { + type BrainSession, + brainSessionSchema, + sessionErrorSchema, +} from "./session-schema"; + +/** + * The page's side of `POST /api/session` (spec §A.5, §A.8): one call, one + * validated answer. `unauthorized` is the 401 the overlay reacts to; every + * other failure carries the route's error code so the caller can show the + * generic session error without reading Desktop text. + */ + +export const SESSION_API_PATH = "/api/session"; + +export type FetchBrainSessionResult = + | { kind: "ok"; session: BrainSession } + | { kind: "unauthorized" } + | { code: string; kind: "failed"; status: number } + | { kind: "network" }; + +export type SessionFetch = ( + input: string, + init: RequestInit +) => Promise; + +export async function fetchBrainSession( + input: { nsid: string | null }, + fetchImpl: SessionFetch = (url, init) => fetch(url, init) +): Promise { + const nsid = input.nsid?.trim() ?? ""; + let response: Response; + try { + response = await fetchImpl(SESSION_API_PATH, { + body: JSON.stringify(nsid === "" ? {} : { nsid }), + // The shared login cookie rides along on this same-origin request. + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + } catch { + return { kind: "network" }; + } + if (response.status === 401) { + await response.body?.cancel(); + return { kind: "unauthorized" }; + } + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) { + const parsed = sessionErrorSchema.safeParse(payload); + return { + code: parsed.success ? parsed.data.error : "unknown", + kind: "failed", + status: response.status, + }; + } + const parsed = brainSessionSchema.safeParse(payload); + return parsed.success + ? { kind: "ok", session: parsed.data } + : { code: "malformed_session", kind: "failed", status: response.status }; +} diff --git a/apps/ui/src/features/session/session-expired-overlay.tsx b/apps/ui/src/features/session/session-expired-overlay.tsx new file mode 100644 index 00000000..03ccc76b --- /dev/null +++ b/apps/ui/src/features/session/session-expired-overlay.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { AppDialog } from "@workspace/ui/components/app-dialog"; +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; + +import { desktopDomainAtom, sessionStatusAtom } from "@/lib/auth-store"; + +import { isInsideDesktopIframe } from "./desktop-sdk"; + +const DESKTOP_DOMAIN_SCHEME_RE = /^https?:\/\//i; +const TRAILING_SLASHES_RE = /\/+$/; + +/** Desktop's sign-in page for the deployment, or null without a domain. */ +export function desktopSigninUrl(domain: string): string | null { + const trimmed = domain.trim().replace(TRAILING_SLASHES_RE, ""); + if (trimmed === "") { + return null; + } + const origin = DESKTOP_DOMAIN_SCHEME_RE.test(trimmed) + ? trimmed + : `https://${trimmed}`; + return `${origin}/signin`; +} + +/** Copy for the generic session error (spec §A.3): the code, never Desktop text. */ +function sessionErrorDescription(code: string): string { + if (code === "workspace_not_inited") { + return "Your Sealos account has no Workspace in this region yet. Open Sealos Desktop to finish setting it up, then reload."; + } + if (code === "desktop_timeout") { + return "Sealos Desktop did not answer in time. Reload to try again."; + } + return "Brain could not establish a session with Sealos Desktop. Reload to try again."; +} + +/** + * The session overlays (spec §A.3, §A.8). "Session expired" shows when the + * login cookie itself is stale — the session's own 401, or a second 401 + * after a silent re-exchange. It is click-through by design: a cross-origin + * frame cannot navigate its top window without a user gesture, so the + * button hands `window.top` to Desktop's sign-in page. Outside the Desktop + * iframe (local development, where `DEV_GLOBAL_TOKEN` stands in for the + * cookie) there is no Desktop to go to, so the button reloads once the + * token is refreshed. Any other establish failure shows the generic session + * error with a reload. + */ +export function SessionExpiredOverlay() { + const status = useAtomValue(sessionStatusAtom); + const desktopDomain = useAtomValue(desktopDomainAtom); + const inIframe = isInsideDesktopIframe(); + // The sign-in target comes only from the SDK host config's domain: the + // embedding page is not proven to be Desktop (there is no frame-ancestors + // policy), so a referrer fallback would aim `window.top` at a stranger. + // Without a domain the button below reloads instead. + const signinUrl = inIframe ? desktopSigninUrl(desktopDomain) : null; + + const handleSignIn = useCallback(() => { + if (signinUrl != null) { + const top = window.top ?? window; + top.location.href = signinUrl; + return; + } + window.location.reload(); + }, [signinUrl]); + + const handleReload = useCallback(() => { + window.location.reload(); + }, []); + + if (status.kind === "error") { + return ( + undefined} open> + + + + Session unavailable + + + + {sessionErrorDescription(status.code)} + + + + + Reload + + + + + ); + } + + return ( + undefined} + open={status.kind === "expired"} + > + + + + Session expired + + + + {inIframe + ? "Your Sealos sign-in has expired. Sign in again to keep working." + : "The development session token has expired. Refresh DEV_GLOBAL_TOKEN from a signed-in Desktop, then reload."} + + + + + {signinUrl == null ? "Reload" : "Sign in again"} + + + + + ); +} diff --git a/apps/ui/src/features/session/session-fetch.test.ts b/apps/ui/src/features/session/session-fetch.test.ts new file mode 100644 index 00000000..fec9652d --- /dev/null +++ b/apps/ui/src/features/session/session-fetch.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { createStore } from "jotai"; + +import { + appTokenAtom, + regionalTokenAtom, + sessionStatusAtom, +} from "@/lib/auth-store"; +import { REGION_TOKEN_HEADER } from "@/lib/region-token-header"; + +import type { SessionFetch } from "./session-client"; +import { createSessionFetch } from "./session-fetch"; +import type { BrainSession } from "./session-schema"; +import { applyBrainSession } from "./session-store"; + +const WORKSPACE = { + createdAt: "2026-02-01T00:00:00.000Z", + id: "ns-team", + isPersonal: false, + name: "Acme", + role: "Owner" as const, + uid: "uid-team", +}; + +function session(tokens: { + appToken: string; + regionalToken: string; +}): BrainSession { + return { + appToken: tokens.appToken, + kubeconfig: "apiVersion: v1\ncurrent-context: c\n", + namespace: "ns-team", + regionalToken: tokens.regionalToken, + user: { + avatar: "", + crName: "abc", + name: "Ada", + userId: "u", + userUid: "uu", + }, + workspace: WORKSPACE, + workspaces: [WORKSPACE], + }; +} + +interface Seen { + regionToken: string | null; + url: string; +} + +function harness(input: { + answers: number[]; + reexchange: (body: unknown) => Response | Promise; +}) { + const store = createStore(); + applyBrainSession( + store, + session({ appToken: "app-1", regionalToken: "regional-1" }) + ); + const seen: Seen[] = []; + const sessionCalls: unknown[] = []; + const answers = [...input.answers]; + const fetchImpl = (url: string, init?: RequestInit) => { + seen.push({ + regionToken: new Headers(init?.headers).get(REGION_TOKEN_HEADER), + url, + }); + const status = answers.shift() ?? 200; + return Promise.resolve( + new Response(status === 200 ? '{"ok":true}' : null, { status }) + ); + }; + const sessionFetchImpl: SessionFetch = (_url, init) => { + const body = JSON.parse(String(init.body)); + sessionCalls.push(body); + return Promise.resolve(input.reexchange(body)); + }; + return { + fetch: createSessionFetch({ fetchImpl, sessionFetchImpl, store }), + seen, + sessionCalls, + store, + }; +} + +describe("createSessionFetch", () => { + test("attaches the regional token header and passes a non-401 answer through", async () => { + const h = harness({ + answers: [200], + reexchange: () => new Response(null, { status: 500 }), + }); + const response = await h.fetch("/api/workspace/list", { method: "GET" }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(h.seen, [ + { regionToken: "regional-1", url: "/api/workspace/list" }, + ]); + assert.deepEqual(h.sessionCalls, []); + }); + + test("on 401 re-establishes the session for the current Workspace and retries once with the new token", async () => { + const h = harness({ + answers: [401, 200], + reexchange: () => + Response.json( + session({ appToken: "app-2", regionalToken: "regional-2" }) + ), + }); + const response = await h.fetch("/api/workspace/list", { method: "GET" }); + assert.equal(response.status, 200); + assert.deepEqual(h.sessionCalls, [{ nsid: "ns-team" }]); + assert.deepEqual( + h.seen.map((call) => call.regionToken), + ["regional-1", "regional-2"] + ); + // the atoms updated in place, so every credential-keyed cache moves on + assert.equal(h.store.get(appTokenAtom), "app-2"); + assert.equal(h.store.get(regionalTokenAtom), "regional-2"); + assert.deepEqual(h.store.get(sessionStatusAtom), { kind: "ready" }); + }); + + test("a second 401 after the re-exchange marks the session expired", async () => { + const h = harness({ + answers: [401, 401], + reexchange: () => + Response.json( + session({ appToken: "app-2", regionalToken: "regional-2" }) + ), + }); + const response = await h.fetch("/api/workspace/list", { method: "GET" }); + assert.equal(response.status, 401); + assert.equal(h.seen.length, 2); + assert.deepEqual(h.store.get(sessionStatusAtom), { kind: "expired" }); + assert.equal(h.store.get(regionalTokenAtom), ""); + }); + + test("a 401 from the re-exchange itself marks the session expired without a retry", async () => { + const h = harness({ + answers: [401, 200], + reexchange: () => new Response(null, { status: 401 }), + }); + const response = await h.fetch("/api/workspace/list", { method: "GET" }); + assert.equal(response.status, 401); + assert.equal(h.seen.length, 1); + assert.deepEqual(h.store.get(sessionStatusAtom), { kind: "expired" }); + }); + + test("a failed re-exchange (Desktop outage) hands the original 401 back and keeps the credentials", async () => { + const h = harness({ + answers: [401, 200], + reexchange: () => new Response(null, { status: 502 }), + }); + const response = await h.fetch("/api/workspace/list", { method: "GET" }); + assert.equal(response.status, 401); + assert.equal(h.seen.length, 1); + assert.deepEqual(h.store.get(sessionStatusAtom), { kind: "ready" }); + assert.equal(h.store.get(regionalTokenAtom), "regional-1"); + }); +}); diff --git a/apps/ui/src/features/session/session-fetch.ts b/apps/ui/src/features/session/session-fetch.ts new file mode 100644 index 00000000..8d6b8ef8 --- /dev/null +++ b/apps/ui/src/features/session/session-fetch.ts @@ -0,0 +1,70 @@ +import { currentWorkspaceAtom, regionalTokenAtom } from "@/lib/auth-store"; +import { regionTokenRequestHeaders } from "@/lib/region-token-header"; + +import type { SessionFetch } from "./session-client"; +import { + appSessionStore, + establishSession, + type JotaiStore, + markSessionExpired, +} from "./session-store"; + +/** + * The fetch every Workspace-management fetcher goes through (spec §A.7, + * §A.8): it attaches `X-Sealos-Region-Token` from the session and runs the + * 401 two-step — on a 401, silently re-establish the session for the current + * Workspace (the atoms update in place, so every credential-keyed cache + * invalidates), retry the request once with the new token, and on a second + * 401 mark the session expired so the overlay takes over. No lifetime + * pre-check, no renewal, no cookie rewrite: the user re-logs in on Desktop. + */ +export type BrainFetch = ( + input: string, + init?: RequestInit +) => Promise; + +export function createSessionFetch(options: { + fetchImpl?: BrainFetch; + sessionFetchImpl?: SessionFetch; + store: JotaiStore; +}): BrainFetch { + const fetchImpl: BrainFetch = + options.fetchImpl ?? ((url, init) => fetch(url, init)); + + const send = (input: string, init: RequestInit | undefined) => { + const headers = new Headers(init?.headers); + for (const [name, value] of Object.entries( + regionTokenRequestHeaders(options.store.get(regionalTokenAtom)) + )) { + headers.set(name, value); + } + return fetchImpl(input, { ...init, headers }); + }; + + return async (input, init) => { + const first = await send(input, init); + if (first.status !== 401) { + return first; + } + const nsid = options.store.get(currentWorkspaceAtom)?.id ?? null; + const reestablished = await establishSession(options.store, { + fetchImpl: options.sessionFetchImpl, + nsid, + }); + if (reestablished.kind !== "ok") { + // `unauthorized` already marked the session expired; any other + // failure leaves the old credentials and hands the 401 back. + return first; + } + await first.body?.cancel(); + const second = await send(input, init); + if (second.status === 401) { + markSessionExpired(options.store); + } + return second; + }; +} + +/** The app's Workspace-management fetch, bound to the app store. */ +export const sessionFetch: BrainFetch = (input, init) => + createSessionFetch({ store: appSessionStore() })(input, init); diff --git a/apps/ui/src/features/session/session-schema.ts b/apps/ui/src/features/session/session-schema.ts new file mode 100644 index 00000000..bfead50a --- /dev/null +++ b/apps/ui/src/features/session/session-schema.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +/** + * The Brain Session contract (ADR-0083, spec §A.2): what `POST /api/session` + * answers and the page holds in memory. This is Brain's own shape — the + * server translates Desktop's DTOs into it and the client validates it — so + * the page never depends on a Desktop response shape. The response body is + * the one place the three credentials travel to the page; nothing else in + * Brain may log or echo them. + */ + +export const WORKSPACE_ROLES = ["Owner", "Manager", "Developer"] as const; + +export const workspaceRoleSchema = z.enum(WORKSPACE_ROLES); + +export type WorkspaceRole = z.infer; + +export const sessionWorkspaceSchema = z.object({ + createdAt: z.string(), + /** The Kubernetes namespace name, `ns-…`; what the SDK calls `nsid`. */ + id: z.string().min(1), + isPersonal: z.boolean(), + name: z.string(), + role: workspaceRoleSchema, + /** The stable Workspace uid (uuid); what Desktop's `switch` takes. */ + uid: z.string().min(1), +}); + +export type SessionWorkspace = z.infer; + +export const sessionUserSchema = z.object({ + avatar: z.string(), + /** The regional User CR name; the "You" comparison key in member lists. */ + crName: z.string(), + name: z.string(), + /** Legacy platform user id (account-service's `userId`). */ + userId: z.string(), + /** The global user UID (ADR-0059). */ + userUid: z.string(), +}); + +export type SessionUser = z.infer; + +export const SESSION_FALLBACKS = ["not_member"] as const; + +export const brainSessionSchema = z.object({ + appToken: z.string().min(1), + /** + * Set when the requested `nsid` was not in the user's Workspace list and + * the session landed in the Personal Workspace instead (spec §A.3). + */ + fallback: z.enum(SESSION_FALLBACKS).optional(), + /** The kubeconfig with its context namespace rewritten to `namespace`. */ + kubeconfig: z.string().min(1), + namespace: z.string().min(1), + regionalToken: z.string().min(1), + user: sessionUserSchema, + /** The Workspace the session is established in — Desktop's current one. */ + workspace: sessionWorkspaceSchema, + /** Every Workspace the user belongs to in this region, Personal first. */ + workspaces: z.array(sessionWorkspaceSchema), +}); + +export type BrainSession = z.infer; + +export const sessionRequestSchema = z.object({ + /** Desktop's current namespace id (`ns-…`) as read from the SDK; omitted outside the iframe. */ + nsid: z.string().trim().optional(), +}); + +export type SessionRequest = z.infer; + +/** + * The structured error codes `POST /api/session` answers with (spec §A.3); + * the client keys its reaction on these, never on Desktop's message text. + */ +export const SESSION_ERROR_CODES = { + desktopTimeout: "desktop_timeout", + desktopUnavailable: "desktop_unavailable", + forbidden: "session_forbidden", + invalidRequest: "invalid_session_request", + sessionExpired: "session_expired", + workspaceNotInited: "workspace_not_inited", +} as const; + +export const sessionErrorSchema = z.object({ + error: z.string(), +}); diff --git a/apps/ui/src/features/session/session-store.test.ts b/apps/ui/src/features/session/session-store.test.ts new file mode 100644 index 00000000..c3d66c83 --- /dev/null +++ b/apps/ui/src/features/session/session-store.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { createStore } from "jotai"; + +import { + appTokenAtom, + currentWorkspaceAtom, + desktopUserNameAtom, + kubeconfigAtom, + namespaceAtom, + regionalTokenAtom, + sessionStatusAtom, + sessionUserAtom, + workspacesAtom, +} from "@/lib/auth-store"; + +import type { SessionFetch } from "./session-client"; +import type { BrainSession } from "./session-schema"; +import { + applyBrainSession, + establishSession, + markSessionExpired, +} from "./session-store"; + +const PERSONAL = { + createdAt: "2026-01-01T00:00:00.000Z", + id: "ns-abc", + isPersonal: true, + name: "private team", + role: "Owner" as const, + uid: "uid-personal", +}; + +const TEAM = { + createdAt: "2026-02-01T00:00:00.000Z", + id: "ns-team", + isPersonal: false, + name: "Acme", + role: "Manager" as const, + uid: "uid-team", +}; + +function session(overrides: Partial = {}): BrainSession { + return { + appToken: "app-1", + kubeconfig: + "apiVersion: v1\ncurrent-context: c\ncontexts:\n - name: c\n context:\n namespace: ns-team\n", + namespace: "ns-team", + regionalToken: "regional-1", + user: { + avatar: "", + crName: "abc", + name: "Ada", + userId: "user-id", + userUid: "user-uid", + }, + workspace: TEAM, + workspaces: [PERSONAL, TEAM], + ...overrides, + }; +} + +function fetchAnswering( + respond: (body: unknown) => Response | Promise +): { calls: unknown[]; fetchImpl: SessionFetch } { + const calls: unknown[] = []; + return { + calls, + fetchImpl: (_url, init) => { + const body = JSON.parse(String(init.body)); + calls.push(body); + return Promise.resolve(respond(body)); + }, + }; +} + +describe("applyBrainSession", () => { + test("writes every credential and Workspace fact into the atoms and marks the session ready", () => { + const store = createStore(); + applyBrainSession(store, session()); + assert.equal(store.get(kubeconfigAtom).includes("ns-team"), true); + assert.equal(store.get(namespaceAtom), "ns-team"); + assert.equal(store.get(appTokenAtom), "app-1"); + assert.equal(store.get(regionalTokenAtom), "regional-1"); + assert.deepEqual(store.get(currentWorkspaceAtom), TEAM); + assert.deepEqual(store.get(workspacesAtom), [PERSONAL, TEAM]); + assert.equal(store.get(sessionUserAtom)?.crName, "abc"); + assert.equal(store.get(desktopUserNameAtom), "Ada"); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "ready" }); + }); +}); + +describe("establishSession", () => { + test("posts the nsid and applies the session", async () => { + const store = createStore(); + const desktop = fetchAnswering(() => Response.json(session())); + const result = await establishSession(store, { + fetchImpl: desktop.fetchImpl, + nsid: "ns-team", + }); + assert.equal(result.kind, "ok"); + assert.deepEqual(desktop.calls, [{ nsid: "ns-team" }]); + assert.equal(store.get(regionalTokenAtom), "regional-1"); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "ready" }); + }); + + test("posts an empty body without a nsid", async () => { + const store = createStore(); + const desktop = fetchAnswering(() => Response.json(session())); + await establishSession(store, { fetchImpl: desktop.fetchImpl, nsid: null }); + assert.deepEqual(desktop.calls, [{}]); + }); + + test("a 401 clears the credentials and marks the session expired", async () => { + const store = createStore(); + applyBrainSession(store, session()); + const desktop = fetchAnswering(() => + Response.json({ error: "session_expired" }, { status: 401 }) + ); + const result = await establishSession(store, { + fetchImpl: desktop.fetchImpl, + nsid: "ns-team", + }); + assert.equal(result.kind, "unauthorized"); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "expired" }); + assert.equal(store.get(kubeconfigAtom), ""); + assert.equal(store.get(appTokenAtom), ""); + assert.equal(store.get(regionalTokenAtom), ""); + // the shell keeps its shape under the overlay + assert.deepEqual(store.get(currentWorkspaceAtom), TEAM); + assert.equal(store.get(sessionUserAtom)?.name, "Ada"); + }); + + test("any other failure records the error code before the first session and keeps a ready session intact", async () => { + const fresh = createStore(); + const failing = fetchAnswering(() => + Response.json({ error: "workspace_not_inited" }, { status: 409 }) + ); + const result = await establishSession(fresh, { + fetchImpl: failing.fetchImpl, + nsid: null, + }); + assert.deepEqual(result, { + code: "workspace_not_inited", + kind: "failed", + status: 409, + }); + assert.deepEqual(fresh.get(sessionStatusAtom), { + code: "workspace_not_inited", + kind: "error", + }); + + const ready = createStore(); + applyBrainSession(ready, session()); + const outage = fetchAnswering(() => Promise.reject(new Error("down"))); + const retried = await establishSession(ready, { + fetchImpl: outage.fetchImpl, + nsid: "ns-team", + }); + assert.equal(retried.kind, "network"); + assert.deepEqual(ready.get(sessionStatusAtom), { kind: "ready" }); + assert.equal(ready.get(regionalTokenAtom), "regional-1"); + }); + + test("a malformed session body is a failure, never applied", async () => { + const store = createStore(); + const desktop = fetchAnswering(() => Response.json({ appToken: "only" })); + const result = await establishSession(store, { + fetchImpl: desktop.fetchImpl, + nsid: null, + }); + assert.equal(result.kind, "failed"); + assert.equal(store.get(appTokenAtom), ""); + }); + + test("concurrent establishes against one store share a single request", async () => { + const store = createStore(); + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const desktop = fetchAnswering(async () => { + await gate; + return Response.json(session()); + }); + const first = establishSession(store, { + fetchImpl: desktop.fetchImpl, + nsid: "ns-team", + }); + const second = establishSession(store, { + fetchImpl: desktop.fetchImpl, + nsid: "ns-team", + }); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "establishing" }); + release(); + const [a, b] = await Promise.all([first, second]); + assert.equal(a, b); + assert.equal(desktop.calls.length, 1); + + // and a later establish starts a new request + await establishSession(store, { + fetchImpl: desktop.fetchImpl, + nsid: "ns-team", + }); + assert.equal(desktop.calls.length, 2); + }); +}); + +describe("markSessionExpired", () => { + test("drops the three credentials and raises the expired status", () => { + const store = createStore(); + applyBrainSession(store, session()); + markSessionExpired(store); + assert.equal(store.get(kubeconfigAtom), ""); + assert.equal(store.get(appTokenAtom), ""); + assert.equal(store.get(regionalTokenAtom), ""); + assert.deepEqual(store.get(sessionStatusAtom), { kind: "expired" }); + }); +}); diff --git a/apps/ui/src/features/session/session-store.ts b/apps/ui/src/features/session/session-store.ts new file mode 100644 index 00000000..55cb417c --- /dev/null +++ b/apps/ui/src/features/session/session-store.ts @@ -0,0 +1,98 @@ +import { getDefaultStore } from "jotai"; + +import { + appTokenAtom, + currentWorkspaceAtom, + kubeconfigAtom, + namespaceAtom, + regionalTokenAtom, + sessionStatusAtom, + sessionUserAtom, + workspacesAtom, +} from "@/lib/auth-store"; + +import { + type FetchBrainSessionResult, + fetchBrainSession, + type SessionFetch, +} from "./session-client"; +import type { BrainSession } from "./session-schema"; + +/** + * Writes the Brain Session into the atoms and runs the establish flow + * against a Jotai store. Module functions rather than hooks so the session + * bootstrap and the 401 re-exchange inside a fetcher share one code path; + * concurrent establishes against the same store collapse into one request. + */ + +export type JotaiStore = ReturnType; + +export function applyBrainSession(store: JotaiStore, session: BrainSession) { + store.set(kubeconfigAtom, session.kubeconfig); + store.set(namespaceAtom, session.namespace); + store.set(appTokenAtom, session.appToken); + store.set(regionalTokenAtom, session.regionalToken); + store.set(currentWorkspaceAtom, session.workspace); + store.set(workspacesAtom, session.workspaces); + store.set(sessionUserAtom, session.user); + store.set(sessionStatusAtom, { kind: "ready" }); +} + +/** + * The credentials stop being sent the moment the session is known stale; + * the Workspace list and user stay so the shell keeps its shape under the + * overlay. + */ +export function markSessionExpired(store: JotaiStore) { + store.set(kubeconfigAtom, ""); + store.set(appTokenAtom, ""); + store.set(regionalTokenAtom, ""); + store.set(sessionStatusAtom, { kind: "expired" }); +} + +const inFlight = new WeakMap>(); + +/** + * Establishes (or re-establishes) the session for `nsid` and applies it. A + * 401 marks the session expired; any other failure records the error code + * and leaves the previous credentials untouched, so a transient Desktop + * outage during a re-exchange does not log the user out. + */ +export function establishSession( + store: JotaiStore, + input: { fetchImpl?: SessionFetch; nsid: string | null } +): Promise { + const pending = inFlight.get(store); + if (pending != null) { + return pending; + } + if (store.get(sessionStatusAtom).kind !== "ready") { + store.set(sessionStatusAtom, { kind: "establishing" }); + } + const run = fetchBrainSession({ nsid: input.nsid }, input.fetchImpl) + .then((result) => { + if (result.kind === "ok") { + applyBrainSession(store, result.session); + } else if (result.kind === "unauthorized") { + markSessionExpired(store); + } else if (store.get(sessionStatusAtom).kind !== "ready") { + store.set(sessionStatusAtom, { + code: result.kind === "network" ? "network" : result.code, + kind: "error", + }); + } + return result; + }) + .finally(() => { + if (inFlight.get(store) === run) { + inFlight.delete(store); + } + }); + inFlight.set(store, run); + return run; +} + +/** The store the app tree uses (`JotaiProvider` mounts the default store). */ +export function appSessionStore(): JotaiStore { + return getDefaultStore(); +} diff --git a/apps/ui/src/features/session/swr-keys.test.ts b/apps/ui/src/features/session/swr-keys.test.ts new file mode 100644 index 00000000..445bcaca --- /dev/null +++ b/apps/ui/src/features/session/swr-keys.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { SESSION_SWR_KEYS, type SessionCredentials } from "./swr-keys"; + +const BASE: SessionCredentials = { + appToken: "app-token-a", + kubeconfig: "apiVersion: v1\ncurrent-context: a\n", + namespace: "ns-a", + regionalToken: "regional-a", +}; + +const VARIANTS: Record = { + appToken: { ...BASE, appToken: "app-token-b" }, + kubeconfig: { ...BASE, kubeconfig: "apiVersion: v1\ncurrent-context: b\n" }, + namespace: { ...BASE, namespace: "ns-b" }, + regionalToken: { ...BASE, regionalToken: "regional-b" }, +}; + +// Spec §A.9: every client cache key derives from the credential atoms, so an +// in-place session re-establish invalidates every cache. Walk every key +// constructor and prove each credential is part of each key. +test("every session SWR key changes when any one credential changes", () => { + for (const [name, build] of Object.entries(SESSION_SWR_KEYS)) { + const base = JSON.stringify(build(BASE)); + assert.equal(JSON.stringify(build({ ...BASE })), base, `${name} is stable`); + for (const [credential, variant] of Object.entries(VARIANTS)) { + assert.notEqual( + JSON.stringify(build(variant)), + base, + `${name} ignores ${credential}` + ); + } + } +}); + +test("keys keep their prefix as the first element for the dev-mock matchers", () => { + assert.equal( + SESSION_SWR_KEYS.appSidebarSubscription(BASE)[0], + "app-sidebar-subscription" + ); + assert.equal( + SESSION_SWR_KEYS.notificationsFeed(BASE)[0], + "notifications-feed" + ); + assert.equal(SESSION_SWR_KEYS.statusHintQuota(BASE)[0], "status-hint-quota"); + assert.equal( + SESSION_SWR_KEYS.billingWorkspacePlans(BASE)[0], + "billing-workspace-plans" + ); +}); diff --git a/apps/ui/src/features/session/swr-keys.ts b/apps/ui/src/features/session/swr-keys.ts new file mode 100644 index 00000000..03f6cdec --- /dev/null +++ b/apps/ui/src/features/session/swr-keys.ts @@ -0,0 +1,64 @@ +import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; + +/** + * The invariant every client cache key obeys (ADR-0083, spec §A.9): a key + * derives from the Brain Session's credential atoms — kubeconfig fingerprint, + * namespace, app token, regional token — so re-establishing the session in + * place (the silent 401 re-exchange today, an in-place Workspace switch when + * Brain opens standalone) invalidates every cache without any consumer + * knowing. Keys are built here so `swr-keys.test.ts` can walk them all and + * prove that changing any one credential changes every key. + * + * The onboarding gate keys its judgment on its own credentials key + * (`onboardingCredentialsKey`) and is deliberately left alone. + */ + +export interface SessionCredentials { + appToken: string; + kubeconfig: string; + namespace: string; + regionalToken: string; +} + +const FINGERPRINT_SEPARATOR = "|"; + +/** The session's credential fingerprint: what every key below embeds. */ +export function sessionCredentialFingerprint( + credentials: SessionCredentials +): string { + return [ + credentials.namespace.trim(), + kubeconfigCredentialKey(credentials.kubeconfig), + credentials.appToken.trim(), + credentials.regionalToken.trim(), + ].join(FINGERPRINT_SEPARATOR); +} + +function sessionKey

(prefix: P) { + return (credentials: SessionCredentials) => + [prefix, sessionCredentialFingerprint(credentials)] as const; +} + +/** + * Every SWR key constructor that reads the session. Prefixes are the + * dev-mock revalidation contract (`billing/dev-mock-swr-keys.ts` matches on + * them), so a rename here is a rename there. + */ +export const SESSION_SWR_KEYS = { + appSidebarSubscription: sessionKey("app-sidebar-subscription"), + /** The Switcher's plan badges; the `billing-` prefix is the billing mock's. */ + billingWorkspacePlans: sessionKey("billing-workspace-plans"), + githubConnection: sessionKey("github-connection"), + githubUserRepos: sessionKey("github-user-repos"), + notificationsCredits: sessionKey("notifications-credits"), + notificationsFeed: sessionKey("notifications-feed"), + notificationsToppedUp: sessionKey("notifications-topped-up"), + statusHintBalance: sessionKey("status-hint-balance"), + statusHintPlans: sessionKey("status-hint-plans"), + statusHintQuota: sessionKey("status-hint-quota"), + /** The Workspace Area's Managed Workspace read, `POST /api/workspace/details`. */ + workspaceDetails: sessionKey("workspace-details"), + /** The Switcher's list refresh through `GET /api/workspace/list`. */ + workspaceList: sessionKey("workspace-list"), + workspaceOwner: sessionKey("workspace-owner"), +} as const; diff --git a/apps/ui/src/features/session/use-session-credentials.ts b/apps/ui/src/features/session/use-session-credentials.ts new file mode 100644 index 00000000..79fa92c4 --- /dev/null +++ b/apps/ui/src/features/session/use-session-credentials.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; + +import { + appTokenAtom, + kubeconfigAtom, + namespaceAtom, + regionalTokenAtom, +} from "@/lib/auth-store"; + +import type { SessionCredentials } from "./swr-keys"; + +/** The Brain Session's credentials as one trimmed record, plus readiness. */ +export function useSessionCredentials(): SessionCredentials & { + /** True once the three request credentials and the namespace are held. */ + ready: boolean; +} { + const appToken = useAtomValue(appTokenAtom).trim(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const namespace = useAtomValue(namespaceAtom).trim(); + const regionalToken = useAtomValue(regionalTokenAtom).trim(); + // One stable record per credential set, so consumers can hold it in + // hook dependencies without re-running on every render. + return useMemo( + () => ({ + appToken, + kubeconfig, + namespace, + ready: appToken !== "" && kubeconfig !== "" && namespace !== "", + regionalToken, + }), + [appToken, kubeconfig, namespace, regionalToken] + ); +} diff --git a/apps/ui/src/features/shell/app-sidebar-account.tsx b/apps/ui/src/features/shell/app-sidebar-account.tsx index e140fa18..f85dc071 100644 --- a/apps/ui/src/features/shell/app-sidebar-account.tsx +++ b/apps/ui/src/features/shell/app-sidebar-account.tsx @@ -1,6 +1,5 @@ "use client"; -import { PlanBadge } from "@workspace/ui/components/plan-badge"; import { Popover, PopoverContent, @@ -26,7 +25,6 @@ import { type ReactNode, useCallback, useEffect, - useMemo, useRef, useState, } from "react"; @@ -35,11 +33,6 @@ import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-pl import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client"; import { fetchFreeChatTurnsUsage } from "@/features/chat/persistence/client"; -import { - type AppSidebarAccountBadge, - type AppSidebarAccountHint, - deriveAppSidebarAccountPresentation, -} from "@/features/shell/app-sidebar-account-presentation"; import { AI_CREDITS_ROW_LABEL, aiUsageRowFromCredits, @@ -63,7 +56,7 @@ import { } from "@/lib/auth-store"; import { useSealosDesktopUrl } from "@/lib/sealos-desktop-url"; -const HINT_TEXT_CLASS: Record = { +const HINT_TEXT_CLASS: Record<"danger" | "warn", string> = { danger: "text-red-400", warn: "text-amber-400", }; @@ -112,20 +105,6 @@ function AppSidebarAccountAvatar({ ); } -function AppSidebarAccountBadgeSlot({ - badge, -}: { - badge: AppSidebarAccountBadge | null; -}) { - if (badge == null) { - return null; - } - if (badge.kind === "payg") { - return PAYG; - } - return ; -} - const USAGE_BAR_CLASS: Record<"danger" | "warn", string> = { danger: "bg-red-400", warn: "bg-amber-400", @@ -650,15 +629,14 @@ function AppSidebarAccountMenuRows({ } /** - * The account popover's body: identity, copyable ID, status hint, the menu - * rows (Usage, Billing, Sealos Desktop), and the Upgrade entry. + * The account popover's body: identity, copyable ID, the menu rows (Usage, + * Billing, Sealos Desktop), and the Upgrade entry. The plan badge and the + * subscription hint are Workspace facts and live on the Workspace Switcher. */ function AppSidebarAccountMenuView({ aiRow, - badge, copied, displayName, - hint, onCopyId, onToggleUsage, quotaRows, @@ -669,10 +647,8 @@ function AppSidebarAccountMenuView({ userName, }: { aiRow: AppSidebarQuotaRow | null; - badge: AppSidebarAccountBadge | null; copied: boolean; displayName: string; - hint: AppSidebarAccountHint | null; onCopyId: () => void; onToggleUsage: () => void; quotaRows: AppSidebarQuotaRow[] | null; @@ -693,7 +669,6 @@ function AppSidebarAccountMenuView({ {displayName} -

{userId === "" ? null : ( + ))} + {switchBlock == null ? null : ( +

+ {SWITCH_BLOCK_NOTICE[switchBlock]} +

+ )} + + + )} +
+
+ } + label="New Workspace" + onClick={() => { + recordBillingReturnRoute(); + onClose(); + }} + /> + } + label="Manage Workspaces" + onClick={() => { + recordWorkspaceReturnRoute(); + onClose(); + }} + /> +
+
+ ); +} + +/** + * The Workspace Switcher (spec §C.2–C.6, CONTEXT.md): the row under the + * brand slot naming the current Workspace — square avatar, name, the plan + * of its Workspace Subscription (PAYG without one), ⇕ — and the popover it + * opens. When the subscription needs attention the row grows a second line + * with the hint (the account row carries none of this). In the Collapsed + * rail only the avatar remains and still opens the popover, to the right. + * Choosing another Workspace hands the top window to Desktop's deep link + * built from the SDK host config's cloud domain; outside the Desktop + * iframe (or before Desktop answered) those rows are disabled with a notice. + */ +export function AppSidebarWorkspaceSwitcher() { + const { state } = useSidebar(); + const expanded = state === "expanded"; + const current = useAtomValue(currentWorkspaceAtom); + const desktopDomain = useAtomValue(desktopDomainAtom); + const workspaces = useWorkspaceList(); + const workspaceIds = useMemo( + () => workspaces.map((workspace) => workspace.id), + [workspaces] + ); + const plans = useWorkspacePlans(workspaceIds); + const { data: subscriptionSummary } = useWorkspaceSubscriptionSummary(); + const { badge, hint } = useMemo( + () => + deriveWorkspaceSwitcherPresentation( + subscriptionSummary ?? null, + new Date() + ), + [subscriptionSummary] + ); + + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + useCloseOnSidebarToggle(expanded, close); + // Collapsed anchor: the row keeps its full expanded width under the + // rail's clipping, so the popover anchors the w-9 icon slot. + const iconSlotRef = useRef(null); + + const cloudDomain = desktopDomain.trim(); + let switchBlock: SwitchBlock = null; + if (!isSwitchAvailable()) { + switchBlock = "outside-desktop"; + } else if (cloudDomain === "") { + switchBlock = "desktop-pending"; + } + const handleSwitch = useCallback( + (workspace: SessionWorkspace) => { + const url = workspaceSwitchUrl({ + cloudDomain, + landing: workspaceSwitchLanding(window.location), + workspaceUid: workspace.uid, + }); + if (url == null) { + return; + } + setOpen(false); + navigateTopWindow(url); + }, + [cloudDomain] + ); + + if (current == null) { + return null; + } + const others = workspaces.filter( + (workspace) => workspace.uid !== current.uid + ); + const twoLines = hint != null; + + const trigger = ( +