From 3e297d4fb7bc1a0b18c7f29d14cb7d8b9bb26727 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 14:36:38 +0800 Subject: [PATCH 01/17] feat(session): establish the Brain Session from the Desktop login cookie (AIM-444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brain no longer takes credentials from the Desktop SDK. `POST /api/session` reads the shared `sealos_auth_token` cookie on the server and exchanges it with Desktop (`regionToken` → `namespace/list` → `namespace/switch` ∥ `auth/info`), rewrites the kubeconfig context namespace to Desktop's current Workspace, and answers Brain's own zod-validated session shape. Desktop's "HTTP 200 + body.code" envelope is translated into real statuses (401 / 409 / 502 / 504); nothing logs a token. Client side, the three credentials plus the Workspace list, current Workspace, and user live only in Jotai atoms. `SessionBootstrap` replaces `AuthBootstrap` / `SealosSdkBootstrap`: the SDK reading layer returns only `nsid` (and language / host domain), never credentials. Workspace-management fetchers go through `createSessionFetch`, which attaches `X-Sealos-Region-Token` and runs the 401 two-step (silent re-exchange, one retry, then the click-through "Session expired" overlay that hands `window.top` to Desktop `/signin`). Every credential-keyed SWR key is now built in `features/session/swr-keys.ts`, with a test that walks them all. Local development runs the real path against a staging Desktop via `DESKTOP_API_BASE_URL` + `DEV_GLOBAL_TOKEN` (dev builds only); the self-signed pair (`NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG`, `NEXT_PUBLIC_DEV_APP_TOKEN`, the mint script, `hasDevCredentialBypass()`) is gone. A session dev-mock (Owner / Manager / Developer / Personal-only) answers `/api/session` from fixtures when selected. The chart derives `DESKTOP_API_BASE_URL` to the in-cluster Desktop Service and documents Desktop's `allowedOrigins` requirement. Docs: CONTEXT.md (Workspace, Workspace Role, Workspace Area, Managed Workspace, Workspace Invite Link, Brain Session, Workspace Switcher, Workspace Creation), ADR-0083 (Proposed), ADR-0059 status note, ADR index. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/helm-chart.yml | 3 + CONTEXT.md | 54 ++- apps/ui/.env.example | 21 +- apps/ui/scripts/devbox-api-smoke.mjs | 4 +- apps/ui/scripts/mint-dev-app-token.mjs | 108 ------ .../app/api/project-canvas/layout/route.ts | 5 - .../project-navigation-preferences/route.ts | 5 - apps/ui/src/app/api/projects/route.ts | 5 - apps/ui/src/app/api/session/route.ts | 7 + apps/ui/src/app/billing/layout.test.tsx | 14 +- apps/ui/src/app/billing/layout.tsx | 9 +- apps/ui/src/app/project/layout.test.tsx | 16 +- apps/ui/src/app/project/layout.tsx | 11 +- .../billing/server/dev-fixtures/index.ts | 15 +- .../billing/use-workspace-owner-standing.ts | 21 +- .../features/deploy/github/use-github-auth.ts | 17 +- .../deploy/github/use-github-repos.test.ts | 38 +- .../deploy/github/use-github-repos.ts | 26 +- apps/ui/src/features/dev-mock/dev-mocks.tsx | 2 + .../notifications/use-notification-feed.ts | 26 +- .../explorer/use-projects-explorer.test.ts | 2 +- .../explorer/use-projects-explorer.ts | 2 +- apps/ui/src/features/session/desktop-sdk.ts | 123 +++++++ .../src/features/session/dev-mock-cookie.ts | 28 ++ apps/ui/src/features/session/dev-mock.tsx | 31 ++ .../session/server/create-session-route.ts | 24 ++ .../session/server/desktop-auth-api.ts | 154 ++++++++ .../session/server/desktop-client.test.ts | 145 ++++++++ .../features/session/server/desktop-client.ts | 160 +++++++++ .../session/server/desktop-test-double.ts | 178 ++++++++++ .../session/server/dev-fixtures.test.ts | 90 +++++ .../features/session/server/dev-fixtures.ts | 131 +++++++ .../features/session/server/jwt-payload.ts | 55 +++ .../features/session/server/login-cookie.ts | 50 +++ .../session/server/session-handler.test.ts | 328 ++++++++++++++++++ .../session/server/session-handler.ts | 125 +++++++ .../session/server/session-service.ts | 197 +++++++++++ .../session/session-bootstrap.test.tsx | 215 ++++++++++++ .../features/session/session-bootstrap.tsx | 78 +++++ .../ui/src/features/session/session-client.ts | 61 ++++ .../session/session-expired-overlay.tsx | 75 ++++ .../features/session/session-fetch.test.ts | 161 +++++++++ apps/ui/src/features/session/session-fetch.ts | 69 ++++ .../ui/src/features/session/session-schema.ts | 90 +++++ .../features/session/session-store.test.ts | 220 ++++++++++++ apps/ui/src/features/session/session-store.ts | 98 ++++++ apps/ui/src/features/session/swr-keys.test.ts | 47 +++ apps/ui/src/features/session/swr-keys.ts | 60 ++++ .../session/use-session-credentials.ts | 30 ++ .../src/features/shell/app-sidebar.test.tsx | 14 +- .../src/features/shell/auth-bootstrap-core.ts | 38 -- .../src/features/shell/auth-bootstrap.test.ts | 159 --------- apps/ui/src/features/shell/auth-bootstrap.tsx | 182 ---------- .../src/features/shell/devbox-bootstrap.tsx | 42 +++ .../use-workspace-subscription-summary.ts | 21 +- .../status-hint/use-status-hint-inputs.ts | 31 +- apps/ui/src/lib/app-token.test.ts | 61 ---- apps/ui/src/lib/auth-store.tsx | 82 +++-- apps/ui/src/lib/kubeconfig-identity.ts | 20 -- .../src/lib/kubeconfig-namespace-core.test.ts | 59 ++++ apps/ui/src/lib/kubeconfig-namespace-core.ts | 40 ++- apps/ui/src/lib/region-token-header.ts | 22 ++ .../ui/src/lib/resolve-chat-namespace.test.ts | 180 ++++------ apps/ui/src/lib/resolve-chat-namespace.ts | 74 +--- apps/ui/src/lib/server-credentials.ts | 37 -- charts/brain-system/README.md | 4 +- charts/brain-system/templates/_helpers.tpl | 3 + charts/brain-system/tests/desktop-api-env.sh | 70 ++++ charts/brain-system/values.local.example.yaml | 5 +- charts/brain-system/values.yaml | 3 + ...y-personal-resources-by-global-user-uid.md | 5 + ...n-session-from-the-desktop-login-cookie.md | 172 +++++++++ docs/adr/README.md | 1 + docs/testing/github-deploy-smoke.md | 11 +- 74 files changed, 3770 insertions(+), 1000 deletions(-) delete mode 100644 apps/ui/scripts/mint-dev-app-token.mjs create mode 100644 apps/ui/src/app/api/session/route.ts create mode 100644 apps/ui/src/features/session/desktop-sdk.ts create mode 100644 apps/ui/src/features/session/dev-mock-cookie.ts create mode 100644 apps/ui/src/features/session/dev-mock.tsx create mode 100644 apps/ui/src/features/session/server/create-session-route.ts create mode 100644 apps/ui/src/features/session/server/desktop-auth-api.ts create mode 100644 apps/ui/src/features/session/server/desktop-client.test.ts create mode 100644 apps/ui/src/features/session/server/desktop-client.ts create mode 100644 apps/ui/src/features/session/server/desktop-test-double.ts create mode 100644 apps/ui/src/features/session/server/dev-fixtures.test.ts create mode 100644 apps/ui/src/features/session/server/dev-fixtures.ts create mode 100644 apps/ui/src/features/session/server/jwt-payload.ts create mode 100644 apps/ui/src/features/session/server/login-cookie.ts create mode 100644 apps/ui/src/features/session/server/session-handler.test.ts create mode 100644 apps/ui/src/features/session/server/session-handler.ts create mode 100644 apps/ui/src/features/session/server/session-service.ts create mode 100644 apps/ui/src/features/session/session-bootstrap.test.tsx create mode 100644 apps/ui/src/features/session/session-bootstrap.tsx create mode 100644 apps/ui/src/features/session/session-client.ts create mode 100644 apps/ui/src/features/session/session-expired-overlay.tsx create mode 100644 apps/ui/src/features/session/session-fetch.test.ts create mode 100644 apps/ui/src/features/session/session-fetch.ts create mode 100644 apps/ui/src/features/session/session-schema.ts create mode 100644 apps/ui/src/features/session/session-store.test.ts create mode 100644 apps/ui/src/features/session/session-store.ts create mode 100644 apps/ui/src/features/session/swr-keys.test.ts create mode 100644 apps/ui/src/features/session/swr-keys.ts create mode 100644 apps/ui/src/features/session/use-session-credentials.ts delete mode 100644 apps/ui/src/features/shell/auth-bootstrap-core.ts delete mode 100644 apps/ui/src/features/shell/auth-bootstrap.test.ts delete mode 100644 apps/ui/src/features/shell/auth-bootstrap.tsx create mode 100644 apps/ui/src/features/shell/devbox-bootstrap.tsx delete mode 100644 apps/ui/src/lib/kubeconfig-identity.ts create mode 100644 apps/ui/src/lib/kubeconfig-namespace-core.test.ts create mode 100644 apps/ui/src/lib/region-token-header.ts delete mode 100644 apps/ui/src/lib/server-credentials.ts create mode 100755 charts/brain-system/tests/desktop-api-env.sh create mode 100644 docs/adr/0083-establish-the-brain-session-from-the-desktop-login-cookie.md 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..01c1d048 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. 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/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/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/project/layout.test.tsx b/apps/ui/src/app/project/layout.test.tsx index 2c2ffa49..2a158523 100644 --- a/apps/ui/src/app/project/layout.test.tsx +++ b/apps/ui/src/app/project/layout.test.tsx @@ -12,12 +12,13 @@ const { BillingEscalationDialog } = await import( const { OnboardingGate } = await import( "@/features/onboarding/onboarding-gate" ); +const { SessionBootstrap } = await import( + "@/features/session/session-bootstrap" +); const { StatusHintBanner } = await import( "@/features/status-hint/status-hint-banner" ); -const { DevboxBootstrap, SealosSdkBootstrap } = await import( - "@/features/shell/auth-bootstrap" -); +const { DevboxBootstrap } = await import("@/features/shell/devbox-bootstrap"); const { default: ProjectLayout } = await import("./layout"); /** @@ -51,7 +52,14 @@ test("project layout mounts the Devbox warmup", () => { 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/features/billing/server/dev-fixtures/index.ts b/apps/ui/src/features/billing/server/dev-fixtures/index.ts index 0baeecda..95ed4047 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -7,7 +7,6 @@ import { type DevMockResolution, resolveDevMock, } from "@/features/dev-mock/server/resolve"; -import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; import { WORKSPACE_OWNER_FIXTURE_PATHNAME } from "./pathnames"; @@ -165,16 +164,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", @@ -1016,7 +1007,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( 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/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/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/use-notification-feed.ts b/apps/ui/src/features/notifications/use-notification-feed.ts index 511b184b..f9b38670 100644 --- a/apps/ui/src/features/notifications/use-notification-feed.ts +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -6,13 +6,14 @@ import { NOTIFICATION_CR_REFRESH_INTERVAL_MS, useNotificationCRList, } from "@workspace/api/hooks"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } 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, @@ -20,7 +21,6 @@ import { } 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 { fetchNotificationFeed, @@ -57,20 +57,20 @@ 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(); - 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 +135,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 +147,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 } 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..f4a97d1a 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", }; } 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..552b3c64 --- /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 = 1500; +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..e93675f4 --- /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 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..c62eaaec --- /dev/null +++ b/apps/ui/src/features/session/server/create-session-route.ts @@ -0,0 +1,24 @@ +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. + */ +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..8f98bee5 --- /dev/null +++ b/apps/ui/src/features/session/server/desktop-auth-api.ts @@ -0,0 +1,154 @@ +import "server-only"; + +import { z } from "zod"; + +import type { SessionWorkspace, WorkspaceRole } from "../session-schema"; +import { + type DesktopCallResult, + type DesktopClient, + encodedTokenAuthorization, +} from "./desktop-client"; + +/** + * The four Desktop `/api/auth/*` calls the Brain Session needs (spec §A.1), + * 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; the session + * service turns DTOs into Brain's own shapes. + */ + +export const DESKTOP_AUTH_PATHS = { + info: "/api/auth/info", + namespaceList: "/api/auth/namespace/list", + 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", +}; + +/** `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, + }; +} + +/** 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; + } +); + +export interface DesktopAuthApi { + authInfo(regionalToken: string): Promise>; + namespaceList( + regionalToken: string + ): Promise>; + namespaceSwitch( + regionalToken: string, + workspaceUid: string + ): Promise>; + regionToken(globalToken: string): Promise>; +} + +export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { + return { + authInfo: (regionalToken) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + dataSchema: authInfoDataSchema, + method: "GET", + path: DESKTOP_AUTH_PATHS.info, + }), + namespaceList: (regionalToken) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + dataSchema: desktopWorkspaceListSchema, + method: "GET", + path: DESKTOP_AUTH_PATHS.namespaceList, + }), + 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..44c3bf1a --- /dev/null +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + SESSION_DEV_SCENARIOS, + sessionDevMockCookie, +} from "../dev-mock-cookie"; +import { brainSessionSchema } from "../session-schema"; +import { sessionDevMockResponse } 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.name, "Acme"); +}); 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..99b700ed --- /dev/null +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -0,0 +1,131 @@ +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; + +import { + type SessionDevScenario, + sessionDevMockCookie, +} from "../dev-mock-cookie"; +import type { BrainSession, SessionWorkspace } 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. + */ + +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 workspacesFor(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")]; + } +} + +function sessionFor( + scenario: SessionDevScenario, + requestedNsid: string | null +): BrainSession { + const workspaces = workspacesFor(scenario); + const defaultCurrent = workspaces[1] ?? PERSONAL; + const requested = + requestedNsid == null + ? null + : workspaces.find((workspace) => workspace.id === requestedNsid); + const current = requested ?? defaultCurrent ?? 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" }, + }); +} 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..9594fe30 --- /dev/null +++ b/apps/ui/src/features/session/server/login-cookie.ts @@ -0,0 +1,50 @@ +import "server-only"; + +/** + * 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"; + +function cookieValue(header: string | null, name: string): string { + 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 { + return raw; + } + } + } + return ""; +} + +/** + * 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 = cookieValue( + 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..72bf50ef --- /dev/null +++ b/apps/ui/src/features/session/server/session-handler.test.ts @@ -0,0 +1,328 @@ +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; + cookie?: string | null; +}): Request { + const headers: Record = { + "content-type": "application/json", + }; + 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("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", 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" }); + expect(calls).toEqual([]); + }); + + 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..6f4dfeed --- /dev/null +++ b/apps/ui/src/features/session/server/session-handler.ts @@ -0,0 +1,125 @@ +import "server-only"; + +import { + type BrainSession, + SESSION_ERROR_CODES, + sessionRequestSchema, +} from "../session-schema"; +import type { DesktopAuthApi } from "./desktop-auth-api"; +import { createDesktopAuthApi } from "./desktop-auth-api"; +import { + createDesktopClient, + type DesktopFetch, + desktopApiBaseUrlFromEnv, +} from "./desktop-client"; +import { globalTokenFromRequest } from "./login-cookie"; +import { + type EstablishSessionOutcome, + 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; + establish?: ( + input: { globalToken: string; nsid: string | null }, + desktop: DesktopAuthApi + ) => Promise; + 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" } }); +} + +export function createSessionHandler( + dependencies: SessionHandlerDependencies = {} +): (request: Request) => Promise { + const env = dependencies.env ?? process.env; + const establish = dependencies.establish ?? establishBrainSession; + const log: SessionLog = + dependencies.log ?? + ((message, fields) => console.warn(`[session] ${message}`, fields)); + + return async function handler(request: Request): Promise { + const payload: unknown = + request.headers.get("content-length") === "0" + ? {} + : await request.json().catch(() => null); + const parsed = sessionRequestSchema.safeParse(payload ?? {}); + if (!parsed.success) { + return errorResponse(SESSION_ERROR_CODES.invalidRequest, 400); + } + const nsid = parsed.data.nsid?.trim() ?? ""; + + const globalToken = globalTokenFromRequest(request, env); + if (globalToken === "") { + log("no login cookie on the request", { nsid: nsid !== "" }); + 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 establish( + { globalToken, nsid: nsid === "" ? null : nsid }, + desktop + ); + if (!outcome.ok) { + log("establish failed", { + ...outcome.failure, + requestedNsid: nsid !== "", + }); + 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..90eb3e14 --- /dev/null +++ b/apps/ui/src/features/session/server/session-service.ts @@ -0,0 +1,197 @@ +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": + if (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 }; +} + +function personalWorkspace( + workspaces: SessionWorkspace[], + claimedUid: string +): SessionWorkspace | null { + return ( + workspaces.find((workspace) => workspace.uid === claimedUid) ?? + workspaces.find((workspace) => workspace.isPersonal) ?? + 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.test.tsx b/apps/ui/src/features/session/session-bootstrap.test.tsx new file mode 100644 index 00000000..7e1790b7 --- /dev/null +++ b/apps/ui/src/features/session/session-bootstrap.test.tsx @@ -0,0 +1,215 @@ +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("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..aa8aefa7 --- /dev/null +++ b/apps/ui/src/features/session/session-bootstrap.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useSetAtom, useStore } from "jotai"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +import { desktopDomainAtom, desktopLanguageAtom } from "@/lib/auth-store"; + +import { + connectDesktopSdk, + isInsideDesktopIframe, + readDesktopDomain, + readDesktopLanguage, + readDesktopShellState, +} from "./desktop-sdk"; +import { SessionExpiredOverlay } from "./session-expired-overlay"; +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. Until the session lands the + * shell keeps its existing empty-credentials state; a 401 raises the + * "session expired" overlay this component also mounts. + */ +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 [shell, language, domain] = await Promise.all([ + readDesktopShellState(), + readDesktopLanguage(), + isInsideDesktopIframe() ? readDesktopDomain() : Promise.resolve(null), + ]); + if (cancelled) { + return; + } + setDesktopLanguage(language ?? "en"); + if (domain != null) { + setDesktopDomain(domain); + } + const result = await establishSession(store, { + nsid: shell?.nsid ?? null, + }); + 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..a26d6126 --- /dev/null +++ b/apps/ui/src/features/session/session-expired-overlay.tsx @@ -0,0 +1,75 @@ +"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`; +} + +/** + * The "session expired" overlay (spec §A.8): shown 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. + */ +export function SessionExpiredOverlay() { + const status = useAtomValue(sessionStatusAtom); + const desktopDomain = useAtomValue(desktopDomainAtom); + const signinUrl = desktopSigninUrl(desktopDomain); + const inIframe = isInsideDesktopIframe(); + + const handleSignIn = useCallback(() => { + if (signinUrl != null && inIframe) { + const top = window.top ?? window; + top.location.href = signinUrl; + return; + } + window.location.reload(); + }, [inIframe, signinUrl]); + + 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."} + + + + + {inIframe ? "Sign in again" : "Reload"} + + + + + ); +} 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..09242147 --- /dev/null +++ b/apps/ui/src/features/session/session-fetch.ts @@ -0,0 +1,69 @@ +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) => + fetchImpl(input, { + ...init, + headers: { + ...(init?.headers as Record | undefined), + ...regionTokenRequestHeaders(options.store.get(regionalTokenAtom)), + }, + }); + + 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..8eaec148 --- /dev/null +++ b/apps/ui/src/features/session/session-schema.ts @@ -0,0 +1,90 @@ +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", + invalidRequest: "invalid_session_request", + sessionExpired: "session_expired", + workspaceNotInited: "workspace_not_inited", +} as const; + +export type SessionErrorCode = + (typeof SESSION_ERROR_CODES)[keyof typeof SESSION_ERROR_CODES]; + +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..37b59181 --- /dev/null +++ b/apps/ui/src/features/session/swr-keys.test.ts @@ -0,0 +1,47 @@ +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"); +}); 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..36755547 --- /dev/null +++ b/apps/ui/src/features/session/swr-keys.ts @@ -0,0 +1,60 @@ +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"), + 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"), + workspaceOwner: sessionKey("workspace-owner"), +} as const; + +export type SessionSwrKeyName = keyof typeof SESSION_SWR_KEYS; 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..f2e886b4 --- /dev/null +++ b/apps/ui/src/features/session/use-session-credentials.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useAtomValue } from "jotai"; + +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(); + return { + appToken, + kubeconfig, + namespace, + ready: appToken !== "" && kubeconfig !== "" && namespace !== "", + regionalToken, + }; +} diff --git a/apps/ui/src/features/shell/app-sidebar.test.tsx b/apps/ui/src/features/shell/app-sidebar.test.tsx index 4791c0f1..5b023db7 100644 --- a/apps/ui/src/features/shell/app-sidebar.test.tsx +++ b/apps/ui/src/features/shell/app-sidebar.test.tsx @@ -20,11 +20,9 @@ import type { } from "@/features/projects/explorer/project-explorer.types"; import { appTokenAtom, - desktopUserAvatarAtom, - desktopUserIdAtom, - desktopUserNameAtom, kubeconfigAtom, namespaceAtom, + sessionUserAtom, } from "@/lib/auth-store"; const projects: ProjectExplorerProject[] = [ @@ -177,9 +175,13 @@ function hydrateAccountAtoms(workspace: string) { store.set(appTokenAtom, "desktop-app-token"); store.set(kubeconfigAtom, "apiVersion: v1"); store.set(namespaceAtom, workspace); - store.set(desktopUserIdAtom, ACCOUNT_USER.id); - store.set(desktopUserNameAtom, ACCOUNT_USER.name); - store.set(desktopUserAvatarAtom, ""); + store.set(sessionUserAtom, { + avatar: "", + crName: "ada", + name: ACCOUNT_USER.name, + userId: ACCOUNT_USER.id, + userUid: "user-uid-ada", + }); } mock.module("next/navigation", () => ({ diff --git a/apps/ui/src/features/shell/auth-bootstrap-core.ts b/apps/ui/src/features/shell/auth-bootstrap-core.ts deleted file mode 100644 index be3fa704..00000000 --- a/apps/ui/src/features/shell/auth-bootstrap-core.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; - -export function applySealosSdkHydration(input: { - language: { lng: string } | null; - session: { - kubeconfig: string; - token?: string; - user?: { avatar?: string; id?: string; name?: string }; - } | null; - setAppToken?: (appToken: string) => void; - setDesktopLanguage: (language: string) => void; - setDesktopUserAvatar: (avatarUrl: string) => void; - setDesktopUserId: (userId: string) => void; - setDesktopUserName: (userName: string) => void; - setKubeconfig: (kubeconfig: string) => void; - setNamespace: (namespace: string) => void; -}) { - if (input.language !== null) { - input.setDesktopLanguage(input.language.lng.trim() || "en"); - } - - // Desktop mints the app token only at login / region switch / workspace - // switch, so an absent token must not clear a previously hydrated one. - const appToken = input.session?.token?.trim() ?? ""; - if (appToken !== "") { - input.setAppToken?.(appToken); - } - - const kubeconfig = input.session?.kubeconfig.trim() ?? ""; - input.setDesktopUserId(input.session?.user?.id?.trim() ?? ""); - input.setDesktopUserName(input.session?.user?.name?.trim() ?? ""); - input.setDesktopUserAvatar(input.session?.user?.avatar?.trim() ?? ""); - if (kubeconfig === "") { - return; - } - input.setKubeconfig(kubeconfig); - input.setNamespace(namespaceFromKubeconfigText(kubeconfig) ?? ""); -} diff --git a/apps/ui/src/features/shell/auth-bootstrap.test.ts b/apps/ui/src/features/shell/auth-bootstrap.test.ts deleted file mode 100644 index b64c4466..00000000 --- a/apps/ui/src/features/shell/auth-bootstrap.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, test } from "node:test"; -import { applySealosSdkHydration } from "./auth-bootstrap-core"; - -describe("Sealos SDK bootstrap hydration", () => { - test("applies desktop language even when session kubeconfig is empty", () => { - const updates: string[] = []; - - applySealosSdkHydration({ - language: { lng: "zh" }, - session: { kubeconfig: "" }, - setDesktopLanguage: (language) => updates.push(`language:${language}`), - setDesktopUserAvatar: (avatarUrl) => updates.push(`avatar:${avatarUrl}`), - setDesktopUserId: (userId) => updates.push(`user:${userId}`), - setDesktopUserName: (userName) => updates.push(`name:${userName}`), - setKubeconfig: (kubeconfig) => updates.push(`kubeconfig:${kubeconfig}`), - setNamespace: (namespace) => updates.push(`namespace:${namespace}`), - }); - - assert.deepEqual(updates, ["language:zh", "user:", "name:", "avatar:"]); - }); - - test("falls back to English when desktop language is blank", () => { - let language = ""; - - applySealosSdkHydration({ - language: { lng: " " }, - session: null, - setDesktopLanguage: (value) => { - language = value; - }, - setDesktopUserAvatar: () => undefined, - setDesktopUserId: () => undefined, - setDesktopUserName: () => undefined, - setKubeconfig: () => undefined, - setNamespace: () => undefined, - }); - - assert.equal(language, "en"); - }); - - test("applies non-empty kubeconfig and derived namespace", () => { - const updates: string[] = []; - const kubeconfig = ` -apiVersion: v1 -kind: Config -current-context: demo -contexts: - - name: demo - context: - cluster: demo - user: demo - namespace: ns-demo -`; - - applySealosSdkHydration({ - language: null, - session: { kubeconfig, user: { id: " admin " } }, - setDesktopLanguage: (language) => updates.push(`language:${language}`), - setDesktopUserAvatar: (avatarUrl) => updates.push(`avatar:${avatarUrl}`), - setDesktopUserId: (userId) => updates.push(`user:${userId}`), - setDesktopUserName: (userName) => updates.push(`name:${userName}`), - setKubeconfig: (value) => updates.push(`kubeconfig:${value}`), - setNamespace: (namespace) => updates.push(`namespace:${namespace}`), - }); - - assert.equal(updates.length, 5); - assert.equal(updates[0], "user:admin"); - assert.equal(updates[1], "name:"); - assert.equal(updates[2], "avatar:"); - assert.equal(updates[3]?.startsWith("kubeconfig:"), true); - assert.equal(updates[4], "namespace:ns-demo"); - }); - - test("captures the session user's name and avatar for the account section", () => { - const updates: string[] = []; - - applySealosSdkHydration({ - language: null, - session: { - kubeconfig: "apiVersion: v1", - user: { - avatar: " https://desktop.test/avatar.png ", - id: "usr-1", - name: " Ada ", - }, - }, - setDesktopLanguage: () => undefined, - setDesktopUserAvatar: (avatarUrl) => updates.push(`avatar:${avatarUrl}`), - setDesktopUserId: (userId) => updates.push(`user:${userId}`), - setDesktopUserName: (userName) => updates.push(`name:${userName}`), - setKubeconfig: () => undefined, - setNamespace: () => undefined, - }); - - assert.deepEqual(updates, [ - "user:usr-1", - "name:Ada", - "avatar:https://desktop.test/avatar.png", - ]); - }); - - test("clears identity fields when the session carries no user", () => { - const updates: string[] = []; - - applySealosSdkHydration({ - language: null, - session: { kubeconfig: "apiVersion: v1" }, - setDesktopLanguage: () => undefined, - setDesktopUserAvatar: (avatarUrl) => updates.push(`avatar:${avatarUrl}`), - setDesktopUserId: (userId) => updates.push(`user:${userId}`), - setDesktopUserName: (userName) => updates.push(`name:${userName}`), - setKubeconfig: () => undefined, - setNamespace: () => undefined, - }); - - assert.deepEqual(updates, ["user:", "name:", "avatar:"]); - }); - - test("hydrates the session app token alongside the kubeconfig", () => { - const updates: string[] = []; - - applySealosSdkHydration({ - language: null, - session: { - kubeconfig: "apiVersion: v1", - token: " session-app-token ", - user: { id: "admin" }, - }, - setAppToken: (token) => updates.push(`appToken:${token}`), - setDesktopLanguage: () => undefined, - setDesktopUserAvatar: () => undefined, - setDesktopUserId: () => undefined, - setDesktopUserName: () => undefined, - setKubeconfig: () => undefined, - setNamespace: () => undefined, - }); - - assert.deepEqual(updates, ["appToken:session-app-token"]); - }); - - test("a session without an app token never clears a hydrated token", () => { - const updates: string[] = []; - - applySealosSdkHydration({ - language: null, - session: { kubeconfig: "apiVersion: v1", user: { id: "admin" } }, - setAppToken: (token) => updates.push(`appToken:${token}`), - setDesktopLanguage: () => undefined, - setDesktopUserAvatar: () => undefined, - setDesktopUserId: () => undefined, - setDesktopUserName: () => undefined, - setKubeconfig: () => undefined, - setNamespace: () => undefined, - }); - - assert.deepEqual(updates, []); - }); -}); diff --git a/apps/ui/src/features/shell/auth-bootstrap.tsx b/apps/ui/src/features/shell/auth-bootstrap.tsx deleted file mode 100644 index e0848ec2..00000000 --- a/apps/ui/src/features/shell/auth-bootstrap.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -import { EVENT_NAME } from "@labring/sealos-desktop-sdk"; -import { createSealosApp, sealosApp } from "@labring/sealos-desktop-sdk/app"; -import { useAtomValue, useSetAtom } from "jotai"; -import { useHydrateAtoms } from "jotai/utils"; -import { useEffect } from "react"; -import { scheduleChatDevboxWarmup } from "@/features/chat/devbox/devbox.actions"; -import { applySealosSdkHydration } from "@/features/shell/auth-bootstrap-core"; -import { - appTokenAtom, - desktopLanguageAtom, - desktopUserAvatarAtom, - desktopUserIdAtom, - desktopUserNameAtom, - kubeconfigAtom, - namespaceAtom, -} from "@/lib/auth-store"; -import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; - -/** Hydrates kubeconfig / namespace into Jotai from server props or dev env overrides. */ - -interface AuthBootstrapProps { - serverEncodedKubeconfig: string; - serverNamespace: string; -} - -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return ""; - } -} - -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 ""; -} - -export default function AuthBootstrap({ - serverEncodedKubeconfig, - serverNamespace, -}: AuthBootstrapProps) { - const devEncodedKubeconfig = safeDecode( - process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG ?? "" - ).trim(); - const fallbackKubeconfig = safeDecode(serverEncodedKubeconfig).trim(); - const fallbackNamespace = serverNamespace.trim(); - - // Dev env is an all-or-nothing override vs server: when a dev kubeconfig is - // set, derive its namespace from the same kubeconfig current context. - const hasDevOverride = devEncodedKubeconfig !== ""; - const kubeconfig = hasDevOverride ? devEncodedKubeconfig : fallbackKubeconfig; - const namespace = hasDevOverride - ? (namespaceFromKubeconfigText(devEncodedKubeconfig) ?? "") - : fallbackNamespace; - - useHydrateAtoms([ - [kubeconfigAtom, kubeconfig], - [namespaceAtom, namespace], - ]); - - return null; -} - -/** Hydrates credentials from the Sealos Desktop iframe SDK when available. */ -export function SealosSdkBootstrap() { - const setAppToken = useSetAtom(appTokenAtom); - const setDesktopLanguage = useSetAtom(desktopLanguageAtom); - const setDesktopUserAvatar = useSetAtom(desktopUserAvatarAtom); - const setDesktopUserId = useSetAtom(desktopUserIdAtom); - const setDesktopUserName = useSetAtom(desktopUserNameAtom); - const setKubeconfig = useSetAtom(kubeconfigAtom); - const setNamespace = useSetAtom(namespaceAtom); - - useEffect(() => { - let cancelled = false; - const cleanup = createSealosApp(); - let unsubscribeLanguage: (() => void) | undefined; - - const hydrate = async () => { - try { - const [session, language] = await Promise.all([ - sealosApp.getSession().catch(() => null), - sealosApp.getLanguage().catch(() => null), - ]); - if (cancelled) { - return; - } - applySealosSdkHydration({ - language, - session, - setAppToken, - setDesktopLanguage, - setDesktopUserAvatar, - setDesktopUserId, - setDesktopUserName, - setKubeconfig, - setNamespace, - }); - } catch (e: unknown) { - if (!cancelled) { - console.warn("[SealosSdkBootstrap] session hydrate failed:", e); - } - } - }; - - unsubscribeLanguage = sealosApp.addAppEventListen( - EVENT_NAME.CHANGE_I18N, - (event) => { - const language = eventLanguage(event); - if (language !== "") { - setDesktopLanguage(language); - } - } - ); - - hydrate().catch(() => undefined); - - return () => { - cancelled = true; - unsubscribeLanguage?.(); - cleanup?.(); - }; - }, [ - setAppToken, - setDesktopLanguage, - setDesktopUserAvatar, - setDesktopUserId, - setDesktopUserName, - setKubeconfig, - setNamespace, - ]); - - return null; -} - -/** - * Dispatches {@link scheduleChatDevboxWarmup} once credentials are hydrated. - * Devbox work runs on the server after the action resolves (does not block the UI). - */ -export function DevboxBootstrap() { - const kubeconfig = useAtomValue(kubeconfigAtom); - const namespace = useAtomValue(namespaceAtom); - - useEffect(() => { - const kubeconfigDecoded = kubeconfig.trim(); - const namespaceTrimmed = namespace.trim(); - if (kubeconfigDecoded === "" || namespaceTrimmed === "") { - return; - } - - const run = async () => { - try { - const result = await scheduleChatDevboxWarmup( - encodeURIComponent(kubeconfigDecoded), - namespaceTrimmed - ); - if (!result.ok && result.reason === "credentials") { - console.warn("[DevboxBootstrap] skipped: invalid credentials"); - } - } catch (e: unknown) { - console.warn("[DevboxBootstrap] schedule failed:", e); - } - }; - - run().catch(() => undefined); - }, [kubeconfig, namespace]); - - return null; -} diff --git a/apps/ui/src/features/shell/devbox-bootstrap.tsx b/apps/ui/src/features/shell/devbox-bootstrap.tsx new file mode 100644 index 00000000..591ba55b --- /dev/null +++ b/apps/ui/src/features/shell/devbox-bootstrap.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; +import { scheduleChatDevboxWarmup } from "@/features/chat/devbox/devbox.actions"; +import { kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +/** + * Dispatches {@link scheduleChatDevboxWarmup} once the Brain Session has + * landed its credentials. Devbox work runs on the server after the action + * resolves (does not block the UI). + */ +export function DevboxBootstrap() { + const kubeconfig = useAtomValue(kubeconfigAtom); + const namespace = useAtomValue(namespaceAtom); + + useEffect(() => { + const kubeconfigDecoded = kubeconfig.trim(); + const namespaceTrimmed = namespace.trim(); + if (kubeconfigDecoded === "" || namespaceTrimmed === "") { + return; + } + + const run = async () => { + try { + const result = await scheduleChatDevboxWarmup( + encodeURIComponent(kubeconfigDecoded), + namespaceTrimmed + ); + if (!result.ok && result.reason === "credentials") { + console.warn("[DevboxBootstrap] skipped: invalid credentials"); + } + } catch (e: unknown) { + console.warn("[DevboxBootstrap] schedule failed:", e); + } + }; + + run().catch(() => undefined); + }, [kubeconfig, namespace]); + + return null; +} diff --git a/apps/ui/src/features/shell/use-workspace-subscription-summary.ts b/apps/ui/src/features/shell/use-workspace-subscription-summary.ts index b150c303..7da5d801 100644 --- a/apps/ui/src/features/shell/use-workspace-subscription-summary.ts +++ b/apps/ui/src/features/shell/use-workspace-subscription-summary.ts @@ -1,11 +1,10 @@ "use client"; -import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; -import { useAtomValue } from "jotai"; import useSWR from "swr"; import { loadWorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; -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"; /** * The App Sidebar's shared read of the Workspace Subscription summary — the @@ -17,22 +16,14 @@ import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; export function useWorkspaceSubscriptionSummary( 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, namespace: workspace } = credentials; // Live billing data, not the login-time session snapshot: the badge and // hint follow the same subscription route as the Billing Area's hooks. return useSWR( - credentialsReady - ? ([ - "app-sidebar-subscription", - workspace, - kubeconfigCredentialKey(kubeconfig), - appToken, - ] as const) + credentials.ready + ? SESSION_SWR_KEYS.appSidebarSubscription(credentials) : null, () => loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }), { diff --git a/apps/ui/src/features/status-hint/use-status-hint-inputs.ts b/apps/ui/src/features/status-hint/use-status-hint-inputs.ts index da1c7281..bc897ace 100644 --- a/apps/ui/src/features/status-hint/use-status-hint-inputs.ts +++ b/apps/ui/src/features/status-hint/use-status-hint-inputs.ts @@ -1,10 +1,7 @@ "use client"; -import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; -import { useAtomValue } from "jotai"; import { useEffect, useMemo, useState } from "react"; import useSWR from "swr"; - import { loadAccountBalanceTerms } from "@/features/billing/account-balance"; import { loadAccountCredits } from "@/features/billing/account-credits"; import { planUpgradeCeiling } from "@/features/billing/billing-plan-catalog"; @@ -13,8 +10,9 @@ import { accountCreditsSwrKey } from "@/features/billing/billing-subscription-se import { loadWorkspaceQuotaData } from "@/features/billing/billing-usage-data"; import { useWorkspaceOwnerStanding } from "@/features/billing/use-workspace-owner-standing"; import { observeWorkspaceQuotaSnapshotForInbox } from "@/features/notifications/quota-observation"; +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; -import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; import type { StatusHintInputs } from "./status-hint-model"; @@ -51,12 +49,13 @@ function planCeilingFrom( * state unknown — never lit, never cleared. */ export function useStatusHintInputs(): StatusHintInputs { - const appToken = useAtomValue(appTokenAtom).trim(); - const kubeconfig = useAtomValue(kubeconfigAtom).trim(); - const workspace = useAtomValue(namespaceAtom).trim(); - const credentialsReady = - appToken !== "" && kubeconfig !== "" && workspace !== ""; - const credentialKey = kubeconfigCredentialKey(kubeconfig); + const credentials = useSessionCredentials(); + const { + appToken, + kubeconfig, + namespace: workspace, + ready: credentialsReady, + } = credentials; const subscription = useWorkspaceSubscriptionSummary({ refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS, @@ -72,9 +71,7 @@ export function useStatusHintInputs(): StatusHintInputs { shouldRetryOnError: false, }; const balance = useSWR( - credentialsReady - ? (["status-hint-balance", credentialKey, appToken] as const) - : null, + credentialsReady ? SESSION_SWR_KEYS.statusHintBalance(credentials) : null, () => loadAccountBalanceTerms({ appToken, kubeconfig }), swrOptions ); @@ -90,9 +87,7 @@ export function useStatusHintInputs(): StatusHintInputs { // cannot disagree for minutes, and a recovery between chat turns still // releases the live key. const quota = useSWR( - credentialsReady - ? (["status-hint-quota", workspace, credentialKey, appToken] as const) - : null, + credentialsReady ? SESSION_SWR_KEYS.statusHintQuota(credentials) : null, () => loadWorkspaceQuotaData({ appToken, kubeconfig, namespace: workspace }), { @@ -116,9 +111,7 @@ export function useStatusHintInputs(): StatusHintInputs { // ceiling, so the catalog rides the same cadence; an unanswered read // leaves the ceiling unknown, never assumed. const plans = useSWR( - credentialsReady - ? (["status-hint-plans", credentialKey, appToken] as const) - : null, + credentialsReady ? SESSION_SWR_KEYS.statusHintPlans(credentials) : null, () => loadBillingPlans({ appToken, kubeconfig }), swrOptions ); diff --git a/apps/ui/src/lib/app-token.test.ts b/apps/ui/src/lib/app-token.test.ts index faa81563..ea124451 100644 --- a/apps/ui/src/lib/app-token.test.ts +++ b/apps/ui/src/lib/app-token.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import { test } from "node:test"; import { SignJWT } from "jose"; @@ -343,63 +342,3 @@ test("reads the bare app token from the request header", () => { "" ); }); - -test("a script-minted dev token passes the production verifier for the dev kubeconfig", () => { - const saJwt = [ - Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString( - "base64url" - ), - Buffer.from( - JSON.stringify({ sub: `system:serviceaccount:user-system:${CR_NAME}` }) - ).toString("base64url"), - "signature", - ].join("."); - const devKubeconfig = encodeURIComponent(` -apiVersion: v1 -clusters: - - name: cluster - cluster: - server: https://example.test -contexts: - - name: current - context: - cluster: cluster - namespace: ns-dev - user: dev-user -current-context: current -users: - - name: dev-user - user: - token: ${saJwt} -`); - - const minted = spawnSync( - process.execPath, - ["scripts/mint-dev-app-token.mjs"], - { - encoding: "utf8", - env: { - ...process.env, - JWT_INTERNAL: SECRET, - NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG: devKubeconfig, - }, - } - ); - assert.equal(minted.status, 0, minted.stderr); - const token = minted.stdout.trim(); - assert.notEqual(token, ""); - - return verifyAppTokenBinding({ - config: { secret: SECRET }, - expectedCrName: CR_NAME, - token, - }).then((verification) => { - assert.equal(verification.ok, true); - if (verification.ok) { - assert.equal(verification.binding.crName, CR_NAME); - assert.equal(verification.expired, false); - assert.notEqual(verification.binding.mintedAt, null); - assert.notEqual(verification.binding.userUid, ""); - } - }); -}); diff --git a/apps/ui/src/lib/auth-store.tsx b/apps/ui/src/lib/auth-store.tsx index aeb03f50..6782578b 100644 --- a/apps/ui/src/lib/auth-store.tsx +++ b/apps/ui/src/lib/auth-store.tsx @@ -1,37 +1,67 @@ import { atom } from "jotai"; -import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; -function devKubeconfigFromEnv(): string { - try { - return decodeURIComponent( - process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG ?? "" - ); - } catch { - return process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG ?? ""; - } -} +import type { + SessionUser, + SessionWorkspace, +} from "@/features/session/session-schema"; -const devKubeconfig = devKubeconfigFromEnv(); +/** + * The Brain Session (ADR-0083, CONTEXT.md): the Desktop-issued credentials + * Brain holds in one browser tab, exchanged from Desktop's shared login + * cookie by `POST /api/session` and kept only here, in page memory. Brain + * writes no cookie and no storage of its own; a reload re-establishes the + * session, and the Workspace it points at is Desktop's current one, which + * Brain follows rather than remembers. + * + * Every client cache key must derive from these atoms (spec §A.9): see + * `features/session/swr-keys.ts`. The three credentials and their headers: + * kubeconfig → `Authorization: Bearer`, app token → `X-Sealos-App-Token` + * (ADR-0059), regional token → `X-Sealos-Region-Token`. + */ -export const kubeconfigAtom = atom(devKubeconfig); +export const kubeconfigAtom = atom(""); -export const namespaceAtom = atom( - namespaceFromKubeconfigText(devKubeconfig) ?? "" -); +export const namespaceAtom = atom(""); -/** - * Desktop-minted App Token for personal-resource requests (ADR-0059). - * Hydrated from the Desktop SDK session; the dev override pairs with - * `NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG` (mint via `scripts/mint-dev-app-token.mjs`). - */ -export const appTokenAtom = atom( - process.env.NEXT_PUBLIC_DEV_APP_TOKEN?.trim() ?? "" -); +/** Desktop-minted App Token for personal-resource requests (ADR-0059). */ +export const appTokenAtom = atom(""); + +/** Desktop regional token for Workspace-management requests (ADR-0083). */ +export const regionalTokenAtom = atom(""); + +/** The Workspace the session is established in — Desktop's current one. */ +export const currentWorkspaceAtom = atom(null); -export const desktopUserIdAtom = atom(""); +/** Every Workspace the user belongs to in this region, Personal first. */ +export const workspacesAtom = atom([]); -export const desktopUserNameAtom = atom(""); +/** The signed-in user's display data, from Desktop's `auth/info`. */ +export const sessionUserAtom = atom(null); -export const desktopUserAvatarAtom = atom(""); +export type SessionStatus = + | { kind: "idle" } + | { kind: "establishing" } + | { kind: "ready" } + /** The login cookie is stale: the "session expired" overlay is up. */ + | { kind: "expired" } + | { kind: "error"; code: string }; + +export const sessionStatusAtom = atom({ kind: "idle" }); + +/** Read-only projections the App Sidebar's account section renders. */ +export const desktopUserIdAtom = atom( + (get) => get(sessionUserAtom)?.userId ?? "" +); + +export const desktopUserNameAtom = atom( + (get) => get(sessionUserAtom)?.name ?? "" +); + +export const desktopUserAvatarAtom = atom( + (get) => get(sessionUserAtom)?.avatar ?? "" +); export const desktopLanguageAtom = atom("en"); + +/** Desktop's cloud domain from the SDK host config; "" outside the iframe. */ +export const desktopDomainAtom = atom(""); diff --git a/apps/ui/src/lib/kubeconfig-identity.ts b/apps/ui/src/lib/kubeconfig-identity.ts deleted file mode 100644 index 50ac8461..00000000 --- a/apps/ui/src/lib/kubeconfig-identity.ts +++ /dev/null @@ -1,20 +0,0 @@ -import "server-only"; - -import { - kubeconfigCredentialsMatch as kubeconfigCredentialsMatchCore, - kubeconfigYamlFromEncoded as kubeconfigYamlFromEncodedCore, -} from "./kubeconfig-identity-core"; - -/** Canonical YAML text for comparing kubeconfig credentials (decoded, trimmed). */ -export function kubeconfigYamlFromEncoded( - encoded: string | undefined -): string | null { - return kubeconfigYamlFromEncodedCore(encoded); -} - -export function kubeconfigCredentialsMatch( - encodedA: string | undefined, - encodedB: string | undefined -): boolean { - return kubeconfigCredentialsMatchCore(encodedA, encodedB); -} diff --git a/apps/ui/src/lib/kubeconfig-namespace-core.test.ts b/apps/ui/src/lib/kubeconfig-namespace-core.test.ts new file mode 100644 index 00000000..66e7f1bc --- /dev/null +++ b/apps/ui/src/lib/kubeconfig-namespace-core.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + namespaceFromKubeconfigText, + rewriteKubeconfigContextNamespace, +} from "./kubeconfig-namespace-core"; + +const KUBECONFIG = ` +apiVersion: v1 +kind: Config +current-context: user +contexts: + - name: user + context: + cluster: sealos + user: user + namespace: ns-personal +clusters: + - name: sealos + cluster: + server: https://apiserver.test +users: + - name: user + user: + token: sa-token +`; + +test("rewrites the first context's namespace like Desktop does and keeps the rest", () => { + const rewritten = rewriteKubeconfigContextNamespace(KUBECONFIG, "ns-team"); + assert.notEqual(rewritten, null); + assert.equal(namespaceFromKubeconfigText(rewritten ?? ""), "ns-team"); + assert.equal(rewritten?.includes("token: sa-token"), true); + assert.equal(rewritten?.includes("server: https://apiserver.test"), true); +}); + +test("adds the namespace when the first context has none", () => { + const withoutNamespace = KUBECONFIG.replace( + " namespace: ns-personal\n", + "" + ); + const rewritten = rewriteKubeconfigContextNamespace( + withoutNamespace, + "ns-team" + ); + assert.equal(namespaceFromKubeconfigText(rewritten ?? ""), "ns-team"); +}); + +test("returns null for text that is not a kubeconfig", () => { + assert.equal( + rewriteKubeconfigContextNamespace("- just\n- a list", "ns-x"), + null + ); + assert.equal( + rewriteKubeconfigContextNamespace("apiVersion: v1", "ns-x"), + null + ); + assert.equal(rewriteKubeconfigContextNamespace(":::", "ns-x"), null); +}); diff --git a/apps/ui/src/lib/kubeconfig-namespace-core.ts b/apps/ui/src/lib/kubeconfig-namespace-core.ts index bfd631c4..8ea12889 100644 --- a/apps/ui/src/lib/kubeconfig-namespace-core.ts +++ b/apps/ui/src/lib/kubeconfig-namespace-core.ts @@ -1,4 +1,4 @@ -import { parse } from "yaml"; +import { parse, stringify } from "yaml"; interface KubeconfigContext { cluster?: string; @@ -14,11 +14,7 @@ interface KubeconfigYaml { /** Kubernetes default when the active context omits `namespace`. */ export const KUBECONFIG_DEFAULT_NAMESPACE = "default"; -/** - * Namespace from the kubeconfig's `current-context` entry (YAML parse only). - * Returns `default` when the context has no explicit namespace. - */ -export function namespaceFromKubeconfigText(yamlText: string): string | null { +function parseKubeconfig(yamlText: string): KubeconfigYaml | null { let doc: unknown; try { doc = parse(yamlText); @@ -28,8 +24,18 @@ export function namespaceFromKubeconfigText(yamlText: string): string | null { if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { return null; } + return doc as KubeconfigYaml; +} - const kc = doc as KubeconfigYaml; +/** + * Namespace from the kubeconfig's `current-context` entry (YAML parse only). + * Returns `default` when the context has no explicit namespace. + */ +export function namespaceFromKubeconfigText(yamlText: string): string | null { + const kc = parseKubeconfig(yamlText); + if (kc == null) { + return null; + } const current = kc["current-context"]?.trim(); if (!current) { return null; @@ -43,3 +49,23 @@ export function namespaceFromKubeconfigText(yamlText: string): string | null { const ns = contextEntry.context?.namespace?.trim(); return ns && ns.length > 0 ? ns : KUBECONFIG_DEFAULT_NAMESPACE; } + +/** + * The kubeconfig with `contexts[0].context.namespace` set to `namespace` — + * Desktop's own seven-line rewrite, mirrored (ADR-0083): a Desktop user's + * kubeconfig has one context, so the first context is the current one, and + * switching Workspaces changes only the namespace it points at. Returns + * null when the text is not a kubeconfig with at least one context. + */ +export function rewriteKubeconfigContextNamespace( + yamlText: string, + namespace: string +): string | null { + const kc = parseKubeconfig(yamlText); + const first = kc?.contexts?.[0]; + if (kc == null || first == null || typeof first !== "object") { + return null; + } + first.context = { ...first.context, namespace }; + return stringify(kc); +} diff --git a/apps/ui/src/lib/region-token-header.ts b/apps/ui/src/lib/region-token-header.ts new file mode 100644 index 00000000..44acb9d0 --- /dev/null +++ b/apps/ui/src/lib/region-token-header.ts @@ -0,0 +1,22 @@ +/** + * The third credential header (ADR-0083): Workspace-management fetchers send + * the Desktop regional token bare in this header, and only the + * Workspace-management routes read it — mirroring `X-Sealos-App-Token` + * (ADR-0059). Kubernetes requests keep the kubeconfig in `Authorization: + * Bearer`; personal-resource requests keep the app token in its own header. + * Client-safe: no verification happens in Brain, Desktop is the verifier. + */ +export const REGION_TOKEN_HEADER = "X-Sealos-Region-Token"; + +/** Header record for Workspace-management fetchers; empty without a token. */ +export function regionTokenRequestHeaders( + regionalToken: string +): Record { + const token = regionalToken.trim(); + return token === "" ? {} : { [REGION_TOKEN_HEADER]: token }; +} + +/** Bare regional token from a Workspace-management request, "" when absent. */ +export function regionTokenFromRequest(request: Request): string { + return request.headers.get(REGION_TOKEN_HEADER)?.trim() ?? ""; +} diff --git a/apps/ui/src/lib/resolve-chat-namespace.test.ts b/apps/ui/src/lib/resolve-chat-namespace.test.ts index 3ad63064..e47c6009 100644 --- a/apps/ui/src/lib/resolve-chat-namespace.test.ts +++ b/apps/ui/src/lib/resolve-chat-namespace.test.ts @@ -29,129 +29,101 @@ users: `); } -async function withoutDevCredentialBypass( - run: () => Promise -): Promise { - const previous = process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG; - delete process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG; - try { - return await run(); - } finally { - if (previous === undefined) { - delete process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG; - } else { - process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG = previous; - } - } -} - test("normalizes the chat namespace before Kubernetes authorization", async () => { - await withoutDevCredentialBypass(async () => { - let verifiedNamespace = ""; + let verifiedNamespace = ""; - assert.deepEqual( - await resolveAuthoritativeChatNamespace({ - clientNamespace: " ns-sdk ", - encodedKubeconfig: kubeconfig("ns-sdk"), - verify: ({ namespace }) => { - verifiedNamespace = namespace; - return Promise.resolve({ ok: true }); - }, - }), - { namespace: "ns-sdk", ok: true } - ); - assert.equal(verifiedNamespace, "ns-sdk"); - }); + assert.deepEqual( + await resolveAuthoritativeChatNamespace({ + clientNamespace: " ns-sdk ", + encodedKubeconfig: kubeconfig("ns-sdk"), + verify: ({ namespace }) => { + verifiedNamespace = namespace; + return Promise.resolve({ ok: true }); + }, + }), + { namespace: "ns-sdk", ok: true } + ); + assert.equal(verifiedNamespace, "ns-sdk"); }); test("preserves the default namespace for a chat kubeconfig context", async () => { - await withoutDevCredentialBypass(async () => { - assert.deepEqual( - await resolveAuthoritativeChatNamespace({ - clientNamespace: " default ", - encodedKubeconfig: kubeconfig(), - verify: async () => ({ ok: true }), - }), - { namespace: "default", ok: true } - ); - }); + assert.deepEqual( + await resolveAuthoritativeChatNamespace({ + clientNamespace: " default ", + encodedKubeconfig: kubeconfig(), + verify: async () => ({ ok: true }), + }), + { namespace: "default", ok: true } + ); }); test("preserves the chat namespace mismatch response before access review", async () => { - await withoutDevCredentialBypass(async () => { - let verified = false; + let verified = false; - assert.deepEqual( - await resolveAuthoritativeChatNamespace({ - clientNamespace: "ns-other", - encodedKubeconfig: kubeconfig("ns-sdk"), - verify: () => { - verified = true; - return Promise.resolve({ ok: true }); - }, - }), - { - message: "namespace does not match kubeconfig current context.", - ok: false, - status: 403, - } - ); - assert.equal(verified, false); - }); + assert.deepEqual( + await resolveAuthoritativeChatNamespace({ + clientNamespace: "ns-other", + encodedKubeconfig: kubeconfig("ns-sdk"), + verify: () => { + verified = true; + return Promise.resolve({ ok: true }); + }, + }), + { + message: "namespace does not match kubeconfig current context.", + ok: false, + status: 403, + } + ); + assert.equal(verified, false); }); test("preserves a rejected chat credential response", async () => { - await withoutDevCredentialBypass(async () => { - assert.deepEqual( - await resolveAuthoritativeChatNamespace({ - clientNamespace: "ns-sdk", - encodedKubeconfig: kubeconfig("ns-sdk"), - verify: async () => ({ - message: "Kubeconfig token is not authenticated.", - ok: false, - status: 401, - }), - }), - { + assert.deepEqual( + await resolveAuthoritativeChatNamespace({ + clientNamespace: "ns-sdk", + encodedKubeconfig: kubeconfig("ns-sdk"), + verify: async () => ({ message: "Kubeconfig token is not authenticated.", ok: false, status: 401, - } - ); - }); + }), + }), + { + message: "Kubeconfig token is not authenticated.", + ok: false, + status: 401, + } + ); }); test("preserves the malformed chat kubeconfig response", async () => { - await withoutDevCredentialBypass(async () => { - assert.deepEqual( - await resolveAuthoritativeChatNamespace({ - clientNamespace: "ns-sdk", - encodedKubeconfig: "%E0%A4%A", - verify: async () => ({ ok: true }), - }), - { - message: "Missing or invalid kubeconfig", - ok: false, - status: 400, - } - ); - }); + assert.deepEqual( + await resolveAuthoritativeChatNamespace({ + clientNamespace: "ns-sdk", + encodedKubeconfig: "%E0%A4%A", + verify: async () => ({ ok: true }), + }), + { + message: "Missing or invalid kubeconfig", + ok: false, + status: 400, + } + ); }); test("preserves the unresolved namespace response for whitespace chat credentials", async () => { - await withoutDevCredentialBypass(async () => { - assert.deepEqual( - await resolveAuthoritativeChatNamespace({ - clientNamespace: "ns-sdk", - encodedKubeconfig: " ", - verify: async () => ({ ok: true }), - }), - { - message: - "Could not resolve namespace from kubeconfig (missing or invalid current-context).", - ok: false, - status: 400, - } - ); - }); + assert.deepEqual( + await resolveAuthoritativeChatNamespace({ + clientNamespace: "ns-sdk", + encodedKubeconfig: " ", + verify: async () => ({ ok: true }), + }), + { + message: + "Could not resolve namespace from kubeconfig (missing or invalid current-context).", + ok: false, + status: 400, + } + ); }); diff --git a/apps/ui/src/lib/resolve-chat-namespace.ts b/apps/ui/src/lib/resolve-chat-namespace.ts index 9b467948..7ba1a866 100644 --- a/apps/ui/src/lib/resolve-chat-namespace.ts +++ b/apps/ui/src/lib/resolve-chat-namespace.ts @@ -5,82 +5,35 @@ import { authorizeKubeconfigNamespace, type VerifyKubeconfigNamespace, } from "@/lib/request-kubeconfig-auth"; -import { - devCredentialsFromEnv, - hasDevCredentialBypass, -} from "@/lib/server-credentials"; - -import { kubeconfigCredentialsMatch } from "./kubeconfig-identity"; export type ResolveChatNamespaceOutcome = | { ok: true; namespace: string } | { ok: false; status: number; message: string }; -function rejectClientNamespaceMismatch( - clientNamespace: string, - authoritativeNamespace: string -): ResolveChatNamespaceOutcome | null { - const clientTrimmed = clientNamespace.trim(); - if ( - clientTrimmed !== "" && - normalizeAssistantNamespace(clientTrimmed) !== authoritativeNamespace - ) { - return { - ok: false, - status: 403, - message: "namespace does not match authenticated workspace.", - }; - } - return null; -} - /** * Authoritative namespace for chat ACL and future per-ns quota. * * Authenticity (always): * - Valid client kubeconfig YAML * - Client `namespace` consistent with kubeconfig `current-context` when set + * - The kubeconfig's live access to that namespace (`verify`) * - * Then one of: - * - **Sealos Desktop iframe:** the client kubeconfig comes from `sealosApp.getSession()`; - * namespace comes from the kubeconfig current context. - * - **Dev bypass:** optional match against `NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG`; - * namespace from kubeconfig context. + * The client kubeconfig comes from the Brain Session (ADR-0083) — the one + * Desktop issued for the current Workspace — and the namespace comes from + * its context; there is no development branch here. */ export async function resolveAuthoritativeChatNamespace(options: { encodedKubeconfig: string | undefined; clientNamespace: string; verify?: VerifyKubeconfigNamespace; }): Promise { - const useDevCredentialBypass = hasDevCredentialBypass(); - const devCredentials = useDevCredentialBypass - ? devCredentialsFromEnv() - : undefined; const clientNamespace = options.clientNamespace.trim(); const authorization = await authorizeKubeconfigNamespace({ encodedKubeconfig: options.encodedKubeconfig, expectedNamespace: clientNamespace === "" ? undefined : options.clientNamespace, normalizeNamespace: normalizeAssistantNamespace, - verify: useDevCredentialBypass - ? () => { - if ( - devCredentials != null && - devCredentials.encodedKubeconfig !== "" && - !kubeconfigCredentialsMatch( - options.encodedKubeconfig, - devCredentials.encodedKubeconfig - ) - ) { - return Promise.resolve({ - message: "kubeconfig does not match local dev credentials.", - ok: false as const, - status: 403, - }); - } - return Promise.resolve({ ok: true as const }); - } - : options.verify, + verify: options.verify, }); if (!authorization.ok) { if (authorization.code === "verification_failed") { @@ -115,22 +68,5 @@ export async function resolveAuthoritativeChatNamespace(options: { }; } - if (devCredentials != null) { - const authoritativeNamespace = - devCredentials.namespace === "" - ? authorization.namespace - : normalizeAssistantNamespace(devCredentials.namespace); - - const nsMismatch = rejectClientNamespaceMismatch( - options.clientNamespace, - authoritativeNamespace - ); - if (nsMismatch != null) { - return nsMismatch; - } - - return { ok: true, namespace: authoritativeNamespace }; - } - return { ok: true, namespace: authorization.namespace }; } diff --git a/apps/ui/src/lib/server-credentials.ts b/apps/ui/src/lib/server-credentials.ts deleted file mode 100644 index 7434955a..00000000 --- a/apps/ui/src/lib/server-credentials.ts +++ /dev/null @@ -1,37 +0,0 @@ -import "server-only"; - -import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; - -function envTrimDecoded(raw: string | undefined): string { - try { - return decodeURIComponent(raw ?? "").trim(); - } catch { - return (raw ?? "").trim(); - } -} - -/** - * Local dev can bypass Desktop SDK when `NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG` - * is set — never in production builds, where a stray env var must not replace - * SelfSubjectAccessReview verification. - */ -export function hasDevCredentialBypass(): boolean { - return ( - process.env.NODE_ENV !== "production" && - envTrimDecoded(process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG) !== "" - ); -} - -/** Local dev overrides (`AuthBootstrap`); production auth comes from Desktop SDK. */ -export function devCredentialsFromEnv(): { - encodedKubeconfig: string; - namespace: string; -} { - const encodedKubeconfig = envTrimDecoded( - process.env.NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG - ); - return { - encodedKubeconfig, - namespace: namespaceFromKubeconfigText(encodedKubeconfig) ?? "", - }; -} diff --git a/charts/brain-system/README.md b/charts/brain-system/README.md index b0b652cf..40215017 100644 --- a/charts/brain-system/README.md +++ b/charts/brain-system/README.md @@ -89,7 +89,9 @@ without renaming its Kubernetes resources. When left empty, `ui.env.API_URL` and `ui.env.APP_URL` are derived from the API/UI Ingress hosts rendered by this chart. -`ui.env.DATABASE_URL` and `api.env.DATABASE_URL` are derived from the chart-created `brain-pg-conn-credential` Secret when left empty. `api.env.DB_PUBLIC_HOST`, `api.env.WHODB_URL`, and `ui.env.DEVBOX_API_BASE_URL` are also derived from the release namespace or platform cloud domain when left empty. `ui.env.ACCOUNT_API_BASE_URL` derives to the in-cluster `http://account-service.account-system.svc:2333` address when left empty. +`ui.env.DATABASE_URL` and `api.env.DATABASE_URL` are derived from the chart-created `brain-pg-conn-credential` Secret when left empty. `api.env.DB_PUBLIC_HOST`, `api.env.WHODB_URL`, and `ui.env.DEVBOX_API_BASE_URL` are also derived from the release namespace or platform cloud domain when left empty. `ui.env.ACCOUNT_API_BASE_URL` derives to the in-cluster `http://account-service.account-system.svc:2333` address when left empty. `ui.env.DESKTOP_API_BASE_URL` derives to the in-cluster Desktop frontend Service `http://sealos-desktop.sealos.svc:3000` when left empty; the UI server exchanges the shared Desktop login cookie there to establish the Brain Session (ADR-0083), so the page never calls Desktop itself. + +The Brain Session also depends on the Desktop deployment: Desktop's postMessage `allowedOrigins` (the `desktop-frontend` chart's `desktopConfig.allowedOrigins`, or `allowedAllOrigins`) must include the Brain UI origin (`https://.`), which is not in Desktop's default allowlist, or the SDK handshake that reads Desktop's current Workspace is refused. `ui.env.BILLING_CURRENCY` controls the Billing Area's cluster-level display currency and defaults to `usd`. `ui.env.BILLING_GPU_ENABLED` controls GPU quota and pricing rows and defaults to `false`. Both values are read by the UI server at request time. diff --git a/charts/brain-system/templates/_helpers.tpl b/charts/brain-system/templates/_helpers.tpl index 5c891d86..9d9ad0cf 100644 --- a/charts/brain-system/templates/_helpers.tpl +++ b/charts/brain-system/templates/_helpers.tpl @@ -191,6 +191,9 @@ app.kubernetes.io/instance: {{ .name | quote }} {{ else if and (eq $component "ui") (eq $key "ACCOUNT_API_BASE_URL") (eq (toString $value) "") }} - name: {{ $key }} value: "http://account-service.account-system.svc:2333" +{{ else if and (eq $component "ui") (eq $key "DESKTOP_API_BASE_URL") (eq (toString $value) "") }} +- name: {{ $key }} + value: "http://sealos-desktop.sealos.svc:3000" {{ else if and (eq $component "ui") (eq $key "API_URL") (eq (toString $value) "") }} - name: {{ $key }} value: {{ include "brain-system.publicUrl" (dict "root" $root "namespace" $root.Release.Namespace "name" $root.Values.api.name "platformAddresses" $root.Values.api.platformAddresses "cloudDomain" (include "brain-system.cloudDomain" $root)) | quote }} diff --git a/charts/brain-system/tests/desktop-api-env.sh b/charts/brain-system/tests/desktop-api-env.sh new file mode 100755 index 00000000..5af2e31e --- /dev/null +++ b/charts/brain-system/tests/desktop-api-env.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -euo pipefail + +chart_dir="${1:-charts/brain-system}" +ui_deployment_name="brain-ui-staging" +api_deployment_name="brain-api-staging" +derived_url="http://sealos-desktop.sealos.svc:3000" +explicit_url="https://desktop.example.test" + +deployment_from_manifest() { + local manifest="$1" + local deployment_name="$2" + + awk -v deployment_name="$deployment_name" ' + /^---$/ { capture = 0 } + capture { print } + $0 == " name: " deployment_name { capture = 1; print } + ' <<<"$manifest" +} + +desktop_api_value_from_deployment() { + awk ' + $0 ~ /- name: DESKTOP_API_BASE_URL$/ { found = 1; next } + found && $0 ~ /^[[:space:]]+value:/ { + sub(/^[[:space:]]+value:[[:space:]]*/, "") + gsub(/^"|"$/, "") + print + exit + } + ' +} + +default_manifest="$( + helm template brain-system "$chart_dir" -n brain-system +)" +default_ui_deployment="$( + deployment_from_manifest "$default_manifest" "$ui_deployment_name" +)" +default_api_deployment="$( + deployment_from_manifest "$default_manifest" "$api_deployment_name" +)" +default_value="$( + desktop_api_value_from_deployment <<<"$default_ui_deployment" +)" + +if [[ "$default_value" != "$derived_url" ]]; then + echo "Expected UI DESKTOP_API_BASE_URL to derive to $derived_url, got: ${default_value:-}" >&2 + exit 1 +fi +if grep -q 'DESKTOP_API_BASE_URL' <<<"$default_api_deployment"; then + echo "DESKTOP_API_BASE_URL must be owned by the UI deployment, not the API deployment" >&2 + exit 1 +fi + +explicit_manifest="$( + helm template brain-system "$chart_dir" -n brain-system \ + --set-string "ui.env.DESKTOP_API_BASE_URL=$explicit_url" +)" +explicit_ui_deployment="$( + deployment_from_manifest "$explicit_manifest" "$ui_deployment_name" +)" +explicit_value="$( + desktop_api_value_from_deployment <<<"$explicit_ui_deployment" +)" + +if [[ "$explicit_value" != "$explicit_url" ]]; then + echo "Expected explicit UI DESKTOP_API_BASE_URL to remain $explicit_url, got: ${explicit_value:-}" >&2 + exit 1 +fi diff --git a/charts/brain-system/values.local.example.yaml b/charts/brain-system/values.local.example.yaml index bbe0bb01..f3d8b22b 100644 --- a/charts/brain-system/values.local.example.yaml +++ b/charts/brain-system/values.local.example.yaml @@ -5,8 +5,9 @@ # The install script reads cloudDomain/cloudPort from # sealos-system/sealos-config and passes them to Helm. Do not set global # cloud values here unless you intentionally want to override platform config. -# DATABASE_URL, DB_PUBLIC_HOST, ACCOUNT_API_BASE_URL, API_URL, APP_URL, and -# DEVBOX_API_BASE_URL are generated by chart helpers when left empty. +# DATABASE_URL, DB_PUBLIC_HOST, ACCOUNT_API_BASE_URL, DESKTOP_API_BASE_URL, +# API_URL, APP_URL, and DEVBOX_API_BASE_URL are generated by chart helpers +# when left empty. apPublicAccess: # New APs use the first entry. The wildcard Secret must exist in each user namespace. diff --git a/charts/brain-system/values.yaml b/charts/brain-system/values.yaml index bebae256..51779156 100644 --- a/charts/brain-system/values.yaml +++ b/charts/brain-system/values.yaml @@ -109,6 +109,9 @@ ui: port: 3000 env: ACCOUNT_API_BASE_URL: "" + # Desktop upstream for the Brain Session (ADR-0083); derived to the + # in-cluster Desktop frontend Service when left empty. + DESKTOP_API_BASE_URL: "" AP_USER_DOMAIN: "" AP_USER_DOMAIN_TLS_SECRET_NAME: "" API_URL: "" diff --git a/docs/adr/0059-key-personal-resources-by-global-user-uid.md b/docs/adr/0059-key-personal-resources-by-global-user-uid.md index aeecf3f7..e599e9af 100644 --- a/docs/adr/0059-key-personal-resources-by-global-user-uid.md +++ b/docs/adr/0059-key-personal-resources-by-global-user-uid.md @@ -1,5 +1,10 @@ # Key Personal Resources by the Global User UID +## Status + +Accepted; the token-minting frequency premise and the local-development +minting paragraph are revised by ADR-0083. + Personal resources (Assistant Conversations, GitHub Connections) have been owned by `(namespace, crName)` since ADR-0056. `crName` is a per-region identity: the same human holds a different `crName` in every region, so diff --git a/docs/adr/0083-establish-the-brain-session-from-the-desktop-login-cookie.md b/docs/adr/0083-establish-the-brain-session-from-the-desktop-login-cookie.md new file mode 100644 index 00000000..5b007129 --- /dev/null +++ b/docs/adr/0083-establish-the-brain-session-from-the-desktop-login-cookie.md @@ -0,0 +1,172 @@ +# Establish the Brain Session from the Desktop Login Cookie + +## Status + +Proposed (2026-09-14); pending team review of the session-model decisions. +Revises two premises of ADR-0059 (see "Revisions to earlier ADRs"). + +Brain has never held a session of its own. It runs inside the Sealos Desktop +iframe and receives its credentials once, at mount, through the Desktop SDK's +`getSession()`: the kubeconfig, the desktop-minted app token, and the user's +display data. That is enough to act inside the one Workspace Desktop chose, +and nothing more. Switching Workspaces from inside Brain and managing them +(members, roles, invitations, transfer, deletion) need the Desktop **regional +token**, which the SDK never delivers and the Desktop Workspace routes alone +accept. Brain therefore needs a way to obtain Desktop credentials itself. + +## Decision + +### Exchange the shared login cookie through the Brain server + +Desktop writes its **global token** into the shared login cookie +`sealos_auth_token` on the parent domain (`.`, seven days, +not HttpOnly). Brain is deployed at `brain.`, same site as +Desktop, so the browser attaches that cookie to Brain's own same-origin +requests. The **Brain Session** is established by one route handler, +`POST /api/session`, whose server side reads the cookie and calls Desktop: +`regionToken` (always lands in the Personal Workspace), then, when the +requested Workspace differs, `namespace/switch`, then `auth/info` for the +user's display data. The result is the regional token, the app token, the +kubeconfig with its context namespace rewritten to the target Workspace, the +Workspace list, and the user. + +The browser never calls Desktop. Desktop's `/api/auth/*` routes carry no CORS +headers, and every Desktop call needs a custom `Authorization` header, so a +direct call is blocked by preflight. The Brain server reaches Desktop over its +in-cluster Service address (`DESKTOP_API_BASE_URL`, derived by the chart like +`ACCOUNT_API_BASE_URL`); local development points it at a staging Desktop. +Brain exposes **purpose-built route handlers**, never a generic pass-through +to Desktop paths: each handler translates Desktop's "HTTP 200 with the +business code in `body.code`" convention into real HTTP statuses and +validates the response shape. The page depends only on Brain's own contract. + +`regionToken` answering `409 workspace is not inited` is treated as an +anomaly (logged, surfaced as the generic session error), not repaired: Brain +does not call `autoInitRegionToken`, because establishing a session must +never create a Workspace as a side effect. + +### Hold the session in page memory only + +The three credentials live in Jotai atoms for the lifetime of the tab. Brain +writes no cookie and no storage of its own. A reload re-establishes the +session from the shared cookie. The regional token travels back to the Brain +server in a third credential header, `X-Sealos-Region-Token`, attached only +by Workspace-management fetchers and read only by Workspace-management +routes, mirroring `X-Sealos-App-Token` (ADR-0059). Kubernetes requests keep +the kubeconfig in `Authorization: Bearer`; personal-resource requests keep +the app token in `X-Sealos-App-Token`. Three credentials, three headers, each +present only on the routes that consume it. + +### Desktop owns the current Workspace while Brain runs in its iframe + +Desktop enforces one Workspace per browser: its switcher listens to the +`storage` event and reloads every other tab the moment one tab switches, and +its home page honours `?workspaceUid=` on load by calling its own `switch`. +Brain inside that iframe therefore **does not remember a current Workspace**. +At start it asks the SDK for Desktop's current `nsid`, resolves the uid from +the Workspace list it fetches anyway, and aligns to it. A `nsid` no longer in +the list falls back to the Personal Workspace with a visible notice, as +Desktop itself does. + +Brain's Workspace switcher does not switch. On click it hands the top-level +window to Desktop: `https:///?openapp=system-brain&workspaceUid=`. +Desktop switches, every tab converges, Desktop reopens Brain, and Brain +re-establishes its session in the new Workspace. This needs no upstream +change and gives Brain's switch the exact feel of Desktop's own. Outside an +iframe (`window.top === window`, i.e. local development) the switcher hides +or points at the local dev bridge instead. + +### The SDK provides Desktop state, never credentials + +Brain keeps the SDK for five things: the `createSealosApp` handshake, +`getSession` **read only for `user.nsid`**, `getHostConfig` for the Desktop +domain, the `CHANGE_I18N` language event, and `openApp`. The SDK reader's +return type has no `kubeconfig`, `token`, or user-display fields, so no +fallback to SDK-delivered credentials can be added without changing a type. +User display data comes from Desktop's `auth/info` through `/api/session`. + +### Local development runs the real path against staging + +Without a Desktop shell, `DEV_GLOBAL_TOKEN` stands in for the shared cookie +(decided with the login handling); the server otherwise runs the same code. +The app token that staging Desktop mints is signed with staging's +`jwtInternal`, so the developer's `.env.local` carries staging's +`JWT_INTERNAL`, which ADR-0060's account-service calls already require. The +self-signed development pair (`NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG`, +`NEXT_PUBLIC_DEV_APP_TOKEN`, `scripts/mint-dev-app-token.mjs`, +`hasDevCredentialBypass()`) is removed. The Sealos App Dev Bridge extension +keeps answering the SDK on `localhost`; Brain reads only `nsid` from it, so +the bridge needs no change. + +## Considered Options + +- Keep obtaining credentials through the SDK and add only the regional token + upstream: rejected with the login handling — the SDK path is the iframe + premise this map retires, and a second credential channel would coexist + with the cookie path indefinitely. +- Hold the regional token and app token in a Brain-owned HttpOnly cookie so a + reload costs one call and the current Workspace survives it: rejected. A + cookie is shared by every tab, so one tab's switch silently changed the + Workspace another tab's server calls acted in, and it duplicated a memory + Desktop already owns and enforces. Per-tab `sessionStorage` was rejected + for the mirror reason: it can disagree with the Desktop shell in the same + tab after Desktop reloads. +- Let Brain switch itself via Desktop's `switch` and then tell Desktop: + rejected for now — Desktop has no channel to be told (the SDK event bus + registers four events, none for switching), so a switch Brain performs + alone leaves the Desktop top bar and every other tab in the old Workspace. + A `switchWorkspace` SDK event is a candidate upstream change that would + replace the URL hand-off, not the model. +- Fetch the namespace-patched kubeconfig from Desktop's `getKubeconfig` + instead of rewriting `contexts[0].context.namespace` locally: rejected in + favour of mirroring Desktop's own seven-line rewrite, saving a call; the + server decodes (without verifying — Brain holds no regional key) the token + Desktop just returned and trusts `contexts[0]`, as Desktop does. +- Establish the session during React Server Component render: rejected — + the SDK's `nsid` is only available in the browser, so render could only + land in the Personal Workspace and alignment would need a second path. +- A generic `/api/desktop/[...path]` proxy: rejected — it exposes every + Desktop route (account, real-name, top-up) to page code, forces the + regional token into page JS for arbitrary use, and repeats the status + translation at every call site. +- Skip app-token verification in development, or re-sign staging tokens + with a local key: rejected — ADR-0059 forbids development branches in the + verifier, and re-signing is forging. + +## Revisions to earlier ADRs + +ADR-0059 recorded that "desktop mints the token only at login, region switch, +and workspace switch and never refreshes it". Brain now has Desktop mint a +fresh app token at every Brain start (every Desktop reload). The Identity +Fingerprint rule is unaffected — a re-mint for the same `userUid` is always a +`match` — and expiry stays unenforced for the reasons ADR-0059 gives; only +the frequency premise changes. ADR-0059's "Keep one code path everywhere" +paragraph described local development as minting a real token with a dev +`JWT_INTERNAL` and a script; that is replaced by the staging path above. + +ADR-0056's and ADR-0059's credential prohibition extends to the regional +token and the global token: neither may appear in logs, telemetry, audit +records, or API responses, and the shared login cookie's value is forwarded +to exactly one place, Desktop's `regionToken`. + +## Consequences + +Brain gains a session service module with a single entry (`/api/session`) +used for start, reload, and the silent 401 re-exchange; one new server +setting, `DESKTOP_API_BASE_URL`; one new credential header; and no +persistence. The current Workspace has one owner, Desktop, so there is no +cross-tab or shell-versus-content inconsistency to reconcile. Every Brain +start costs three Desktop calls (`regionToken`, `switch` when not Personal, +and the list it needs regardless); a per-tab cache can shave that later +without changing the model. + +The decision is scoped to the iframe period. When Brain opens standalone +there is no Desktop shell to own the current Workspace, and Brain will need +its own memory of it; that is a later decision, not a gap in this one. +Candidate upstream changes recorded for the Sealos change list: a +`switchWorkspace` SDK event; a single Desktop session endpoint that takes the +global token and a Workspace uid and returns all four artefacts at once +(which would remove the local kubeconfig rewrite and the separate +`auth/info` call); and letting every Workspace-management route accept the +app token, which would make the regional token, and with it most of this +session, unnecessary while Brain stays inside the iframe. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3db2bf91..bfea8b5e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,6 +54,7 @@ One line per decision; the linked record is authoritative. When adding an ADR, t - [0080 — Port Display Names Live on the Service, Not the Ingress](0080-port-display-names-live-on-the-service.md) *(extends ADR-0066's annotation pattern to App Listening Ports; carves out a read-time fallback exception; complements ADR-0079's Deployment Access Endpoint labels)* - [0081 — Declare Template Entries for Open and Share](0081-declare-template-entries-for-open-and-share.md) *(declares Open and Share beside ADR-0079's endpoints, unprobed on an Ingress host they verify; adds a Brain-side writer for ADR-0080's Default Open Port store; revises CONTEXT.md's "no Template-Instance-level open link")* - [0082 — Judge Account Debt by the Workspace Owner, Not the Workspace Actor](0082-judge-account-debt-by-the-workspace-owner.md) *(re-scopes ADR-0068's debt input to the Workspace Owner via the namespace's platform marks; keeps ADR-0060's token claims; introduces Workspace Owner in CONTEXT.md)* +- [0083 — Establish the Brain Session from the Desktop Login Cookie](0083-establish-the-brain-session-from-the-desktop-login-cookie.md) *(proposed; revises ADR-0059's minting-frequency premise and local-dev minting paragraph; extends ADR-0056/0059's credential prohibition to the regional and global tokens; introduces Brain Session in CONTEXT.md)* ## Conventions diff --git a/docs/testing/github-deploy-smoke.md b/docs/testing/github-deploy-smoke.md index 954cb8d8..096a174f 100644 --- a/docs/testing/github-deploy-smoke.md +++ b/docs/testing/github-deploy-smoke.md @@ -6,11 +6,12 @@ or iframe mode. ## Prerequisites -1. Configure `apps/ui/.env.local` with a working - `NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG`, `DATABASE_URL`, Devbox deployment - settings, and GitHub OAuth settings. -2. Configure `JWT_INTERNAL`, then run `bun scripts/mint-dev-app-token.mjs` from - `apps/ui` and set the emitted token as `NEXT_PUBLIC_DEV_APP_TOKEN`. +1. Configure `apps/ui/.env.local` with a working `DATABASE_URL`, Devbox + deployment settings, and GitHub OAuth settings. +2. Configure the Brain Session path (ADR-0083): `DESKTOP_API_BASE_URL` + pointing at a staging Desktop, that Desktop's real `JWT_INTERNAL`, and + `DEV_GLOBAL_TOKEN` copied from the `sealos_auth_token` cookie of a browser + signed in to it. The App Token is then minted by Desktop at every start. 3. Start Brain locally with `bun dev` from the repository root. 4. Connect GitHub once in the local UI for the development user represented by the App Token. From 2be033c31f06e5b6af13bda6843c442981b7e8db Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 14:46:52 +0800 Subject: [PATCH 02/17] fix(session): address review findings on the Brain Session - memoize useSessionCredentials so hook dependencies stay stable (the GitHub auth hook was re-subscribing every render) - render the generic session error (409 / 502 / 504) as an overlay with a reload, beside the session-expired overlay - map only regionToken's 401 to session-expired; a 401 on a token Desktop just minted is a Desktop anomaly (502) - pick the Personal Workspace by the list's nstype before the token claim - answer 400 for a body that is not JSON instead of treating it as {} - fall back to the referrer origin for the Desktop sign-in target when the host config never answered inside the iframe - session dev-mock: an unknown nsid lands in Personal, like the real path - share one cookie-header parser between the login cookie and dev-mock cookies; build region-token headers on a Headers instance; drop the unused establish dependency and type exports; document the smoke script's kubeconfig variable Co-Authored-By: Claude Fable 5.1 --- apps/ui/.env.example | 3 + apps/ui/src/features/dev-mock/cookie.ts | 26 +----- apps/ui/src/features/session/desktop-sdk.ts | 2 +- .../session/server/create-session-route.ts | 4 +- .../session/server/dev-fixtures.test.ts | 2 +- .../features/session/server/dev-fixtures.ts | 7 +- .../features/session/server/login-cookie.ts | 29 ++----- .../session/server/session-handler.test.ts | 29 ++++++- .../session/server/session-handler.ts | 51 ++++++------ .../session/server/session-service.ts | 7 +- .../session/session-bootstrap.test.tsx | 17 ++++ .../session/session-expired-overlay.tsx | 79 ++++++++++++++++--- apps/ui/src/features/session/session-fetch.ts | 17 ++-- .../ui/src/features/session/session-schema.ts | 3 - apps/ui/src/features/session/swr-keys.ts | 2 - .../session/use-session-credentials.ts | 20 +++-- apps/ui/src/lib/cookie-header.ts | 28 +++++++ 17 files changed, 219 insertions(+), 107 deletions(-) create mode 100644 apps/ui/src/lib/cookie-header.ts diff --git a/apps/ui/.env.example b/apps/ui/.env.example index 01c1d048..ba336452 100644 --- a/apps/ui/.env.example +++ b/apps/ui/.env.example @@ -63,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/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/session/desktop-sdk.ts b/apps/ui/src/features/session/desktop-sdk.ts index 552b3c64..d70f1ce7 100644 --- a/apps/ui/src/features/session/desktop-sdk.ts +++ b/apps/ui/src/features/session/desktop-sdk.ts @@ -51,7 +51,7 @@ function withTimeout(promise: Promise, ms: number): Promise { * 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 = 1500; +const OUTSIDE_IFRAME_SDK_TIMEOUT_MS = 2000; const INSIDE_IFRAME_SDK_TIMEOUT_MS = 12_000; function sdkTimeoutMs(): number { diff --git a/apps/ui/src/features/session/server/create-session-route.ts b/apps/ui/src/features/session/server/create-session-route.ts index c62eaaec..19e2da83 100644 --- a/apps/ui/src/features/session/server/create-session-route.ts +++ b/apps/ui/src/features/session/server/create-session-route.ts @@ -5,7 +5,9 @@ type SessionRouteHandler = (request: Request) => Promise; * 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 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 diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts index 44c3bf1a..1894f376 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.test.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -86,5 +86,5 @@ test("a requested nsid that the scenario knows is honoured; an unknown one falls )?.json() ); assert.equal(unknown.fallback, "not_member"); - assert.equal(unknown.workspace.name, "Acme"); + assert.equal(unknown.workspace.isPersonal, true); }); diff --git a/apps/ui/src/features/session/server/dev-fixtures.ts b/apps/ui/src/features/session/server/dev-fixtures.ts index 99b700ed..19063240 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -86,12 +86,15 @@ function sessionFor( requestedNsid: string | null ): BrainSession { const workspaces = workspacesFor(scenario); - const defaultCurrent = workspaces[1] ?? PERSONAL; const requested = requestedNsid == null ? null : workspaces.find((workspace) => workspace.id === requestedNsid); - const current = requested ?? defaultCurrent ?? PERSONAL; + // 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 diff --git a/apps/ui/src/features/session/server/login-cookie.ts b/apps/ui/src/features/session/server/login-cookie.ts index 9594fe30..7d4e49f6 100644 --- a/apps/ui/src/features/session/server/login-cookie.ts +++ b/apps/ui/src/features/session/server/login-cookie.ts @@ -1,5 +1,7 @@ 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 @@ -8,24 +10,6 @@ import "server-only"; */ export const SEALOS_AUTH_COOKIE = "sealos_auth_token"; -function cookieValue(header: string | null, name: string): string { - 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 { - return raw; - } - } - } - return ""; -} - /** * 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 @@ -36,10 +20,11 @@ export function globalTokenFromRequest( request: Request, env: Record = process.env ): string { - const fromCookie = cookieValue( - request.headers.get("cookie"), - SEALOS_AUTH_COOKIE - ).trim(); + const fromCookie = + cookieValueFromHeader( + request.headers.get("cookie"), + SEALOS_AUTH_COOKIE + )?.trim() ?? ""; if (fromCookie !== "") { return fromCookie; } diff --git a/apps/ui/src/features/session/server/session-handler.test.ts b/apps/ui/src/features/session/server/session-handler.test.ts index 72bf50ef..c7842673 100644 --- a/apps/ui/src/features/session/server/session-handler.test.ts +++ b/apps/ui/src/features/session/server/session-handler.test.ts @@ -307,14 +307,41 @@ describe("POST /api/session", () => { ]); }); - it("rejects a body that is not the session request shape", async () => { + 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( diff --git a/apps/ui/src/features/session/server/session-handler.ts b/apps/ui/src/features/session/server/session-handler.ts index 6f4dfeed..3ba12a46 100644 --- a/apps/ui/src/features/session/server/session-handler.ts +++ b/apps/ui/src/features/session/server/session-handler.ts @@ -5,7 +5,6 @@ import { SESSION_ERROR_CODES, sessionRequestSchema, } from "../session-schema"; -import type { DesktopAuthApi } from "./desktop-auth-api"; import { createDesktopAuthApi } from "./desktop-auth-api"; import { createDesktopClient, @@ -13,11 +12,7 @@ import { desktopApiBaseUrlFromEnv, } from "./desktop-client"; import { globalTokenFromRequest } from "./login-cookie"; -import { - type EstablishSessionOutcome, - establishBrainSession, - type SessionFailure, -} from "./session-service"; +import { establishBrainSession, type SessionFailure } from "./session-service"; /** * `POST /api/session` (ADR-0083, spec §A): the single entry that establishes @@ -36,10 +31,6 @@ export type SessionLog = ( export interface SessionHandlerDependencies { env?: Record; - establish?: ( - input: { globalToken: string; nsid: string | null }, - desktop: DesktopAuthApi - ) => Promise; fetchDesktop?: DesktopFetch; log?: SessionLog; } @@ -69,29 +60,44 @@ function sessionResponse(session: BrainSession): Response { return Response.json(session, { headers: { "cache-control": "no-store" } }); } +/** 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: {} }; + } + try { + return { payload: JSON.parse(text) }; + } catch { + return { invalid: true }; + } +} + export function createSessionHandler( dependencies: SessionHandlerDependencies = {} ): (request: Request) => Promise { const env = dependencies.env ?? process.env; - const establish = dependencies.establish ?? establishBrainSession; const log: SessionLog = dependencies.log ?? ((message, fields) => console.warn(`[session] ${message}`, fields)); return async function handler(request: Request): Promise { - const payload: unknown = - request.headers.get("content-length") === "0" - ? {} - : await request.json().catch(() => null); - const parsed = sessionRequestSchema.safeParse(payload ?? {}); - if (!parsed.success) { + 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", { nsid: nsid !== "" }); + log("no login cookie on the request", { requestedNsid }); return errorResponse(SESSION_ERROR_CODES.sessionExpired, 401); } @@ -104,15 +110,12 @@ export function createSessionHandler( createDesktopClient({ baseUrl, fetch: dependencies.fetchDesktop }) ); - const outcome = await establish( - { globalToken, nsid: nsid === "" ? null : nsid }, + const outcome = await establishBrainSession( + { globalToken, nsid: requestedNsid ? nsid : null }, desktop ); if (!outcome.ok) { - log("establish failed", { - ...outcome.failure, - requestedNsid: nsid !== "", - }); + log("establish failed", { ...outcome.failure, requestedNsid }); return sessionFailureResponse(outcome.failure); } if (outcome.session.fallback != null) { diff --git a/apps/ui/src/features/session/server/session-service.ts b/apps/ui/src/features/session/server/session-service.ts index 90eb3e14..c8f80e6e 100644 --- a/apps/ui/src/features/session/server/session-service.ts +++ b/apps/ui/src/features/session/server/session-service.ts @@ -57,7 +57,9 @@ function failureOf( ): SessionFailure { switch (failure.kind) { case "desktop_code": - if (failure.code === 401) { + // 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) { @@ -97,13 +99,14 @@ export function resolveTargetWorkspace(input: { : { 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.uid === claimedUid) ?? workspaces.find((workspace) => workspace.isPersonal) ?? + workspaces.find((workspace) => workspace.uid === claimedUid) ?? null ); } diff --git a/apps/ui/src/features/session/session-bootstrap.test.tsx b/apps/ui/src/features/session/session-bootstrap.test.tsx index 7e1790b7..bafc92ee 100644 --- a/apps/ui/src/features/session/session-bootstrap.test.tsx +++ b/apps/ui/src/features/session/session-bootstrap.test.tsx @@ -205,6 +205,23 @@ test("a 401 from /api/session raises the session-expired overlay and holds no cr }); }); +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( diff --git a/apps/ui/src/features/session/session-expired-overlay.tsx b/apps/ui/src/features/session/session-expired-overlay.tsx index a26d6126..bcb5a1b5 100644 --- a/apps/ui/src/features/session/session-expired-overlay.tsx +++ b/apps/ui/src/features/session/session-expired-overlay.tsx @@ -24,28 +24,85 @@ export function desktopSigninUrl(domain: string): string | null { } /** - * The "session expired" overlay (spec §A.8): shown 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. + * The Desktop origin when the host config never answered: the page that + * embedded this iframe is Desktop, and the browser records it as the + * referrer. Null outside an iframe or without a referrer. + */ +function referrerOrigin(): string | null { + try { + const referrer = document.referrer.trim(); + return referrer === "" ? null : new URL(referrer).origin; + } catch { + return null; + } +} + +/** 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 signinUrl = desktopSigninUrl(desktopDomain); const inIframe = isInsideDesktopIframe(); + const signinUrl = inIframe + ? (desktopSigninUrl(desktopDomain) ?? + desktopSigninUrl(referrerOrigin() ?? "")) + : null; const handleSignIn = useCallback(() => { - if (signinUrl != null && inIframe) { + if (signinUrl != null) { const top = window.top ?? window; top.location.href = signinUrl; return; } window.location.reload(); - }, [inIframe, signinUrl]); + }, [signinUrl]); + + const handleReload = useCallback(() => { + window.location.reload(); + }, []); + + if (status.kind === "error") { + return ( + undefined} open> + + + + Session unavailable + + + + {sessionErrorDescription(status.code)} + + + + + Reload + + + + + ); + } return ( - {inIframe ? "Sign in again" : "Reload"} + {signinUrl == null ? "Reload" : "Sign in again"} diff --git a/apps/ui/src/features/session/session-fetch.ts b/apps/ui/src/features/session/session-fetch.ts index 09242147..8d6b8ef8 100644 --- a/apps/ui/src/features/session/session-fetch.ts +++ b/apps/ui/src/features/session/session-fetch.ts @@ -31,14 +31,15 @@ export function createSessionFetch(options: { const fetchImpl: BrainFetch = options.fetchImpl ?? ((url, init) => fetch(url, init)); - const send = (input: string, init: RequestInit | undefined) => - fetchImpl(input, { - ...init, - headers: { - ...(init?.headers as Record | undefined), - ...regionTokenRequestHeaders(options.store.get(regionalTokenAtom)), - }, - }); + 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); diff --git a/apps/ui/src/features/session/session-schema.ts b/apps/ui/src/features/session/session-schema.ts index 8eaec148..c8733a38 100644 --- a/apps/ui/src/features/session/session-schema.ts +++ b/apps/ui/src/features/session/session-schema.ts @@ -82,9 +82,6 @@ export const SESSION_ERROR_CODES = { workspaceNotInited: "workspace_not_inited", } as const; -export type SessionErrorCode = - (typeof SESSION_ERROR_CODES)[keyof typeof SESSION_ERROR_CODES]; - export const sessionErrorSchema = z.object({ error: z.string(), }); diff --git a/apps/ui/src/features/session/swr-keys.ts b/apps/ui/src/features/session/swr-keys.ts index 36755547..39c12ddf 100644 --- a/apps/ui/src/features/session/swr-keys.ts +++ b/apps/ui/src/features/session/swr-keys.ts @@ -56,5 +56,3 @@ export const SESSION_SWR_KEYS = { statusHintQuota: sessionKey("status-hint-quota"), workspaceOwner: sessionKey("workspace-owner"), } as const; - -export type SessionSwrKeyName = keyof typeof SESSION_SWR_KEYS; diff --git a/apps/ui/src/features/session/use-session-credentials.ts b/apps/ui/src/features/session/use-session-credentials.ts index f2e886b4..79fa92c4 100644 --- a/apps/ui/src/features/session/use-session-credentials.ts +++ b/apps/ui/src/features/session/use-session-credentials.ts @@ -1,6 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; +import { useMemo } from "react"; import { appTokenAtom, @@ -20,11 +21,16 @@ export function useSessionCredentials(): SessionCredentials & { const kubeconfig = useAtomValue(kubeconfigAtom).trim(); const namespace = useAtomValue(namespaceAtom).trim(); const regionalToken = useAtomValue(regionalTokenAtom).trim(); - return { - appToken, - kubeconfig, - namespace, - ready: appToken !== "" && kubeconfig !== "" && namespace !== "", - regionalToken, - }; + // 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/lib/cookie-header.ts b/apps/ui/src/lib/cookie-header.ts new file mode 100644 index 00000000..ec3d0291 --- /dev/null +++ b/apps/ui/src/lib/cookie-header.ts @@ -0,0 +1,28 @@ +/** + * Reads one cookie's value off a `Cookie`-header-shaped string (a request's + * `cookie` header, or `document.cookie`, which reads back in the same + * shape). Shared by every server-side cookie reader so the parsing — and + * its tolerance of another cookie's malformed %-sequence — lives once. + */ +export function cookieValueFromHeader( + header: string | null | undefined, + 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 + // the raw value, not throw out of every reader. + return raw; + } + } + } + return undefined; +} From f139af3123222ef4d5718ff10a22a629f268bcba Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 16:46:54 +0800 Subject: [PATCH 03/17] feat(shell): brand row and Workspace Switcher on the App Sidebar (AIM-445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The App Sidebar's top becomes the brand slot plus the Workspace Switcher (spec AIM-443 §C, §H.2, §J.1, §B.2 `list`): - Brand row: the logo slot is the only collapse / expand control. It crossfades into the PanelLeft glyph while the pointer is over the sidebar or the button has focus (200ms `--ease-out-strong`, opacity / scale / blur; opacity only under reduced motion). One element in both states: `data-slot`, `aria-label`, and `aria-expanded` flip, so focus stays put. No wordmark, no separate collapse button, tooltip only in the rail. - Workspace Switcher row (`data-slot="app-sidebar-workspace"`): square avatar (`WorkspaceAvatar` gains `square`), name, plan badge or PAYG, ⇕; the rail keeps the avatar and still opens the popover. The popover shows the current Workspace card, "Switch to" (Personal first, role + plan per row), New Workspace (`/billing?mode=create`), Manage Workspaces (`/workspace/`, return address recorded). Switching hands the top window to Desktop's `?openapp=system-brain?? &workspaceUid=` deep link; the Billing and Workspace Areas keep their page, everywhere else lands on `/project`. Outside the Desktop iframe the rows are disabled with a notice. - Data: the list comes from the session atoms and stays fresh through `GET /api/workspace/list` (route table + on-disk guard test, session dev-mock answers it); plan names come from the new Brain route `GET /api/billing/workspace-plans`, read per Workspace with the internal JWT (account-service allows any member to read). - The plan badge and subscription hint move from the account row to the Switcher row (two-line state on payment-due / cancelling). - `WorkspaceSubscriptionRole` retired: the notification read dispatch and Billing's `canManage` (Owner only) read the session's Workspace Role. - `/project/` guard: a loaded list without the Project replaces the route with `/project` and toasts; nothing is judged while loading. - Billing's return-route module generalised into an area return route shared with the Workspace Area. Co-Authored-By: Claude Fable 5.1 --- .../billing/workspace-plans/handler.test.ts | 142 +++++ .../api/billing/workspace-plans/handler.ts | 124 +++++ .../app/api/billing/workspace-plans/route.ts | 17 + apps/ui/src/app/api/workspace/list/route.ts | 11 + apps/ui/src/app/project/[uid]/page.tsx | 2 + .../billing/billing-plan-data.test.ts | 24 +- .../src/features/billing/billing-plan-data.ts | 37 +- .../billing-plan-surface.states.test.tsx | 8 +- .../billing/billing-plan.interaction.test.tsx | 17 +- apps/ui/src/features/billing/billing-plan.tsx | 20 +- .../src/features/billing/billing-pricing.tsx | 17 +- .../features/billing/billing-return-route.ts | 53 +- .../billing/server/billing-route-table.ts | 6 + .../server/dev-fixtures/dev-fixtures.test.ts | 25 +- .../billing/server/dev-fixtures/index.ts | 29 + .../dev-fixtures/scenario-test-fetch.ts | 20 +- .../features/billing/workspace-plans-data.ts | 39 ++ .../deploy/deploy-billing-notice.test.ts | 2 - .../notifications/read-dispatch.test.ts | 6 +- .../features/notifications/read-dispatch.ts | 13 +- .../notifications/use-notification-feed.ts | 12 +- .../explorer/use-projects-explorer.ts | 28 +- .../project-workspace-guard-core.test.ts | 45 ++ .../projects/project-workspace-guard-core.ts | 20 + .../projects/project-workspace-guard.test.tsx | 130 +++++ .../projects/project-workspace-guard.tsx | 48 ++ apps/ui/src/features/session/dev-mock.tsx | 2 +- .../session/server/dev-fixtures.test.ts | 44 +- .../features/session/server/dev-fixtures.ts | 41 +- apps/ui/src/features/session/swr-keys.test.ts | 4 + apps/ui/src/features/session/swr-keys.ts | 4 + .../features/shell/app-sidebar-account.tsx | 81 +-- ...pp-sidebar-workspace-presentation.test.ts} | 21 +- ... => app-sidebar-workspace-presentation.ts} | 22 +- .../shell/app-sidebar-workspace-switcher.tsx | 366 +++++++++++++ .../src/features/shell/app-sidebar.test.tsx | 494 ++++++++++++++++-- apps/ui/src/features/shell/app-sidebar.tsx | 173 +++--- .../src/features/shell/area-return-route.ts | 58 ++ .../src/features/shell/use-reduced-motion.ts | 29 + .../status-hint/status-hint-model.test.ts | 2 - .../server/create-workspace-route.ts | 30 ++ .../server/workspace-list-handler.test.ts | 150 ++++++ .../server/workspace-list-handler.ts | 37 ++ .../server/workspace-route-context.ts | 138 +++++ .../server/workspace-route-table.test.ts | 45 ++ .../workspace/server/workspace-route-table.ts | 22 + .../features/workspace/use-workspace-list.ts | 49 ++ .../features/workspace/use-workspace-plans.ts | 35 ++ .../features/workspace/workspace-errors.ts | 20 + .../workspace/workspace-list-schema.ts | 12 + .../workspace/workspace-return-route.ts | 20 + .../workspace/workspace-switch-core.test.ts | 75 +++ .../workspace/workspace-switch-core.ts | 62 +++ .../workspace/workspace-switch-environment.ts | 36 ++ .../ui/src/components/workspace-avatar.tsx | 13 +- 55 files changed, 2674 insertions(+), 306 deletions(-) create mode 100644 apps/ui/src/app/api/billing/workspace-plans/handler.test.ts create mode 100644 apps/ui/src/app/api/billing/workspace-plans/handler.ts create mode 100644 apps/ui/src/app/api/billing/workspace-plans/route.ts create mode 100644 apps/ui/src/app/api/workspace/list/route.ts create mode 100644 apps/ui/src/features/billing/workspace-plans-data.ts create mode 100644 apps/ui/src/features/projects/project-workspace-guard-core.test.ts create mode 100644 apps/ui/src/features/projects/project-workspace-guard-core.ts create mode 100644 apps/ui/src/features/projects/project-workspace-guard.test.tsx create mode 100644 apps/ui/src/features/projects/project-workspace-guard.tsx rename apps/ui/src/features/shell/{app-sidebar-account-presentation.test.ts => app-sidebar-workspace-presentation.test.ts} (80%) rename apps/ui/src/features/shell/{app-sidebar-account-presentation.ts => app-sidebar-workspace-presentation.ts} (72%) create mode 100644 apps/ui/src/features/shell/app-sidebar-workspace-switcher.tsx create mode 100644 apps/ui/src/features/shell/area-return-route.ts create mode 100644 apps/ui/src/features/shell/use-reduced-motion.ts create mode 100644 apps/ui/src/features/workspace/server/create-workspace-route.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-list-handler.test.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-list-handler.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-route-context.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-route-table.test.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-route-table.ts create mode 100644 apps/ui/src/features/workspace/use-workspace-list.ts create mode 100644 apps/ui/src/features/workspace/use-workspace-plans.ts create mode 100644 apps/ui/src/features/workspace/workspace-errors.ts create mode 100644 apps/ui/src/features/workspace/workspace-list-schema.ts create mode 100644 apps/ui/src/features/workspace/workspace-return-route.ts create mode 100644 apps/ui/src/features/workspace/workspace-switch-core.test.ts create mode 100644 apps/ui/src/features/workspace/workspace-switch-core.ts create mode 100644 apps/ui/src/features/workspace/workspace-switch-environment.ts 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..17384c21 --- /dev/null +++ b/apps/ui/src/app/api/billing/workspace-plans/handler.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import { isDeletedSubscriptionRecord } from "@/features/billing/billing-plan-data"; +import { + type AuthorizeWorkspaceActor, + authorizeBillingActor, +} from "@/features/billing/server/authorized-proxy"; +import { BILLING_JUDGMENT_TIMEOUT_MS } from "@/features/billing/server/judgment-budget"; +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"; +const MAX_WORKSPACES_PER_READ = 50; + +const workspacePlanSubscriptionSchema = z.object({ + subscription: z.object({ + PlanName: z.string().optional(), + Status: z.string().optional(), + type: z.string().optional(), + }), +}); + +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); +} + +function planNameFromPayload(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; +} + +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 planNameFromPayload(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/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/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 ( <> + 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..aea34de9 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -12,7 +12,12 @@ 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"; @@ -553,6 +558,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..c0ae5c91 100644 --- a/apps/ui/src/features/billing/billing-plan.tsx +++ b/apps/ui/src/features/billing/billing-plan.tsx @@ -64,7 +64,12 @@ import { 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, +} from "@/lib/auth-store"; import { errorDescription, toastErrorDetail } from "@/lib/toast-utils"; export interface BillingStripeReturn { @@ -385,6 +390,8 @@ export function BillingPlan({ 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 [actionPending, setActionPending] = useState(null); const [cardManagementPending, setCardManagementPending] = useState(false); @@ -422,7 +429,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 +657,7 @@ export function BillingPlan({ appToken, kubeconfig, workspace: targetWorkspace, + workspaceRole, }); if (nextSnapshot == null) { throw new Error("The refreshed subscription is unavailable."); @@ -674,7 +688,7 @@ export function BillingPlan({ } return nextSnapshot; }, - [appToken, currency, kubeconfig, refreshSnapshot, workspace] + [appToken, currency, kubeconfig, refreshSnapshot, workspace, workspaceRole] ); if (!credentialsReady || snapshotLoading) { 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.ts b/apps/ui/src/features/billing/billing-return-route.ts index c137e4d7..eebd2df2 100644 --- a/apps/ui/src/features/billing/billing-return-route.ts +++ b/apps/ui/src/features/billing/billing-return-route.ts @@ -1,50 +1,25 @@ -const BILLING_RETURN_ROUTE_STORAGE_KEY = "billing-return-route"; +import { createAreaReturnRoute } from "@/features/shell/area-return-route"; /** - * 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. */ +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); } -/** - * 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. - */ export function recordBillingReturnRoute(): void { - if (typeof window === "undefined") { - return; - } - const route = `${window.location.pathname}${window.location.search}`; - if (route.startsWith("/billing")) { - return; - } - try { - window.sessionStorage.setItem(BILLING_RETURN_ROUTE_STORAGE_KEY, route); - } catch { - // Storage can be unavailable (private browsing); close falls back to home. - } + billingReturnRoute.record(); } export function readBillingReturnRoute(): string { - if (typeof window === "undefined") { - return "/"; - } - try { - return sanitizeBillingReturnRoute( - window.sessionStorage.getItem(BILLING_RETURN_ROUTE_STORAGE_KEY) - ); - } catch { - return "/"; - } + return billingReturnRoute.read(); } 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..db1b3cd3 100644 --- a/apps/ui/src/features/billing/server/billing-route-table.ts +++ b/apps/ui/src/features/billing/server/billing-route-table.ts @@ -115,6 +115,12 @@ export const BILLING_ROUTES = { 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..febf16e1 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 @@ -19,6 +19,7 @@ import { formatBillingDevMockCookie, } from "../../dev-mock-cookie"; 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 +90,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 +649,20 @@ 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`); + } +}); 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 95ed4047..d22081c5 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -35,6 +35,8 @@ import { WORKSPACE_OWNER_FIXTURE_PATHNAME } from "./pathnames"; interface FixtureContext { body: Record; scenario: BillingDevScenario; + /** The request's query string, for the GET routes that read it. */ + searchParams: URLSearchParams; workspace: string; } @@ -630,7 +632,33 @@ 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")) { + const subscription = subscriptionPayload(scenario, workspace); + plans[workspace] = + workspace === PAYG_SWITCHER_WORKSPACE || + subscription.type === "PAYG" || + subscription.Status === "DELETED" + ? null + : String(subscription.PlanName); + } + 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 }) => @@ -1031,6 +1059,7 @@ export async function billingDevMockResponse( const context: FixtureContext = { body, scenario, + searchParams: new URL(request.url).searchParams, workspace: billingDevMockWorkspace(body.workspace), }; 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/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/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 f9b38670..a2fb555a 100644 --- a/apps/ui/src/features/notifications/use-notification-feed.ts +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -6,7 +6,7 @@ import { NOTIFICATION_CR_REFRESH_INTERVAL_MS, useNotificationCRList, } from "@workspace/api/hooks"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { toast } from "sonner"; import useSWR from "swr"; @@ -20,7 +20,7 @@ import { 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 { currentWorkspaceAtom } from "@/lib/auth-store"; import { fetchNotificationFeed, @@ -65,7 +65,9 @@ export function useNotificationFeed(): NotificationFeed { 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 credentialKey = kubeconfigCredentialKey(kubeconfig); @@ -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.ts b/apps/ui/src/features/projects/explorer/use-projects-explorer.ts index f4a97d1a..d9fab483 100644 --- a/apps/ui/src/features/projects/explorer/use-projects-explorer.ts +++ b/apps/ui/src/features/projects/explorer/use-projects-explorer.ts @@ -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..d2c04b60 --- /dev/null +++ b/apps/ui/src/features/projects/project-workspace-guard.test.tsx @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, mock, test } from "bun:test"; +import assert from "node:assert/strict"; +import { getDefaultStore } from "jotai"; + +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, + projects: [] as ProjectExplorerProject[], + projectsLoaded: false, +}; +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: () => ({ + data: { aps: undefined, dbs: undefined }, + devMockActive: explorer.devMockActive, + projectsLoaded: explorer.projectsLoaded, + refreshProjects: async () => undefined, + 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.projects = []; + explorer.projectsLoaded = false; + 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(); + assert.deepEqual(route.replaced, ["/project"]); + assert.deepEqual(toasts, [PROJECT_NOT_IN_WORKSPACE_NOTICE]); +}); + +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..7e306bac --- /dev/null +++ b/apps/ui/src/features/projects/project-workspace-guard.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { useRouter } from "next/navigation"; +import { useEffect } 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. + */ +export function ProjectWorkspaceGuard() { + const projectId = useProjectId(); + const router = useRouter(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const namespace = useAtomValue(namespaceAtom); + const { devMockActive, projectsLoaded, states } = + useProjectsExplorerReadModel({ kubeconfig, ns: namespace }); + const decision = devMockActive + ? "stay" + : projectWorkspaceGuardDecision({ + loaded: projectsLoaded, + projectId, + projectIds: states.projects.map((project) => project.id), + }); + + useEffect(() => { + if (decision !== "leave") { + return; + } + router.replace("/project"); + toast(PROJECT_NOT_IN_WORKSPACE_NOTICE); + }, [decision, router]); + + return null; +} diff --git a/apps/ui/src/features/session/dev-mock.tsx b/apps/ui/src/features/session/dev-mock.tsx index e93675f4..8f505d39 100644 --- a/apps/ui/src/features/session/dev-mock.tsx +++ b/apps/ui/src/features/session/dev-mock.tsx @@ -19,7 +19,7 @@ const sessionDevMockSource = createDevMockCookieSource(sessionDevMockCookie); export function SessionDevMockTweaks() { useDevTweaksMock(SESSION_DEV_MOCK_KEY, { defaultScenario: DEFAULT_SESSION_DEV_SCENARIO, - note: "Serves POST /api/session from fixtures (fake credentials, one scenario per Workspace Role); toggling reloads the page", + 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, diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts index 1894f376..e0cca309 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.test.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -1,12 +1,17 @@ import assert from "node:assert/strict"; import { test } from "node:test"; - +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +import { workspaceListResponseSchema } from "@/features/workspace/workspace-list-schema"; import { SESSION_DEV_SCENARIOS, sessionDevMockCookie, } from "../dev-mock-cookie"; + import { brainSessionSchema } from "../session-schema"; -import { sessionDevMockResponse } from "./dev-fixtures"; +import { + sessionDevMockResponse, + workspaceDevMockResponse, +} from "./dev-fixtures"; function request(input: { body?: unknown; cookie?: string }): Request { return new Request("https://brain.test/api/session", { @@ -88,3 +93,38 @@ test("a requested nsid that the scenario knows is honoured; an unknown one falls 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)) { + const response = await workspaceDevMockResponse( + entry.desktopPath, + new Request(`https://brain.test${entry.apiPath}`, { + headers: { cookie }, + }) + ); + assert.equal(response?.status, 200, `${scenario} ${entry.apiPath}`); + if (entry === WORKSPACE_ROUTES.list) { + assert.deepEqual( + workspaceListResponseSchema.parse(await response?.json()), + session.workspaces, + scenario + ); + } + } + } + assert.equal( + await workspaceDevMockResponse( + WORKSPACE_ROUTES.list.desktopPath, + new Request("https://brain.test/api/workspace/list") + ), + null + ); +}); diff --git a/apps/ui/src/features/session/server/dev-fixtures.ts b/apps/ui/src/features/session/server/dev-fixtures.ts index 19063240..1ad82ff4 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -1,5 +1,5 @@ import { resolveDevMock } from "@/features/dev-mock/server/resolve"; - +import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; import { type SessionDevScenario, sessionDevMockCookie, @@ -12,6 +12,8 @@ import type { BrainSession, SessionWorkspace } from "../session-schema"; * 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. */ const MOCK_KUBECONFIG = (namespace: string) => `apiVersion: v1 @@ -132,3 +134,40 @@ export async function sessionDevMockResponse( headers: { "cache-control": "no-store" }, }); } + +const WORKSPACE_FIXTURES: Record< + string, + (scenario: SessionDevScenario) => unknown +> = { + [WORKSPACE_ROUTES.list.desktopPath]: (scenario) => workspacesFor(scenario), +}; + +/** 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 Promise.resolve( + Response.json(fixture(resolution.scenario), { + headers: { "cache-control": "no-store" }, + }) + ); +} diff --git a/apps/ui/src/features/session/swr-keys.test.ts b/apps/ui/src/features/session/swr-keys.test.ts index 37b59181..445bcaca 100644 --- a/apps/ui/src/features/session/swr-keys.test.ts +++ b/apps/ui/src/features/session/swr-keys.test.ts @@ -44,4 +44,8 @@ test("keys keep their prefix as the first element for the dev-mock matchers", () "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 index 39c12ddf..fdb0aeb4 100644 --- a/apps/ui/src/features/session/swr-keys.ts +++ b/apps/ui/src/features/session/swr-keys.ts @@ -46,6 +46,8 @@ function sessionKey

(prefix: P) { */ 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"), @@ -54,5 +56,7 @@ export const SESSION_SWR_KEYS = { statusHintBalance: sessionKey("status-hint-balance"), statusHintPlans: sessionKey("status-hint-plans"), statusHintQuota: sessionKey("status-hint-quota"), + /** 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/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 : ( + ))} + {canSwitch ? null : ( +

+ {SWITCH_UNAVAILABLE_NOTICE} +

+ )} + + + )} +
+
+ } + 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; + * outside the Desktop iframe 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() === "" ? (embeddingOrigin() ?? "") : desktopDomain; + const canSwitch = isSwitchAvailable() && cloudDomain !== ""; + 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 = ( + ))} - {canSwitch ? null : ( + {switchBlock == null ? null : (

- {SWITCH_UNAVAILABLE_NOTICE} + {SWITCH_BLOCK_NOTICE[switchBlock]}

)} @@ -222,8 +232,9 @@ function WorkspaceSwitcherMenu({ * 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; - * outside the Desktop iframe those rows are disabled with a notice. + * 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(); @@ -253,9 +264,13 @@ export function AppSidebarWorkspaceSwitcher() { // rail's clipping, so the popover anchors the w-9 icon slot. const iconSlotRef = useRef(null); - const cloudDomain = - desktopDomain.trim() === "" ? (embeddingOrigin() ?? "") : desktopDomain; - const canSwitch = isSwitchAvailable() && cloudDomain !== ""; + 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({ @@ -352,13 +367,13 @@ export function AppSidebarWorkspaceSwitcher() { sideOffset={6} > diff --git a/apps/ui/src/features/shell/app-sidebar.test.tsx b/apps/ui/src/features/shell/app-sidebar.test.tsx index fdd56e15..bd7f03e8 100644 --- a/apps/ui/src/features/shell/app-sidebar.test.tsx +++ b/apps/ui/src/features/shell/app-sidebar.test.tsx @@ -297,7 +297,6 @@ mock.module("@/features/projects/explorer/use-projects-explorer", () => ({ })); mock.module("@/features/workspace/workspace-switch-environment", () => ({ - embeddingOrigin: () => null, isSwitchAvailable: () => switchEnvironment.inIframe, navigateTopWindow: (url: string) => { switchEnvironment.navigations.push(url); @@ -914,6 +913,35 @@ test("outside the Desktop iframe the Switch to rows are disabled with a notice; ); }); +test("inside Desktop, the rows wait for the host config's domain instead of guessing one", async () => { + billing.subscription = proSubscription(); + await withSidebar( + async () => { + const popover = await openWorkspaceSwitcher(); + const rows = [ + ...popover.querySelectorAll( + '[data-slot="app-sidebar-workspace-switch"]' + ), + ]; + assert.equal( + rows.every((row) => row.disabled), + true + ); + assert.equal( + popover + .querySelector('[data-slot="app-sidebar-workspace-switch-notice"]') + ?.textContent?.includes("Waiting for Sealos Desktop"), + true + ); + }, + true, + () => { + hydrateAccountAtoms("ws-switcher-no-domain"); + getDefaultStore().set(desktopDomainAtom, ""); + } + ); +}); + test("a failed plans read leaves the Switch to rows without badges", async () => { billing.subscription = proSubscription(); workspaceRoutes.plans = null; diff --git a/apps/ui/src/features/shell/app-sidebar.tsx b/apps/ui/src/features/shell/app-sidebar.tsx index 97b333ae..a633e452 100644 --- a/apps/ui/src/features/shell/app-sidebar.tsx +++ b/apps/ui/src/features/shell/app-sidebar.tsx @@ -290,7 +290,7 @@ const BRAND_SWAP_TRANSITION = const BRAND_SWAP_TRANSITION_REDUCED = "transition-opacity duration-200 ease-out-strong"; const LOGO_REST = "opacity-100"; -const LOGO_REST_MOTION = "scale-100 blur-[0px]"; +const LOGO_REST_MOTION = "scale-100 blur-none"; const LOGO_SWAPPED = "group-focus-visible/brand:opacity-0 [[data-slot=sidebar-container]:hover_&]:opacity-0"; const LOGO_SWAPPED_MOTION = @@ -300,7 +300,7 @@ const GLYPH_REST_MOTION = "scale-80 blur-[2px]"; const GLYPH_SWAPPED = "group-focus-visible/brand:opacity-100 [[data-slot=sidebar-container]:hover_&]:opacity-100"; const GLYPH_SWAPPED_MOTION = - "group-focus-visible/brand:scale-100 group-focus-visible/brand:blur-[0px] [[data-slot=sidebar-container]:hover_&]:scale-100 [[data-slot=sidebar-container]:hover_&]:blur-[0px]"; + "group-focus-visible/brand:scale-100 group-focus-visible/brand:blur-none [[data-slot=sidebar-container]:hover_&]:scale-100 [[data-slot=sidebar-container]:hover_&]:blur-none"; function AppSidebarHeader() { const { setOpen, state } = useSidebar(); diff --git a/apps/ui/src/features/workspace/workspace-errors.ts b/apps/ui/src/features/workspace/workspace-errors.ts index 2d8c84ae..e89764b9 100644 --- a/apps/ui/src/features/workspace/workspace-errors.ts +++ b/apps/ui/src/features/workspace/workspace-errors.ts @@ -1,20 +1,23 @@ +import { SESSION_ERROR_CODES } from "@/features/session/session-schema"; + /** * The structured error codes the Workspace-management routes answer with * (spec §B.1): Brain translates Desktop's "HTTP 200 + `body.code`" envelope * into real HTTP statuses and its own codes, never forwarding Desktop's * hard-coded message text. Client-safe: the page keys its reaction on - * these. + * these. The codes shared with `POST /api/session` are the session's own + * constants, so the 401 two-step keys on one `session_expired`. */ export const WORKSPACE_ERROR_CODES = { conflict: "workspace_conflict", desktopError: "desktop_error", - desktopTimeout: "desktop_timeout", - desktopUnavailable: "desktop_unavailable", + desktopTimeout: SESSION_ERROR_CODES.desktopTimeout, + desktopUnavailable: SESSION_ERROR_CODES.desktopUnavailable, forbidden: "workspace_forbidden", invalidRequest: "invalid_workspace_request", notFound: "workspace_not_found", - /** The regional token was refused: the session fetch's 401 two-step runs. */ - sessionExpired: "session_expired", /** `X-Sealos-Region-Token` is missing on the request. */ regionTokenRequired: "region_token_required", + /** The regional token was refused: the session fetch's 401 two-step runs. */ + sessionExpired: SESSION_ERROR_CODES.sessionExpired, } as const; diff --git a/apps/ui/src/features/workspace/workspace-switch-environment.ts b/apps/ui/src/features/workspace/workspace-switch-environment.ts index b9383966..6b2ed716 100644 --- a/apps/ui/src/features/workspace/workspace-switch-environment.ts +++ b/apps/ui/src/features/workspace/workspace-switch-environment.ts @@ -6,8 +6,9 @@ import { isInsideDesktopIframe } from "@/features/session/desktop-sdk"; * The browser facts a Workspace switch depends on (spec §C.6), kept behind * one module so the Switcher's tests can stand in for the Desktop iframe: * whether Brain runs inside one (switching is Desktop's job, so outside an - * iframe the rows are disabled), the embedding page's origin as a fallback - * for the cloud domain, and the top-window navigation itself. + * iframe the rows are disabled) and the top-window navigation itself. The + * cloud domain comes only from the SDK host config (spec §C.6) — never + * from the embedding page, which under the local Dev Bridge is not Desktop. */ /** Whether the page is embedded (in Desktop, or any parent frame). */ @@ -15,20 +16,6 @@ export function isSwitchAvailable(): boolean { return isInsideDesktopIframe(); } -/** - * The Desktop origin when the host config never answered: the page that - * embedded this iframe is Desktop, recorded as the referrer. Null without - * a usable referrer. - */ -export function embeddingOrigin(): string | null { - try { - const referrer = document.referrer.trim(); - return referrer === "" ? null : new URL(referrer).origin; - } catch { - return null; - } -} - /** Hands the top window to Desktop: the whole page leaves for the deep link. */ export function navigateTopWindow(url: string): void { const top = window.top ?? window; From a137864d37535c0b1a42f9e789b369fe6537f828 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 17:20:45 +0800 Subject: [PATCH 05/17] feat(workspace): Workspace Area, read-only (AIM-446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Workspace Area lands at `/workspace` and `/workspace/` (spec AIM-443 §D.1–D.5, §D.9, §E, §B.2 `details`), rendering every control its gates allow while the write operations wait for the next ticket: - Area shell: the Billing Area's title bar, close button, and aside are extracted into `AreaShell`, parameterized by icon, title, close label, return-route reader, and aside width; Billing composes it unchanged. - Routes: `/workspace` replaces itself with the current Workspace's uid; a uid outside the list falls back to it with a notice. The close button returns to the recorded entry point, home on a direct entry. - `POST /api/workspace/details { uid }`: Desktop `namespace/details` through the Desktop client, answered as `{ workspace, members }` in Brain's shape with Desktop's envelope codes translated; joined the route table, its on-disk guard, and the session dev-mock (members per scenario, 404 for a uid outside the list). - Gating (`workspace-gating-core`): a pure module mirroring Desktop's `vaildManage` — role gates hide, state gates disable with a reason (delete / leave the current Workspace, transfer with nobody to transfer to), Personal never deleted or transferred, Owner never removed or self-removed, roles offered never Owner. - UI: the 240px list (Desktop order, current dot, role, Create row), the detail header (square avatar, plan badge, Current, role line, copyable id, Leave or the Owner's ⋯ menu with reasons on a second line), and the Members panel (You badge, alias subline and pencil, role select or text, Joined, remove icon; the action column only when someone is removable; no Status column). Co-Authored-By: Claude Fable 5.1 --- .../ui/src/app/api/workspace/details/route.ts | 11 + apps/ui/src/app/workspace/[uid]/page.tsx | 4 + apps/ui/src/app/workspace/layout.test.tsx | 56 ++ apps/ui/src/app/workspace/layout.tsx | 37 ++ apps/ui/src/app/workspace/page.tsx | 7 + .../features/billing/billing-tab-shell.tsx | 138 ++--- .../session/server/desktop-auth-api.ts | 95 ++- .../session/server/dev-fixtures.test.ts | 61 +- .../features/session/server/dev-fixtures.ts | 140 ++++- apps/ui/src/features/session/swr-keys.ts | 2 + .../features/shell/area-return-route.test.ts | 33 + apps/ui/src/features/shell/area-shell.tsx | 107 ++++ .../server/workspace-details-handler.test.ts | 241 ++++++++ .../server/workspace-details-handler.ts | 70 +++ .../workspace/server/workspace-route-table.ts | 4 + .../workspace/use-workspace-details.ts | 78 +++ .../workspace/workspace-area-list.tsx | 95 +++ .../workspace-area-route-core.test.ts | 96 +++ .../workspace/workspace-area-route-core.ts | 51 ++ .../workspace/workspace-area.test.tsx | 583 ++++++++++++++++++ .../src/features/workspace/workspace-area.tsx | 163 +++++ .../workspace/workspace-detail-header.tsx | 260 ++++++++ .../workspace/workspace-details-schema.ts | 47 ++ .../workspace/workspace-gating-core.test.ts | 155 +++++ .../workspace/workspace-gating-core.ts | 133 ++++ .../workspace/workspace-members-panel.tsx | 334 ++++++++++ 26 files changed, 2901 insertions(+), 100 deletions(-) create mode 100644 apps/ui/src/app/api/workspace/details/route.ts create mode 100644 apps/ui/src/app/workspace/[uid]/page.tsx create mode 100644 apps/ui/src/app/workspace/layout.test.tsx create mode 100644 apps/ui/src/app/workspace/layout.tsx create mode 100644 apps/ui/src/app/workspace/page.tsx create mode 100644 apps/ui/src/features/shell/area-return-route.test.ts create mode 100644 apps/ui/src/features/shell/area-shell.tsx create mode 100644 apps/ui/src/features/workspace/server/workspace-details-handler.test.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-details-handler.ts create mode 100644 apps/ui/src/features/workspace/use-workspace-details.ts create mode 100644 apps/ui/src/features/workspace/workspace-area-list.tsx create mode 100644 apps/ui/src/features/workspace/workspace-area-route-core.test.ts create mode 100644 apps/ui/src/features/workspace/workspace-area-route-core.ts create mode 100644 apps/ui/src/features/workspace/workspace-area.test.tsx create mode 100644 apps/ui/src/features/workspace/workspace-area.tsx create mode 100644 apps/ui/src/features/workspace/workspace-detail-header.tsx create mode 100644 apps/ui/src/features/workspace/workspace-details-schema.ts create mode 100644 apps/ui/src/features/workspace/workspace-gating-core.test.ts create mode 100644 apps/ui/src/features/workspace/workspace-gating-core.ts create mode 100644 apps/ui/src/features/workspace/workspace-members-panel.tsx 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/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-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/session/server/desktop-auth-api.ts b/apps/ui/src/features/session/server/desktop-auth-api.ts index 8f98bee5..877eadd3 100644 --- a/apps/ui/src/features/session/server/desktop-auth-api.ts +++ b/apps/ui/src/features/session/server/desktop-auth-api.ts @@ -2,6 +2,8 @@ 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, @@ -10,15 +12,17 @@ import { } from "./desktop-client"; /** - * The four Desktop `/api/auth/*` calls the Brain Session needs (spec §A.1), - * 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; the session - * service turns DTOs into Brain's own shapes. + * 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", + namespaceDetails: "/api/auth/namespace/details", namespaceList: "/api/auth/namespace/list", namespaceSwitch: "/api/auth/namespace/switch", regionToken: "/api/auth/regionToken", @@ -107,8 +111,81 @@ export const desktopWorkspaceListSchema = namespaceListDataSchema.transform( } ); +/** `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(), +}); + +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: joined == null ? "" : String(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>; + namespaceDetails( + regionalToken: string, + workspaceUid: string + ): Promise>; namespaceList( regionalToken: string ): Promise>; @@ -128,6 +205,14 @@ export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { method: "GET", path: DESKTOP_AUTH_PATHS.info, }), + namespaceDetails: (regionalToken, workspaceUid) => + client.call({ + authorization: encodedTokenAuthorization(regionalToken), + body: { ns_uid: workspaceUid }, + dataSchema: desktopWorkspaceDetailsSchema, + method: "POST", + path: DESKTOP_AUTH_PATHS.namespaceDetails, + }), namespaceList: (regionalToken) => client.call({ authorization: encodedTokenAuthorization(regionalToken), diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts index e0cca309..5a063ca7 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.test.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { WORKSPACE_ROUTES } 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 { SESSION_DEV_SCENARIOS, @@ -107,7 +109,9 @@ test("every scenario answers every Workspace route with the session's own list", const response = await workspaceDevMockResponse( entry.desktopPath, new Request(`https://brain.test${entry.apiPath}`, { - headers: { cookie }, + body: JSON.stringify({ uid: session.workspace.uid }), + headers: { "content-type": "application/json", cookie }, + method: "POST", }) ); assert.equal(response?.status, 200, `${scenario} ${entry.apiPath}`); @@ -118,8 +122,63 @@ test("every scenario answers every Workspace route with the session's own list", 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, diff --git a/apps/ui/src/features/session/server/dev-fixtures.ts b/apps/ui/src/features/session/server/dev-fixtures.ts index 1ad82ff4..c8d5aec5 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -1,5 +1,11 @@ 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 { type SessionDevScenario, sessionDevMockCookie, @@ -70,6 +76,98 @@ const 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 membersFor( + 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 workspacesFor(scenario: SessionDevScenario): SessionWorkspace[] { switch (scenario) { case "personal-only": @@ -135,11 +233,43 @@ export async function sessionDevMockResponse( }); } +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); +} + const WORKSPACE_FIXTURES: Record< string, - (scenario: SessionDevScenario) => unknown + (scenario: SessionDevScenario, request: Request) => Promise > = { - [WORKSPACE_ROUTES.list.desktopPath]: (scenario) => workspacesFor(scenario), + [WORKSPACE_ROUTES.details.desktopPath]: detailsFixture, + [WORKSPACE_ROUTES.list.desktopPath]: (scenario) => + Promise.resolve(mockJson(workspacesFor(scenario))), }; /** Answers a `/api/workspace/*` route by its Desktop path from the scenario. */ @@ -165,9 +295,5 @@ export function workspaceDevMockResponse( ) ); } - return Promise.resolve( - Response.json(fixture(resolution.scenario), { - headers: { "cache-control": "no-store" }, - }) - ); + return fixture(resolution.scenario, request); } diff --git a/apps/ui/src/features/session/swr-keys.ts b/apps/ui/src/features/session/swr-keys.ts index fdb0aeb4..03f6cdec 100644 --- a/apps/ui/src/features/session/swr-keys.ts +++ b/apps/ui/src/features/session/swr-keys.ts @@ -56,6 +56,8 @@ export const SESSION_SWR_KEYS = { 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"), diff --git a/apps/ui/src/features/shell/area-return-route.test.ts b/apps/ui/src/features/shell/area-return-route.test.ts new file mode 100644 index 00000000..c8a7c543 --- /dev/null +++ b/apps/ui/src/features/shell/area-return-route.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createAreaReturnRoute } from "./area-return-route"; + +const area = createAreaReturnRoute({ + prefix: "/workspace", + storageKey: "workspace-return-route", +}); + +test("an area's return route accepts an in-app route outside the area", () => { + assert.equal(area.sanitize("/project"), "/project"); + assert.equal( + area.sanitize("/project/abc?selected=db:main"), + "/project/abc?selected=db:main" + ); + // Another area's path is a fine place to return to. + assert.equal(area.sanitize("/billing/costs"), "/billing/costs"); +}); + +test("an area's return route never points inside the area or outside the app", () => { + assert.equal(area.sanitize(null), "/"); + assert.equal(area.sanitize(""), "/"); + assert.equal(area.sanitize("https://evil.example"), "/"); + assert.equal(area.sanitize("//evil.example"), "/"); + assert.equal(area.sanitize("/workspace"), "/"); + assert.equal(area.sanitize("/workspace/uid-1"), "/"); +}); + +test("without a window the return route reads home and records nothing", () => { + assert.equal(area.read(), "/"); + assert.doesNotThrow(() => area.record()); +}); diff --git a/apps/ui/src/features/shell/area-shell.tsx b/apps/ui/src/features/shell/area-shell.tsx new file mode 100644 index 00000000..d8d3f652 --- /dev/null +++ b/apps/ui/src/features/shell/area-shell.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; +import { cn } from "@workspace/ui/lib/utils"; +import { X } from "lucide-react"; +import Link from "next/link"; +import { type ReactNode, useSyncExternalStore } from "react"; + +/** + * The chrome every product area shares (the Billing Area, the Workspace + * Area; spec §D.2): a title bar with the area's icon and name, a close + * button that returns to the area's recorded return address, and a + * two-column body — an aside the area fills (section navigation, the + * Workspace list) beside the area's content. The area supplies what + * differs: the icon, the title, the close label, the return-route reader, + * the aside and its width, and the content column. + */ + +// The entry point is recorded once per navigation into an area and never +// changes while the 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 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. + */ +export function AreaCloseButton({ + label, + readReturnRoute, +}: { + label: string; + readReturnRoute: () => string; +}) { + const returnHref = useSyncExternalStore( + subscribeToNothing, + readReturnRoute, + () => "/" + ); + return ( + } + size="lg" + variant="quiet" + > + + + ); +} + +export function AreaShell({ + aside, + asideClassName, + children, + closeLabel, + icon, + readReturnRoute, + slot, + title, +}: { + /** The aside column's content: section navigation, a list. */ + aside: ReactNode; + /** The aside's desktop width and anything else the area adds to it. */ + asideClassName?: string; + /** The content column; the area owns its scrolling and padding. */ + children: ReactNode; + closeLabel: string; + icon: ReactNode; + readReturnRoute: () => string; + /** The `data-slot` naming the area's shell, e.g. `billing-tab-shell`. */ + slot: string; + title: string; +}) { + return ( +
+
+
+ {icon} +

+ {title} +

+
+ +
+
+ + {children} +
+
+ ); +} diff --git a/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts b/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts new file mode 100644 index 00000000..969c3a71 --- /dev/null +++ b/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { FakeDesktopOptions } from "@/features/session/server/desktop-test-double"; +import { REGION_TOKEN_HEADER } from "@/lib/region-token-header"; + +import { workspaceDetailsResponseSchema } from "../workspace-details-schema"; +import { WORKSPACE_ERROR_CODES } from "../workspace-errors"; + +mock.module("server-only", () => ({})); +const { createWorkspaceDetailsHandler } = await import( + "./workspace-details-handler" +); +const { createFakeDesktop, defaultDesktopAnswers, TEAM } = 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 REGIONAL_TOKEN = "regional.token/with+chars"; + +/** Desktop's `TeamUserDto` rows for the Team Workspace, as `details` answers. */ +const OWNER_USER = { + alias: "Team lead", + avatarUrl: "https://desktop.test/kai.png", + crUid: "cr-uid-kai", + createdTime: "2026-02-01T00:00:00.000Z", + joinTime: "2026-02-01T00:00:00.000Z", + k8s_username: "kai00001", + nickname: "Kai", + role: 0, + status: 1, + uid: "user-uid-kai", +}; +const ME_USER = { + avatarUrl: "", + crUid: "cr-uid-1", + createdTime: "2026-01-01T00:00:00.000Z", + joinTime: "2026-02-14T09:00:00.000Z", + k8s_username: "abc12345", + nickname: "Ada", + role: 1, + status: 1, + uid: "user-uid-1", +}; +/** A row without `joinTime` (Desktop's DTO leaves it optional). */ +const DEV_USER = { + avatarUrl: "", + crUid: "cr-uid-dev", + createdTime: "2026-03-01T00:00:00.000Z", + k8s_username: "dev00001", + nickname: "Dev", + role: 2, + status: 1, + uid: "user-uid-dev", +}; + +function detailsAnswers(): FakeDesktopOptions["answers"] { + return { + ...defaultDesktopAnswers(), + "/api/auth/namespace/details": { + code: 200, + data: { namespace: TEAM, users: [OWNER_USER, ME_USER, DEV_USER] }, + }, + }; +} + +function detailsRequest( + input: { body?: unknown; rawBody?: string; token?: string | null } = {} +): Request { + const headers: Record = { + "content-type": "application/json", + }; + if (input.token !== null) { + headers[REGION_TOKEN_HEADER] = input.token ?? REGIONAL_TOKEN; + } + return new Request("https://brain.test/api/workspace/details", { + body: input.rawBody ?? JSON.stringify(input.body ?? { uid: TEAM.uid }), + headers, + method: "POST", + }); +} + +interface LogEntry { + fields: Record; + message: string; +} + +function handlerWith( + answers = detailsAnswers(), + env: Record = DEV_ENV +) { + const desktop = createFakeDesktop({ answers }); + const logs: LogEntry[] = []; + const handler = createWorkspaceDetailsHandler({ + env, + fetchDesktop: desktop.fetch, + log: (message, fields) => logs.push({ fields, message }), + }); + return { calls: desktop.calls, handler, logs }; +} + +describe("POST /api/workspace/details", () => { + it("answers the Workspace and its members in Brain's shape, calling Desktop with the uid as ns_uid", async () => { + const { calls, handler } = handlerWith(); + const response = await handler(detailsRequest()); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const details = workspaceDetailsResponseSchema.parse(await response.json()); + expect(details.workspace).toEqual({ + createdAt: TEAM.createTime, + id: TEAM.id, + isPersonal: false, + name: TEAM.teamName, + role: "Manager", + uid: TEAM.uid, + }); + expect(details.members).toEqual([ + { + alias: "Team lead", + avatarUrl: "https://desktop.test/kai.png", + crName: "kai00001", + crUid: "cr-uid-kai", + joinedAt: "2026-02-01T00:00:00.000Z", + nickname: "Kai", + role: "Owner", + userUid: "user-uid-kai", + }, + { + alias: null, + avatarUrl: "", + crName: "abc12345", + crUid: "cr-uid-1", + joinedAt: "2026-02-14T09:00:00.000Z", + nickname: "Ada", + role: "Manager", + userUid: "user-uid-1", + }, + { + alias: null, + avatarUrl: "", + crName: "dev00001", + crUid: "cr-uid-dev", + // No joinTime: the CR's creation time stands in. + joinedAt: "2026-03-01T00:00:00.000Z", + nickname: "Dev", + role: "Developer", + userUid: "user-uid-dev", + }, + ]); + expect(calls).toEqual([ + { + authorization: encodeURIComponent(REGIONAL_TOKEN), + body: { ns_uid: TEAM.uid }, + method: "POST", + path: "/api/auth/namespace/details", + }, + ]); + }); + + it("answers 400 for a missing or blank uid and for a non-JSON body, never calling Desktop", async () => { + const { calls, handler } = handlerWith(); + for (const request of [ + detailsRequest({ body: {} }), + detailsRequest({ body: { uid: " " } }), + detailsRequest({ body: { uid: 42 } }), + detailsRequest({ rawBody: "not json" }), + ]) { + const response = await handler(request); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: WORKSPACE_ERROR_CODES.invalidRequest, + }); + } + expect(calls).toEqual([]); + }); + + it("answers 401 without the region token header and never calls Desktop", async () => { + const { calls, handler } = handlerWith(); + const response = await handler(detailsRequest({ token: null })); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: WORKSPACE_ERROR_CODES.regionTokenRequired, + }); + expect(calls).toEqual([]); + }); + + it("translates Desktop's business codes into real HTTP statuses without its message text", async () => { + for (const [code, status, error] of [ + [400, 400, WORKSPACE_ERROR_CODES.invalidRequest], + [401, 401, WORKSPACE_ERROR_CODES.sessionExpired], + [403, 403, WORKSPACE_ERROR_CODES.forbidden], + [404, 404, WORKSPACE_ERROR_CODES.notFound], + [500, 500, WORKSPACE_ERROR_CODES.desktopError], + ] as const) { + const { handler } = handlerWith({ + "/api/auth/namespace/details": { + code, + message: "You are not in the namespace", + }, + }); + const response = await handler(detailsRequest()); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ error }); + } + }); + + it("answers 502 for a malformed Desktop answer and 504 for a timeout", async () => { + const malformed = handlerWith({ + "/api/auth/namespace/details": { + code: 200, + data: { namespace: TEAM, users: [{ nickname: "no ids" }] }, + }, + }); + expect((await malformed.handler(detailsRequest())).status).toBe(502); + + const timeout = handlerWith({ + "/api/auth/namespace/details": Object.assign(new Error("aborted"), { + name: "TimeoutError", + }), + }); + const response = await timeout.handler(detailsRequest()); + expect(response.status).toBe(504); + expect(await response.json()).toEqual({ + error: WORKSPACE_ERROR_CODES.desktopTimeout, + }); + }); + + it("logs failures structurally without the regional token", async () => { + const { handler, logs } = handlerWith({ + "/api/auth/namespace/details": { code: 404 }, + }); + await handler(detailsRequest()); + expect(logs.length).toBeGreaterThan(0); + const serialized = JSON.stringify(logs); + expect(serialized.includes(REGIONAL_TOKEN)).toBe(false); + expect(serialized.includes(encodeURIComponent(REGIONAL_TOKEN))).toBe(false); + }); +}); diff --git a/apps/ui/src/features/workspace/server/workspace-details-handler.ts b/apps/ui/src/features/workspace/server/workspace-details-handler.ts new file mode 100644 index 00000000..b5e3df4a --- /dev/null +++ b/apps/ui/src/features/workspace/server/workspace-details-handler.ts @@ -0,0 +1,70 @@ +import "server-only"; + +import { + type WorkspaceDetailsResponse, + workspaceDetailsRequestSchema, +} from "../workspace-details-schema"; +import { WORKSPACE_ERROR_CODES } from "../workspace-errors"; +import { + desktopFailureLogFields, + desktopFailureResponse, + type WorkspaceRouteDependencies, + workspaceErrorResponse, + workspaceJsonResponse, + workspaceRouteContext, +} from "./workspace-route-context"; +import { WORKSPACE_ROUTES } from "./workspace-route-table"; + +/** + * `POST /api/workspace/details { uid }` (spec §B.2): Desktop's + * `namespace/details` for the regional token on the request, answered as + * `{ workspace, members }` in Brain's shape. Desktop is the authority on + * membership — a caller outside the Workspace gets its 404, translated. + */ + +/** The request body must be JSON; absent or blank is `{}` (then invalid). */ +async function requestPayload(request: Request): Promise { + const text = (await request.text().catch(() => null))?.trim() ?? ""; + if (text === "") { + return {}; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + +export function createWorkspaceDetailsHandler( + dependencies: WorkspaceRouteDependencies = {} +): (request: Request) => Promise { + return async function handler(request: Request): Promise { + const context = workspaceRouteContext( + request, + dependencies, + WORKSPACE_ROUTES.details.apiPath + ); + if (!context.ok) { + return context.response; + } + const payload = await requestPayload(request); + const parsed = + payload == null ? null : workspaceDetailsRequestSchema.safeParse(payload); + if (parsed == null || !parsed.success) { + return workspaceErrorResponse(WORKSPACE_ERROR_CODES.invalidRequest, 400); + } + const result = await context.desktop.namespaceDetails( + context.regionalToken, + parsed.data.uid + ); + if (!result.ok) { + context.log("Desktop details failed", desktopFailureLogFields(result)); + return desktopFailureResponse(result); + } + const response: WorkspaceDetailsResponse = { + members: result.data.members, + workspace: result.data.workspace, + }; + return workspaceJsonResponse(response); + }; +} diff --git a/apps/ui/src/features/workspace/server/workspace-route-table.ts b/apps/ui/src/features/workspace/server/workspace-route-table.ts index ed0ef053..ecb6c453 100644 --- a/apps/ui/src/features/workspace/server/workspace-route-table.ts +++ b/apps/ui/src/features/workspace/server/workspace-route-table.ts @@ -15,6 +15,10 @@ export interface WorkspaceRouteEntry { } export const WORKSPACE_ROUTES = { + details: { + apiPath: "/api/workspace/details", + desktopPath: "/api/auth/namespace/details", + }, list: { apiPath: "/api/workspace/list", desktopPath: "/api/auth/namespace/list", diff --git a/apps/ui/src/features/workspace/use-workspace-details.ts b/apps/ui/src/features/workspace/use-workspace-details.ts new file mode 100644 index 00000000..9b85e577 --- /dev/null +++ b/apps/ui/src/features/workspace/use-workspace-details.ts @@ -0,0 +1,78 @@ +"use client"; + +import useSWR from "swr"; + +import { sessionFetch } from "@/features/session/session-fetch"; +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; + +import { + type WorkspaceDetailsResponse, + workspaceDetailsResponseSchema, +} from "./workspace-details-schema"; + +export const WORKSPACE_DETAILS_API_PATH = "/api/workspace/details"; + +/** A failed details read, carrying Brain's status and error code. */ +export class WorkspaceDetailsError extends Error { + readonly code: string; + readonly status: number; + + constructor(status: number, code: string) { + super(`workspace details ${status} ${code}`); + this.name = "WorkspaceDetailsError"; + this.code = code; + this.status = status; + } +} + +async function fetchWorkspaceDetails( + uid: string +): Promise { + const response = await sessionFetch(WORKSPACE_DETAILS_API_PATH, { + body: JSON.stringify({ uid }), + cache: "no-store", + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + const payload: unknown = await response.json().catch(() => null); + const code = + typeof payload === "object" && + payload != null && + "error" in payload && + typeof payload.error === "string" + ? payload.error + : "unknown"; + throw new WorkspaceDetailsError(response.status, code); + } + return workspaceDetailsResponseSchema.parse(await response.json()); +} + +/** + * The Managed Workspace's members (spec §D.5) through the session fetch + * (regional token attached, 401 two-step). The key carries the credential + * fingerprint and the uid, so a re-established session or another + * selection refetches; null uid reads nothing. + */ +export function useWorkspaceDetails(uid: string | null): { + data: WorkspaceDetailsResponse | undefined; + /** A `WorkspaceDetailsError` for a refused read; any other Error otherwise. */ + error: Error | undefined; +} { + const credentials = useSessionCredentials(); + const { data, error } = useSWR( + uid == null || credentials.regionalToken === "" + ? null + : ([...SESSION_SWR_KEYS.workspaceDetails(credentials), uid] as const), + ([, , workspaceUid]) => fetchWorkspaceDetails(workspaceUid), + { revalidateOnFocus: false, shouldRetryOnError: false } + ); + let failure: Error | undefined; + if (error instanceof Error) { + failure = error; + } else if (error != null) { + failure = new Error(String(error)); + } + return { data, error: failure }; +} diff --git a/apps/ui/src/features/workspace/workspace-area-list.tsx b/apps/ui/src/features/workspace/workspace-area-list.tsx new file mode 100644 index 00000000..6cad5deb --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-area-list.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { WorkspaceAvatar } from "@workspace/ui/components/workspace-avatar"; +import { cn } from "@workspace/ui/lib/utils"; +import { Plus } from "lucide-react"; +import Link from "next/link"; + +import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import { workspaceAreaPath } from "./workspace-area-route-core"; + +const ROW_CLASS = + "flex h-9 shrink-0 items-center gap-2 rounded-md p-2 text-left text-sm leading-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 lg:w-full"; + +function roleLabel(workspace: SessionWorkspace): string { + return workspace.isPersonal ? "Personal" : workspace.role; +} + +/** + * The Workspace Area's list (spec §D.3): every Workspace the user belongs + * to, in Desktop's order, one row each — square avatar, name, a blue dot on + * the current Workspace, the user's role (or "Personal") — then a divider + * and the Create Workspace row into the Billing Area's creation mode. + * Choosing a row names the Managed Workspace in the URL and never switches + * the current Workspace. + */ +export function WorkspaceAreaList({ + currentUid, + managedUid, + workspaces, +}: { + currentUid: string | null; + managedUid: string | null; + workspaces: readonly SessionWorkspace[]; +}) { + return ( + + ); +} diff --git a/apps/ui/src/features/workspace/workspace-area-route-core.test.ts b/apps/ui/src/features/workspace/workspace-area-route-core.test.ts new file mode 100644 index 00000000..c3d7fec1 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-area-route-core.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import { + resolveManagedWorkspace, + WORKSPACE_NOT_IN_LIST_NOTICE, + workspaceAreaPath, +} from "./workspace-area-route-core"; + +const PERSONAL: SessionWorkspace = { + createdAt: "2026-01-05T09:00:00.000Z", + id: "ns-personal", + isPersonal: true, + name: "private team", + role: "Owner", + uid: "uid-personal", +}; +const ACME: SessionWorkspace = { + createdAt: "2026-02-14T09:00:00.000Z", + id: "ns-acme", + isPersonal: false, + name: "Acme", + role: "Manager", + uid: "uid-acme", +}; +const WORKSPACES = [PERSONAL, ACME]; + +test("/workspace replaces itself with the current Workspace, silently", () => { + assert.deepEqual( + resolveManagedWorkspace({ + currentUid: ACME.uid, + uid: null, + workspaces: WORKSPACES, + }), + { kind: "redirect", notice: null, to: "/workspace/uid-acme" } + ); + assert.deepEqual( + resolveManagedWorkspace({ + currentUid: ACME.uid, + uid: "", + workspaces: WORKSPACES, + }), + { kind: "redirect", notice: null, to: "/workspace/uid-acme" } + ); +}); + +test("a uid in the list is the Managed Workspace, whether or not it is the current one", () => { + assert.deepEqual( + resolveManagedWorkspace({ + currentUid: ACME.uid, + uid: PERSONAL.uid, + workspaces: WORKSPACES, + }), + { kind: "managed", workspace: PERSONAL } + ); + assert.deepEqual( + resolveManagedWorkspace({ + currentUid: ACME.uid, + uid: ACME.uid, + workspaces: WORKSPACES, + }), + { kind: "managed", workspace: ACME } + ); +}); + +test("a uid outside the list falls back to the current Workspace with a notice", () => { + assert.deepEqual( + resolveManagedWorkspace({ + currentUid: ACME.uid, + uid: "uid-elsewhere", + workspaces: WORKSPACES, + }), + { + kind: "redirect", + notice: WORKSPACE_NOT_IN_LIST_NOTICE, + to: "/workspace/uid-acme", + } + ); +}); + +test("nothing is judged before the session names a current Workspace", () => { + assert.deepEqual( + resolveManagedWorkspace({ + currentUid: null, + uid: "uid-elsewhere", + workspaces: [], + }), + { kind: "pending" } + ); +}); + +test("the area path URL-encodes the uid", () => { + assert.equal(workspaceAreaPath("a b/c"), "/workspace/a%20b%2Fc"); +}); diff --git a/apps/ui/src/features/workspace/workspace-area-route-core.ts b/apps/ui/src/features/workspace/workspace-area-route-core.ts new file mode 100644 index 00000000..c19a86bf --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-area-route-core.ts @@ -0,0 +1,51 @@ +import type { SessionWorkspace } from "@/features/session/session-schema"; + +/** + * The Workspace Area's route judgment (spec §D.1): which Workspace the URL + * names as the Managed Workspace, or where to go instead. `/workspace` + * without a uid replaces itself with the current Workspace; a uid outside + * the user's list does the same and says so. Nothing is judged before the + * session has a current Workspace. + */ + +export const WORKSPACE_NOT_IN_LIST_NOTICE = + "That Workspace is not in your list."; + +export function workspaceAreaPath(uid: string): string { + return `/workspace/${encodeURIComponent(uid)}`; +} + +export type ManagedWorkspaceResolution = + | { kind: "managed"; workspace: SessionWorkspace } + | { kind: "pending" } + | { kind: "redirect"; notice: string | null; to: string }; + +export function resolveManagedWorkspace(input: { + /** The current Workspace's uid; null until the session is established. */ + currentUid: string | null; + /** The `[uid]` segment; null on `/workspace` itself. */ + uid: string | null; + workspaces: readonly SessionWorkspace[]; +}): ManagedWorkspaceResolution { + if (input.currentUid == null) { + return { kind: "pending" }; + } + if (input.uid == null || input.uid === "") { + return { + kind: "redirect", + notice: null, + to: workspaceAreaPath(input.currentUid), + }; + } + const workspace = input.workspaces.find( + (candidate) => candidate.uid === input.uid + ); + if (workspace != null) { + return { kind: "managed", workspace }; + } + return { + kind: "redirect", + notice: WORKSPACE_NOT_IN_LIST_NOTICE, + to: workspaceAreaPath(input.currentUid), + }; +} diff --git a/apps/ui/src/features/workspace/workspace-area.test.tsx b/apps/ui/src/features/workspace/workspace-area.test.tsx new file mode 100644 index 00000000..b0011a97 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-area.test.tsx @@ -0,0 +1,583 @@ +import { afterEach, beforeEach, mock, test } from "bun:test"; +import assert from "node:assert/strict"; +import { getDefaultStore } from "jotai"; +import type { ReactNode } from "react"; + +import { + actAndDrain, + defineGlobal, + type GlobalOverride, + installTestDom, + jsonResponse, + requestUrl, + restoreActEnvironment, + restoreGlobal, + setActEnvironment, + type TestDom, +} from "@/features/project-canvas/react-test-harness"; +import type { SessionWorkspace } from "@/features/session/session-schema"; +import { + appTokenAtom, + currentWorkspaceAtom, + kubeconfigAtom, + namespaceAtom, + regionalTokenAtom, + sessionUserAtom, + workspacesAtom, +} from "@/lib/auth-store"; +import { REGION_TOKEN_HEADER } from "@/lib/region-token-header"; + +import type { WorkspaceMember } from "./workspace-details-schema"; +import { + INVITE_FIRST_REASON, + SWITCH_FIRST_REASON, +} from "./workspace-gating-core"; + +// The area's external facts — the route and Brain's `/api` — are stood in +// for; the assertions are the rendered DOM, the router calls, the toasts, +// and the requests issued. +const route = { + params: {} as { uid?: string }, + replaced: [] as string[], +}; +const toasts: string[] = []; +const copied: string[] = []; + +// Next's router is one stable object per app; the stand-in must be too, or +// every effect that lists it re-runs on every render. +const router = { + replace: (href: string) => { + route.replaced.push(href); + }, +}; +mock.module("next/navigation", () => ({ + useParams: () => route.params, + usePathname: () => "/workspace", + useRouter: () => router, +})); +mock.module("next/link", () => ({ + default({ + children, + href, + ...props + }: { children?: ReactNode; href: string } & Record) { + return ( + + {children} + + ); + }, +})); +mock.module("sonner", () => ({ + toast: (message: string) => { + toasts.push(message); + }, +})); + +const ACME_NAME_RE = /Acme/; +const ACME_ID_RE = /ns-acme/; + +const ME = { + avatar: "", + crName: "ada", + name: "Ada Lovelace", + userId: "usr-ada", + userUid: "user-uid-ada", +}; + +const PERSONAL: SessionWorkspace = { + createdAt: "2026-01-05T09:00:00.000Z", + id: "ns-personal", + isPersonal: true, + name: "private team", + role: "Owner", + uid: "uid-personal", +}; +const acme = (role: SessionWorkspace["role"]): SessionWorkspace => ({ + createdAt: "2026-02-14T09:00:00.000Z", + id: "ns-acme", + isPersonal: false, + name: "Acme", + role, + uid: "uid-acme", +}); +const SOLO: SessionWorkspace = { + createdAt: "2026-09-01T09:00:00.000Z", + id: "ns-solo", + isPersonal: false, + name: "Solo", + role: "Owner", + uid: "uid-solo", +}; + +function member( + crName: string, + nickname: string, + role: WorkspaceMember["role"], + joinedAt: string, + alias: string | null = null +): WorkspaceMember { + return { + alias, + avatarUrl: "", + crName, + crUid: `cr-${crName}`, + joinedAt, + nickname, + role, + userUid: `uid-${crName}`, + }; +} + +const MEMBERS: Record = { + // Acme as its Owner (scenario "owner"), a Manager, or a Developer. + "developer:uid-acme": [ + member("kai", "Kai", "Owner", "2026-02-14T09:00:00.000Z"), + member("ming", "Ming", "Manager", "2026-02-15T09:00:00.000Z", "Docs PM"), + member("ada", "Ada Lovelace", "Developer", "2026-03-05T09:00:00.000Z"), + ], + "manager:uid-acme": [ + member("rui", "Rui", "Owner", "2026-02-14T09:00:00.000Z"), + member("ada", "Ada Lovelace", "Manager", "2026-02-15T09:00:00.000Z"), + member("yu", "Yu", "Manager", "2026-02-25T09:00:00.000Z"), + member("qi", "Qi", "Developer", "2026-07-30T09:00:00.000Z"), + ], + "owner:uid-acme": [ + member("ada", "Ada Lovelace", "Owner", "2026-02-14T09:00:00.000Z"), + member( + "lin", + "Lin Wei", + "Manager", + "2026-02-20T09:00:00.000Z", + "Frontend lead" + ), + member("chen", "Chen Jie", "Developer", "2026-04-11T09:00:00.000Z"), + ], + "owner:uid-solo": [ + member("ada", "Ada Lovelace", "Owner", "2026-09-01T09:00:00.000Z"), + ], +}; +const PERSONAL_MEMBERS = [ + member("ada", "Ada Lovelace", "Owner", "2026-01-05T09:00:00.000Z"), +]; + +type Scenario = "developer" | "manager" | "owner" | "personal-only"; + +const WORKSPACES: Record = { + developer: [PERSONAL, acme("Developer")], + manager: [PERSONAL, acme("Manager")], + owner: [PERSONAL, acme("Owner"), SOLO], + "personal-only": [PERSONAL], +}; + +const fixtures = { + plans: { + "ns-acme": "Pro", + "ns-personal": "Hobby", + "ns-solo": null, + } as Record, + scenario: "owner" as Scenario, +}; +const requests: { + body: unknown; + headers: Headers; + method: string; + url: string; +}[] = []; + +function answer(url: string, body: unknown): Response { + if (url === "/api/workspace/list") { + return jsonResponse(WORKSPACES[fixtures.scenario]); + } + if (url === "/api/workspace/details") { + const uid = (body as { uid: string }).uid; + const workspace = WORKSPACES[fixtures.scenario].find( + (candidate) => candidate.uid === uid + ); + if (workspace == null) { + return new Response(JSON.stringify({ error: "workspace_not_found" }), { + headers: { "content-type": "application/json" }, + status: 404, + }); + } + const members = workspace.isPersonal + ? PERSONAL_MEMBERS + : MEMBERS[`${fixtures.scenario}:${uid}`]; + return jsonResponse({ members, workspace }); + } + if (url.startsWith("/api/billing/workspace-plans?")) { + return jsonResponse({ plans: fixtures.plans }); + } + return new Response("{}", { status: 404 }); +} + +function fetchStub(input: unknown, init?: RequestInit): Promise { + const url = requestUrl(input); + const headers = new Headers(init?.headers); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + requests.push({ body, headers, method: init?.method ?? "GET", url }); + return Promise.resolve(answer(url, body)); +} + +// Base UI resolves its isomorphic layout effect at module load — with no +// DOM registered it becomes a permanent noop and menus can never open. +const moduleDom = installTestDom(); +const { render } = await import("@testing-library/react/pure"); +const { JotaiProvider } = await import("@/features/shell/jotai-provider"); +const { WorkspaceArea } = await import("./workspace-area"); +const { WORKSPACE_NOT_IN_LIST_NOTICE } = await import( + "./workspace-area-route-core" +); +const { WORKSPACE_ID_COPIED_NOTICE } = await import( + "./workspace-detail-header" +); +await moduleDom.restore(); + +let dom: TestDom; +let actEnvironment: boolean | undefined; +let fetchOverride: GlobalOverride; +let rendered: ReturnType | undefined; +let sessionCounter = 0; + +// Each scenario gets its own regional token so SWR never replays another +// test's details across the credential-keyed cache. +function hydrate(scenario: Scenario, currentUid: string) { + fixtures.scenario = scenario; + sessionCounter += 1; + const workspaces = WORKSPACES[scenario]; + const current = workspaces.find((workspace) => workspace.uid === currentUid); + assert.ok(current, `${currentUid} is in the ${scenario} list`); + const store = getDefaultStore(); + store.set(appTokenAtom, "desktop-app-token"); + store.set(kubeconfigAtom, "apiVersion: v1"); + store.set(namespaceAtom, current.id); + store.set(regionalTokenAtom, `regional-${scenario}-${sessionCounter}`); + store.set(currentWorkspaceAtom, current); + store.set(workspacesAtom, workspaces); + store.set(sessionUserAtom, ME); +} + +beforeEach(() => { + dom = installTestDom(); + actEnvironment = setActEnvironment(true); + fetchOverride = defineGlobal("fetch", fetchStub); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + copied.push(text); + return Promise.resolve(); + }, + }, + }); + route.params = {}; + route.replaced = []; + toasts.length = 0; + copied.length = 0; + requests.length = 0; +}); + +afterEach(async () => { + await actAndDrain(() => { + rendered?.unmount(); + rendered = undefined; + }).catch(() => undefined); + restoreGlobal(fetchOverride); + restoreActEnvironment(actEnvironment); + await dom.restore(); +}); + +async function mountArea(uid: string | undefined) { + route.params = uid == null ? {} : { uid }; + await actAndDrain(() => { + rendered = render( + + + + ); + }); + // The details and plans land on the next ticks. + await actAndDrain(() => undefined, 20); +} + +function byLabel(label: string): HTMLElement | null { + return document.querySelector(`[aria-label="${label}"]`); +} + +function bySlot(slot: string): HTMLElement | null { + return document.querySelector(`[data-slot="${slot}"]`); +} + +function allBySlot(slot: string): HTMLElement[] { + return [...document.querySelectorAll(`[data-slot="${slot}"]`)]; +} + +function memberRowNames(): string[] { + return allBySlot("workspace-member-row").map( + (row) => row.querySelector("td span.truncate")?.textContent ?? "" + ); +} + +function tableHeadCount(): number { + return document.querySelectorAll('[data-slot="table-head"]').length; +} + +async function openActionsMenu(): Promise { + const trigger = byLabel("Workspace actions"); + assert.ok(trigger, "the ⋯ menu trigger is rendered"); + await actAndDrain(() => { + trigger.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, button: 0 }) + ); + trigger.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0 }) + ); + trigger.click(); + }); + const menu = bySlot("dropdown-menu-content"); + assert.ok(menu, "the ⋯ menu opened"); + return menu; +} + +function menuItems(menu: HTMLElement): { + disabled: boolean; + label: string; + reason: string | null; +}[] { + return [ + ...menu.querySelectorAll('[data-slot="dropdown-menu-item"]'), + ].map((item) => ({ + disabled: + item.hasAttribute("data-disabled") || item.ariaDisabled === "true", + label: item.querySelector("span > span")?.textContent ?? "", + reason: + item.querySelector('[data-slot="workspace-action-reason"]') + ?.textContent ?? null, + })); +} + +test("the Owner of the current Team Workspace: every control, delete waiting for a switch, rows gated per member", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + + // The list: Desktop's order, the current dot, roles, the create row. + const rows = allBySlot("workspace-area-row"); + assert.deepEqual( + rows.map((row) => row.textContent), + ["private teamPersonal", "AcmeCurrent workspaceOwner", "SoloOwner"].map( + (text) => text.replace("Current workspace", "") + ) + ); + assert.equal(rows[1]?.getAttribute("aria-current"), "page"); + assert.ok(rows[1]?.querySelector('[data-slot="workspace-area-current-dot"]')); + assert.equal( + rows[0]?.querySelector('[data-slot="workspace-area-current-dot"]'), + null + ); + assert.equal( + bySlot("workspace-area-create")?.getAttribute("href"), + "/billing?mode=create" + ); + + // The header: name, plan, Current, the role line, the copyable id. + const header = bySlot("workspace-detail-header"); + assert.ok(header); + assert.match(header.textContent ?? "", ACME_NAME_RE); + assert.equal(bySlot("plan-badge")?.textContent, "Pro"); + assert.ok(bySlot("workspace-current-badge")); + assert.equal(bySlot("workspace-detail-role")?.textContent, "You're Owner"); + assert.match(byLabel("Copy workspace ID")?.textContent ?? "", ACME_ID_RE); + assert.equal(byLabel("Leave workspace"), null, "the Owner cannot leave"); + + const items = menuItems(await openActionsMenu()); + assert.deepEqual(items, [ + { disabled: false, label: "Rename…", reason: null }, + { disabled: false, label: "Transfer ownership…", reason: null }, + { + disabled: true, + label: "Delete workspace…", + reason: SWITCH_FIRST_REASON, + }, + ]); + + // The members: count, Invite, a select for the others, You on my row, + // the alias subline, the pencil everywhere, remove for the others only. + assert.equal(bySlot("workspace-members-count")?.textContent, "3"); + assert.ok(byLabel("Invite member")); + assert.deepEqual(memberRowNames(), ["Ada Lovelace", "Lin Wei", "Chen Jie"]); + assert.equal(allBySlot("workspace-member-you").length, 1); + assert.equal(bySlot("workspace-member-alias")?.textContent, "Frontend lead"); + assert.ok(byLabel("Role of Lin Wei")); + assert.ok(byLabel("Role of Chen Jie")); + assert.equal(byLabel("Role of Ada Lovelace"), null); + assert.deepEqual( + allBySlot("workspace-member-role").map((role) => role.textContent), + ["Owner"] + ); + assert.ok(byLabel("Set alias for Ada Lovelace")); + assert.ok(byLabel("Edit alias for Lin Wei")); + assert.ok(byLabel("Remove Lin Wei")); + assert.ok(byLabel("Remove Chen Jie")); + assert.equal(byLabel("Remove Ada Lovelace"), null); + assert.equal(tableHeadCount(), 4); + + // The details read went through the session fetch with the uid. + const details = requests.find((r) => r.url === "/api/workspace/details"); + assert.ok(details); + assert.equal(details.method, "POST"); + assert.deepEqual(details.body, { uid: "uid-acme" }); + assert.equal( + details.headers.get(REGION_TOKEN_HEADER), + `regional-owner-${sessionCounter}` + ); + assert.deepEqual(route.replaced, []); + assert.deepEqual(toasts, []); +}); + +test("the Owner managing a Workspace they are alone in and not working in: delete is live, transfer waits for a member", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-solo"); + + assert.equal(bySlot("workspace-current-badge"), null); + assert.equal(bySlot("plan-payg")?.textContent, "PAYG"); + const items = menuItems(await openActionsMenu()); + assert.deepEqual(items, [ + { disabled: false, label: "Rename…", reason: null }, + { + disabled: true, + label: "Transfer ownership…", + reason: INVITE_FIRST_REASON, + }, + { disabled: false, label: "Delete workspace…", reason: null }, + ]); + assert.deepEqual(memberRowNames(), ["Ada Lovelace"]); + // Nobody removable: no action column at all. + assert.equal(tableHeadCount(), 3); +}); + +test("a Manager: invites and removes Developers, sets any alias, changes no role, leaves unless it is the current Workspace", async () => { + hydrate("manager", "uid-personal"); + await mountArea("uid-acme"); + + assert.equal(byLabel("Workspace actions"), null, "no ⋯ menu"); + const leave = byLabel("Leave workspace"); + assert.ok(leave); + assert.equal(leave.hasAttribute("disabled"), false); + assert.equal(bySlot("workspace-detail-role")?.textContent, "You're Manager"); + assert.ok(byLabel("Invite member")); + + assert.deepEqual(memberRowNames(), ["Rui", "Ada Lovelace", "Yu", "Qi"]); + assert.equal(document.querySelectorAll('[aria-label^="Role of"]').length, 0); + assert.deepEqual( + allBySlot("workspace-member-role").map((role) => role.textContent), + ["Owner", "Manager", "Manager", "Developer"] + ); + assert.ok(byLabel("Set alias for Rui"), "the Owner's alias too"); + assert.ok(byLabel("Set alias for Ada Lovelace"), "my own alias too"); + assert.ok(byLabel("Remove Qi")); + assert.equal(byLabel("Remove Yu"), null); + assert.equal(byLabel("Remove Rui"), null); + assert.equal(byLabel("Remove Ada Lovelace"), null); + assert.equal(tableHeadCount(), 4); +}); + +test("a Manager working in the Workspace cannot leave it yet", async () => { + hydrate("manager", "uid-acme"); + await mountArea("uid-acme"); + + const leave = byLabel("Leave workspace"); + assert.ok(leave); + assert.equal(leave.hasAttribute("disabled"), true); + assert.ok(bySlot("workspace-current-badge")); +}); + +test("a Developer reads the member table and can leave; nothing else is offered", async () => { + hydrate("developer", "uid-personal"); + await mountArea("uid-acme"); + + assert.equal(byLabel("Workspace actions"), null); + assert.ok(byLabel("Leave workspace")); + assert.equal(byLabel("Invite member"), null); + assert.deepEqual(memberRowNames(), ["Kai", "Ming", "Ada Lovelace"]); + assert.equal(allBySlot("workspace-member-alias-edit").length, 0); + assert.equal(allBySlot("workspace-member-remove").length, 0); + assert.equal(tableHeadCount(), 3, "no action column"); + assert.equal(document.querySelectorAll('[aria-label^="Role of"]').length, 0); + assert.equal(bySlot("workspace-member-alias")?.textContent, "Docs PM"); +}); + +test("only a Personal Workspace: one row and Create, the detail with Rename alone and a member table of one", async () => { + hydrate("personal-only", "uid-personal"); + await mountArea("uid-personal"); + + assert.equal(allBySlot("workspace-area-row").length, 1); + assert.ok(bySlot("workspace-area-create")); + assert.equal( + bySlot("workspace-detail-role")?.textContent, + "Personal workspace" + ); + assert.equal(byLabel("Leave workspace"), null); + const menu = await openActionsMenu(); + assert.deepEqual(menuItems(menu), [ + { disabled: false, label: "Rename…", reason: null }, + ]); + assert.equal( + menu.querySelector('[data-slot="dropdown-menu-separator"]'), + null + ); + assert.equal(bySlot("workspace-members-count")?.textContent, "1"); + assert.equal(allBySlot("workspace-member-you").length, 1); + assert.equal(tableHeadCount(), 3); +}); + +test("/workspace replaces itself with the current Workspace, silently", async () => { + hydrate("owner", "uid-acme"); + await mountArea(undefined); + + assert.deepEqual(route.replaced, ["/workspace/uid-acme"]); + assert.deepEqual(toasts, []); + assert.equal(bySlot("workspace-detail"), null); +}); + +test("a uid outside the list falls back to the current Workspace and says so once", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-elsewhere"); + + assert.deepEqual(route.replaced, ["/workspace/uid-acme"]); + assert.deepEqual(toasts, [WORKSPACE_NOT_IN_LIST_NOTICE]); + assert.equal( + requests.some((r) => r.url === "/api/workspace/details"), + false, + "no details read for a Workspace outside the list" + ); +}); + +test("the close button returns to the recorded entry point, or home on a direct entry", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + assert.equal(byLabel("Close workspaces")?.getAttribute("href"), "/"); + + await actAndDrain(() => { + rendered?.unmount(); + rendered = undefined; + }); + window.sessionStorage.setItem("workspace-return-route", "/project/alpha"); + await mountArea("uid-acme"); + assert.equal( + byLabel("Close workspaces")?.getAttribute("href"), + "/project/alpha" + ); +}); + +test("copying the workspace id writes the namespace id and says so", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + + const copy = byLabel("Copy workspace ID"); + assert.ok(copy); + await actAndDrain(() => { + copy.click(); + }); + assert.deepEqual(copied, ["ns-acme"]); + assert.deepEqual(toasts, [WORKSPACE_ID_COPIED_NOTICE]); +}); diff --git a/apps/ui/src/features/workspace/workspace-area.tsx b/apps/ui/src/features/workspace/workspace-area.tsx new file mode 100644 index 00000000..88996203 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-area.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { UsersRound } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef } from "react"; +import { toast } from "sonner"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; +import { AreaShell } from "@/features/shell/area-shell"; +import { currentWorkspaceAtom, sessionUserAtom } from "@/lib/auth-store"; + +import { useWorkspaceDetails } from "./use-workspace-details"; +import { useWorkspaceList } from "./use-workspace-list"; +import { useWorkspacePlans } from "./use-workspace-plans"; +import { WorkspaceAreaList } from "./workspace-area-list"; +import { resolveManagedWorkspace } from "./workspace-area-route-core"; +import { WorkspaceDetailHeader } from "./workspace-detail-header"; +import { + gateWorkspaceActions, + type WorkspaceGateInput, +} from "./workspace-gating-core"; +import { WorkspaceMembersPanel } from "./workspace-members-panel"; +import { readWorkspaceReturnRoute } from "./workspace-return-route"; + +/** The area's icon; matches the Manage Workspaces row of the Switcher. */ +function WorkspaceAreaIcon() { + return ( + + ); +} + +/** + * The Managed Workspace's detail column (spec §D.4–D.5): the header from + * the list's own entry (name, role, Personal are the list's verdict), the + * members from `POST /api/workspace/details`. + */ +function WorkspaceDetail({ + isCurrent, + meCrName, + planName, + workspace, +}: { + isCurrent: boolean; + meCrName: string; + planName: string | null | undefined; + workspace: SessionWorkspace; +}) { + const details = useWorkspaceDetails(workspace.uid); + const members = details.data?.members; + const gateInput: WorkspaceGateInput = useMemo( + () => ({ + actorRole: workspace.role, + isCurrent, + isPersonal: workspace.isPersonal, + // The member count is the one fact the gates wait on; until the + // members land, transfer is judged as if there were someone to + // transfer to (Desktop is the authority either way). + memberCount: members?.length ?? 2, + }), + [isCurrent, members, workspace.isPersonal, workspace.role] + ); + const gates = gateWorkspaceActions(gateInput); + return ( +
+ + +
+ ); +} + +/** + * The Workspace Area (spec §D, CONTEXT.md): the list of every Workspace + * the user belongs to beside the detail of the Managed Workspace the URL + * names. `/workspace` replaces itself with the current Workspace; a uid + * outside the list falls back to it with a notice (§D.1). Selecting a + * row never switches the current Workspace. The shell's close button + * returns to the address recorded on the way in — home on a URL-direct + * entry. + */ +export function WorkspaceArea() { + const params = useParams<{ uid?: string }>(); + const router = useRouter(); + const current = useAtomValue(currentWorkspaceAtom); + const user = useAtomValue(sessionUserAtom); + const workspaces = useWorkspaceList(); + const workspaceIds = useMemo( + () => workspaces.map((workspace) => workspace.id), + [workspaces] + ); + const plans = useWorkspacePlans(workspaceIds); + + const uid = params.uid ?? null; + const resolution = resolveManagedWorkspace({ + currentUid: current?.uid ?? null, + uid, + workspaces, + }); + const redirectTo = resolution.kind === "redirect" ? resolution.to : null; + const notice = resolution.kind === "redirect" ? resolution.notice : null; + // One notice per unknown uid, whatever React's effect cadence. + const noticedUid = useRef(null); + useEffect(() => { + if (redirectTo == null) { + return; + } + router.replace(redirectTo); + if (notice != null && noticedUid.current !== uid) { + noticedUid.current = uid; + toast(notice); + } + }, [notice, redirectTo, router, uid]); + + const managed = resolution.kind === "managed" ? resolution.workspace : null; + + return ( + + } + asideClassName="lg:w-60" + closeLabel="Close workspaces" + icon={} + readReturnRoute={readWorkspaceReturnRoute} + slot="workspace-area-shell" + title="Workspaces" + > +
+ {managed == null || current == null ? null : ( + + )} +
+
+ ); +} diff --git a/apps/ui/src/features/workspace/workspace-detail-header.tsx b/apps/ui/src/features/workspace/workspace-detail-header.tsx new file mode 100644 index 00000000..485822b6 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-detail-header.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { AppButton } from "@workspace/ui/components/app-button"; +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; +import { Badge } from "@workspace/ui/components/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@workspace/ui/components/dropdown-menu"; +import { PlanBadge } from "@workspace/ui/components/plan-badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip"; +import { WorkspaceAvatar } from "@workspace/ui/components/workspace-avatar"; +import { + ArrowLeftRight, + Copy, + LogOut, + MoreHorizontal, + Pencil, + Trash2, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { toast } from "sonner"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import type { + WorkspaceActionGate, + WorkspaceActionGates, +} from "./workspace-gating-core"; + +export const WORKSPACE_ID_COPIED_NOTICE = "Workspace ID copied"; + +function copyWorkspaceId(id: string): void { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return; + } + navigator.clipboard + .writeText(id) + .then(() => toast(WORKSPACE_ID_COPIED_NOTICE)) + .catch(() => undefined); +} + +/** The plan badge, the quiet PAYG word, or nothing while the plan is unknown. */ +function PlanSlot({ planName }: { planName: string | null | undefined }) { + if (planName === undefined) { + return null; + } + if (planName === null) { + return ( + + PAYG + + ); + } + return ; +} + +/** A disabled control explained by a tooltip (a state gate). */ +function WithReason({ + children, + reason, +}: { + children: ReactNode; + reason: string | null; +}) { + if (reason == null) { + return children; + } + return ( + + {/* A disabled button fires no pointer events; the span carries them. */} + }> + {children} + + {reason} + + ); +} + +function disabledReason(gate: WorkspaceActionGate): string | null { + return gate.kind === "disabled" ? gate.reason : null; +} + +/** A menu row whose second line, when present, says why it is disabled. */ +function MenuAction({ + gate, + icon, + label, + variant, +}: { + gate: WorkspaceActionGate; + icon: ReactNode; + label: string; + variant?: "default" | "destructive"; +}) { + const reason = disabledReason(gate); + return ( + + {icon} + + {label} + {reason == null ? null : ( + + {reason} + + )} + + + ); +} + +/** + * The Owner's ⋯ menu (spec §D.4): Rename for every Owner; for a Team + * Workspace a divider, Transfer ownership, and Delete workspace, each + * disabled with its reason on a second line when a state gate holds. + */ +function WorkspaceActionsMenu({ gates }: { gates: WorkspaceActionGates }) { + const dangerous = + gates.transfer.kind !== "hidden" || gates.delete.kind !== "hidden"; + return ( + + + + + } + /> + + {gates.rename.kind === "hidden" ? null : ( + } + label="Rename…" + /> + )} + {gates.rename.kind !== "hidden" && dangerous ? ( + + ) : null} + {gates.transfer.kind === "hidden" ? null : ( + } + label="Transfer ownership…" + /> + )} + {gates.delete.kind === "hidden" ? null : ( + } + label="Delete workspace…" + variant="destructive" + /> + )} + + + ); +} + +/** + * The Managed Workspace's detail header (spec §D.4): a 40px square avatar, + * the name, the plan badge, a Current badge when it is the session's + * Workspace; below, the facts line — the user's role or "Personal + * workspace", and the copyable namespace id. On the right a non-Owner's + * Leave workspace button (disabled with a tooltip while it is the current + * Workspace) or the Owner's ⋯ menu. The controls are rendered from the + * gates; the operations behind them arrive with the write routes. + */ +export function WorkspaceDetailHeader({ + gates, + isCurrent, + planName, + workspace, +}: { + gates: WorkspaceActionGates; + isCurrent: boolean; + planName: string | null | undefined; + workspace: SessionWorkspace; +}) { + const leaveReason = disabledReason(gates.leave); + const showMenu = + gates.rename.kind !== "hidden" || + gates.transfer.kind !== "hidden" || + gates.delete.kind !== "hidden"; + return ( +
+ +
+
+

+ {workspace.name} +

+ + {isCurrent ? ( + + Current + + ) : null} +
+
+ + {workspace.isPersonal + ? "Personal workspace" + : `You're ${workspace.role}`} + + · + +
+
+ {gates.leave.kind === "hidden" ? null : ( + + + + Leave workspace + + + )} + {showMenu ? : null} +
+ ); +} diff --git a/apps/ui/src/features/workspace/workspace-details-schema.ts b/apps/ui/src/features/workspace/workspace-details-schema.ts new file mode 100644 index 00000000..7f5d8eae --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-details-schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +import { + sessionWorkspaceSchema, + workspaceRoleSchema, +} from "@/features/session/session-schema"; + +/** + * `POST /api/workspace/details { uid }` (spec §B.2): the Managed Workspace + * and its members in Brain's own shape. Client-safe: the Workspace Area + * validates the response with it and the server answers in it. + */ + +export const workspaceDetailsRequestSchema = z.object({ + /** The Workspace uid (uuid); what Desktop's `details` takes as `ns_uid`. */ + uid: z.string().trim().min(1), +}); + +export type WorkspaceDetailsRequest = z.infer< + typeof workspaceDetailsRequestSchema +>; + +export const workspaceMemberSchema = z.object({ + /** The alias set for this member in this Workspace; null when unset. */ + alias: z.string().nullable(), + avatarUrl: z.string(), + /** The regional User CR name; `=== session.user.crName` marks "You". */ + crName: z.string(), + /** The membership record's User CR uid; what the member routes target. */ + crUid: z.string().min(1), + joinedAt: z.string(), + nickname: z.string(), + role: workspaceRoleSchema, + /** The global user UID. */ + userUid: z.string(), +}); + +export type WorkspaceMember = z.infer; + +export const workspaceDetailsResponseSchema = z.object({ + members: z.array(workspaceMemberSchema), + workspace: sessionWorkspaceSchema, +}); + +export type WorkspaceDetailsResponse = z.infer< + typeof workspaceDetailsResponseSchema +>; diff --git a/apps/ui/src/features/workspace/workspace-gating-core.test.ts b/apps/ui/src/features/workspace/workspace-gating-core.test.ts new file mode 100644 index 00000000..1bce2705 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-gating-core.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + ASSIGNABLE_ROLES, + gateMemberActions, + gateWorkspaceActions, + INVITE_FIRST_REASON, + inviteRoleOptions, + SWITCH_FIRST_REASON, + type WorkspaceGateInput, +} from "./workspace-gating-core"; + +const ENABLED = { kind: "enabled" } as const; +const HIDDEN = { kind: "hidden" } as const; + +const OWNER_TEAM: WorkspaceGateInput = { + actorRole: "Owner", + isCurrent: false, + isPersonal: false, + memberCount: 4, +}; +const MANAGER: WorkspaceGateInput = { ...OWNER_TEAM, actorRole: "Manager" }; +const DEVELOPER: WorkspaceGateInput = { + ...OWNER_TEAM, + actorRole: "Developer", +}; +const PERSONAL: WorkspaceGateInput = { + actorRole: "Owner", + isCurrent: true, + isPersonal: true, + memberCount: 1, +}; + +// Spec §E.1–E.2: the Owner of a Team Workspace manages everything; the +// role gates of the others hide, the state gates disable with a reason. +test("the Owner of a Team Workspace sees every action; state gates disable with a reason", () => { + assert.deepEqual(gateWorkspaceActions(OWNER_TEAM), { + delete: ENABLED, + invite: ENABLED, + leave: HIDDEN, + rename: ENABLED, + transfer: ENABLED, + }); + // Working in the Workspace: delete waits for a switch. + assert.deepEqual( + gateWorkspaceActions({ ...OWNER_TEAM, isCurrent: true }).delete, + { kind: "disabled", reason: SWITCH_FIRST_REASON } + ); + // Alone in the Workspace: transfer waits for a member. + assert.deepEqual( + gateWorkspaceActions({ ...OWNER_TEAM, memberCount: 1 }).transfer, + { kind: "disabled", reason: INVITE_FIRST_REASON } + ); +}); + +test("a Manager invites and reads the rest; leaving waits while it is the current Workspace", () => { + assert.deepEqual(gateWorkspaceActions(MANAGER), { + delete: HIDDEN, + invite: ENABLED, + leave: ENABLED, + rename: HIDDEN, + transfer: HIDDEN, + }); + assert.deepEqual( + gateWorkspaceActions({ ...MANAGER, isCurrent: true }).leave, + { + kind: "disabled", + reason: SWITCH_FIRST_REASON, + } + ); +}); + +test("a Developer only reads, and can leave", () => { + assert.deepEqual(gateWorkspaceActions(DEVELOPER), { + delete: HIDDEN, + invite: HIDDEN, + leave: ENABLED, + rename: HIDDEN, + transfer: HIDDEN, + }); +}); + +test("the Personal Workspace can be renamed but never deleted, transferred, or left", () => { + assert.deepEqual(gateWorkspaceActions(PERSONAL), { + delete: HIDDEN, + invite: ENABLED, + leave: HIDDEN, + rename: ENABLED, + transfer: HIDDEN, + }); + // Not the current Workspace either: still hidden, not merely disabled. + assert.deepEqual( + gateWorkspaceActions({ ...PERSONAL, isCurrent: false }).delete, + HIDDEN + ); +}); + +// Spec §E.1, §E.3–E.4: the member rows. +test("the Owner manages every other member and never their own row or another Owner", () => { + const others = { isSelf: false, targetRole: "Manager" } as const; + assert.deepEqual(gateMemberActions(OWNER_TEAM, others), { + changeRole: ENABLED, + remove: ENABLED, + setAlias: ENABLED, + }); + assert.deepEqual( + gateMemberActions(OWNER_TEAM, { isSelf: false, targetRole: "Developer" }), + { changeRole: ENABLED, remove: ENABLED, setAlias: ENABLED } + ); + // The Owner's own row: alias only; the role changes through transfer. + assert.deepEqual( + gateMemberActions(OWNER_TEAM, { isSelf: true, targetRole: "Owner" }), + { changeRole: HIDDEN, remove: HIDDEN, setAlias: ENABLED } + ); +}); + +test("a Manager removes Developers, sets anyone's alias, changes nobody's role", () => { + assert.deepEqual( + gateMemberActions(MANAGER, { isSelf: false, targetRole: "Developer" }), + { changeRole: HIDDEN, remove: ENABLED, setAlias: ENABLED } + ); + assert.deepEqual( + gateMemberActions(MANAGER, { isSelf: false, targetRole: "Manager" }), + { changeRole: HIDDEN, remove: HIDDEN, setAlias: ENABLED } + ); + assert.deepEqual( + gateMemberActions(MANAGER, { isSelf: false, targetRole: "Owner" }), + { changeRole: HIDDEN, remove: HIDDEN, setAlias: ENABLED } + ); + // Their own row: removal is "Leave" in the header, not a row action. + assert.deepEqual( + gateMemberActions(MANAGER, { isSelf: true, targetRole: "Manager" }), + { changeRole: HIDDEN, remove: HIDDEN, setAlias: ENABLED } + ); +}); + +test("a Developer's rows carry no action at all", () => { + for (const targetRole of ["Owner", "Manager", "Developer"] as const) { + for (const isSelf of [false, true]) { + assert.deepEqual( + gateMemberActions(DEVELOPER, { isSelf, targetRole }), + { changeRole: HIDDEN, remove: HIDDEN, setAlias: HIDDEN }, + `${targetRole} self=${isSelf}` + ); + } + } +}); + +test("role choices never include Owner; invite roles follow the actor", () => { + assert.deepEqual(ASSIGNABLE_ROLES, ["Manager", "Developer"]); + assert.deepEqual(inviteRoleOptions("Owner"), ["Manager", "Developer"]); + assert.deepEqual(inviteRoleOptions("Manager"), ["Developer"]); + assert.deepEqual(inviteRoleOptions("Developer"), []); +}); diff --git a/apps/ui/src/features/workspace/workspace-gating-core.ts b/apps/ui/src/features/workspace/workspace-gating-core.ts new file mode 100644 index 00000000..4ee00c22 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-gating-core.ts @@ -0,0 +1,133 @@ +import type { WorkspaceRole } from "@/features/session/session-schema"; + +/** + * The Workspace Area's gating (spec §E, mirroring Desktop's `vaildManage` + * matrix): for each action, whether the actor sees it, sees it disabled + * with a reason, or can use it. Two kinds of gate — a *role* gate is + * permanent and hides the control (a Developer never invites); a *state* + * gate lifts when a condition changes and disables the control with one + * line saying which condition (you cannot delete the Workspace you are + * working in). The Personal Workspace can never be deleted or transferred, + * so those are hidden there; it can be renamed. + * + * A pure module: the Workspace Area and the Switcher read from it, Desktop + * stays the authority (a stale verdict is corrected by its 403 / 404). + */ + +export type WorkspaceActionGate = + | { kind: "enabled" } + | { kind: "disabled"; reason: string } + | { kind: "hidden" }; + +export const SWITCH_FIRST_REASON = "Switch to another Workspace first."; +export const INVITE_FIRST_REASON = "Invite a member first."; + +const ENABLED: WorkspaceActionGate = { kind: "enabled" }; +const HIDDEN: WorkspaceActionGate = { kind: "hidden" }; + +function disabled(reason: string): WorkspaceActionGate { + return { kind: "disabled", reason }; +} + +export interface WorkspaceGateInput { + /** The actor's Workspace Role in the Managed Workspace. */ + actorRole: WorkspaceRole; + /** Whether the Managed Workspace is the one the session works in. */ + isCurrent: boolean; + isPersonal: boolean; + /** How many members the Managed Workspace has, the actor included. */ + memberCount: number; +} + +export interface WorkspaceActionGates { + delete: WorkspaceActionGate; + invite: WorkspaceActionGate; + leave: WorkspaceActionGate; + rename: WorkspaceActionGate; + transfer: WorkspaceActionGate; +} + +/** The Workspace-level actions: the detail header and the Members panel. */ +export function gateWorkspaceActions( + input: WorkspaceGateInput +): WorkspaceActionGates { + const owner = input.actorRole === "Owner"; + const dangerous = owner && !input.isPersonal; + // The state gate delete and leave share: never the Workspace you are in. + const unlessCurrent = input.isCurrent + ? disabled(SWITCH_FIRST_REASON) + : ENABLED; + let transfer: WorkspaceActionGate = HIDDEN; + if (dangerous) { + transfer = input.memberCount < 2 ? disabled(INVITE_FIRST_REASON) : ENABLED; + } + return { + delete: dangerous ? unlessCurrent : HIDDEN, + invite: input.actorRole === "Developer" ? HIDDEN : ENABLED, + // Leaving is removing yourself; the Owner leaves only by transferring. + leave: owner ? HIDDEN : unlessCurrent, + rename: owner ? ENABLED : HIDDEN, + transfer, + }; +} + +export interface MemberGateInput { + /** Whether the row is the actor's own membership. */ + isSelf: boolean; + targetRole: WorkspaceRole; +} + +export interface MemberActionGates { + changeRole: WorkspaceActionGate; + remove: WorkspaceActionGate; + setAlias: WorkspaceActionGate; +} + +/** The per-row actions of the members table. */ +export function gateMemberActions( + input: WorkspaceGateInput, + member: MemberGateInput +): MemberActionGates { + const actor = input.actorRole; + let remove: WorkspaceActionGate = HIDDEN; + // Nobody removes themselves here (that is "Leave") and nobody removes + // the Owner; the Owner removes anyone else, a Manager removes Developers. + if (!member.isSelf && member.targetRole !== "Owner") { + if (actor === "Owner") { + remove = ENABLED; + } else if (actor === "Manager" && member.targetRole === "Developer") { + remove = ENABLED; + } + } + return { + // Only the Owner changes roles, and only for other non-Owners; the + // Owner's own role changes only through a transfer. + changeRole: + actor === "Owner" && !member.isSelf && member.targetRole !== "Owner" + ? ENABLED + : HIDDEN, + remove, + // Owner and Manager may set anyone's alias, the Owner's and their own included. + setAlias: actor === "Developer" ? HIDDEN : ENABLED, + }; +} + +/** The roles a change-role control offers: never Owner (spec §E.4). */ +export const ASSIGNABLE_ROLES: readonly WorkspaceRole[] = [ + "Manager", + "Developer", +]; + +/** The roles an actor may put on a Workspace Invite Link (spec §D.6). */ +export function inviteRoleOptions( + actorRole: WorkspaceRole +): readonly WorkspaceRole[] { + switch (actorRole) { + case "Owner": + return ASSIGNABLE_ROLES; + case "Manager": + return ["Developer"]; + default: + return []; + } +} diff --git a/apps/ui/src/features/workspace/workspace-members-panel.tsx b/apps/ui/src/features/workspace/workspace-members-panel.tsx new file mode 100644 index 00000000..40db1444 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-members-panel.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { AppButton } from "@workspace/ui/components/app-button"; +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; +import { AppSelect } from "@workspace/ui/components/app-select"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { Badge } from "@workspace/ui/components/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@workspace/ui/components/table"; +import { cn } from "@workspace/ui/lib/utils"; +import { Pencil, UserMinus, UserRoundPlus, UsersRound } from "lucide-react"; + +import type { WorkspaceMember } from "./workspace-details-schema"; +import { + ASSIGNABLE_ROLES, + gateMemberActions, + type WorkspaceActionGate, + type WorkspaceGateInput, +} from "./workspace-gating-core"; + +export const MEMBERS_LOAD_FAILED_NOTICE = "Couldn't load the members."; + +const HEAD_CLASS = "h-11 bg-input/30 font-medium text-muted-foreground text-xs"; +const CELL_CLASS = "h-14 py-0 text-sm"; + +const ROLE_OPTIONS = ASSIGNABLE_ROLES.map((role) => ({ + label: role, + value: role, +})); + +/** "Feb 14, 2026" (spec §D.5); "-" for a date Desktop did not give. */ +export function formatJoinedDate(iso: string): string { + const date = new Date(iso); + if (iso === "" || Number.isNaN(date.getTime())) { + return "-"; + } + return date.toLocaleDateString("en-US", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function memberName(member: WorkspaceMember): string { + return member.nickname.trim() === "" ? member.crName : member.nickname; +} + +function MemberAvatar({ member }: { member: WorkspaceMember }) { + const name = memberName(member); + return ( + + {member.avatarUrl === "" ? null : ( + + )} + {name.slice(0, 1).toUpperCase()} + + ); +} + +function RoleCell({ + gate, + member, +}: { + gate: WorkspaceActionGate; + member: WorkspaceMember; +}) { + if (gate.kind === "enabled") { + // The operation behind the choice arrives with the write routes; the + // control is rendered where it will live so the gating is reviewable. + return ( + + ); + } + return ( + + {member.role} + + ); +} + +function MemberRow({ + gateInput, + isSelf, + member, + showActions, +}: { + gateInput: WorkspaceGateInput; + isSelf: boolean; + member: WorkspaceMember; + showActions: boolean; +}) { + const gates = gateMemberActions(gateInput, { + isSelf, + targetRole: member.role, + }); + const name = memberName(member); + return ( + + +
+ +
+
+ {name} + {isSelf ? ( + + You + + ) : null} +
+ {member.alias == null ? null : ( + + {member.alias} + + )} +
+ {gates.setAlias.kind === "enabled" ? ( + + ) : null} +
+
+ + + + + {formatJoinedDate(member.joinedAt)} + + {showActions ? ( + + {gates.remove.kind === "enabled" ? ( + + + + ) : null} + + ) : null} +
+ ); +} + +function MembersTable({ + gateInput, + meCrName, + members, +}: { + gateInput: WorkspaceGateInput; + meCrName: string; + members: readonly WorkspaceMember[]; +}) { + // The action column exists only when the actor can remove someone. + const showActions = members.some( + (member) => + gateMemberActions(gateInput, { + isSelf: member.crName === meCrName, + targetRole: member.role, + }).remove.kind === "enabled" + ); + return ( +
+ + + + Member + Role + Joined + {showActions ? ( + + Actions + + ) : null} + + + + {members.map((member) => ( + + ))} + +
+
+ ); +} + +/** The table once the members landed; otherwise why they have not. */ +function MembersBody({ + error, + gateInput, + meCrName, + members, +}: { + error: Error | undefined; + gateInput: WorkspaceGateInput; + meCrName: string; + members: readonly WorkspaceMember[] | undefined; +}) { + if (members != null) { + return ( + + ); + } + if (error != null) { + return ( +

+ {MEMBERS_LOAD_FAILED_NOTICE} +

+ ); + } + return ( +

+ Loading members… +

+ ); +} + +/** + * The Members panel (spec §D.5): flush to the bottom with rounded top + * corners, "Members · N" and the Invite member button (Owner and Manager) + * over a bordered table that scrolls inside itself with its header pinned. + * Columns Member (avatar, name, You, alias, the hover pencil) / Role (a + * quiet select where the actor may change it, text otherwise, Owner in + * blue) / Joined / the remove icon where the actor may remove that row — + * the whole column is absent when no row is removable. No Status column: + * everyone listed has joined. + */ +export function WorkspaceMembersPanel({ + error, + gateInput, + inviteGate, + meCrName, + members, +}: { + error: Error | undefined; + gateInput: WorkspaceGateInput; + inviteGate: WorkspaceActionGate; + meCrName: string; + /** Undefined while the members are loading. */ + members: readonly WorkspaceMember[] | undefined; +}) { + return ( +
+
+

+ + Members + {members == null ? null : ( + + {members.length} + + )} +

+ {inviteGate.kind === "hidden" ? null : ( + + + Invite member + + )} +
+ +
+ ); +} From c66639c684a11d451800024a154b5295a6160dbd Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 17:26:51 +0800 Subject: [PATCH 06/17] fix(workspace): address review findings on the read-only Workspace Area (AIM-446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The ⋯ menu stays disabled until the members landed, so transfer's member-count gate is never guessed (was assumed transferable). - Desktop epoch timestamps become ISO text (were stringified numbers that rendered "-" in Joined). - One PlanSlot / workspaceRoleLabel / planNameFor shared by the Switcher and the area; an id absent from the plans record means "unknown" in both (the area showed PAYG). - Member gates computed once per row; copy-id and alias controls are AppButtons; "Members · N" carries its separator; comments on the Personal-invite gate, Desktop's `details` isPersonal, and the §D.5 column widths. Co-Authored-By: Claude Fable 5.1 --- .../session/server/desktop-auth-api.ts | 14 +++- .../shell/app-sidebar-workspace-switcher.tsx | 57 ++++++++-------- .../server/workspace-details-handler.test.ts | 6 +- .../workspace/workspace-area-list.tsx | 7 +- .../src/features/workspace/workspace-area.tsx | 13 ++-- .../workspace/workspace-detail-header.tsx | 46 +++++++------ .../workspace/workspace-details-schema.ts | 6 ++ .../workspace/workspace-gating-core.ts | 2 + .../workspace/workspace-members-panel.tsx | 65 ++++++++++--------- .../workspace/workspace-plan-slot.tsx | 45 +++++++++++++ 10 files changed, 161 insertions(+), 100 deletions(-) create mode 100644 apps/ui/src/features/workspace/workspace-plan-slot.tsx diff --git a/apps/ui/src/features/session/server/desktop-auth-api.ts b/apps/ui/src/features/session/server/desktop-auth-api.ts index 877eadd3..91e305af 100644 --- a/apps/ui/src/features/session/server/desktop-auth-api.ts +++ b/apps/ui/src/features/session/server/desktop-auth-api.ts @@ -124,6 +124,18 @@ const teamUserDtoSchema = z.object({ 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 { @@ -139,7 +151,7 @@ function memberFromDto( avatarUrl: dto.avatarUrl ?? "", crName: dto.k8s_username, crUid: dto.crUid, - joinedAt: joined == null ? "" : String(joined), + joinedAt: isoTimestamp(joined), nickname: dto.nickname ?? "", role, userUid: dto.uid ?? "", diff --git a/apps/ui/src/features/shell/app-sidebar-workspace-switcher.tsx b/apps/ui/src/features/shell/app-sidebar-workspace-switcher.tsx index a4d7b8d5..022aceea 100644 --- a/apps/ui/src/features/shell/app-sidebar-workspace-switcher.tsx +++ b/apps/ui/src/features/shell/app-sidebar-workspace-switcher.tsx @@ -1,6 +1,5 @@ "use client"; -import { PlanBadge } from "@workspace/ui/components/plan-badge"; import { Popover, PopoverContent, @@ -25,6 +24,11 @@ import { useCloseOnSidebarToggle } from "@/features/shell/use-close-on-sidebar-t import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; import { useWorkspaceList } from "@/features/workspace/use-workspace-list"; import { useWorkspacePlans } from "@/features/workspace/use-workspace-plans"; +import { + PlanSlot, + planNameFor, + workspaceRoleLabel, +} from "@/features/workspace/workspace-plan-slot"; import { recordWorkspaceReturnRoute } from "@/features/workspace/workspace-return-route"; import { workspaceSwitchLanding, @@ -63,35 +67,17 @@ function fadeClass(expanded: boolean): string { : "opacity-0 duration-200 ease-out"; } -/** Plan badge, or the quiet PAYG word for a Workspace without a subscription. */ -function PlanSlot({ badge }: { badge: WorkspaceSwitcherBadge | null }) { +/** The current Workspace's badge as the shared plan slot reads it. */ +function planNameFromBadge( + badge: WorkspaceSwitcherBadge | null +): string | null | undefined { if (badge == null) { - return null; + return undefined; } - if (badge.kind === "payg") { - return ( - - PAYG - - ); - } - return ; -} - -function roleLabel(workspace: SessionWorkspace): string { - return workspace.isPersonal ? "Personal" : workspace.role; + return badge.kind === "payg" ? null : badge.planName; } -function badgeFromPlan( - plans: Record | undefined, - workspace: SessionWorkspace -): WorkspaceSwitcherBadge | null { - if (plans == null || !(workspace.id in plans)) { - return null; - } - const planName = plans[workspace.id]; - return planName == null ? { kind: "payg" } : { kind: "plan", planName }; -} +const SWITCHER_BADGE_CLASS = "h-4 text-xs"; function WorkspaceSwitcherMenuRow({ href, @@ -151,10 +137,13 @@ function WorkspaceSwitcherMenu({ {current.name} - {roleLabel(current)} + {workspaceRoleLabel(current)} - +
{others.length === 0 ? null : ( <> @@ -184,9 +173,12 @@ function WorkspaceSwitcherMenu({ {workspace.name} - {roleLabel(workspace)} + {workspaceRoleLabel(workspace)} - + ))} {switchBlock == null ? null : ( @@ -355,7 +347,10 @@ export function AppSidebarWorkspaceSwitcher() { fadeClass(expanded) )} > - + diff --git a/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts b/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts index 969c3a71..d1b68fba 100644 --- a/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts +++ b/apps/ui/src/features/workspace/server/workspace-details-handler.test.ts @@ -44,11 +44,11 @@ const ME_USER = { status: 1, uid: "user-uid-1", }; -/** A row without `joinTime` (Desktop's DTO leaves it optional). */ +/** A row without `joinTime` (Desktop's DTO leaves it optional), created at an epoch. */ const DEV_USER = { avatarUrl: "", crUid: "cr-uid-dev", - createdTime: "2026-03-01T00:00:00.000Z", + createdTime: Date.UTC(2026, 2, 1), k8s_username: "dev00001", nickname: "Dev", role: 2, @@ -143,7 +143,7 @@ describe("POST /api/workspace/details", () => { avatarUrl: "", crName: "dev00001", crUid: "cr-uid-dev", - // No joinTime: the CR's creation time stands in. + // No joinTime: the CR's creation time stands in, an epoch made ISO. joinedAt: "2026-03-01T00:00:00.000Z", nickname: "Dev", role: "Developer", diff --git a/apps/ui/src/features/workspace/workspace-area-list.tsx b/apps/ui/src/features/workspace/workspace-area-list.tsx index 6cad5deb..adc84435 100644 --- a/apps/ui/src/features/workspace/workspace-area-list.tsx +++ b/apps/ui/src/features/workspace/workspace-area-list.tsx @@ -9,14 +9,11 @@ import { recordBillingReturnRoute } from "@/features/billing/billing-return-rout import type { SessionWorkspace } from "@/features/session/session-schema"; import { workspaceAreaPath } from "./workspace-area-route-core"; +import { workspaceRoleLabel } from "./workspace-plan-slot"; const ROW_CLASS = "flex h-9 shrink-0 items-center gap-2 rounded-md p-2 text-left text-sm leading-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 lg:w-full"; -function roleLabel(workspace: SessionWorkspace): string { - return workspace.isPersonal ? "Personal" : workspace.role; -} - /** * The Workspace Area's list (spec §D.3): every Workspace the user belongs * to, in Desktop's order, one row each — square avatar, name, a blue dot on @@ -72,7 +69,7 @@ export function WorkspaceAreaList({ /> ) : null} - {roleLabel(workspace)} + {workspaceRoleLabel(workspace)} ); diff --git a/apps/ui/src/features/workspace/workspace-area.tsx b/apps/ui/src/features/workspace/workspace-area.tsx index 88996203..82df1d75 100644 --- a/apps/ui/src/features/workspace/workspace-area.tsx +++ b/apps/ui/src/features/workspace/workspace-area.tsx @@ -21,9 +21,10 @@ import { type WorkspaceGateInput, } from "./workspace-gating-core"; import { WorkspaceMembersPanel } from "./workspace-members-panel"; +import { planNameFor } from "./workspace-plan-slot"; import { readWorkspaceReturnRoute } from "./workspace-return-route"; -/** The area's icon; matches the Manage Workspaces row of the Switcher. */ +/** The area's icon: the members glyph, in the title bar's accent. */ function WorkspaceAreaIcon() { return ( @@ -153,7 +154,7 @@ export function WorkspaceArea() { isCurrent={managed.uid === current.uid} key={managed.uid} meCrName={user?.crName ?? ""} - planName={plans == null ? undefined : (plans[managed.id] ?? null)} + planName={planNameFor(plans, managed.id)} workspace={managed} /> )} diff --git a/apps/ui/src/features/workspace/workspace-detail-header.tsx b/apps/ui/src/features/workspace/workspace-detail-header.tsx index 485822b6..53072a64 100644 --- a/apps/ui/src/features/workspace/workspace-detail-header.tsx +++ b/apps/ui/src/features/workspace/workspace-detail-header.tsx @@ -10,7 +10,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@workspace/ui/components/dropdown-menu"; -import { PlanBadge } from "@workspace/ui/components/plan-badge"; import { Tooltip, TooltipContent, @@ -34,6 +33,7 @@ import type { WorkspaceActionGate, WorkspaceActionGates, } from "./workspace-gating-core"; +import { PlanSlot } from "./workspace-plan-slot"; export const WORKSPACE_ID_COPIED_NOTICE = "Workspace ID copied"; @@ -47,21 +47,6 @@ function copyWorkspaceId(id: string): void { .catch(() => undefined); } -/** The plan badge, the quiet PAYG word, or nothing while the plan is unknown. */ -function PlanSlot({ planName }: { planName: string | null | undefined }) { - if (planName === undefined) { - return null; - } - if (planName === null) { - return ( - - PAYG - - ); - } - return ; -} - /** A disabled control explained by a tooltip (a state gate). */ function WithReason({ children, @@ -124,7 +109,14 @@ function MenuAction({ * Workspace a divider, Transfer ownership, and Delete workspace, each * disabled with its reason on a second line when a state gate holds. */ -function WorkspaceActionsMenu({ gates }: { gates: WorkspaceActionGates }) { +function WorkspaceActionsMenu({ + gates, + ready, +}: { + gates: WorkspaceActionGates; + /** False until the members landed: transfer's gate waits on their count. */ + ready: boolean; +}) { const dangerous = gates.transfer.kind !== "hidden" || gates.delete.kind !== "hidden"; return ( @@ -134,6 +126,7 @@ function WorkspaceActionsMenu({ gates }: { gates: WorkspaceActionGates }) { @@ -184,11 +177,14 @@ function WorkspaceActionsMenu({ gates }: { gates: WorkspaceActionGates }) { export function WorkspaceDetailHeader({ gates, isCurrent, + membersLoaded, planName, workspace, }: { gates: WorkspaceActionGates; isCurrent: boolean; + /** The ⋯ menu opens only once the member count behind its gates is known. */ + membersLoaded: boolean; planName: string | null | undefined; workspace: SessionWorkspace; }) { @@ -212,7 +208,7 @@ export function WorkspaceDetailHeader({

{workspace.name}

- + {isCurrent ? ( · - + {gates.leave.kind === "hidden" ? null : ( @@ -254,7 +250,9 @@ export function WorkspaceDetailHeader({ )} - {showMenu ? : null} + {showMenu ? ( + + ) : null} ); } diff --git a/apps/ui/src/features/workspace/workspace-details-schema.ts b/apps/ui/src/features/workspace/workspace-details-schema.ts index 7f5d8eae..91358a07 100644 --- a/apps/ui/src/features/workspace/workspace-details-schema.ts +++ b/apps/ui/src/features/workspace/workspace-details-schema.ts @@ -39,6 +39,12 @@ export type WorkspaceMember = z.infer; export const workspaceDetailsResponseSchema = z.object({ members: z.array(workspaceMemberSchema), + /** + * Desktop's own view of the Workspace. Its `isPersonal` comes from + * Desktop's `details` rule (`id === 'ns-' + crName`), not from the + * membership row `list` uses; the Workspace Area keeps the list's entry + * for the header and reads only `members` from here. + */ workspace: sessionWorkspaceSchema, }); diff --git a/apps/ui/src/features/workspace/workspace-gating-core.ts b/apps/ui/src/features/workspace/workspace-gating-core.ts index 4ee00c22..7475cfbf 100644 --- a/apps/ui/src/features/workspace/workspace-gating-core.ts +++ b/apps/ui/src/features/workspace/workspace-gating-core.ts @@ -63,6 +63,8 @@ export function gateWorkspaceActions( } return { delete: dangerous ? unlessCurrent : HIDDEN, + // Owner and Manager invite (spec §D.5); the Personal Workspace is not + // excepted — §E.2 withholds only its delete and transfer. invite: input.actorRole === "Developer" ? HIDDEN : ENABLED, // Leaving is removing yourself; the Owner leaves only by transferring. leave: owner ? HIDDEN : unlessCurrent, diff --git a/apps/ui/src/features/workspace/workspace-members-panel.tsx b/apps/ui/src/features/workspace/workspace-members-panel.tsx index 40db1444..cdbf22c6 100644 --- a/apps/ui/src/features/workspace/workspace-members-panel.tsx +++ b/apps/ui/src/features/workspace/workspace-members-panel.tsx @@ -24,6 +24,7 @@ import type { WorkspaceMember } from "./workspace-details-schema"; import { ASSIGNABLE_ROLES, gateMemberActions, + type MemberActionGates, type WorkspaceActionGate, type WorkspaceGateInput, } from "./workspace-gating-core"; @@ -97,20 +98,16 @@ function RoleCell({ } function MemberRow({ - gateInput, + gates, isSelf, member, showActions, }: { - gateInput: WorkspaceGateInput; + gates: MemberActionGates; isSelf: boolean; member: WorkspaceMember; showActions: boolean; }) { - const gates = gateMemberActions(gateInput, { - isSelf, - targetRole: member.role, - }); const name = memberName(member); return ( {gates.setAlias.kind === "enabled" ? ( - + ) : null} @@ -190,14 +187,16 @@ function MembersTable({ meCrName: string; members: readonly WorkspaceMember[]; }) { + const rows = members.map((member) => { + const isSelf = member.crName === meCrName; + return { + gates: gateMemberActions(gateInput, { isSelf, targetRole: member.role }), + isSelf, + member, + }; + }); // The action column exists only when the actor can remove someone. - const showActions = members.some( - (member) => - gateMemberActions(gateInput, { - isSelf: member.crName === meCrName, - targetRole: member.role, - }).remove.kind === "enabled" - ); + const showActions = rows.some((row) => row.gates.remove.kind === "enabled"); return (
+ {/* Column widths are the design's (spec §D.5: 46 / 22 / 22, the rest to actions). */} Member Role Joined @@ -217,12 +217,12 @@ function MembersTable({ - {members.map((member) => ( + {rows.map((row) => ( ))} @@ -308,12 +308,17 @@ export function WorkspaceMembersPanel({ Members {members == null ? null : ( - - {members.length} - + <> + + · + + + {members.length} + + )} {inviteGate.kind === "hidden" ? null : ( diff --git a/apps/ui/src/features/workspace/workspace-plan-slot.tsx b/apps/ui/src/features/workspace/workspace-plan-slot.tsx new file mode 100644 index 00000000..5fa47166 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-plan-slot.tsx @@ -0,0 +1,45 @@ +import { PlanBadge } from "@workspace/ui/components/plan-badge"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; + +/** "Personal" for the Personal Workspace, otherwise the user's Workspace Role. */ +export function workspaceRoleLabel(workspace: SessionWorkspace): string { + return workspace.isPersonal ? "Personal" : workspace.role; +} + +/** + * The plan of a Workspace's subscription as the Switcher and the Workspace + * Area show it: the tier badge for a plan, the quiet PAYG word for a + * Workspace without a subscription (null), nothing while the plan is + * unknown (undefined — still loading, or the plans route failed). + */ +export function PlanSlot({ + className, + planName, +}: { + className?: string; + planName: string | null | undefined; +}) { + if (planName === undefined) { + return null; + } + if (planName === null) { + return ( + + PAYG + + ); + } + return ; +} + +/** The plan for one Workspace from the plans record; undefined when unknown. */ +export function planNameFor( + plans: Record | undefined, + workspaceId: string +): string | null | undefined { + if (plans == null || !(workspaceId in plans)) { + return undefined; + } + return plans[workspaceId] ?? null; +} From ce0a788df179263706d3691a7c62bdb9c15557ae Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 17:51:04 +0800 Subject: [PATCH 07/17] feat(workspace): Workspace Area write operations (AIM-447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner actions, member management, and the Workspace Invite Link take effect in the Workspace Area (spec AIM-443 §B.2, §D.6–D.8, §E, §F). Server: seven write routes join the route table — rename, delete, invite-link (answers `{ code }`), member/remove, member/role, member/alias, transfer — each validating its body with a shared schema (roles never Owner, alias trimmed and capped at 128, name at 32), requiring `X-Sealos-Region-Token`, calling Desktop through the auth API module, and translating Desktop's envelope codes into Brain's own statuses and error codes. The dev-mock fixtures become mutable per scenario so every write lands in memory and shows on the next read. Client: the detail header's ⋯ menu and Leave button and the members table's role select, alias pencil, remove icon, and Invite member button open the dialogs the spec's three confirmation tiers call for — typed name for delete and transfer (with "You will become a Developer"), plain confirmation for remove and leave, direct effect for rename, alias, and role. After a write the list and members are re-read (the fresh list is written back to the session atoms), a delete or leave moves the page to the current Workspace, and a 403 / 404 re-reads, re-gates, and says the permissions changed. The invite dialog builds Desktop's `/WorkspaceInvite/?code=` link on the cloud domain and copies it. Co-Authored-By: Claude Fable 5.1 --- apps/ui/src/app/api/workspace/delete/route.ts | 11 + .../app/api/workspace/invite-link/route.ts | 11 + .../app/api/workspace/member/alias/route.ts | 11 + .../app/api/workspace/member/remove/route.ts | 11 + .../app/api/workspace/member/role/route.ts | 11 + apps/ui/src/app/api/workspace/rename/route.ts | 11 + .../src/app/api/workspace/transfer/route.ts | 11 + .../session/server/desktop-auth-api.ts | 127 +++++ .../session/server/dev-fixtures.test.ts | 241 +++++++- .../features/session/server/dev-fixtures.ts | 361 +++++++++++- .../server/workspace-details-handler.ts | 16 +- .../server/workspace-route-context.ts | 18 + .../workspace/server/workspace-route-table.ts | 28 + .../server/workspace-write-handlers.test.ts | 352 ++++++++++++ .../server/workspace-write-handlers.ts | 183 ++++++ .../workspace/use-workspace-actions.ts | 223 ++++++++ .../workspace/use-workspace-details.ts | 47 +- .../workspace/use-workspace-refresh.ts | 59 ++ .../features/workspace/workspace-actions.ts | 98 ++++ .../workspace/workspace-area.test.tsx | 538 +++++++++++++++++- .../src/features/workspace/workspace-area.tsx | 64 ++- .../workspace/workspace-confirm-field.tsx | 39 ++ .../workspace/workspace-detail-dialogs.tsx | 286 ++++++++++ .../workspace/workspace-detail-header.tsx | 89 ++- .../workspace/workspace-invite-core.test.ts | 28 + .../workspace/workspace-invite-core.ts | 27 + .../workspace/workspace-invite-dialog.tsx | 153 +++++ .../workspace/workspace-member-dialogs.tsx | 148 +++++ .../workspace/workspace-members-panel.tsx | 103 +++- .../features/workspace/workspace-request.ts | 59 ++ .../workspace/workspace-switch-core.ts | 23 +- .../workspace/workspace-write-schema.ts | 128 +++++ 32 files changed, 3422 insertions(+), 93 deletions(-) create mode 100644 apps/ui/src/app/api/workspace/delete/route.ts create mode 100644 apps/ui/src/app/api/workspace/invite-link/route.ts create mode 100644 apps/ui/src/app/api/workspace/member/alias/route.ts create mode 100644 apps/ui/src/app/api/workspace/member/remove/route.ts create mode 100644 apps/ui/src/app/api/workspace/member/role/route.ts create mode 100644 apps/ui/src/app/api/workspace/rename/route.ts create mode 100644 apps/ui/src/app/api/workspace/transfer/route.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts create mode 100644 apps/ui/src/features/workspace/server/workspace-write-handlers.ts create mode 100644 apps/ui/src/features/workspace/use-workspace-actions.ts create mode 100644 apps/ui/src/features/workspace/use-workspace-refresh.ts create mode 100644 apps/ui/src/features/workspace/workspace-actions.ts create mode 100644 apps/ui/src/features/workspace/workspace-confirm-field.tsx create mode 100644 apps/ui/src/features/workspace/workspace-detail-dialogs.tsx create mode 100644 apps/ui/src/features/workspace/workspace-invite-core.test.ts create mode 100644 apps/ui/src/features/workspace/workspace-invite-core.ts create mode 100644 apps/ui/src/features/workspace/workspace-invite-dialog.tsx create mode 100644 apps/ui/src/features/workspace/workspace-member-dialogs.tsx create mode 100644 apps/ui/src/features/workspace/workspace-request.ts create mode 100644 apps/ui/src/features/workspace/workspace-write-schema.ts 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/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/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/features/session/server/desktop-auth-api.ts b/apps/ui/src/features/session/server/desktop-auth-api.ts index 91e305af..17eb4a47 100644 --- a/apps/ui/src/features/session/server/desktop-auth-api.ts +++ b/apps/ui/src/features/session/server/desktop-auth-api.ts @@ -22,8 +22,15 @@ import { export const DESKTOP_AUTH_PATHS = { info: "/api/auth/info", + namespaceAbdicate: "/api/auth/namespace/abdicate", + 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; @@ -43,6 +50,20 @@ const DESKTOP_ROLES: Record = { 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; @@ -194,13 +215,53 @@ export const desktopWorkspaceDetailsSchema = z export interface DesktopAuthApi { authInfo(regionalToken: string): Promise>; + /** Transfers ownership to `targetCrUid`; the caller becomes a Developer. */ + namespaceAbdicate( + regionalToken: string, + workspaceUid: string, + targetCrUid: 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 @@ -209,6 +270,19 @@ export interface DesktopAuthApi { } 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({ @@ -217,6 +291,20 @@ export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { method: "GET", path: DESKTOP_AUTH_PATHS.info, }), + namespaceAbdicate: (regionalToken, workspaceUid, targetCrUid) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceAbdicate, + { ns_uid: workspaceUid, targetUserCrUid: targetCrUid }, + voidDataSchema + ), + namespaceDelete: (regionalToken, workspaceUid) => + post( + regionalToken, + DESKTOP_AUTH_PATHS.namespaceDelete, + { ns_uid: workspaceUid }, + voidDataSchema + ), namespaceDetails: (regionalToken, workspaceUid) => client.call({ authorization: encodedTokenAuthorization(regionalToken), @@ -225,6 +313,13 @@ export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { 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), @@ -232,6 +327,38 @@ export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { 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), diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts index 5a063ca7..b84b360e 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.test.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -4,6 +4,7 @@ import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-ta 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, @@ -11,6 +12,7 @@ import { import { brainSessionSchema } from "../session-schema"; import { + resetWorkspaceDevMockState, sessionDevMockResponse, workspaceDevMockResponse, } from "./dev-fixtures"; @@ -106,6 +108,8 @@ test("every scenario answers every Workspace route with the session's own list", 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}`, { @@ -114,7 +118,15 @@ test("every scenario answers every Workspace route with the session's own list", method: "POST", }) ); - assert.equal(response?.status, 200, `${scenario} ${entry.apiPath}`); + // 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()), @@ -187,3 +199,230 @@ test("every scenario answers every Workspace route with the session's own 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: { apiPath: string; desktopPath: string }, + 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 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 index c8d5aec5..7b58e338 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -6,11 +6,30 @@ import { 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 { + type WorkspaceInviteLinkResponse, + type WorkspaceWriteResponse, + 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 } from "../session-schema"; +import type { + BrainSession, + SessionWorkspace, + WorkspaceRole, +} from "../session-schema"; /** * Session dev-mock fixtures (dev and demo builds only): one Brain Session @@ -19,7 +38,10 @@ import type { BrainSession, SessionWorkspace } from "../session-schema"; * 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 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 @@ -104,7 +126,7 @@ const ME = (role: WorkspaceMember["role"], joinedAt: string) => * 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 membersFor( +function seedMembersFor( scenario: SessionDevScenario, workspaceUid: string ): WorkspaceMember[] { @@ -168,7 +190,7 @@ function membersFor( } } -function workspacesFor(scenario: SessionDevScenario): SessionWorkspace[] { +function seedWorkspacesFor(scenario: SessionDevScenario): SessionWorkspace[] { switch (scenario) { case "personal-only": return [PERSONAL]; @@ -181,6 +203,52 @@ function workspacesFor(scenario: SessionDevScenario): SessionWorkspace[] { } } +/** + * 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 @@ -263,6 +331,120 @@ async function detailsFixture( return mockJson(details); } +const WRITE_OK: WorkspaceWriteResponse = { ok: true }; + +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 ?? 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 @@ -270,6 +452,177 @@ const WORKSPACE_FIXTURES: Record< [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 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 FORBIDDEN; + } + 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 FORBIDDEN; + } + 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 FORBIDDEN; + } + 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 FORBIDDEN; + } + 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. */ diff --git a/apps/ui/src/features/workspace/server/workspace-details-handler.ts b/apps/ui/src/features/workspace/server/workspace-details-handler.ts index b5e3df4a..5260bdd7 100644 --- a/apps/ui/src/features/workspace/server/workspace-details-handler.ts +++ b/apps/ui/src/features/workspace/server/workspace-details-handler.ts @@ -11,6 +11,7 @@ import { type WorkspaceRouteDependencies, workspaceErrorResponse, workspaceJsonResponse, + workspaceRequestPayload, workspaceRouteContext, } from "./workspace-route-context"; import { WORKSPACE_ROUTES } from "./workspace-route-table"; @@ -22,19 +23,6 @@ import { WORKSPACE_ROUTES } from "./workspace-route-table"; * membership — a caller outside the Workspace gets its 404, translated. */ -/** The request body must be JSON; absent or blank is `{}` (then invalid). */ -async function requestPayload(request: Request): Promise { - const text = (await request.text().catch(() => null))?.trim() ?? ""; - if (text === "") { - return {}; - } - try { - return JSON.parse(text); - } catch { - return null; - } -} - export function createWorkspaceDetailsHandler( dependencies: WorkspaceRouteDependencies = {} ): (request: Request) => Promise { @@ -47,7 +35,7 @@ export function createWorkspaceDetailsHandler( if (!context.ok) { return context.response; } - const payload = await requestPayload(request); + const payload = await workspaceRequestPayload(request); const parsed = payload == null ? null : workspaceDetailsRequestSchema.safeParse(payload); if (parsed == null || !parsed.success) { diff --git a/apps/ui/src/features/workspace/server/workspace-route-context.ts b/apps/ui/src/features/workspace/server/workspace-route-context.ts index e3d22fce..3775745b 100644 --- a/apps/ui/src/features/workspace/server/workspace-route-context.ts +++ b/apps/ui/src/features/workspace/server/workspace-route-context.ts @@ -45,6 +45,24 @@ export function workspaceJsonResponse(payload: unknown): Response { return Response.json(payload, { headers: { "cache-control": "no-store" } }); } +/** + * The request body as JSON: absent or blank is `{}` (then invalid against + * every route's schema), unparseable is null. + */ +export async function workspaceRequestPayload( + request: Request +): Promise { + const text = (await request.text().catch(() => null))?.trim() ?? ""; + if (text === "") { + return {}; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + const DESKTOP_CODE_RESPONSES: Record = { 400: [WORKSPACE_ERROR_CODES.invalidRequest, 400], 401: [WORKSPACE_ERROR_CODES.sessionExpired, 401], diff --git a/apps/ui/src/features/workspace/server/workspace-route-table.ts b/apps/ui/src/features/workspace/server/workspace-route-table.ts index ecb6c453..593a58e4 100644 --- a/apps/ui/src/features/workspace/server/workspace-route-table.ts +++ b/apps/ui/src/features/workspace/server/workspace-route-table.ts @@ -15,12 +15,40 @@ export interface WorkspaceRouteEntry { } export const WORKSPACE_ROUTES = { + delete: { + apiPath: "/api/workspace/delete", + desktopPath: "/api/auth/namespace/delete", + }, details: { apiPath: "/api/workspace/details", desktopPath: "/api/auth/namespace/details", }, + inviteLink: { + apiPath: "/api/workspace/invite-link", + desktopPath: "/api/auth/namespace/getInviteCode", + }, list: { apiPath: "/api/workspace/list", desktopPath: "/api/auth/namespace/list", }, + memberAlias: { + apiPath: "/api/workspace/member/alias", + desktopPath: "/api/auth/namespace/setAlias", + }, + memberRemove: { + apiPath: "/api/workspace/member/remove", + desktopPath: "/api/auth/namespace/removeUser", + }, + memberRole: { + apiPath: "/api/workspace/member/role", + desktopPath: "/api/auth/namespace/modifyRole", + }, + rename: { + apiPath: "/api/workspace/rename", + desktopPath: "/api/auth/namespace/rename", + }, + transfer: { + apiPath: "/api/workspace/transfer", + desktopPath: "/api/auth/namespace/abdicate", + }, } as const satisfies Record; diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts new file mode 100644 index 00000000..8941505e --- /dev/null +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { FakeDesktopOptions } from "@/features/session/server/desktop-test-double"; +import { REGION_TOKEN_HEADER } from "@/lib/region-token-header"; +import { WORKSPACE_ERROR_CODES } from "../workspace-errors"; +import { + WORKSPACE_ALIAS_MAX_LENGTH, + WORKSPACE_NAME_MAX_LENGTH, +} from "../workspace-write-schema"; +import type { WorkspaceRouteDependencies } from "./workspace-route-context"; + +mock.module("server-only", () => ({})); +const { + createWorkspaceDeleteHandler, + createWorkspaceInviteLinkHandler, + createWorkspaceMemberAliasHandler, + createWorkspaceMemberRemoveHandler, + createWorkspaceMemberRoleHandler, + createWorkspaceRenameHandler, + createWorkspaceTransferHandler, +} = await import("./workspace-write-handlers"); +const { WORKSPACE_ROUTES } = await import("./workspace-route-table"); +const { createFakeDesktop, TEAM } = 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 REGIONAL_TOKEN = "regional.token/with+chars"; +const TARGET_CR_UID = "cr-uid-target"; + +interface LogEntry { + fields: Record; + message: string; +} + +type Handler = (request: Request) => Promise; +type HandlerFactory = (dependencies: WorkspaceRouteDependencies) => Handler; + +/** + * One row per write route (spec §B.2): the handler, its public path, the + * Desktop path it calls, a valid body, the Desktop body that must result, + * and the bodies the route must refuse before calling Desktop. + */ +const ROUTES: { + create: HandlerFactory; + desktopBody: unknown; + entry: { apiPath: string; desktopPath: string }; + invalidBodies: unknown[]; + name: string; + validBody: unknown; +}[] = [ + { + create: createWorkspaceRenameHandler, + desktopBody: { ns_uid: TEAM.uid, teamName: "Acme Robotics" }, + entry: WORKSPACE_ROUTES.rename, + invalidBodies: [ + {}, + { uid: TEAM.uid }, + { name: "Acme", uid: " " }, + { name: " ", uid: TEAM.uid }, + { name: "x".repeat(WORKSPACE_NAME_MAX_LENGTH + 1), uid: TEAM.uid }, + { name: 42, uid: TEAM.uid }, + ], + name: "rename", + validBody: { name: " Acme Robotics ", uid: TEAM.uid }, + }, + { + create: createWorkspaceDeleteHandler, + desktopBody: { ns_uid: TEAM.uid }, + entry: WORKSPACE_ROUTES.delete, + invalidBodies: [{}, { uid: "" }, { uid: 1 }], + name: "delete", + validBody: { uid: TEAM.uid }, + }, + { + create: createWorkspaceInviteLinkHandler, + // Desktop's `UserRole`: Owner 0, Manager 1, Developer 2. + desktopBody: { ns_uid: TEAM.uid, role: 2 }, + entry: WORKSPACE_ROUTES.inviteLink, + invalidBodies: [ + {}, + { uid: TEAM.uid }, + { role: "Owner", uid: TEAM.uid }, + { role: 2, uid: TEAM.uid }, + { role: "developer", uid: TEAM.uid }, + ], + name: "invite-link", + validBody: { role: "Developer", uid: TEAM.uid }, + }, + { + create: createWorkspaceMemberRemoveHandler, + desktopBody: { ns_uid: TEAM.uid, targetUserCrUid: TARGET_CR_UID }, + entry: WORKSPACE_ROUTES.memberRemove, + invalidBodies: [{}, { uid: TEAM.uid }, { crUid: " ", uid: TEAM.uid }], + name: "member/remove", + validBody: { crUid: TARGET_CR_UID, uid: TEAM.uid }, + }, + { + create: createWorkspaceMemberRoleHandler, + desktopBody: { ns_uid: TEAM.uid, tRole: 1, targetUserCrUid: TARGET_CR_UID }, + entry: WORKSPACE_ROUTES.memberRole, + invalidBodies: [ + {}, + { crUid: TARGET_CR_UID, uid: TEAM.uid }, + { crUid: TARGET_CR_UID, role: "Owner", uid: TEAM.uid }, + { crUid: TARGET_CR_UID, role: 0, uid: TEAM.uid }, + ], + name: "member/role", + validBody: { crUid: TARGET_CR_UID, role: "Manager", uid: TEAM.uid }, + }, + { + create: createWorkspaceMemberAliasHandler, + desktopBody: { + alias: "Frontend lead", + ns_uid: TEAM.uid, + targetUserCrUid: TARGET_CR_UID, + }, + entry: WORKSPACE_ROUTES.memberAlias, + invalidBodies: [ + {}, + { alias: "x", uid: TEAM.uid }, + { alias: 7, crUid: TARGET_CR_UID, uid: TEAM.uid }, + { + alias: "x".repeat(WORKSPACE_ALIAS_MAX_LENGTH + 1), + crUid: TARGET_CR_UID, + uid: TEAM.uid, + }, + ], + name: "member/alias", + validBody: { + alias: " Frontend lead ", + crUid: TARGET_CR_UID, + uid: TEAM.uid, + }, + }, + { + create: createWorkspaceTransferHandler, + desktopBody: { ns_uid: TEAM.uid, targetUserCrUid: TARGET_CR_UID }, + entry: WORKSPACE_ROUTES.transfer, + invalidBodies: [{}, { uid: TEAM.uid }, { crUid: "", uid: TEAM.uid }], + name: "transfer", + validBody: { crUid: TARGET_CR_UID, uid: TEAM.uid }, + }, +]; + +function successAnswer(desktopPath: string): FakeDesktopOptions["answers"] { + return { + [desktopPath]: + desktopPath === WORKSPACE_ROUTES.inviteLink.desktopPath + ? { code: 200, data: { code: "0f2c1a9e-invite-code" } } + : { code: 200, data: null }, + }; +} + +function writeRequest( + apiPath: string, + input: { body?: unknown; rawBody?: string; token?: string | null } = {} +): Request { + const headers: Record = { + "content-type": "application/json", + }; + if (input.token !== null) { + headers[REGION_TOKEN_HEADER] = input.token ?? REGIONAL_TOKEN; + } + return new Request(`https://brain.test${apiPath}`, { + body: input.rawBody ?? JSON.stringify(input.body ?? {}), + headers, + method: "POST", + }); +} + +function handlerWith( + create: HandlerFactory, + answers: FakeDesktopOptions["answers"] +) { + const desktop = createFakeDesktop({ answers }); + const logs: LogEntry[] = []; + const handler = create({ + env: DEV_ENV, + fetchDesktop: desktop.fetch, + log: (message, fields) => logs.push({ fields, message }), + }); + return { calls: desktop.calls, handler, logs }; +} + +for (const route of ROUTES) { + describe(`POST ${route.entry.apiPath}`, () => { + it("calls Desktop with the translated body and answers Brain's shape", async () => { + const { calls, handler } = handlerWith( + route.create, + successAnswer(route.entry.desktopPath) + ); + const response = await handler( + writeRequest(route.entry.apiPath, { body: route.validBody }) + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual( + route.entry === WORKSPACE_ROUTES.inviteLink + ? { code: "0f2c1a9e-invite-code" } + : { ok: true } + ); + expect(calls).toEqual([ + { + authorization: encodeURIComponent(REGIONAL_TOKEN), + body: route.desktopBody, + method: "POST", + path: route.entry.desktopPath, + }, + ]); + }); + + it("answers 400 for an invalid or non-JSON body, never calling Desktop", async () => { + const { calls, handler } = handlerWith( + route.create, + successAnswer(route.entry.desktopPath) + ); + for (const body of route.invalidBodies) { + const response = await handler( + writeRequest(route.entry.apiPath, { body }) + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: WORKSPACE_ERROR_CODES.invalidRequest, + }); + } + const nonJson = await handler( + writeRequest(route.entry.apiPath, { rawBody: "not json" }) + ); + expect(nonJson.status).toBe(400); + expect(calls).toEqual([]); + }); + + it("answers 401 without the region token header, never calling Desktop", async () => { + const { calls, handler } = handlerWith( + route.create, + successAnswer(route.entry.desktopPath) + ); + const response = await handler( + writeRequest(route.entry.apiPath, { + body: route.validBody, + token: null, + }) + ); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: WORKSPACE_ERROR_CODES.regionTokenRequired, + }); + expect(calls).toEqual([]); + }); + + it("translates Desktop's 403 / 404 / 409 into Brain's codes without its message text", async () => { + for (const [code, status, error] of [ + [403, 403, WORKSPACE_ERROR_CODES.forbidden], + [404, 404, WORKSPACE_ERROR_CODES.notFound], + [409, 409, WORKSPACE_ERROR_CODES.conflict], + [500, 500, WORKSPACE_ERROR_CODES.desktopError], + ] as const) { + const { handler, logs } = handlerWith(route.create, { + [route.entry.desktopPath]: { + code, + message: "you are not manager", + }, + }); + const response = await handler( + writeRequest(route.entry.apiPath, { body: route.validBody }) + ); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ error }); + const serialized = JSON.stringify(logs); + expect(logs.length).toBeGreaterThan(0); + expect(serialized.includes(REGIONAL_TOKEN)).toBe(false); + expect(serialized.includes(encodeURIComponent(REGIONAL_TOKEN))).toBe( + false + ); + } + }); + + it("answers 504 for a Desktop timeout and 502 for a malformed answer", async () => { + const timeout = handlerWith(route.create, { + [route.entry.desktopPath]: Object.assign(new Error("aborted"), { + name: "TimeoutError", + }), + }); + const timedOut = await timeout.handler( + writeRequest(route.entry.apiPath, { body: route.validBody }) + ); + expect(timedOut.status).toBe(504); + + const malformed = handlerWith(route.create, { + [route.entry.desktopPath]: new Response("", { status: 200 }), + }); + const broken = await malformed.handler( + writeRequest(route.entry.apiPath, { body: route.validBody }) + ); + expect(broken.status).toBe(502); + }); + }); +} + +describe("POST /api/workspace/member/alias", () => { + it("sends Desktop null for a blank alias so it clears", async () => { + for (const alias of ["", " ", null]) { + const { calls, handler } = handlerWith( + createWorkspaceMemberAliasHandler, + successAnswer(WORKSPACE_ROUTES.memberAlias.desktopPath) + ); + const response = await handler( + writeRequest(WORKSPACE_ROUTES.memberAlias.apiPath, { + body: { alias, crUid: TARGET_CR_UID, uid: TEAM.uid }, + }) + ); + expect(response.status).toBe(200); + expect(calls[0]?.body).toEqual({ + alias: null, + ns_uid: TEAM.uid, + targetUserCrUid: TARGET_CR_UID, + }); + } + }); +}); + +describe("POST /api/workspace/invite-link", () => { + it("sends Desktop the Manager code for a Manager link", async () => { + const { calls, handler } = handlerWith( + createWorkspaceInviteLinkHandler, + successAnswer(WORKSPACE_ROUTES.inviteLink.desktopPath) + ); + await handler( + writeRequest(WORKSPACE_ROUTES.inviteLink.apiPath, { + body: { role: "Manager", uid: TEAM.uid }, + }) + ); + expect(calls[0]?.body).toEqual({ ns_uid: TEAM.uid, role: 1 }); + }); + + it("answers 502 when Desktop's data carries no code", async () => { + const { handler } = handlerWith(createWorkspaceInviteLinkHandler, { + [WORKSPACE_ROUTES.inviteLink.desktopPath]: { code: 200, data: {} }, + }); + const response = await handler( + writeRequest(WORKSPACE_ROUTES.inviteLink.apiPath, { + body: { role: "Developer", uid: TEAM.uid }, + }) + ); + expect(response.status).toBe(502); + }); +}); diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts new file mode 100644 index 00000000..5690088a --- /dev/null +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts @@ -0,0 +1,183 @@ +import "server-only"; + +import type { z } from "zod"; + +import type { DesktopAuthApi } from "@/features/session/server/desktop-auth-api"; +import type { DesktopCallResult } from "@/features/session/server/desktop-client"; + +import { WORKSPACE_ERROR_CODES } from "../workspace-errors"; +import { + type WorkspaceInviteLinkResponse, + type WorkspaceWriteResponse, + workspaceDeleteRequestSchema, + workspaceInviteLinkRequestSchema, + workspaceMemberAliasRequestSchema, + workspaceMemberRemoveRequestSchema, + workspaceMemberRoleRequestSchema, + workspaceRenameRequestSchema, + workspaceTransferRequestSchema, +} from "../workspace-write-schema"; +import { + desktopFailureLogFields, + desktopFailureResponse, + type WorkspaceRouteDependencies, + workspaceErrorResponse, + workspaceJsonResponse, + workspaceRequestPayload, + workspaceRouteContext, +} from "./workspace-route-context"; +import { + WORKSPACE_ROUTES, + type WorkspaceRouteEntry, +} from "./workspace-route-table"; + +/** + * The Workspace-management write routes (spec §B.2), one handler each and + * one shape between them: the shared preamble (regional token → 401, + * Desktop client from the environment), the route's zod body, one Desktop + * call, Desktop's envelope code translated into a real status with Brain's + * own error code, and Brain's own success body. Brain judges no permission + * here — Desktop is the sole authority — and the schemas close the holes + * Desktop leaves open (a role is never Owner). + */ + +type WorkspaceRouteHandler = (request: Request) => Promise; + +const WRITE_OK: WorkspaceWriteResponse = { ok: true }; + +function createWorkspaceWriteHandler( + entry: WorkspaceRouteEntry, + requestSchema: z.ZodType, + call: ( + desktop: DesktopAuthApi, + regionalToken: string, + body: TBody + ) => Promise>, + respond: (data: TData) => unknown, + dependencies: WorkspaceRouteDependencies +): WorkspaceRouteHandler { + return async function handler(request: Request): Promise { + const context = workspaceRouteContext(request, dependencies, entry.apiPath); + if (!context.ok) { + return context.response; + } + const payload = await workspaceRequestPayload(request); + const parsed = payload == null ? null : requestSchema.safeParse(payload); + if (parsed == null || !parsed.success) { + return workspaceErrorResponse(WORKSPACE_ERROR_CODES.invalidRequest, 400); + } + const result = await call( + context.desktop, + context.regionalToken, + parsed.data + ); + if (!result.ok) { + context.log("Desktop write failed", { + ...desktopFailureLogFields(result), + desktopPath: entry.desktopPath, + }); + return desktopFailureResponse(result); + } + return workspaceJsonResponse(respond(result.data)); + }; +} + +/** `POST /api/workspace/rename { uid, name }` → `namespace/rename`. */ +export function createWorkspaceRenameHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.rename, + workspaceRenameRequestSchema, + (desktop, token, body) => + desktop.namespaceRename(token, body.uid, body.name), + () => WRITE_OK, + dependencies + ); +} + +/** `POST /api/workspace/delete { uid }` → `namespace/delete`. */ +export function createWorkspaceDeleteHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.delete, + workspaceDeleteRequestSchema, + (desktop, token, body) => desktop.namespaceDelete(token, body.uid), + () => WRITE_OK, + dependencies + ); +} + +/** + * `POST /api/workspace/invite-link { uid, role }` → `namespace/getInviteCode`, + * answered as `{ code }`; the client builds the Desktop link around it. + */ +export function createWorkspaceInviteLinkHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.inviteLink, + workspaceInviteLinkRequestSchema, + (desktop, token, body) => + desktop.namespaceInviteCode(token, body.uid, body.role), + (data): WorkspaceInviteLinkResponse => ({ code: data.code }), + dependencies + ); +} + +/** `POST /api/workspace/member/remove { uid, crUid }` → `namespace/removeUser`. */ +export function createWorkspaceMemberRemoveHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.memberRemove, + workspaceMemberRemoveRequestSchema, + (desktop, token, body) => + desktop.namespaceRemoveUser(token, body.uid, body.crUid), + () => WRITE_OK, + dependencies + ); +} + +/** `POST /api/workspace/member/role { uid, crUid, role }` → `namespace/modifyRole`. */ +export function createWorkspaceMemberRoleHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.memberRole, + workspaceMemberRoleRequestSchema, + (desktop, token, body) => + desktop.namespaceModifyRole(token, body.uid, body.crUid, body.role), + () => WRITE_OK, + dependencies + ); +} + +/** `POST /api/workspace/member/alias { uid, crUid, alias }` → `namespace/setAlias`. */ +export function createWorkspaceMemberAliasHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.memberAlias, + workspaceMemberAliasRequestSchema, + (desktop, token, body) => + desktop.namespaceSetAlias(token, body.uid, body.crUid, body.alias), + () => WRITE_OK, + dependencies + ); +} + +/** `POST /api/workspace/transfer { uid, crUid }` → `namespace/abdicate`. */ +export function createWorkspaceTransferHandler( + dependencies: WorkspaceRouteDependencies = {} +): WorkspaceRouteHandler { + return createWorkspaceWriteHandler( + WORKSPACE_ROUTES.transfer, + workspaceTransferRequestSchema, + (desktop, token, body) => + desktop.namespaceAbdicate(token, body.uid, body.crUid), + () => WRITE_OK, + dependencies + ); +} diff --git a/apps/ui/src/features/workspace/use-workspace-actions.ts b/apps/ui/src/features/workspace/use-workspace-actions.ts new file mode 100644 index 00000000..94972676 --- /dev/null +++ b/apps/ui/src/features/workspace/use-workspace-actions.ts @@ -0,0 +1,223 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import { useWorkspaceRefresh } from "./use-workspace-refresh"; +import { + changeWorkspaceMemberRole, + createWorkspaceInviteLink, + deleteWorkspace, + removeWorkspaceMember, + renameWorkspace, + setWorkspaceMemberAlias, + transferWorkspaceOwnership, +} from "./workspace-actions"; +import type { WorkspaceMember } from "./workspace-details-schema"; +import { WorkspaceRequestError } from "./workspace-request"; +import type { AssignableRole } from "./workspace-write-schema"; + +export const PERMISSIONS_CHANGED_NOTICE = "Your permissions have changed."; + +export const WORKSPACE_ACTION_FAILED_NOTICES = { + alias: "Couldn't save the alias.", + changeRole: "Couldn't change the role.", + delete: "Couldn't delete the workspace.", + inviteLink: "Couldn't create the invite link.", + leave: "Couldn't leave the workspace.", + remove: "Couldn't remove the member.", + rename: "Couldn't rename the workspace.", + transfer: "Couldn't transfer ownership.", +} as const; + +export function workspaceDeletedNotice(name: string): string { + return `Deleted ${name}.`; +} + +export function workspaceLeftNotice(name: string): string { + return `You left ${name}.`; +} + +export function ownershipTransferredNotice(name: string): string { + return `${name} is now the Owner. You're a Developer.`; +} + +/** What happens to the page after a write lands. */ +type Convergence = + /** Re-read the list and the member table; the page re-gates from them. */ + | "refresh" + /** The Managed Workspace is gone for the actor: leave for the current one. */ + | "gone" + /** Nothing on the page changed (an invite link). */ + | "none"; + +export interface WorkspaceActions { + changeRole(member: WorkspaceMember, role: AssignableRole): Promise; + /** The code on success, null when refused. */ + createInviteLink(role: AssignableRole): Promise; + deleteWorkspace(): Promise; + leave(me: WorkspaceMember): Promise; + /** True while any write is in flight; the controls wait on it. */ + pending: boolean; + removeMember(member: WorkspaceMember): Promise; + rename(name: string): Promise; + setAlias(member: WorkspaceMember, alias: string): Promise; + transfer(member: WorkspaceMember): Promise; +} + +/** + * The Workspace Area's writes with their convergence (spec §D.8): after a + * success, `list` and `details` are re-read (never guessed); a delete or a + * leave moves the page to the current Workspace first, since the one it + * showed is gone for the actor (`onGone` is the area's navigation); a + * 403 / 404 means Desktop's view of the actor changed under the page — + * re-read, re-gate, and say so. No write switches the current Workspace. + */ +export function useWorkspaceActions(input: { + /** The Managed Workspace is gone for the actor: leave it for the current one. */ + onGone: () => void; + workspace: SessionWorkspace; +}): WorkspaceActions { + const { onGone, workspace } = input; + const refresh = useWorkspaceRefresh(); + const [pending, setPending] = useState(false); + + const perform = useCallback( + async ( + write: () => Promise, + options: { convergence: Convergence; failureNotice: string } + ): Promise => { + setPending(true); + try { + const result = await write(); + if (options.convergence === "gone") { + onGone(); + await refresh({ details: false }); + } else if (options.convergence === "refresh") { + await refresh(); + } + return result; + } catch (error) { + if (error instanceof WorkspaceRequestError && error.standingChanged) { + await refresh(); + toast(PERMISSIONS_CHANGED_NOTICE); + } else { + toast(options.failureNotice); + } + return null; + } finally { + setPending(false); + } + }, + [onGone, refresh] + ); + + const uid = workspace.uid; + const name = workspace.name; + + return { + changeRole: useCallback( + async (member, role) => + (await perform( + () => changeWorkspaceMemberRole({ crUid: member.crUid, role, uid }), + { + convergence: "refresh", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.changeRole, + } + )) != null, + [perform, uid] + ), + createInviteLink: useCallback( + async (role) => + ( + await perform(() => createWorkspaceInviteLink({ role, uid }), { + convergence: "none", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.inviteLink, + }) + )?.code ?? null, + [perform, uid] + ), + deleteWorkspace: useCallback(async () => { + const done = + (await perform(() => deleteWorkspace({ uid }), { + convergence: "gone", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.delete, + })) != null; + if (done) { + toast(workspaceDeletedNotice(name)); + } + return done; + }, [name, perform, uid]), + leave: useCallback( + async (me) => { + const done = + (await perform( + () => removeWorkspaceMember({ crUid: me.crUid, uid }), + { + convergence: "gone", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.leave, + } + )) != null; + if (done) { + toast(workspaceLeftNotice(name)); + } + return done; + }, + [name, perform, uid] + ), + pending, + removeMember: useCallback( + async (member) => + (await perform( + () => removeWorkspaceMember({ crUid: member.crUid, uid }), + { + convergence: "refresh", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.remove, + } + )) != null, + [perform, uid] + ), + rename: useCallback( + async (nextName) => + (await perform(() => renameWorkspace({ name: nextName, uid }), { + convergence: "refresh", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.rename, + })) != null, + [perform, uid] + ), + setAlias: useCallback( + async (member, alias) => + (await perform( + () => setWorkspaceMemberAlias({ alias, crUid: member.crUid, uid }), + { + convergence: "refresh", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.alias, + } + )) != null, + [perform, uid] + ), + transfer: useCallback( + async (member) => { + const done = + (await perform( + () => transferWorkspaceOwnership({ crUid: member.crUid, uid }), + { + convergence: "refresh", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.transfer, + } + )) != null; + if (done) { + toast( + ownershipTransferredNotice( + member.nickname.trim() === "" ? member.crName : member.nickname + ) + ); + } + return done; + }, + [perform, uid] + ), + }; +} diff --git a/apps/ui/src/features/workspace/use-workspace-details.ts b/apps/ui/src/features/workspace/use-workspace-details.ts index 9b85e577..ee6d8e70 100644 --- a/apps/ui/src/features/workspace/use-workspace-details.ts +++ b/apps/ui/src/features/workspace/use-workspace-details.ts @@ -2,51 +2,22 @@ import useSWR from "swr"; -import { sessionFetch } from "@/features/session/session-fetch"; import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; import { useSessionCredentials } from "@/features/session/use-session-credentials"; +import { WORKSPACE_ROUTES } from "./server/workspace-route-table"; import { type WorkspaceDetailsResponse, workspaceDetailsResponseSchema, } from "./workspace-details-schema"; +import { postWorkspaceJson } from "./workspace-request"; -export const WORKSPACE_DETAILS_API_PATH = "/api/workspace/details"; - -/** A failed details read, carrying Brain's status and error code. */ -export class WorkspaceDetailsError extends Error { - readonly code: string; - readonly status: number; - - constructor(status: number, code: string) { - super(`workspace details ${status} ${code}`); - this.name = "WorkspaceDetailsError"; - this.code = code; - this.status = status; - } -} - -async function fetchWorkspaceDetails( - uid: string -): Promise { - const response = await sessionFetch(WORKSPACE_DETAILS_API_PATH, { - body: JSON.stringify({ uid }), - cache: "no-store", - headers: { "content-type": "application/json" }, - method: "POST", - }); - if (!response.ok) { - const payload: unknown = await response.json().catch(() => null); - const code = - typeof payload === "object" && - payload != null && - "error" in payload && - typeof payload.error === "string" - ? payload.error - : "unknown"; - throw new WorkspaceDetailsError(response.status, code); - } - return workspaceDetailsResponseSchema.parse(await response.json()); +function fetchWorkspaceDetails(uid: string): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.details.apiPath, + { uid }, + workspaceDetailsResponseSchema + ); } /** @@ -57,7 +28,7 @@ async function fetchWorkspaceDetails( */ export function useWorkspaceDetails(uid: string | null): { data: WorkspaceDetailsResponse | undefined; - /** A `WorkspaceDetailsError` for a refused read; any other Error otherwise. */ + /** A `WorkspaceRequestError` for a refused read; any other Error otherwise. */ error: Error | undefined; } { const credentials = useSessionCredentials(); diff --git a/apps/ui/src/features/workspace/use-workspace-refresh.ts b/apps/ui/src/features/workspace/use-workspace-refresh.ts new file mode 100644 index 00000000..521d91f7 --- /dev/null +++ b/apps/ui/src/features/workspace/use-workspace-refresh.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useStore } from "jotai"; +import { useCallback } from "react"; +import { useSWRConfig } from "swr"; + +import { SESSION_SWR_KEYS } from "@/features/session/swr-keys"; +import { useSessionCredentials } from "@/features/session/use-session-credentials"; +import { currentWorkspaceAtom, workspacesAtom } from "@/lib/auth-store"; + +import { workspaceListResponseSchema } from "./workspace-list-schema"; + +const WORKSPACE_DETAILS_KEY_HEAD = SESSION_SWR_KEYS.workspaceDetails({ + appToken: "", + kubeconfig: "", + namespace: "", + regionalToken: "", +})[0]; + +/** + * Convergence after a write (spec §D.8): re-read `list` and `details` from + * Desktop rather than trusting an optimistic guess (the DB is the truth, + * the RoleBinding may lag). The fresh list is also written back to the + * session atoms, so the Switcher shows a rename at once and a transfer of + * the current Workspace re-gates everything that reads the current role. + */ +export function useWorkspaceRefresh(): (options?: { + /** Re-read the cached member tables too (false after a delete or leave). */ + details?: boolean; +}) => Promise { + const { mutate } = useSWRConfig(); + const credentials = useSessionCredentials(); + const store = useStore(); + return useCallback( + async (options = {}) => { + const fresh = await mutate( + SESSION_SWR_KEYS.workspaceList(credentials) + ).catch(() => undefined); + const parsed = workspaceListResponseSchema.safeParse(fresh); + if (parsed.success) { + store.set(workspacesAtom, parsed.data); + const current = store.get(currentWorkspaceAtom); + const refreshed = + current == null + ? undefined + : parsed.data.find((workspace) => workspace.uid === current.uid); + if (refreshed != null) { + store.set(currentWorkspaceAtom, refreshed); + } + } + if (options.details !== false) { + await mutate( + (key) => Array.isArray(key) && key[0] === WORKSPACE_DETAILS_KEY_HEAD + ).catch(() => undefined); + } + }, + [credentials, mutate, store] + ); +} diff --git a/apps/ui/src/features/workspace/workspace-actions.ts b/apps/ui/src/features/workspace/workspace-actions.ts new file mode 100644 index 00000000..53a43f79 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-actions.ts @@ -0,0 +1,98 @@ +import { WORKSPACE_ROUTES } from "./server/workspace-route-table"; +import type { WorkspaceMember } from "./workspace-details-schema"; +import { postWorkspaceJson } from "./workspace-request"; +import { + type AssignableRole, + type WorkspaceInviteLinkResponse, + type WorkspaceWriteResponse, + workspaceInviteLinkResponseSchema, + workspaceWriteResponseSchema, +} from "./workspace-write-schema"; + +/** + * The Workspace-management writes (spec §B.2) as the page calls them: one + * function per route, the body in Brain's own shape, the answer validated. + * Desktop stays the authority — these carry no permission judgment; the + * gates decide what is offered, Desktop's 403 / 404 corrects a stale page. + */ + +export function renameWorkspace(input: { + name: string; + uid: string; +}): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.rename.apiPath, + input, + workspaceWriteResponseSchema + ); +} + +export function deleteWorkspace(input: { + uid: string; +}): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.delete.apiPath, + input, + workspaceWriteResponseSchema + ); +} + +export function createWorkspaceInviteLink(input: { + role: AssignableRole; + uid: string; +}): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.inviteLink.apiPath, + input, + workspaceInviteLinkResponseSchema + ); +} + +/** Removing the actor's own membership is leaving (spec §E.3). */ +export function removeWorkspaceMember(input: { + crUid: WorkspaceMember["crUid"]; + uid: string; +}): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.memberRemove.apiPath, + input, + workspaceWriteResponseSchema + ); +} + +export function changeWorkspaceMemberRole(input: { + crUid: WorkspaceMember["crUid"]; + role: AssignableRole; + uid: string; +}): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.memberRole.apiPath, + input, + workspaceWriteResponseSchema + ); +} + +/** An alias left empty is sent as null: clear it (spec §B.2, §D.5). */ +export function setWorkspaceMemberAlias(input: { + alias: string; + crUid: WorkspaceMember["crUid"]; + uid: string; +}): Promise { + const alias = input.alias.trim(); + return postWorkspaceJson( + WORKSPACE_ROUTES.memberAlias.apiPath, + { alias: alias === "" ? null : alias, crUid: input.crUid, uid: input.uid }, + workspaceWriteResponseSchema + ); +} + +export function transferWorkspaceOwnership(input: { + crUid: WorkspaceMember["crUid"]; + uid: string; +}): Promise { + return postWorkspaceJson( + WORKSPACE_ROUTES.transfer.apiPath, + input, + workspaceWriteResponseSchema + ); +} diff --git a/apps/ui/src/features/workspace/workspace-area.test.tsx b/apps/ui/src/features/workspace/workspace-area.test.tsx index b0011a97..85c78ecd 100644 --- a/apps/ui/src/features/workspace/workspace-area.test.tsx +++ b/apps/ui/src/features/workspace/workspace-area.test.tsx @@ -19,6 +19,7 @@ import type { SessionWorkspace } from "@/features/session/session-schema"; import { appTokenAtom, currentWorkspaceAtom, + desktopDomainAtom, kubeconfigAtom, namespaceAtom, regionalTokenAtom, @@ -76,6 +77,8 @@ mock.module("sonner", () => ({ const ACME_NAME_RE = /Acme/; const ACME_ID_RE = /ns-acme/; +const RENAMED_RE = /Acme Robotics/; +const DEVELOPER_RE = /Developer/; const ME = { avatar: "", @@ -177,6 +180,17 @@ const fixtures = { "ns-solo": null, } as Record, scenario: "owner" as Scenario, + /** When set, every write route answers this status with a Brain error code. */ + writeStatus: null as number | null, +}; +/** + * The stand-in's own state: the list and members the scenario starts with, + * changed by the writes the way Desktop would change them, so a re-read + * after a write shows the change and the page's convergence can be seen. + */ +const state = { + list: [] as SessionWorkspace[], + members: {} as Record, }; const requests: { body: unknown; @@ -185,25 +199,88 @@ const requests: { url: string; }[] = []; +function errorResponse(status: number, error: string): Response { + return new Response(JSON.stringify({ error }), { + headers: { "content-type": "application/json" }, + status, + }); +} + +function membersOf(uid: string): WorkspaceMember[] { + return state.members[uid] ?? []; +} + +function patchMember( + uid: string, + crUid: string, + patch: Partial +): void { + state.members[uid] = membersOf(uid).map((member) => + member.crUid === crUid ? { ...member, ...patch } : member + ); +} + +function answerWrite(url: string, body: Record): Response { + if (fixtures.writeStatus != null) { + return errorResponse(fixtures.writeStatus, "workspace_forbidden"); + } + const uid = body.uid as string; + const crUid = body.crUid as string; + switch (url) { + case "/api/workspace/rename": + state.list = state.list.map((workspace) => + workspace.uid === uid + ? { ...workspace, name: body.name as string } + : workspace + ); + break; + case "/api/workspace/delete": + state.list = state.list.filter((workspace) => workspace.uid !== uid); + break; + case "/api/workspace/invite-link": + return jsonResponse({ code: `code-${body.role as string}` }); + case "/api/workspace/member/remove": + if (crUid === "cr-ada") { + state.list = state.list.filter((workspace) => workspace.uid !== uid); + } else { + state.members[uid] = membersOf(uid).filter( + (member) => member.crUid !== crUid + ); + } + break; + case "/api/workspace/member/role": + patchMember(uid, crUid, { role: body.role as WorkspaceMember["role"] }); + break; + case "/api/workspace/member/alias": + patchMember(uid, crUid, { alias: body.alias as string | null }); + break; + case "/api/workspace/transfer": + patchMember(uid, crUid, { role: "Owner" }); + patchMember(uid, "cr-ada", { role: "Developer" }); + state.list = state.list.map((workspace) => + workspace.uid === uid ? { ...workspace, role: "Developer" } : workspace + ); + break; + default: + return new Response("{}", { status: 404 }); + } + return jsonResponse({ ok: true }); +} + function answer(url: string, body: unknown): Response { if (url === "/api/workspace/list") { - return jsonResponse(WORKSPACES[fixtures.scenario]); + return jsonResponse(state.list); } if (url === "/api/workspace/details") { const uid = (body as { uid: string }).uid; - const workspace = WORKSPACES[fixtures.scenario].find( - (candidate) => candidate.uid === uid - ); + const workspace = state.list.find((candidate) => candidate.uid === uid); if (workspace == null) { - return new Response(JSON.stringify({ error: "workspace_not_found" }), { - headers: { "content-type": "application/json" }, - status: 404, - }); + return errorResponse(404, "workspace_not_found"); } - const members = workspace.isPersonal - ? PERSONAL_MEMBERS - : MEMBERS[`${fixtures.scenario}:${uid}`]; - return jsonResponse({ members, workspace }); + return jsonResponse({ members: membersOf(uid), workspace }); + } + if (url.startsWith("/api/workspace/")) { + return answerWrite(url, (body ?? {}) as Record); } if (url.startsWith("/api/billing/workspace-plans?")) { return jsonResponse({ plans: fixtures.plans }); @@ -222,7 +299,7 @@ function fetchStub(input: unknown, init?: RequestInit): Promise { // Base UI resolves its isomorphic layout effect at module load — with no // DOM registered it becomes a permanent noop and menus can never open. const moduleDom = installTestDom(); -const { render } = await import("@testing-library/react/pure"); +const { fireEvent, render } = await import("@testing-library/react/pure"); const { JotaiProvider } = await import("@/features/shell/jotai-provider"); const { WorkspaceArea } = await import("./workspace-area"); const { WORKSPACE_NOT_IN_LIST_NOTICE } = await import( @@ -231,6 +308,13 @@ const { WORKSPACE_NOT_IN_LIST_NOTICE } = await import( const { WORKSPACE_ID_COPIED_NOTICE } = await import( "./workspace-detail-header" ); +const { + PERMISSIONS_CHANGED_NOTICE, + workspaceDeletedNotice, + workspaceLeftNotice, +} = await import("./use-workspace-actions"); +const { INVITE_LINK_COPIED_NOTICE } = await import("./workspace-invite-dialog"); +const { INVITE_LINK_VALIDITY_NOTE } = await import("./workspace-invite-core"); await moduleDom.restore(); let dom: TestDom; @@ -243,8 +327,18 @@ let sessionCounter = 0; // test's details across the credential-keyed cache. function hydrate(scenario: Scenario, currentUid: string) { fixtures.scenario = scenario; + fixtures.writeStatus = null; sessionCounter += 1; const workspaces = WORKSPACES[scenario]; + state.list = [...workspaces]; + state.members = Object.fromEntries( + workspaces.map((workspace) => [ + workspace.uid, + workspace.isPersonal + ? [...PERSONAL_MEMBERS] + : [...(MEMBERS[`${scenario}:${workspace.uid}`] ?? [])], + ]) + ); const current = workspaces.find((workspace) => workspace.uid === currentUid); assert.ok(current, `${currentUid} is in the ${scenario} list`); const store = getDefaultStore(); @@ -255,6 +349,7 @@ function hydrate(scenario: Scenario, currentUid: string) { store.set(currentWorkspaceAtom, current); store.set(workspacesAtom, workspaces); store.set(sessionUserAtom, ME); + store.set(desktopDomainAtom, "cloud.test"); } beforeEach(() => { @@ -581,3 +676,420 @@ test("copying the workspace id writes the namespace id and says so", async () => assert.deepEqual(copied, ["ns-acme"]); assert.deepEqual(toasts, [WORKSPACE_ID_COPIED_NOTICE]); }); + +// ---- the writes (spec §D.6–D.8) ------------------------------------------- + +async function press(element: HTMLElement | null, what: string) { + assert.ok(element, what); + await actAndDrain(() => { + element.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, button: 0 }) + ); + element.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0 }) + ); + element.click(); + }); +} + +async function type(input: HTMLElement | null, value: string, what: string) { + assert.ok(input, what); + await actAndDrain(() => { + fireEvent.change(input, { target: { value } }); + }); +} + +/** The visible options of an open select popup, in order. */ +function selectOptions(): HTMLElement[] { + return [...document.querySelectorAll('[role="option"]')]; +} + +async function chooseOption(text: string) { + const option = selectOptions().find((candidate) => + (candidate.textContent ?? "").startsWith(text) + ); + await press(option ?? null, `the "${text}" option is offered`); +} + +function dialogAction(slot: string, label: string): HTMLElement | null { + const dialog = bySlot(slot); + assert.ok(dialog, `the ${slot} dialog is open`); + return ( + [...dialog.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === label + ) ?? null + ); +} + +function writesTo(url: string) { + return requests.filter((r) => r.url === url); +} + +function readsAfter(index: number, url: string): number { + return requests.slice(index).filter((r) => r.url === url).length; +} + +test("Rename: the dialog submits the trimmed name, then the list, header, and session show it", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + await openActionsMenu(); + await press( + [ + ...document.querySelectorAll( + '[data-slot="dropdown-menu-item"]' + ), + ].find((item) => item.textContent?.startsWith("Rename")) ?? null, + "the Rename item" + ); + const dialog = bySlot("workspace-rename-dialog"); + assert.ok(dialog); + const rename = dialogAction("workspace-rename-dialog", "Rename"); + assert.equal( + rename?.hasAttribute("disabled"), + true, + "unchanged name: disabled" + ); + await type( + dialog.querySelector("input"), + " Acme Robotics ", + "the name input" + ); + const before = requests.length; + await press(dialogAction("workspace-rename-dialog", "Rename"), "Rename"); + + assert.deepEqual( + writesTo("/api/workspace/rename").map((r) => r.body), + [{ name: "Acme Robotics", uid: "uid-acme" }] + ); + assert.equal(readsAfter(before, "/api/workspace/list"), 1, "list re-read"); + assert.equal( + readsAfter(before, "/api/workspace/details"), + 1, + "details re-read" + ); + assert.equal(bySlot("workspace-rename-dialog"), null, "the dialog closed"); + assert.match( + bySlot("workspace-detail-header")?.textContent ?? "", + RENAMED_RE + ); + assert.match( + allBySlot("workspace-area-row")[1]?.textContent ?? "", + RENAMED_RE + ); + assert.equal( + getDefaultStore().get(currentWorkspaceAtom)?.name, + "Acme Robotics" + ); + assert.deepEqual(route.replaced, []); +}); + +test("Change role: the select offers Manager and Developer only, and the choice is sent and re-read", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + await press(byLabel("Role of Chen Jie"), "the role select"); + assert.deepEqual( + selectOptions().map((option) => option.textContent?.trim()), + ["Manager", "Developer"] + ); + const before = requests.length; + await chooseOption("Manager"); + assert.deepEqual( + writesTo("/api/workspace/member/role").map((r) => r.body), + [{ crUid: "cr-chen", role: "Manager", uid: "uid-acme" }] + ); + assert.equal(readsAfter(before, "/api/workspace/details"), 1); + const chen = allBySlot("workspace-member-row").find((row) => + row.textContent?.includes("Chen Jie") + ); + assert.equal(chen?.getAttribute("data-member-role"), "Manager"); +}); + +test("Alias: saved empty it is sent as null and the subline goes; set, it is sent trimmed", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + await press(byLabel("Edit alias for Lin Wei"), "the alias pencil"); + const dialog = bySlot("workspace-alias-dialog"); + assert.ok(dialog); + assert.equal( + dialog.querySelector("input")?.getAttribute("value"), + "Frontend lead" + ); + await type(dialog.querySelector("input"), " ", "the alias input"); + await press(dialogAction("workspace-alias-dialog", "Save"), "Save"); + assert.deepEqual( + writesTo("/api/workspace/member/alias").map((r) => r.body), + [{ alias: null, crUid: "cr-lin", uid: "uid-acme" }] + ); + assert.equal(bySlot("workspace-alias-dialog"), null); + assert.equal(bySlot("workspace-member-alias"), null, "the subline is gone"); + + await press(byLabel("Set alias for Chen Jie"), "the alias pencil"); + await type( + bySlot("workspace-alias-dialog")?.querySelector("input") ?? null, + " Platform ", + "the alias input" + ); + await press(dialogAction("workspace-alias-dialog", "Save"), "Save"); + assert.deepEqual(writesTo("/api/workspace/member/alias")[1]?.body, { + alias: "Platform", + crUid: "cr-chen", + uid: "uid-acme", + }); + assert.equal(bySlot("workspace-member-alias")?.textContent, "Platform"); +}); + +test("Remove member: a plain confirmation, then the row is gone", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + await press(byLabel("Remove Chen Jie"), "the remove icon"); + assert.equal( + writesTo("/api/workspace/member/remove").length, + 0, + "nothing sent before confirming" + ); + await press( + dialogAction("workspace-remove-member-dialog", "Remove"), + "Remove" + ); + assert.deepEqual( + writesTo("/api/workspace/member/remove").map((r) => r.body), + [{ crUid: "cr-chen", uid: "uid-acme" }] + ); + assert.deepEqual(memberRowNames(), ["Ada Lovelace", "Lin Wei"]); + assert.equal(bySlot("workspace-members-count")?.textContent, "2"); +}); + +test("Delete: the name must be typed; afterwards the page moves to the current Workspace, which stays current", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-solo"); + await openActionsMenu(); + await press( + [ + ...document.querySelectorAll( + '[data-slot="dropdown-menu-item"]' + ), + ].find((item) => item.textContent?.startsWith("Delete")) ?? null, + "the Delete item" + ); + const dialog = bySlot("workspace-delete-dialog"); + assert.ok(dialog); + const confirm = dialog.querySelector( + '[aria-label="Type Solo to confirm."]' + ); + assert.ok(confirm, "the typed confirmation"); + assert.equal( + dialogAction("workspace-delete-dialog", "Delete workspace")?.hasAttribute( + "disabled" + ), + true + ); + await type(confirm, "solo", "the confirmation"); + assert.equal( + dialogAction("workspace-delete-dialog", "Delete workspace")?.hasAttribute( + "disabled" + ), + true, + "case matters" + ); + await type(confirm, "Solo", "the confirmation"); + assert.equal( + dialogAction("workspace-delete-dialog", "Delete workspace")?.hasAttribute( + "disabled" + ), + false + ); + await press( + dialogAction("workspace-delete-dialog", "Delete workspace"), + "Delete workspace" + ); + + assert.deepEqual( + writesTo("/api/workspace/delete").map((r) => r.body), + [{ uid: "uid-solo" }] + ); + assert.deepEqual(route.replaced, ["/workspace/uid-acme"]); + assert.ok(toasts.includes(workspaceDeletedNotice("Solo"))); + assert.equal( + getDefaultStore().get(currentWorkspaceAtom)?.uid, + "uid-acme", + "the current Workspace never switches" + ); + assert.deepEqual( + allBySlot("workspace-area-row").map((row) => + row.textContent?.includes("Solo") + ), + [false, false] + ); +}); + +test("Transfer: a new owner and the typed name, the consequence spelled out; afterwards the actor is gated as a Developer", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + await openActionsMenu(); + await press( + [ + ...document.querySelectorAll( + '[data-slot="dropdown-menu-item"]' + ), + ].find((item) => item.textContent?.startsWith("Transfer")) ?? null, + "the Transfer item" + ); + const dialog = bySlot("workspace-transfer-dialog"); + assert.ok(dialog); + assert.equal( + bySlot("workspace-transfer-consequence")?.textContent, + "You will become a Developer." + ); + const action = () => + dialogAction("workspace-transfer-dialog", "Transfer ownership"); + assert.equal(action()?.hasAttribute("disabled"), true); + await press(byLabel("New owner"), "the new-owner select"); + assert.deepEqual( + selectOptions().map((option) => option.textContent?.trim()), + ["Lin Wei · Manager", "Chen Jie · Developer"] + ); + await chooseOption("Lin Wei"); + assert.equal( + action()?.hasAttribute("disabled"), + true, + "the name is still to type" + ); + await type( + dialog.querySelector('[aria-label="Type Acme to confirm."]'), + "Acme", + "the confirmation" + ); + assert.equal(action()?.hasAttribute("disabled"), false); + await press(action(), "Transfer ownership"); + + assert.deepEqual( + writesTo("/api/workspace/transfer").map((r) => r.body), + [{ crUid: "cr-lin", uid: "uid-acme" }] + ); + assert.equal(bySlot("workspace-transfer-dialog"), null); + assert.equal( + bySlot("workspace-detail-role")?.textContent, + "You're Developer" + ); + assert.equal(byLabel("Workspace actions"), null, "no ⋯ menu for a Developer"); + assert.equal( + byLabel("Leave workspace")?.hasAttribute("disabled"), + true, + "leave waits for a switch" + ); + assert.equal(byLabel("Invite member"), null); + assert.equal( + getDefaultStore().get(currentWorkspaceAtom)?.role, + "Developer", + "the session's role follows" + ); + assert.deepEqual(route.replaced, []); +}); + +test("Leave: a plain confirmation removing the actor's own membership, then the page moves to the current Workspace", async () => { + hydrate("manager", "uid-personal"); + await mountArea("uid-acme"); + await press(byLabel("Leave workspace"), "Leave workspace"); + assert.equal(writesTo("/api/workspace/member/remove").length, 0); + await press(dialogAction("workspace-leave-dialog", "Leave"), "Leave"); + assert.deepEqual( + writesTo("/api/workspace/member/remove").map((r) => r.body), + [{ crUid: "cr-ada", uid: "uid-acme" }] + ); + assert.deepEqual(route.replaced, ["/workspace/uid-personal"]); + assert.ok(toasts.includes(workspaceLeftNotice("Acme"))); + assert.equal( + getDefaultStore().get(currentWorkspaceAtom)?.uid, + "uid-personal" + ); +}); + +test("Desktop's 403 on a write: the list and members are re-read, the page re-gates, and it says the permissions changed", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + fixtures.writeStatus = 403; + // Meanwhile the actor was demoted: the re-read must show that. + state.list = state.list.map((workspace) => + workspace.uid === "uid-acme" ? { ...workspace, role: "Manager" } : workspace + ); + patchMember("uid-acme", "cr-ada", { role: "Manager" }); + patchMember("uid-acme", "cr-lin", { role: "Owner" }); + const before = requests.length; + await press(byLabel("Remove Chen Jie"), "the remove icon"); + await press( + dialogAction("workspace-remove-member-dialog", "Remove"), + "Remove" + ); + + assert.equal(readsAfter(before, "/api/workspace/list"), 1); + assert.equal(readsAfter(before, "/api/workspace/details"), 1); + assert.ok(toasts.includes(PERMISSIONS_CHANGED_NOTICE)); + assert.equal(bySlot("workspace-detail-role")?.textContent, "You're Manager"); + assert.equal(byLabel("Workspace actions"), null); + assert.equal( + byLabel("Remove Chen Jie") != null, + true, + "a Manager still removes Developers" + ); + assert.equal(byLabel("Remove Lin Wei"), null); + assert.deepEqual(route.replaced, []); +}); + +test("Invite: the Owner picks Manager or Developer (Developer first), copies a Desktop link that stays on show, and a role change clears it", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + await press(byLabel("Invite member"), "Invite member"); + const dialog = bySlot("workspace-invite-dialog"); + assert.ok(dialog); + assert.ok(dialog.textContent?.includes(INVITE_LINK_VALIDITY_NOTE)); + assert.match(byLabel("Invite role")?.textContent ?? "", DEVELOPER_RE); + assert.equal(bySlot("workspace-invite-link"), null); + + await press( + dialogAction("workspace-invite-dialog", "Copy invite link"), + "Copy invite link" + ); + assert.deepEqual( + writesTo("/api/workspace/invite-link").map((r) => r.body), + [{ role: "Developer", uid: "uid-acme" }] + ); + const link = "https://cloud.test/WorkspaceInvite/?code=code-Developer"; + assert.equal(bySlot("workspace-invite-link")?.textContent, link); + assert.deepEqual(copied, [link]); + assert.ok(toasts.includes(INVITE_LINK_COPIED_NOTICE)); + + await press(byLabel("Invite role"), "the role select"); + assert.deepEqual( + selectOptions().map((option) => option.textContent?.trim()), + ["Manager", "Developer"] + ); + await chooseOption("Manager"); + assert.equal( + bySlot("workspace-invite-link"), + null, + "a role change clears the link" + ); + await press( + dialogAction("workspace-invite-dialog", "Copy invite link"), + "Copy invite link" + ); + assert.deepEqual(writesTo("/api/workspace/invite-link")[1]?.body, { + role: "Manager", + uid: "uid-acme", + }); + assert.equal( + bySlot("workspace-invite-link")?.textContent, + "https://cloud.test/WorkspaceInvite/?code=code-Manager" + ); +}); + +test("Invite: a Manager is offered Developer only", async () => { + hydrate("manager", "uid-personal"); + await mountArea("uid-acme"); + await press(byLabel("Invite member"), "Invite member"); + await press(byLabel("Invite role"), "the role select"); + assert.deepEqual( + selectOptions().map((option) => option.textContent?.trim()), + ["Developer"] + ); +}); diff --git a/apps/ui/src/features/workspace/workspace-area.tsx b/apps/ui/src/features/workspace/workspace-area.tsx index 82df1d75..5364c14f 100644 --- a/apps/ui/src/features/workspace/workspace-area.tsx +++ b/apps/ui/src/features/workspace/workspace-area.tsx @@ -3,13 +3,20 @@ import { useAtomValue } from "jotai"; import { UsersRound } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { toast } from "sonner"; import type { SessionWorkspace } from "@/features/session/session-schema"; import { AreaShell } from "@/features/shell/area-shell"; -import { currentWorkspaceAtom, sessionUserAtom } from "@/lib/auth-store"; +import { + currentWorkspaceAtom, + desktopDomainAtom, + kubeconfigAtom, + sessionUserAtom, +} from "@/lib/auth-store"; +import { routingDomainFromKubeconfig } from "@/lib/kubeconfig-routing-domain"; +import { useWorkspaceActions } from "./use-workspace-actions"; import { useWorkspaceDetails } from "./use-workspace-details"; import { useWorkspaceList } from "./use-workspace-list"; import { useWorkspacePlans } from "./use-workspace-plans"; @@ -35,24 +42,51 @@ function WorkspaceAreaIcon() { ); } +/** + * Desktop's cloud domain for the links the area builds (spec §B.2): the + * SDK host config inside the iframe, else the kubeconfig's routing domain + * (the card-management route derives it the same way server-side). + */ +function useDesktopCloudDomain(): string { + const desktopDomain = useAtomValue(desktopDomainAtom).trim(); + const kubeconfig = useAtomValue(kubeconfigAtom); + return useMemo( + () => + desktopDomain === "" + ? routingDomainFromKubeconfig(kubeconfig) + : desktopDomain, + [desktopDomain, kubeconfig] + ); +} + /** * The Managed Workspace's detail column (spec §D.4–D.5): the header from * the list's own entry (name, role, Personal are the list's verdict), the - * members from `POST /api/workspace/details`. + * members from `POST /api/workspace/details`, and the writes with their + * convergence behind every control. */ function WorkspaceDetail({ isCurrent, meCrName, + onGone, planName, workspace, }: { isCurrent: boolean; meCrName: string; + /** The Workspace was deleted or left: the area moves on. */ + onGone: (uid: string) => void; planName: string | null | undefined; workspace: SessionWorkspace; }) { const details = useWorkspaceDetails(workspace.uid); const members = details.data?.members; + const uid = workspace.uid; + const actions = useWorkspaceActions({ + onGone: useCallback(() => onGone(uid), [onGone, uid]), + workspace, + }); + const cloudDomain = useDesktopCloudDomain(); const gateInput: WorkspaceGateInput = useMemo( () => ({ actorRole: workspace.role, @@ -71,18 +105,23 @@ function WorkspaceDetail({ data-slot="workspace-detail" >
); @@ -119,8 +158,22 @@ export function WorkspaceArea() { const notice = resolution.kind === "redirect" ? resolution.notice : null; // One notice per unknown uid, whatever React's effect cadence. const noticedUid = useRef(null); + // A Workspace the actor just deleted or left (spec §D.8): the page is + // already on its way to the current one, so the list's re-read finding + // the uid gone is no surprise and earns no notice or second navigation. + const departedUid = useRef(null); + const currentUid = current?.uid ?? null; + const handleGone = useCallback( + (goneUid: string) => { + departedUid.current = goneUid; + if (currentUid != null) { + router.replace(`/workspace/${currentUid}`); + } + }, + [currentUid, router] + ); useEffect(() => { - if (redirectTo == null) { + if (redirectTo == null || (uid != null && departedUid.current === uid)) { return; } router.replace(redirectTo); @@ -154,6 +207,7 @@ export function WorkspaceArea() { isCurrent={managed.uid === current.uid} key={managed.uid} meCrName={user?.crName ?? ""} + onGone={handleGone} planName={planNameFor(plans, managed.id)} workspace={managed} /> diff --git a/apps/ui/src/features/workspace/workspace-confirm-field.tsx b/apps/ui/src/features/workspace/workspace-confirm-field.tsx new file mode 100644 index 00000000..3b6dd295 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-confirm-field.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { AppDialog } from "@workspace/ui/components/app-dialog"; + +/** + * The typed confirmation the destructive Workspace actions share (spec + * §D.7): delete and transfer go through only once the Workspace's name is + * typed back exactly. + */ +export function WorkspaceNameConfirmField({ + name, + onChange, + value, +}: { + name: string; + onChange: (value: string) => void; + value: string; +}) { + return ( + +

+ Type {name} to confirm. +

+ onChange(event.target.value)} + placeholder={name} + type="text" + value={value} + /> +
+ ); +} + +export function nameConfirmed(value: string, name: string): boolean { + return value === name; +} diff --git a/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx b/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx new file mode 100644 index 00000000..e0c39959 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { AppDialog } from "@workspace/ui/components/app-dialog"; +import { AppSelect } from "@workspace/ui/components/app-select"; +import { useId, useState } from "react"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import { + nameConfirmed, + WorkspaceNameConfirmField, +} from "./workspace-confirm-field"; +import type { WorkspaceMember } from "./workspace-details-schema"; +import { WORKSPACE_NAME_MAX_LENGTH } from "./workspace-write-schema"; + +/** + * The detail header's dialogs (spec §D.4, §D.7): Rename takes effect on + * submit; Delete and Transfer ownership ask for the Workspace's name; + * Leave is a plain confirmation. Each is mounted only while open, so its + * fields start fresh every time. + */ + +function memberDisplayName(member: WorkspaceMember): string { + return member.nickname.trim() === "" ? member.crName : member.nickname; +} + +export function WorkspaceRenameDialog({ + onOpenChange, + onRename, + pending, + workspace, +}: { + onOpenChange: (open: boolean) => void; + onRename: (name: string) => Promise; + pending: boolean; + workspace: SessionWorkspace; +}) { + const [name, setName] = useState(workspace.name); + const inputId = useId(); + const trimmed = name.trim(); + const unchanged = trimmed === workspace.name; + const submit = async () => { + if (trimmed === "" || unchanged) { + return; + } + if (await onRename(trimmed)) { + onOpenChange(false); + } + }; + return ( + + + + Rename workspace + + +
{ + event.preventDefault(); + submit().catch(() => undefined); + }} + > + + + Workspace name + + setName(event.target.value)} + value={name} + /> + +
+
+ + + { + submit().catch(() => undefined); + }} + > + Rename + + +
+
+ ); +} + +export function WorkspaceDeleteDialog({ + onDelete, + onOpenChange, + pending, + workspace, +}: { + onDelete: () => Promise; + onOpenChange: (open: boolean) => void; + pending: boolean; + workspace: SessionWorkspace; +}) { + const [typed, setTyped] = useState(""); + return ( + + + + + Delete workspace? + + + + This deletes{" "} + + {workspace.name} + {" "} + with everything in it, and every member loses access. This cannot be + undone. + + + + + + { + onDelete() + .then((done) => { + if (done) { + onOpenChange(false); + } + }) + .catch(() => undefined); + }} + > + Delete workspace + + + + + ); +} + +export function WorkspaceTransferDialog({ + candidates, + onOpenChange, + onTransfer, + pending, + workspace, +}: { + /** The other members: whoever can receive ownership. */ + candidates: readonly WorkspaceMember[]; + onOpenChange: (open: boolean) => void; + onTransfer: (member: WorkspaceMember) => Promise; + pending: boolean; + workspace: SessionWorkspace; +}) { + const [typed, setTyped] = useState(""); + const [targetCrUid, setTargetCrUid] = useState(undefined); + const target = candidates.find((member) => member.crUid === targetCrUid); + const selectId = useId(); + return ( + + + + + Transfer ownership? + + + + The new Owner takes over{" "} + + {workspace.name} + + , its billing included.{" "} + + You will become a Developer. + + + + New owner + ({ + label: `${memberDisplayName(member)} · ${member.role}`, + textValue: memberDisplayName(member), + value: member.crUid, + }))} + placeholder="Select a member" + value={targetCrUid} + /> + + + + + + { + if (target == null) { + return; + } + onTransfer(target) + .then((done) => { + if (done) { + onOpenChange(false); + } + }) + .catch(() => undefined); + }} + > + Transfer ownership + + + + + ); +} + +export function WorkspaceLeaveDialog({ + onLeave, + onOpenChange, + pending, + workspace, +}: { + onLeave: () => Promise; + onOpenChange: (open: boolean) => void; + pending: boolean; + workspace: SessionWorkspace; +}) { + return ( + + + + + Leave workspace? + + + + You lose access to{" "} + + {workspace.name} + + . An Owner or Manager can invite you again. + + + + + { + onLeave() + .then((done) => { + if (done) { + onOpenChange(false); + } + }) + .catch(() => undefined); + }} + > + Leave + + + + + ); +} diff --git a/apps/ui/src/features/workspace/workspace-detail-header.tsx b/apps/ui/src/features/workspace/workspace-detail-header.tsx index 53072a64..41602f55 100644 --- a/apps/ui/src/features/workspace/workspace-detail-header.tsx +++ b/apps/ui/src/features/workspace/workspace-detail-header.tsx @@ -24,17 +24,27 @@ import { Pencil, Trash2, } from "lucide-react"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; import { toast } from "sonner"; import type { SessionWorkspace } from "@/features/session/session-schema"; +import type { WorkspaceActions } from "./use-workspace-actions"; +import { + WorkspaceDeleteDialog, + WorkspaceLeaveDialog, + WorkspaceRenameDialog, + WorkspaceTransferDialog, +} from "./workspace-detail-dialogs"; +import type { WorkspaceMember } from "./workspace-details-schema"; import type { WorkspaceActionGate, WorkspaceActionGates, } from "./workspace-gating-core"; import { PlanSlot } from "./workspace-plan-slot"; +type HeaderDialog = "delete" | "leave" | "rename" | "transfer"; + export const WORKSPACE_ID_COPIED_NOTICE = "Workspace ID copied"; function copyWorkspaceId(id: string): void { @@ -78,16 +88,22 @@ function MenuAction({ gate, icon, label, + onSelect, variant, }: { gate: WorkspaceActionGate; icon: ReactNode; label: string; + onSelect: () => void; variant?: "default" | "destructive"; }) { const reason = disabledReason(gate); return ( - + {icon} {label} @@ -111,9 +127,11 @@ function MenuAction({ */ function WorkspaceActionsMenu({ gates, + onOpen, ready, }: { gates: WorkspaceActionGates; + onOpen: (dialog: HeaderDialog) => void; /** False until the members landed: transfer's gate waits on their count. */ ready: boolean; }) { @@ -140,6 +158,7 @@ function WorkspaceActionsMenu({ gate={gates.rename} icon={} label="Rename…" + onSelect={() => onOpen("rename")} /> )} {gates.rename.kind !== "hidden" && dangerous ? ( @@ -150,6 +169,7 @@ function WorkspaceActionsMenu({ gate={gates.transfer} icon={} label="Transfer ownership…" + onSelect={() => onOpen("transfer")} /> )} {gates.delete.kind === "hidden" ? null : ( @@ -157,6 +177,7 @@ function WorkspaceActionsMenu({ gate={gates.delete} icon={} label="Delete workspace…" + onSelect={() => onOpen("delete")} variant="destructive" /> )} @@ -172,22 +193,36 @@ function WorkspaceActionsMenu({ * workspace", and the copyable namespace id. On the right a non-Owner's * Leave workspace button (disabled with a tooltip while it is the current * Workspace) or the Owner's ⋯ menu. The controls are rendered from the - * gates; the operations behind them arrive with the write routes. + * gates and open the dialogs that run the writes (spec §D.7): Rename + * takes effect on submit, Delete and Transfer ask for the name, Leave + * asks once. */ export function WorkspaceDetailHeader({ + actions, gates, isCurrent, - membersLoaded, + meCrName, + members, planName, workspace, }: { + actions: WorkspaceActions; gates: WorkspaceActionGates; isCurrent: boolean; - /** The ⋯ menu opens only once the member count behind its gates is known. */ - membersLoaded: boolean; + meCrName: string; + /** Undefined until the members landed: the ⋯ menu and Leave wait on them. */ + members: readonly WorkspaceMember[] | undefined; planName: string | null | undefined; workspace: SessionWorkspace; }) { + const [dialog, setDialog] = useState(null); + const closeDialog = (open: boolean) => { + if (!open) { + setDialog(null); + } + }; + const me = members?.find((member) => member.crName === meCrName); + const others = (members ?? []).filter((member) => member.crName !== meCrName); const leaveReason = disabledReason(gates.leave); const showMenu = gates.rename.kind !== "hidden" || @@ -242,7 +277,8 @@ export function WorkspaceDetailHeader({ setDialog("leave")} variant="secondary" > @@ -251,7 +287,44 @@ export function WorkspaceDetailHeader({ )} {showMenu ? ( - + + ) : null} + {dialog === "rename" ? ( + + ) : null} + {dialog === "delete" ? ( + + ) : null} + {dialog === "transfer" ? ( + + ) : null} + {dialog === "leave" && me != null ? ( + actions.leave(me)} + onOpenChange={closeDialog} + pending={actions.pending} + workspace={workspace} + /> ) : null} ); diff --git a/apps/ui/src/features/workspace/workspace-invite-core.test.ts b/apps/ui/src/features/workspace/workspace-invite-core.test.ts new file mode 100644 index 00000000..d838a47f --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-invite-core.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { workspaceInviteUrl } from "./workspace-invite-core"; + +// Spec §B.2: the link has Desktop's own shape, `/WorkspaceInvite/?code=`, +// on the Desktop origin the SDK (or the kubeconfig) names. +test("the invite link is Desktop's landing page on the cloud domain with the code", () => { + assert.equal( + workspaceInviteUrl({ cloudDomain: "cloud.sealos.io", code: "abc-123" }), + "https://cloud.sealos.io/WorkspaceInvite/?code=abc-123" + ); + assert.equal( + workspaceInviteUrl({ + cloudDomain: "https://desktop.staging.test/", + code: "x y", + }), + "https://desktop.staging.test/WorkspaceInvite/?code=x%20y" + ); +}); + +test("no link without a domain or a code", () => { + assert.equal(workspaceInviteUrl({ cloudDomain: " ", code: "abc" }), null); + assert.equal( + workspaceInviteUrl({ cloudDomain: "cloud.test", code: " " }), + null + ); +}); diff --git a/apps/ui/src/features/workspace/workspace-invite-core.ts b/apps/ui/src/features/workspace/workspace-invite-core.ts new file mode 100644 index 00000000..79ab4d7f --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-invite-core.ts @@ -0,0 +1,27 @@ +import { desktopOrigin } from "./workspace-switch-core"; + +/** + * The Workspace Invite Link (spec §B.2, §F): Brain's route answers only the + * code; the page appends it to Desktop's landing page, + * `https:///WorkspaceInvite/?code=`, the same link + * Desktop's own Team Center hands out. The invitee accepts there; Brain + * does nothing more and sees the new member on its next read. + */ + +const INVITE_LANDING_PATH = "/WorkspaceInvite/"; + +/** How long Desktop keeps a code (its TTL index); the dialog's fixed note. */ +export const INVITE_LINK_VALIDITY_NOTE = "The link is valid for 30 minutes."; + +/** Null without a Desktop domain — nothing to build the link on. */ +export function workspaceInviteUrl(input: { + cloudDomain: string; + code: string; +}): string | null { + const origin = desktopOrigin(input.cloudDomain); + const code = input.code.trim(); + if (origin == null || code === "") { + return null; + } + return `${origin}${INVITE_LANDING_PATH}?code=${encodeURIComponent(code)}`; +} diff --git a/apps/ui/src/features/workspace/workspace-invite-dialog.tsx b/apps/ui/src/features/workspace/workspace-invite-dialog.tsx new file mode 100644 index 00000000..82b962c5 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-invite-dialog.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { AppDialog } from "@workspace/ui/components/app-dialog"; +import { AppSelect } from "@workspace/ui/components/app-select"; +import { Link2 } from "lucide-react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; + +import type { + SessionWorkspace, + WorkspaceRole, +} from "@/features/session/session-schema"; + +import { inviteRoleOptions } from "./workspace-gating-core"; +import { + INVITE_LINK_VALIDITY_NOTE, + workspaceInviteUrl, +} from "./workspace-invite-core"; +import type { AssignableRole } from "./workspace-write-schema"; + +export const INVITE_LINK_COPIED_NOTICE = "Invite link copied."; +export const INVITE_LINK_NO_DESKTOP_NOTICE = + "Couldn't build the link: the Desktop domain is unknown."; + +const DEFAULT_INVITE_ROLE: AssignableRole = "Developer"; + +async function copyText(text: string): Promise { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return false; + } + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +/** + * The invite dialog (spec §D.6): a role per the matrix (an Owner offers + * Manager and Developer, a Manager only Developer; Developer by default), + * "Copy invite link" which asks Brain for a code, builds Desktop's link, + * writes it to the clipboard, and keeps it on show, selectable, beside a + * fixed note that it lasts 30 minutes. Changing the role clears the link. + */ +export function WorkspaceInviteDialog({ + actorRole, + cloudDomain, + onCreateLink, + onOpenChange, + pending, + workspace, +}: { + actorRole: WorkspaceRole; + /** Desktop's cloud domain; "" while unknown. */ + cloudDomain: string; + onCreateLink: (role: AssignableRole) => Promise; + onOpenChange: (open: boolean) => void; + pending: boolean; + workspace: SessionWorkspace; +}) { + const roles = inviteRoleOptions(actorRole); + const [role, setRole] = useState( + roles.includes(DEFAULT_INVITE_ROLE) + ? DEFAULT_INVITE_ROLE + : ((roles[0] as AssignableRole | undefined) ?? DEFAULT_INVITE_ROLE) + ); + const [link, setLink] = useState(null); + const selectId = useId(); + + const createLink = async () => { + const code = await onCreateLink(role); + if (code == null) { + return; + } + const url = workspaceInviteUrl({ cloudDomain, code }); + if (url == null) { + toast(INVITE_LINK_NO_DESKTOP_NOTICE); + return; + } + setLink(url); + if (await copyText(url)) { + toast(INVITE_LINK_COPIED_NOTICE); + } + }; + + return ( + + + + Invite member + + + + Anyone with the link joins{" "} + + {workspace.name} + {" "} + in the role you pick. + + + Role + { + setRole(next as AssignableRole); + setLink(null); + }} + options={roles.map((option) => ({ + label: option, + value: option, + }))} + value={role} + /> + +
+
+ { + createLink().catch(() => undefined); + }} + > + + Copy invite link + + {link == null ? null : ( + + {link} + + )} +
+

+ {INVITE_LINK_VALIDITY_NOTE} +

+
+
+ + Close + +
+
+ ); +} diff --git a/apps/ui/src/features/workspace/workspace-member-dialogs.tsx b/apps/ui/src/features/workspace/workspace-member-dialogs.tsx new file mode 100644 index 00000000..77d4b4dc --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-member-dialogs.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { AppDialog } from "@workspace/ui/components/app-dialog"; +import { useId, useState } from "react"; + +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import type { WorkspaceMember } from "./workspace-details-schema"; +import { WORKSPACE_ALIAS_MAX_LENGTH } from "./workspace-write-schema"; + +/** + * The members table's dialogs (spec §D.5, §D.7): the alias editor saves on + * submit (an empty alias clears it), removing a member is a plain + * confirmation. Mounted only while open, so the fields start fresh. + */ + +export function memberDisplayName(member: WorkspaceMember): string { + return member.nickname.trim() === "" ? member.crName : member.nickname; +} + +export function WorkspaceAliasDialog({ + member, + onOpenChange, + onSave, + pending, +}: { + member: WorkspaceMember; + onOpenChange: (open: boolean) => void; + onSave: (alias: string) => Promise; + pending: boolean; +}) { + const [alias, setAlias] = useState(member.alias ?? ""); + const inputId = useId(); + const unchanged = alias.trim() === (member.alias ?? ""); + const submit = async () => { + if (unchanged) { + return; + } + if (await onSave(alias)) { + onOpenChange(false); + } + }; + return ( + + + + + {member.alias == null ? "Set alias" : "Edit alias"} + + + + + How{" "} + + {memberDisplayName(member)} + {" "} + is labelled in this workspace. Leave it empty to clear the alias. + +
{ + event.preventDefault(); + submit().catch(() => undefined); + }} + > + + Alias + setAlias(event.target.value)} + value={alias} + /> + +
+
+ + + { + submit().catch(() => undefined); + }} + > + Save + + +
+
+ ); +} + +export function WorkspaceRemoveMemberDialog({ + member, + onOpenChange, + onRemove, + pending, + workspace, +}: { + member: WorkspaceMember; + onOpenChange: (open: boolean) => void; + onRemove: () => Promise; + pending: boolean; + workspace: SessionWorkspace; +}) { + return ( + + + + + Remove member? + + + + + {memberDisplayName(member)} + {" "} + loses access to{" "} + + {workspace.name} + + . They can be invited again later. + + + + + { + onRemove() + .then((done) => { + if (done) { + onOpenChange(false); + } + }) + .catch(() => undefined); + }} + > + Remove + + + + + ); +} diff --git a/apps/ui/src/features/workspace/workspace-members-panel.tsx b/apps/ui/src/features/workspace/workspace-members-panel.tsx index cdbf22c6..1d70e774 100644 --- a/apps/ui/src/features/workspace/workspace-members-panel.tsx +++ b/apps/ui/src/features/workspace/workspace-members-panel.tsx @@ -19,7 +19,11 @@ import { } from "@workspace/ui/components/table"; import { cn } from "@workspace/ui/lib/utils"; import { Pencil, UserMinus, UserRoundPlus, UsersRound } from "lucide-react"; +import { useState } from "react"; +import type { SessionWorkspace } from "@/features/session/session-schema"; + +import type { WorkspaceActions } from "./use-workspace-actions"; import type { WorkspaceMember } from "./workspace-details-schema"; import { ASSIGNABLE_ROLES, @@ -28,6 +32,24 @@ import { type WorkspaceActionGate, type WorkspaceGateInput, } from "./workspace-gating-core"; +import { WorkspaceInviteDialog } from "./workspace-invite-dialog"; +import { + WorkspaceAliasDialog, + WorkspaceRemoveMemberDialog, +} from "./workspace-member-dialogs"; +import { assignableRoleSchema } from "./workspace-write-schema"; + +type PanelDialog = + | { kind: "alias"; member: WorkspaceMember } + | { kind: "invite" } + | { kind: "remove"; member: WorkspaceMember }; + +/** What a member row can open, and the writes behind its controls. */ +interface RowActions { + changeRole: WorkspaceActions["changeRole"]; + open: (dialog: PanelDialog) => void; + pending: boolean; +} export const MEMBERS_LOAD_FAILED_NOTICE = "Couldn't load the members."; @@ -69,18 +91,26 @@ function MemberAvatar({ member }: { member: WorkspaceMember }) { } function RoleCell({ + actions, gate, member, }: { + actions: RowActions; gate: WorkspaceActionGate; member: WorkspaceMember; }) { if (gate.kind === "enabled") { - // The operation behind the choice arrives with the write routes; the - // control is rendered where it will live so the gating is reviewable. + // Choosing takes effect at once (spec §D.7); the table re-reads after. return ( { + const role = assignableRoleSchema.safeParse(next); + if (role.success && role.data !== member.role) { + actions.changeRole(member, role.data).catch(() => undefined); + } + }} options={ROLE_OPTIONS} triggerClassName="-ml-2 h-8 w-32 border-transparent bg-transparent px-2 text-sm hover:bg-input/30" value={member.role} @@ -98,11 +128,13 @@ function RoleCell({ } function MemberRow({ + actions, gates, isSelf, member, showActions, }: { + actions: RowActions; gates: MemberActionGates; isSelf: boolean; member: WorkspaceMember; @@ -144,6 +176,8 @@ function MemberRow({ actions.open({ kind: "alias", member })} size="sm" variant="quiet" > @@ -154,7 +188,7 @@ function MemberRow({ - + {formatJoinedDate(member.joinedAt)} @@ -166,6 +200,7 @@ function MemberRow({ aria-label={`Remove ${name}`} className="text-muted-foreground hover:text-red-400" data-slot="workspace-member-remove" + onClick={() => actions.open({ kind: "remove", member })} size="md" variant="quiet" > @@ -179,10 +214,12 @@ function MemberRow({ } function MembersTable({ + actions, gateInput, meCrName, members, }: { + actions: RowActions; gateInput: WorkspaceGateInput; meCrName: string; members: readonly WorkspaceMember[]; @@ -219,6 +256,7 @@ function MembersTable({ {rows.map((row) => ( (null); + const closeDialog = (open: boolean) => { + if (!open) { + setDialog(null); + } + }; + const rowActions: RowActions = { + changeRole: actions.changeRole, + open: setDialog, + pending: actions.pending, + }; return (
{inviteGate.kind === "hidden" ? null : ( - + setDialog({ kind: "invite" })} + variant="secondary" + > Invite member )} + {dialog?.kind === "alias" ? ( + actions.setAlias(dialog.member, alias)} + pending={actions.pending} + /> + ) : null} + {dialog?.kind === "remove" ? ( + actions.removeMember(dialog.member)} + pending={actions.pending} + workspace={workspace} + /> + ) : null} + {dialog?.kind === "invite" ? ( + + ) : null}
); } diff --git a/apps/ui/src/features/workspace/workspace-request.ts b/apps/ui/src/features/workspace/workspace-request.ts new file mode 100644 index 00000000..d0eed558 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-request.ts @@ -0,0 +1,59 @@ +import type { z } from "zod"; + +import { sessionFetch } from "@/features/session/session-fetch"; + +/** + * The page's side of the `/api/workspace/*` boundary: every read and write + * goes through the session fetch (regional token attached, 401 two-step) + * and a refused answer becomes a `WorkspaceRequestError` carrying Brain's + * status and error code, which the Workspace Area keys its reaction on — + * a 403 / 404 means the actor's standing changed under them (spec §D.8). + */ +export class WorkspaceRequestError extends Error { + readonly code: string; + readonly status: number; + + constructor(path: string, status: number, code: string) { + super(`${path} ${status} ${code}`); + this.name = "WorkspaceRequestError"; + this.code = code; + this.status = status; + } + + /** Desktop refused the actor: their role or membership changed. */ + get standingChanged(): boolean { + return this.status === 403 || this.status === 404; + } +} + +async function errorCodeOf(response: Response): Promise { + const payload: unknown = await response.json().catch(() => null); + return typeof payload === "object" && + payload != null && + "error" in payload && + typeof payload.error === "string" + ? payload.error + : "unknown"; +} + +/** `POST path` with a JSON body, the answer validated against `schema`. */ +export async function postWorkspaceJson( + path: string, + body: unknown, + schema: z.ZodType +): Promise { + const response = await sessionFetch(path, { + body: JSON.stringify(body), + cache: "no-store", + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + throw new WorkspaceRequestError( + path, + response.status, + await errorCodeOf(response) + ); + } + return schema.parse(await response.json()); +} diff --git a/apps/ui/src/features/workspace/workspace-switch-core.ts b/apps/ui/src/features/workspace/workspace-switch-core.ts index 114bab5b..f9f47ebe 100644 --- a/apps/ui/src/features/workspace/workspace-switch-core.ts +++ b/apps/ui/src/features/workspace/workspace-switch-core.ts @@ -15,6 +15,22 @@ const BRAIN_APP_KEY = "system-brain"; const DESKTOP_DOMAIN_SCHEME_RE = /^https?:\/\//i; const TRAILING_SLASHES_RE = /\/+$/; +/** + * Desktop's origin from its cloud domain (the SDK host config's + * `cloud.domain`, or the kubeconfig's routing domain outside the iframe): + * `https://` unless a scheme is already there, trailing slashes dropped. + * Null for an empty domain — no Desktop link can be built yet. + */ +export function desktopOrigin(cloudDomain: string): string | null { + const trimmed = cloudDomain.trim().replace(TRAILING_SLASHES_RE, ""); + if (trimmed === "") { + return null; + } + return DESKTOP_DOMAIN_SCHEME_RE.test(trimmed) + ? trimmed + : `https://${trimmed}`; +} + function isInsideArea(pathname: string, prefix: string): boolean { return pathname === prefix || pathname.startsWith(`${prefix}/`); } @@ -47,13 +63,10 @@ export function workspaceSwitchUrl(input: { landing: string; workspaceUid: string; }): string | null { - const trimmed = input.cloudDomain.trim().replace(TRAILING_SLASHES_RE, ""); - if (trimmed === "") { + const origin = desktopOrigin(input.cloudDomain); + if (origin == null) { return null; } - const origin = DESKTOP_DOMAIN_SCHEME_RE.test(trimmed) - ? trimmed - : `https://${trimmed}`; const [path = "/", ...query] = input.landing.split("?"); const openapp = encodeURIComponent( `${BRAIN_APP_KEY}?${path}?${query.join("?")}` diff --git a/apps/ui/src/features/workspace/workspace-write-schema.ts b/apps/ui/src/features/workspace/workspace-write-schema.ts new file mode 100644 index 00000000..b5c80e9d --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-write-schema.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +/** + * The request and response shapes of the Workspace-management write routes + * (spec §B.2). Client-safe: the Workspace Area builds its requests from + * these, the route handlers validate bodies with them, and the dev-mock + * fixtures answer in them. Where Desktop leaves a hole, the schema closes + * it — a role on a link or a role change is never Owner, an alias is + * trimmed and capped at Desktop's 128, a Workspace name at the spec's 32. + */ + +/** The roles a Workspace Invite Link or a role change may carry (spec §E.4). */ +export const ASSIGNABLE_ROLE_VALUES = ["Manager", "Developer"] as const; + +export const assignableRoleSchema = z.enum(ASSIGNABLE_ROLE_VALUES); + +export type AssignableRole = z.infer; + +export const WORKSPACE_NAME_MAX_LENGTH = 32; +export const WORKSPACE_ALIAS_MAX_LENGTH = 128; + +const workspaceUidSchema = z.string().trim().min(1); +const memberCrUidSchema = z.string().trim().min(1); + +/** A Workspace name: trimmed, required, at most 32 characters (spec, Further Notes). */ +export const workspaceNameSchema = z + .string() + .trim() + .min(1) + .max(WORKSPACE_NAME_MAX_LENGTH); + +export const workspaceRenameRequestSchema = z.object({ + name: workspaceNameSchema, + uid: workspaceUidSchema, +}); + +export type WorkspaceRenameRequest = z.infer< + typeof workspaceRenameRequestSchema +>; + +export const workspaceDeleteRequestSchema = z.object({ + uid: workspaceUidSchema, +}); + +export type WorkspaceDeleteRequest = z.infer< + typeof workspaceDeleteRequestSchema +>; + +export const workspaceInviteLinkRequestSchema = z.object({ + role: assignableRoleSchema, + uid: workspaceUidSchema, +}); + +export type WorkspaceInviteLinkRequest = z.infer< + typeof workspaceInviteLinkRequestSchema +>; + +/** `{ code }`: the client appends it to Desktop's `/WorkspaceInvite/?code=`. */ +export const workspaceInviteLinkResponseSchema = z.object({ + code: z.string().min(1), +}); + +export type WorkspaceInviteLinkResponse = z.infer< + typeof workspaceInviteLinkResponseSchema +>; + +export const workspaceMemberRemoveRequestSchema = z.object({ + /** The membership's User CR uid; the actor's own means "leave". */ + crUid: memberCrUidSchema, + uid: workspaceUidSchema, +}); + +export type WorkspaceMemberRemoveRequest = z.infer< + typeof workspaceMemberRemoveRequestSchema +>; + +export const workspaceMemberRoleRequestSchema = z.object({ + crUid: memberCrUidSchema, + role: assignableRoleSchema, + uid: workspaceUidSchema, +}); + +export type WorkspaceMemberRoleRequest = z.infer< + typeof workspaceMemberRoleRequestSchema +>; + +/** + * `alias` arrives as the user typed it; the route trims it and sends + * Desktop null for an empty one (clear). Null is accepted on the wire too. + */ +export const workspaceMemberAliasRequestSchema = z.object({ + alias: z + .string() + .nullable() + .transform((alias) => { + const trimmed = alias?.trim() ?? ""; + return trimmed === "" ? null : trimmed; + }) + .refine( + (alias) => alias == null || alias.length <= WORKSPACE_ALIAS_MAX_LENGTH, + { + message: `alias can have at most ${WORKSPACE_ALIAS_MAX_LENGTH} characters`, + } + ), + crUid: memberCrUidSchema, + uid: workspaceUidSchema, +}); + +export type WorkspaceMemberAliasRequest = z.input< + typeof workspaceMemberAliasRequestSchema +>; + +export const workspaceTransferRequestSchema = z.object({ + /** The member who becomes the Owner; the actor becomes a Developer. */ + crUid: memberCrUidSchema, + uid: workspaceUidSchema, +}); + +export type WorkspaceTransferRequest = z.infer< + typeof workspaceTransferRequestSchema +>; + +/** What every write route but invite-link answers on success. */ +export const workspaceWriteResponseSchema = z.object({ ok: z.literal(true) }); + +export type WorkspaceWriteResponse = z.infer< + typeof workspaceWriteResponseSchema +>; From ce59134a0f36039334b66565ae8112b3cb698f1e Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 17:59:40 +0800 Subject: [PATCH 08/17] fix(workspace): address review findings on the Workspace Area writes (AIM-447) - The "departed" mark that suppresses the list fallback after a delete or leave is spent the one time it is met, so a later visit to that uid gets the ordinary redirect and notice again. - Dialogs ignore Escape and the overlay while a write is in flight, as Cancel already did, so the outcome lands in a mounted dialog. - The invite dialog says so when the clipboard refused, instead of showing the link silently; the transfer dialog no longer claims billing follows. - Rename explains the 32-character cap inline rather than failing with the generic notice on a longer Desktop-made name. - One `memberDisplayName` next to the member type replaces four copies; `inviteRoleOptions` and `ASSIGNABLE_ROLES` are typed as assignable roles, dropping a cast; unused request types and a duplicated success constant go; the confirm field uses colour tokens; the dev-mock answers 404 for an unknown Workspace on every write. Co-Authored-By: Claude Fable 5.1 --- .../session/server/dev-fixtures.test.ts | 7 ++- .../features/session/server/dev-fixtures.ts | 14 ++--- .../server/workspace-write-handlers.test.ts | 3 +- .../server/workspace-write-handlers.ts | 16 +++--- .../workspace/use-workspace-actions.ts | 30 +++------- .../src/features/workspace/workspace-area.tsx | 15 +++-- .../workspace/workspace-confirm-field.tsx | 9 +-- .../workspace/workspace-detail-dialogs.tsx | 56 ++++++++++++------- .../workspace/workspace-details-schema.ts | 5 ++ .../workspace/workspace-dialog-pending.ts | 15 +++++ .../workspace/workspace-gating-core.ts | 13 +++-- .../workspace/workspace-invite-core.ts | 2 +- .../workspace/workspace-invite-dialog.tsx | 19 +++++-- .../workspace/workspace-member-dialogs.tsx | 22 +++++--- .../workspace/workspace-members-panel.tsx | 15 +++-- .../workspace/workspace-write-schema.ts | 30 +--------- 16 files changed, 142 insertions(+), 129 deletions(-) create mode 100644 apps/ui/src/features/workspace/workspace-dialog-pending.ts diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts index b84b360e..4495463d 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.test.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { WORKSPACE_ROUTES } from "@/features/workspace/server/workspace-route-table"; +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"; @@ -207,7 +210,7 @@ const OWNER_COOKIE = `${sessionDevMockCookie.name}=${sessionDevMockCookie.format const ACME_UID = "00000000-0000-4000-8000-000000000002"; async function write( - entry: { apiPath: string; desktopPath: string }, + entry: WorkspaceRouteEntry, body: unknown, cookie = OWNER_COOKIE ): Promise { diff --git a/apps/ui/src/features/session/server/dev-fixtures.ts b/apps/ui/src/features/session/server/dev-fixtures.ts index 7b58e338..5975b6b0 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -11,8 +11,8 @@ import { gateWorkspaceActions, } from "@/features/workspace/workspace-gating-core"; import { + WORKSPACE_WRITE_OK, type WorkspaceInviteLinkResponse, - type WorkspaceWriteResponse, workspaceDeleteRequestSchema, workspaceInviteLinkRequestSchema, workspaceMemberAliasRequestSchema, @@ -331,8 +331,6 @@ async function detailsFixture( return mockJson(details); } -const WRITE_OK: WorkspaceWriteResponse = { ok: true }; - type WriteOutcome = | { kind: "ok"; body?: unknown } | { kind: "error"; code: string; status: number }; @@ -350,7 +348,7 @@ const NOT_FOUND: WriteOutcome = { function outcomeResponse(outcome: WriteOutcome): Response { return outcome.kind === "ok" - ? mockJson(outcome.body ?? WRITE_OK) + ? mockJson(outcome.body ?? WORKSPACE_WRITE_OK) : mockJson({ error: outcome.code }, outcome.status); } @@ -501,7 +499,7 @@ const WORKSPACE_FIXTURES: Record< (scenario, state, body) => { const actor = actorIn(scenario, body.uid); if (actor == null) { - return FORBIDDEN; + return NOT_FOUND; } const target = membersOf(state, body.uid).find( (member) => member.crUid === body.crUid @@ -544,7 +542,7 @@ const WORKSPACE_FIXTURES: Record< (scenario, state, body) => { const actor = actorIn(scenario, body.uid); if (actor == null) { - return FORBIDDEN; + return NOT_FOUND; } const target = membersOf(state, body.uid).find( (member) => member.crUid === body.crUid @@ -573,7 +571,7 @@ const WORKSPACE_FIXTURES: Record< (scenario, state, body) => { const actor = actorIn(scenario, body.uid); if (actor == null) { - return FORBIDDEN; + return NOT_FOUND; } if (actor.role === "Developer") { return FORBIDDEN; @@ -593,7 +591,7 @@ const WORKSPACE_FIXTURES: Record< (scenario, state, body) => { const gates = workspaceGates(scenario, body.uid); if (gates == null) { - return FORBIDDEN; + return NOT_FOUND; } if (gates.transfer.kind !== "enabled") { return FORBIDDEN; diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts index 8941505e..4573bf71 100644 --- a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts @@ -8,6 +8,7 @@ import { WORKSPACE_NAME_MAX_LENGTH, } from "../workspace-write-schema"; import type { WorkspaceRouteDependencies } from "./workspace-route-context"; +import type { WorkspaceRouteEntry } from "./workspace-route-table"; mock.module("server-only", () => ({})); const { @@ -47,7 +48,7 @@ type HandlerFactory = (dependencies: WorkspaceRouteDependencies) => Handler; const ROUTES: { create: HandlerFactory; desktopBody: unknown; - entry: { apiPath: string; desktopPath: string }; + entry: WorkspaceRouteEntry; invalidBodies: unknown[]; name: string; validBody: unknown; diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts index 5690088a..3e852bb1 100644 --- a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts @@ -7,8 +7,8 @@ import type { DesktopCallResult } from "@/features/session/server/desktop-client import { WORKSPACE_ERROR_CODES } from "../workspace-errors"; import { + WORKSPACE_WRITE_OK, type WorkspaceInviteLinkResponse, - type WorkspaceWriteResponse, workspaceDeleteRequestSchema, workspaceInviteLinkRequestSchema, workspaceMemberAliasRequestSchema, @@ -43,8 +43,6 @@ import { type WorkspaceRouteHandler = (request: Request) => Promise; -const WRITE_OK: WorkspaceWriteResponse = { ok: true }; - function createWorkspaceWriteHandler( entry: WorkspaceRouteEntry, requestSchema: z.ZodType, @@ -91,7 +89,7 @@ export function createWorkspaceRenameHandler( workspaceRenameRequestSchema, (desktop, token, body) => desktop.namespaceRename(token, body.uid, body.name), - () => WRITE_OK, + () => WORKSPACE_WRITE_OK, dependencies ); } @@ -104,7 +102,7 @@ export function createWorkspaceDeleteHandler( WORKSPACE_ROUTES.delete, workspaceDeleteRequestSchema, (desktop, token, body) => desktop.namespaceDelete(token, body.uid), - () => WRITE_OK, + () => WORKSPACE_WRITE_OK, dependencies ); } @@ -135,7 +133,7 @@ export function createWorkspaceMemberRemoveHandler( workspaceMemberRemoveRequestSchema, (desktop, token, body) => desktop.namespaceRemoveUser(token, body.uid, body.crUid), - () => WRITE_OK, + () => WORKSPACE_WRITE_OK, dependencies ); } @@ -149,7 +147,7 @@ export function createWorkspaceMemberRoleHandler( workspaceMemberRoleRequestSchema, (desktop, token, body) => desktop.namespaceModifyRole(token, body.uid, body.crUid, body.role), - () => WRITE_OK, + () => WORKSPACE_WRITE_OK, dependencies ); } @@ -163,7 +161,7 @@ export function createWorkspaceMemberAliasHandler( workspaceMemberAliasRequestSchema, (desktop, token, body) => desktop.namespaceSetAlias(token, body.uid, body.crUid, body.alias), - () => WRITE_OK, + () => WORKSPACE_WRITE_OK, dependencies ); } @@ -177,7 +175,7 @@ export function createWorkspaceTransferHandler( workspaceTransferRequestSchema, (desktop, token, body) => desktop.namespaceAbdicate(token, body.uid, body.crUid), - () => WRITE_OK, + () => WORKSPACE_WRITE_OK, dependencies ); } diff --git a/apps/ui/src/features/workspace/use-workspace-actions.ts b/apps/ui/src/features/workspace/use-workspace-actions.ts index 94972676..3d512699 100644 --- a/apps/ui/src/features/workspace/use-workspace-actions.ts +++ b/apps/ui/src/features/workspace/use-workspace-actions.ts @@ -40,10 +40,6 @@ export function workspaceLeftNotice(name: string): string { return `You left ${name}.`; } -export function ownershipTransferredNotice(name: string): string { - return `${name} is now the Owner. You're a Developer.`; -} - /** What happens to the page after a write lands. */ type Convergence = /** Re-read the list and the member table; the page re-gates from them. */ @@ -199,24 +195,14 @@ export function useWorkspaceActions(input: { [perform, uid] ), transfer: useCallback( - async (member) => { - const done = - (await perform( - () => transferWorkspaceOwnership({ crUid: member.crUid, uid }), - { - convergence: "refresh", - failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.transfer, - } - )) != null; - if (done) { - toast( - ownershipTransferredNotice( - member.nickname.trim() === "" ? member.crName : member.nickname - ) - ); - } - return done; - }, + async (member) => + (await perform( + () => transferWorkspaceOwnership({ crUid: member.crUid, uid }), + { + convergence: "refresh", + failureNotice: WORKSPACE_ACTION_FAILED_NOTICES.transfer, + } + )) != null, [perform, uid] ), }; diff --git a/apps/ui/src/features/workspace/workspace-area.tsx b/apps/ui/src/features/workspace/workspace-area.tsx index 5364c14f..18d1292e 100644 --- a/apps/ui/src/features/workspace/workspace-area.tsx +++ b/apps/ui/src/features/workspace/workspace-area.tsx @@ -161,19 +161,26 @@ export function WorkspaceArea() { // A Workspace the actor just deleted or left (spec §D.8): the page is // already on its way to the current one, so the list's re-read finding // the uid gone is no surprise and earns no notice or second navigation. + // The mark is spent the one time it is met, so a later visit to that uid + // (Back, a stale link) gets the ordinary fallback and notice. const departedUid = useRef(null); const currentUid = current?.uid ?? null; const handleGone = useCallback( (goneUid: string) => { - departedUid.current = goneUid; - if (currentUid != null) { - router.replace(`/workspace/${currentUid}`); + if (currentUid == null) { + return; } + departedUid.current = goneUid; + router.replace(`/workspace/${currentUid}`); }, [currentUid, router] ); useEffect(() => { - if (redirectTo == null || (uid != null && departedUid.current === uid)) { + if (redirectTo == null) { + return; + } + if (uid != null && departedUid.current === uid) { + departedUid.current = null; return; } router.replace(redirectTo); diff --git a/apps/ui/src/features/workspace/workspace-confirm-field.tsx b/apps/ui/src/features/workspace/workspace-confirm-field.tsx index 3b6dd295..4785105a 100644 --- a/apps/ui/src/features/workspace/workspace-confirm-field.tsx +++ b/apps/ui/src/features/workspace/workspace-confirm-field.tsx @@ -18,8 +18,9 @@ export function WorkspaceNameConfirmField({ }) { return ( -

- Type {name} to confirm. +

+ Type {name} to + confirm.

); } - -export function nameConfirmed(value: string, name: string): boolean { - return value === name; -} diff --git a/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx b/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx index e0c39959..84c9cae0 100644 --- a/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx +++ b/apps/ui/src/features/workspace/workspace-detail-dialogs.tsx @@ -3,14 +3,13 @@ import { AppDialog } from "@workspace/ui/components/app-dialog"; import { AppSelect } from "@workspace/ui/components/app-select"; import { useId, useState } from "react"; - import type { SessionWorkspace } from "@/features/session/session-schema"; - +import { WorkspaceNameConfirmField } from "./workspace-confirm-field"; import { - nameConfirmed, - WorkspaceNameConfirmField, -} from "./workspace-confirm-field"; -import type { WorkspaceMember } from "./workspace-details-schema"; + memberDisplayName, + type WorkspaceMember, +} from "./workspace-details-schema"; +import { closeUnlessPending } from "./workspace-dialog-pending"; import { WORKSPACE_NAME_MAX_LENGTH } from "./workspace-write-schema"; /** @@ -20,10 +19,6 @@ import { WORKSPACE_NAME_MAX_LENGTH } from "./workspace-write-schema"; * fields start fresh every time. */ -function memberDisplayName(member: WorkspaceMember): string { - return member.nickname.trim() === "" ? member.crName : member.nickname; -} - export function WorkspaceRenameDialog({ onOpenChange, onRename, @@ -39,8 +34,10 @@ export function WorkspaceRenameDialog({ const inputId = useId(); const trimmed = name.trim(); const unchanged = trimmed === workspace.name; + // A name Desktop let through longer than the cap can only get shorter. + const tooLong = trimmed.length > WORKSPACE_NAME_MAX_LENGTH; const submit = async () => { - if (trimmed === "" || unchanged) { + if (trimmed === "" || unchanged || tooLong) { return; } if (await onRename(trimmed)) { @@ -48,7 +45,10 @@ export function WorkspaceRenameDialog({ } }; return ( - + Rename workspace @@ -66,20 +66,27 @@ export function WorkspaceRenameDialog({ Workspace name setName(event.target.value)} value={name} /> +

+ At most {WORKSPACE_NAME_MAX_LENGTH} characters. +

{ submit().catch(() => undefined); @@ -106,7 +113,10 @@ export function WorkspaceDeleteDialog({ }) { const [typed, setTyped] = useState(""); return ( - + @@ -130,7 +140,7 @@ export function WorkspaceDeleteDialog({ { onDelete() @@ -169,7 +179,10 @@ export function WorkspaceTransferDialog({ const target = candidates.find((member) => member.crUid === targetCrUid); const selectId = useId(); return ( - + @@ -181,7 +194,7 @@ export function WorkspaceTransferDialog({ {workspace.name} - , its billing included.{" "} + .{" "} { if (target == null) { @@ -248,7 +261,10 @@ export function WorkspaceLeaveDialog({ workspace: SessionWorkspace; }) { return ( - + diff --git a/apps/ui/src/features/workspace/workspace-details-schema.ts b/apps/ui/src/features/workspace/workspace-details-schema.ts index 91358a07..1feab07f 100644 --- a/apps/ui/src/features/workspace/workspace-details-schema.ts +++ b/apps/ui/src/features/workspace/workspace-details-schema.ts @@ -37,6 +37,11 @@ export const workspaceMemberSchema = z.object({ export type WorkspaceMember = z.infer; +/** What a member is called on screen: the nickname, or the CR name without one. */ +export function memberDisplayName(member: WorkspaceMember): string { + return member.nickname.trim() === "" ? member.crName : member.nickname; +} + export const workspaceDetailsResponseSchema = z.object({ members: z.array(workspaceMemberSchema), /** diff --git a/apps/ui/src/features/workspace/workspace-dialog-pending.ts b/apps/ui/src/features/workspace/workspace-dialog-pending.ts new file mode 100644 index 00000000..e5f04ea5 --- /dev/null +++ b/apps/ui/src/features/workspace/workspace-dialog-pending.ts @@ -0,0 +1,15 @@ +/** + * A dialog running a write keeps its shape until the write answers: Cancel + * is disabled, and Escape or the overlay are ignored the same way, so the + * outcome always lands in a mounted dialog. + */ +export function closeUnlessPending( + onOpenChange: (open: boolean) => void, + pending: boolean +): (open: boolean) => void { + return (open) => { + if (open || !pending) { + onOpenChange(open); + } + }; +} diff --git a/apps/ui/src/features/workspace/workspace-gating-core.ts b/apps/ui/src/features/workspace/workspace-gating-core.ts index 7475cfbf..c1388c61 100644 --- a/apps/ui/src/features/workspace/workspace-gating-core.ts +++ b/apps/ui/src/features/workspace/workspace-gating-core.ts @@ -1,5 +1,10 @@ import type { WorkspaceRole } from "@/features/session/session-schema"; +import { + ASSIGNABLE_ROLE_VALUES, + type AssignableRole, +} from "./workspace-write-schema"; + /** * The Workspace Area's gating (spec §E, mirroring Desktop's `vaildManage` * matrix): for each action, whether the actor sees it, sees it disabled @@ -115,15 +120,13 @@ export function gateMemberActions( } /** The roles a change-role control offers: never Owner (spec §E.4). */ -export const ASSIGNABLE_ROLES: readonly WorkspaceRole[] = [ - "Manager", - "Developer", -]; +export const ASSIGNABLE_ROLES: readonly AssignableRole[] = + ASSIGNABLE_ROLE_VALUES; /** The roles an actor may put on a Workspace Invite Link (spec §D.6). */ export function inviteRoleOptions( actorRole: WorkspaceRole -): readonly WorkspaceRole[] { +): readonly AssignableRole[] { switch (actorRole) { case "Owner": return ASSIGNABLE_ROLES; diff --git a/apps/ui/src/features/workspace/workspace-invite-core.ts b/apps/ui/src/features/workspace/workspace-invite-core.ts index 79ab4d7f..a861c64d 100644 --- a/apps/ui/src/features/workspace/workspace-invite-core.ts +++ b/apps/ui/src/features/workspace/workspace-invite-core.ts @@ -4,7 +4,7 @@ import { desktopOrigin } from "./workspace-switch-core"; * The Workspace Invite Link (spec §B.2, §F): Brain's route answers only the * code; the page appends it to Desktop's landing page, * `https:///WorkspaceInvite/?code=`, the same link - * Desktop's own Team Center hands out. The invitee accepts there; Brain + * Desktop's own workspace management hands out. The invitee accepts there; Brain * does nothing more and sees the new member on its next read. */ diff --git a/apps/ui/src/features/workspace/workspace-invite-dialog.tsx b/apps/ui/src/features/workspace/workspace-invite-dialog.tsx index 82b962c5..197a6066 100644 --- a/apps/ui/src/features/workspace/workspace-invite-dialog.tsx +++ b/apps/ui/src/features/workspace/workspace-invite-dialog.tsx @@ -5,11 +5,11 @@ import { AppSelect } from "@workspace/ui/components/app-select"; import { Link2 } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; - import type { SessionWorkspace, WorkspaceRole, } from "@/features/session/session-schema"; +import { closeUnlessPending } from "./workspace-dialog-pending"; import { inviteRoleOptions } from "./workspace-gating-core"; import { @@ -19,6 +19,8 @@ import { import type { AssignableRole } from "./workspace-write-schema"; export const INVITE_LINK_COPIED_NOTICE = "Invite link copied."; +export const INVITE_LINK_NOT_COPIED_NOTICE = + "Couldn't copy the link. Select it to copy it yourself."; export const INVITE_LINK_NO_DESKTOP_NOTICE = "Couldn't build the link: the Desktop domain is unknown."; @@ -63,7 +65,7 @@ export function WorkspaceInviteDialog({ const [role, setRole] = useState( roles.includes(DEFAULT_INVITE_ROLE) ? DEFAULT_INVITE_ROLE - : ((roles[0] as AssignableRole | undefined) ?? DEFAULT_INVITE_ROLE) + : (roles[0] ?? DEFAULT_INVITE_ROLE) ); const [link, setLink] = useState(null); const selectId = useId(); @@ -79,13 +81,18 @@ export function WorkspaceInviteDialog({ return; } setLink(url); - if (await copyText(url)) { - toast(INVITE_LINK_COPIED_NOTICE); - } + toast( + (await copyText(url)) + ? INVITE_LINK_COPIED_NOTICE + : INVITE_LINK_NOT_COPIED_NOTICE + ); }; return ( - + Invite member diff --git a/apps/ui/src/features/workspace/workspace-member-dialogs.tsx b/apps/ui/src/features/workspace/workspace-member-dialogs.tsx index 77d4b4dc..ae3a513c 100644 --- a/apps/ui/src/features/workspace/workspace-member-dialogs.tsx +++ b/apps/ui/src/features/workspace/workspace-member-dialogs.tsx @@ -2,10 +2,12 @@ import { AppDialog } from "@workspace/ui/components/app-dialog"; import { useId, useState } from "react"; - import type { SessionWorkspace } from "@/features/session/session-schema"; - -import type { WorkspaceMember } from "./workspace-details-schema"; +import { + memberDisplayName, + type WorkspaceMember, +} from "./workspace-details-schema"; +import { closeUnlessPending } from "./workspace-dialog-pending"; import { WORKSPACE_ALIAS_MAX_LENGTH } from "./workspace-write-schema"; /** @@ -14,10 +16,6 @@ import { WORKSPACE_ALIAS_MAX_LENGTH } from "./workspace-write-schema"; * confirmation. Mounted only while open, so the fields start fresh. */ -export function memberDisplayName(member: WorkspaceMember): string { - return member.nickname.trim() === "" ? member.crName : member.nickname; -} - export function WorkspaceAliasDialog({ member, onOpenChange, @@ -41,7 +39,10 @@ export function WorkspaceAliasDialog({ } }; return ( - + @@ -107,7 +108,10 @@ export function WorkspaceRemoveMemberDialog({ workspace: SessionWorkspace; }) { return ( - + diff --git a/apps/ui/src/features/workspace/workspace-members-panel.tsx b/apps/ui/src/features/workspace/workspace-members-panel.tsx index 1d70e774..37fa0f69 100644 --- a/apps/ui/src/features/workspace/workspace-members-panel.tsx +++ b/apps/ui/src/features/workspace/workspace-members-panel.tsx @@ -24,7 +24,10 @@ import { useState } from "react"; import type { SessionWorkspace } from "@/features/session/session-schema"; import type { WorkspaceActions } from "./use-workspace-actions"; -import type { WorkspaceMember } from "./workspace-details-schema"; +import { + memberDisplayName, + type WorkspaceMember, +} from "./workspace-details-schema"; import { ASSIGNABLE_ROLES, gateMemberActions, @@ -74,12 +77,8 @@ export function formatJoinedDate(iso: string): string { }); } -function memberName(member: WorkspaceMember): string { - return member.nickname.trim() === "" ? member.crName : member.nickname; -} - function MemberAvatar({ member }: { member: WorkspaceMember }) { - const name = memberName(member); + const name = memberDisplayName(member); return ( {member.avatarUrl === "" ? null : ( @@ -103,7 +102,7 @@ function RoleCell({ // Choosing takes effect at once (spec §D.7); the table re-reads after. return ( { const role = assignableRoleSchema.safeParse(next); @@ -140,7 +139,7 @@ function MemberRow({ member: WorkspaceMember; showActions: boolean; }) { - const name = memberName(member); + const name = memberDisplayName(member); return ( ; - export const workspaceDeleteRequestSchema = z.object({ uid: workspaceUidSchema, }); -export type WorkspaceDeleteRequest = z.infer< - typeof workspaceDeleteRequestSchema ->; - export const workspaceInviteLinkRequestSchema = z.object({ role: assignableRoleSchema, uid: workspaceUidSchema, }); -export type WorkspaceInviteLinkRequest = z.infer< - typeof workspaceInviteLinkRequestSchema ->; - /** `{ code }`: the client appends it to Desktop's `/WorkspaceInvite/?code=`. */ export const workspaceInviteLinkResponseSchema = z.object({ code: z.string().min(1), @@ -70,20 +58,12 @@ export const workspaceMemberRemoveRequestSchema = z.object({ uid: workspaceUidSchema, }); -export type WorkspaceMemberRemoveRequest = z.infer< - typeof workspaceMemberRemoveRequestSchema ->; - export const workspaceMemberRoleRequestSchema = z.object({ crUid: memberCrUidSchema, role: assignableRoleSchema, uid: workspaceUidSchema, }); -export type WorkspaceMemberRoleRequest = z.infer< - typeof workspaceMemberRoleRequestSchema ->; - /** * `alias` arrives as the user typed it; the route trims it and sends * Desktop null for an empty one (clear). Null is accepted on the wire too. @@ -106,23 +86,17 @@ export const workspaceMemberAliasRequestSchema = z.object({ uid: workspaceUidSchema, }); -export type WorkspaceMemberAliasRequest = z.input< - typeof workspaceMemberAliasRequestSchema ->; - export const workspaceTransferRequestSchema = z.object({ /** The member who becomes the Owner; the actor becomes a Developer. */ crUid: memberCrUidSchema, uid: workspaceUidSchema, }); -export type WorkspaceTransferRequest = z.infer< - typeof workspaceTransferRequestSchema ->; - /** What every write route but invite-link answers on success. */ export const workspaceWriteResponseSchema = z.object({ ok: z.literal(true) }); export type WorkspaceWriteResponse = z.infer< typeof workspaceWriteResponseSchema >; + +export const WORKSPACE_WRITE_OK: WorkspaceWriteResponse = { ok: true }; From 5571f89c8e687845cc48da8d26d255479dfce5db Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 18:37:32 +0800 Subject: [PATCH 09/17] feat(billing): Workspace Creation in the Billing Area (AIM-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/billing?mode=create` opens the Billing Area's creation mode beside `mode=upgrade` (spec AIM-443 §G): the Workspace Switcher's New Workspace row and the Workspace Area's Create Workspace row already led here. Client: a creation dialog with the name field and the Plan Picker on one screen — every paid plan reads Subscribe, Free stays out — that is never gated by the current Workspace's role or lifecycle. Picking a plan checks the name inline (required, trimmed, at most 32 characters, no case-insensitive duplicate of a Workspace the session lists) and stacks a confirmation over the picker; confirming runs Brain's two steps and hands the top window to Stripe Checkout as a whole-page hop, recording which Workspace this tab is creating. Desktop's 409 lands on the field; a failed second step becomes the offer to retry the payment or leave the created Workspace Pay-As-You-Go for now. On the Stripe return the recorded creation words the congratulations as "Workspace created", and the Billing Area's return route is forgotten — it named the Workspace the creation left — so close goes home. Server: `POST /api/billing/workspace-create` authorizes like every Billing route, asks Desktop `namespace/create { teamName, userType: subscription }` with the raw app token, then account-service's pay as Brain (`operator: created`, `payApp: system-brain`). A taken name is a 409 with a code; other Desktop failures translate without their text; a failed Step 2 answers 200 with `payment.status = failed`, and `/workspace-create/retry-payment` redoes Step 2 alone. Both join the route table and the dev-mock, where the name picks the branch. Co-Authored-By: Claude Fable 5.1 --- .../workspace-create/retry-payment/route.ts | 16 + .../app/api/billing/workspace-create/route.ts | 16 + apps/ui/src/app/billing/page.tsx | 9 +- .../billing-plan-congratulations-dialog.tsx | 41 +- .../billing/billing-plan.interaction.test.tsx | 140 ++++++ apps/ui/src/features/billing/billing-plan.tsx | 90 +++- .../billing/billing-return-route.test.ts | 50 +- .../features/billing/billing-return-route.ts | 26 + ...space-creation-dialog.interaction.test.tsx | 354 ++++++++++++++ .../billing-workspace-creation-dialog.tsx | 443 ++++++++++++++++++ .../billing/server/billing-route-table.ts | 11 + .../server/dev-fixtures/dev-fixtures.test.ts | 86 ++++ .../billing/server/dev-fixtures/index.ts | 113 ++++- .../workspace-creation-handlers.test.ts | 378 +++++++++++++++ .../server/workspace-creation-handlers.ts | 314 +++++++++++++ .../billing/workspace-creation-client.test.ts | 145 ++++++ .../billing/workspace-creation-client.ts | 134 ++++++ .../billing/workspace-creation-core.test.ts | 37 ++ .../billing/workspace-creation-core.ts | 39 ++ .../billing/workspace-creation-return.ts | 36 ++ .../billing/workspace-creation-schema.ts | 95 ++++ .../session/server/desktop-auth-api.ts | 39 ++ .../features/shell/area-return-route.test.ts | 29 ++ .../src/features/shell/area-return-route.ts | 12 + 24 files changed, 2636 insertions(+), 17 deletions(-) create mode 100644 apps/ui/src/app/api/billing/workspace-create/retry-payment/route.ts create mode 100644 apps/ui/src/app/api/billing/workspace-create/route.ts create mode 100644 apps/ui/src/features/billing/billing-workspace-creation-dialog.interaction.test.tsx create mode 100644 apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx create mode 100644 apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts create mode 100644 apps/ui/src/features/billing/server/workspace-creation-handlers.ts create mode 100644 apps/ui/src/features/billing/workspace-creation-client.test.ts create mode 100644 apps/ui/src/features/billing/workspace-creation-client.ts create mode 100644 apps/ui/src/features/billing/workspace-creation-core.test.ts create mode 100644 apps/ui/src/features/billing/workspace-creation-core.ts create mode 100644 apps/ui/src/features/billing/workspace-creation-return.ts create mode 100644 apps/ui/src/features/billing/workspace-creation-schema.ts 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/billing/page.tsx b/apps/ui/src/app/billing/page.tsx index 87f4031a..f8522411 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); 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.interaction.test.tsx b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx index aea34de9..4e82aa48 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -21,6 +21,14 @@ import { 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: { @@ -239,6 +247,137 @@ 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"); + 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("Stripe return refreshes before congratulations and clears on close", async () => { await withTestDom(async (act) => { const { BillingPlanWorkflow } = await import("./billing-plan"); @@ -301,6 +440,7 @@ 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); assert.deepEqual(replacements, []); await act(() => { diff --git a/apps/ui/src/features/billing/billing-plan.tsx b/apps/ui/src/features/billing/billing-plan.tsx index c0ae5c91..90409786 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,16 +51,22 @@ 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 } from "@/features/billing/workspace-creation-return"; import { type FreeChatTurnsUsage, fetchFreeChatTurnsUsage, @@ -69,6 +77,7 @@ import { currentWorkspaceAtom, kubeconfigAtom, namespaceAtom, + workspacesAtom, } from "@/lib/auth-store"; import { errorDescription, toastErrorDetail } from "@/lib/toast-utils"; @@ -77,17 +86,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; @@ -99,6 +119,8 @@ interface BillingPlanWorkflowProps { 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 { @@ -115,9 +137,11 @@ export function BillingPlanWorkflow({ balance, cardManagementPending = false, checkoutServices, + creationServices, credentials, credits = null, currency, + existingWorkspaceNames = [], gpuEnabled, initialMode = null, invoiceCancellationPending = false, @@ -130,9 +154,11 @@ export function BillingPlanWorkflow({ snapshot, stripeReturn = null, viewerIsOwner = false, + workspaceName = null, }: BillingPlanWorkflowProps) { const stripeAcknowledgedKeyRef = useRef(null); const stripeRefreshRef = useRef<{ + conclusion: SettledPaymentConclusion; key: string; request: Promise; } | null>(null); @@ -145,6 +171,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 { @@ -155,9 +185,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); @@ -165,9 +196,12 @@ export function BillingPlanWorkflow({ setSelectedPlanId(null); setPlanDialogOpen(true); } + if (initialMode === "create") { + setCreationDialogOpen(true); + } } useEffect(() => { - if (initialMode === "upgrade") { + if (initialMode != null) { replaceUrl(currentUrlWithout(["mode"])); } }, [initialMode, replaceUrl]); @@ -183,12 +217,20 @@ 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, and the recorded return route belongs to the + // Workspace the creation left (spec §G.5) — close returns home. + clearBillingReturnRoute(); refresh = { + conclusion: consumePendingWorkspaceCreation(stripeReturn.workspaceId) + ? "created" + : "changed", key, request: onRefreshSnapshot(stripeReturn.workspaceId), }; stripeRefreshRef.current = refresh; } + const { conclusion } = refresh; let active = true; refresh.request @@ -198,10 +240,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) => { @@ -271,11 +314,24 @@ export function BillingPlanWorkflow({ services={checkoutServices} snapshot={snapshot} /> + ); @@ -383,15 +439,21 @@ export function BillingPlan({ }: { currency: BillingCurrency; gpuEnabled: boolean; - initialMode?: "upgrade" | null; + initialMode?: BillingPlanMode | null; replaceUrl: (url: string) => void; 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 = useAtomValue(currentWorkspaceAtom)?.role ?? null; + 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); @@ -750,6 +812,7 @@ export function BillingPlan({ /> } currency={currency} + existingWorkspaceNames={existingWorkspaceNames} gpuEnabled={gpuEnabled} initialMode={initialMode} invoiceCancellationPending={invoiceCancellationPending} @@ -761,6 +824,15 @@ export function BillingPlan({ snapshot={snapshot} 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 + } /> ); } @@ -773,7 +845,7 @@ export default function BillingPlanRoute({ }: { currency: BillingCurrency; gpuEnabled: boolean; - initialMode?: "upgrade" | null; + initialMode?: BillingPlanMode | null; stripeReturn?: BillingStripeReturn | null; }) { const router = useRouter(); 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..1c3ef97b 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,39 @@ 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"; + +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 +52,19 @@ test("sanitizeBillingReturnRoute falls back to home for unusable values", () => assert.equal(sanitizeBillingReturnRoute("/billing/costs"), "/"); assert.equal(sanitizeBillingReturnRoute("/billing?mode=upgrade"), "/"); }); + +test("a Stripe return voids the recorded entry point: it names the old Workspace's route", () => { + withWindow({ pathname: "/project/abc", search: "" }, (storage) => { + recordBillingReturnRoute(); + assert.equal(readBillingReturnRoute(), "/project/abc"); + + window.location.pathname = "/billing"; + window.location.search = "?stripeState=success&payId=p1&workspaceId=ns-new"; + assert.equal(readBillingReturnRoute(), "/"); + assert.equal(storage.size, 0); + + // Once the return parameters are stripped, nothing recorded remains. + window.location.search = ""; + assert.equal(readBillingReturnRoute(), "/"); + }); +}); diff --git a/apps/ui/src/features/billing/billing-return-route.ts b/apps/ui/src/features/billing/billing-return-route.ts index eebd2df2..cf6dc364 100644 --- a/apps/ui/src/features/billing/billing-return-route.ts +++ b/apps/ui/src/features/billing/billing-return-route.ts @@ -6,6 +6,13 @@ import { createAreaReturnRoute } from "@/features/shell/area-return-route"; * 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. + * + * A Stripe Checkout Round-Trip voids the record: the page arrives on + * `?stripeState=…` from outside, and after Workspace Creation the recorded + * route belongs to the Workspace the user left (spec §G.5). Reading through + * that arrival forgets the record, so the close button — which reads once, + * during hydration — lands on home rather than on a route from another + * Workspace. */ const billingReturnRoute = createAreaReturnRoute({ prefix: "/billing", @@ -20,6 +27,25 @@ export function recordBillingReturnRoute(): void { billingReturnRoute.record(); } +export function clearBillingReturnRoute(): void { + billingReturnRoute.clear(); +} + +const STRIPE_RETURN_PARAMETER = "stripeState"; + +function arrivedFromStripe(): boolean { + if (typeof window === "undefined") { + return false; + } + return new URLSearchParams(window.location.search).has( + STRIPE_RETURN_PARAMETER + ); +} + export function readBillingReturnRoute(): string { + if (arrivedFromStripe()) { + billingReturnRoute.clear(); + return "/"; + } return billingReturnRoute.read(); } 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..d97b215f --- /dev/null +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.interaction.test.tsx @@ -0,0 +1,354 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fireEvent, render, within } from "@testing-library/react/pure"; + +import { withTestDom } from "@/features/project-canvas/react-test-harness"; +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); + } 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); + } 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()); + } + }); +}); 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..5e1e78f7 --- /dev/null +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx @@ -0,0 +1,443 @@ +"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 { X } from "lucide-react"; +import { 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 { WORKSPACE_NAME_MAX_LENGTH } from "@/features/workspace/workspace-write-schema"; +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 [name, setName] = useState(""); + const [nameIssue, setNameIssue] = useState(null); + // Names Desktop already refused this session: the field says so on the + // next attempt without another round-trip. + const [takenNames, setTakenNames] = useState([]); + 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(); + + const selectPlan = (planId: string) => { + const issue = workspaceNameIssue(name, [ + ...existingWorkspaceNames, + ...takenNames, + ]); + 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); + services.redirectTop(payment.redirectUrl); + }; + + const confirm = async (plan: SnapshotPlan) => { + if (submitting) { + return; + } + setSubmitting(true); + setError(null); + try { + const { payment, workspace } = await services.createWorkspace({ + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + name: trimmedName, + planName: plan.name, + regionDomain, + }); + if (payment.status === "started") { + // The page is leaving; the button stays in its submitting state. + handOffToStripe(workspace, payment); + return; + } + setStage({ + error: payment.error, + kind: "payment-failed", + plan, + workspace, + }); + } catch (cause) { + if (cause instanceof WorkspaceNameConflictError) { + setTakenNames((names) => [...names, trimmedName]); + setNameIssue("duplicate"); + setStage({ kind: "pick" }); + return; + } + setError(errorDescription(cause, "The Workspace could not be created.")); + } finally { + setSubmitting(false); + } + }; + + const retry = async (plan: SnapshotPlan, workspace: CreatedWorkspace) => { + if (submitting) { + return; + } + setSubmitting(true); + setError(null); + try { + const payment = await services.retryPayment({ + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + planName: plan.name, + regionDomain, + workspaceId: workspace.id, + }); + if (payment.status === "started") { + handOffToStripe(workspace, payment); + return; + } + setError(payment.error); + } catch (cause) { + setError( + errorDescription( + cause, + "The subscription payment could not be started." + ) + ); + } finally { + 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 db1b3cd3..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,6 +109,17 @@ 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: { 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 febf16e1..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,6 +18,11 @@ 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"; @@ -666,3 +671,84 @@ test("every scenario answers the Switcher's plan read: the scenario's plan every 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 a4790220..5f90e221 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -8,6 +8,13 @@ import { resolveDevMock, } from "@/features/dev-mock/server/resolve"; +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,6 +42,8 @@ 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; @@ -907,6 +916,44 @@ 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, + workspaceId: string, + failureMark: string +): WorkspaceCreationPayment { + if (failureMark.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", workspaceId); + return { + invoiceId: MOCK_CHECKOUT_INVOICE_ID, + payId: MOCK_CHECKOUT_PAY_ID, + redirectUrl: landing.toString(), + status: "started", + }; } /** @@ -939,6 +986,67 @@ 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, + MOCK_CREATED_WORKSPACE_ID, + name + ), + 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, + }; + } + const { workspaceId } = parsed.data; + return { + nextScenario: context.scenario, + payload: { + payment: mockWorkspaceCreationPayment( + context, + workspaceId, + workspaceId + ), + }, + }; + }, "/account/v1alpha1/workspace-subscription/pay": (context) => { const operator = typeof context.body.operator === "string" ? context.body.operator : ""; @@ -1056,10 +1164,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: new URL(request.url).searchParams, + searchParams: requestUrl.searchParams, workspace: billingDevMockWorkspace(body.workspace), }; @@ -1072,6 +1182,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/workspace-creation-handlers.test.ts b/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts new file mode 100644 index 00000000..995c6ef0 --- /dev/null +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts @@ -0,0 +1,378 @@ +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, + }); +} + +function harness( + input: { + answers?: FakeDesktopOptions["answers"]; + authorize?: () => Promise; + env?: Record; + pay?: (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); + return Promise.resolve((input.pay ?? paymentAnswer)(request)); + }, + }; + return { + accountRequests, + create: createBillingWorkspaceCreateHandler(dependencies), + desktopCalls: desktop.calls, + logs, + retry: createBillingWorkspaceCreateRetryPaymentHandler(dependencies), + }; +} + +const CREATE_PATH = BILLING_ROUTES.workspaceCreate.apiPath; +const RETRY_PATH = BILLING_ROUTES.workspaceCreateRetryPayment.apiPath; + +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).toHaveLength(1); + const pay = accountRequests[0]; + 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).toEqual([]); + }); + + 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).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" }, + { ...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).toHaveLength(1); + expect(JSON.parse(String(accountRequests[0]?.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..344f0f52 --- /dev/null +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts @@ -0,0 +1,314 @@ +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 { + type CreatedWorkspace, + WORKSPACE_NAME_CONFLICT_CODE, + WORKSPACE_NAME_CONFLICT_MESSAGE, + type WorkspaceCreationPayment, + type WorkspaceCreationRequest, + type WorkspaceCreationRetryRequest, + 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; + +interface PaymentRequestFields { + cardId?: string; + payMethod: "balance" | "stripe"; + period: "1m" | "1y"; + planName: string; + promotionCode?: string; + regionDomain: 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" } }); +} + +async function parsedBody( + request: Request, + schema: z.ZodType, + invalidMessage: string +): Promise< + { body: T; response?: never } | { body?: never; response: Response } +> { + const payload: unknown = await request.json().catch(() => null); + const parsed = schema.safeParse(payload); + return parsed.success + ? { body: parsed.data } + : { response: errorResponse(invalidMessage, 400) }; +} + +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."; + +/** + * 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: { userId: string; userUid: string }, + workspaceId: string, + fields: PaymentRequestFields +): 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 || redirectUrl === "") { + return { error: PAYMENT_FAILED_FALLBACK, status: "failed" }; + } + 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 = await authorizeBillingActor( + request, + dependencies.authorizeWorkspaceActor + ); + if (!actor.ok) { + return actor.response; + } + if (actor.userId === "") { + return errorResponse("Authentication is required.", 401); + } + const parsed = await parsedBody( + request, + workspaceCreationRequestSchema, + "Invalid workspace creation request." + ); + if (parsed.response != null) { + return parsed.response; + } + const desktop = desktopForCreation(dependencies, log); + if (!desktop.ok) { + return desktop.response; + } + + const created = await desktop.desktop.namespaceCreate( + appTokenFromRequest(request), + parsed.body.name + ); + if (!created.ok) { + log( + "Desktop workspace creation failed", + desktopFailureLogFields(created) + ); + return desktopCreateFailureResponse(created); + } + const workspace: CreatedWorkspace = created.data; + const payment = await startWorkspacePayment( + dependencies, + { userId: actor.userId, userUid: actor.userUid }, + workspace.id, + parsed.body + ); + 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. */ +export function createBillingWorkspaceCreateRetryPaymentHandler( + dependencies: WorkspaceCreationRouteDependencies +): RouteHandler { + return async function handler(request: Request): Promise { + const actor = await authorizeBillingActor( + request, + dependencies.authorizeWorkspaceActor + ); + if (!actor.ok) { + return actor.response; + } + if (actor.userId === "") { + return errorResponse("Authentication is required.", 401); + } + const parsed = await parsedBody( + request, + workspaceCreationRetryRequestSchema, + "Invalid workspace payment retry request." + ); + if (parsed.response != null) { + return parsed.response; + } + const payment = await startWorkspacePayment( + dependencies, + { userId: actor.userId, userUid: actor.userUid }, + parsed.body.workspaceId, + parsed.body + ); + return jsonResponse({ payment }); + }; +} 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..b11ef02b --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-client.ts @@ -0,0 +1,134 @@ +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: { + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + }, + 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..27b9ec6c --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-return.ts @@ -0,0 +1,36 @@ +/** + * 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. + */ + +const STORAGE_KEY = "billing-workspace-creation"; + +export function recordPendingWorkspaceCreation(workspaceId: string): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.setItem(STORAGE_KEY, workspaceId); + } catch { + // See above: the conclusion reads as a plan change instead. + } +} + +/** Whether `workspaceId` is the Workspace this tab was creating; forgets the record either way. */ +export function consumePendingWorkspaceCreation(workspaceId: string): boolean { + if (typeof window === "undefined") { + return false; + } + try { + const recorded = window.sessionStorage.getItem(STORAGE_KEY); + window.sessionStorage.removeItem(STORAGE_KEY); + return recorded != null && recorded === workspaceId; + } catch { + return false; + } +} 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..ada69a19 --- /dev/null +++ b/apps/ui/src/features/billing/workspace-creation-schema.ts @@ -0,0 +1,95 @@ +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. + */ + +const paymentRequestFields = { + cardId: z.string().trim().min(1).optional(), + payMethod: z.enum(["stripe", "balance"]), + period: z.enum(["1m", "1y"]), + planName: z.string().trim().min(1), + promotionCode: z.string().trim().min(1).optional(), + regionDomain: z.string().trim().min(1), +}; + +/** `POST /api/billing/workspace-create`: name the Workspace and its first plan. */ +export const workspaceCreationRequestSchema = z.object({ + ...paymentRequestFields, + 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 = z.object({ + ...paymentRequestFields, + 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"), + }), +]); + +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/session/server/desktop-auth-api.ts b/apps/ui/src/features/session/server/desktop-auth-api.ts index 17eb4a47..e3cbf033 100644 --- a/apps/ui/src/features/session/server/desktop-auth-api.ts +++ b/apps/ui/src/features/session/server/desktop-auth-api.ts @@ -23,6 +23,7 @@ import { 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", @@ -116,6 +117,27 @@ function workspaceFromDto( }; } +/** + * `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[] => { @@ -221,6 +243,15 @@ export interface DesktopAuthApi { 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 @@ -298,6 +329,14 @@ export function createDesktopAuthApi(client: DesktopClient): DesktopAuthApi { { 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, diff --git a/apps/ui/src/features/shell/area-return-route.test.ts b/apps/ui/src/features/shell/area-return-route.test.ts index c8a7c543..2e54effb 100644 --- a/apps/ui/src/features/shell/area-return-route.test.ts +++ b/apps/ui/src/features/shell/area-return-route.test.ts @@ -31,3 +31,32 @@ test("without a window the return route reads home and records nothing", () => { assert.equal(area.read(), "/"); assert.doesNotThrow(() => area.record()); }); + +test("clearing an area's return route forgets the recorded entry point", () => { + const storage = new Map(); + const previous = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { pathname: "/project/abc", search: "?tab=logs" }, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, value), + }, + }, + }); + try { + area.record(); + assert.equal(area.read(), "/project/abc?tab=logs"); + area.clear(); + assert.equal(area.read(), "/"); + assert.equal(storage.has("workspace-return-route"), false); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Object.defineProperty(globalThis, "window", previous); + } + } +}); diff --git a/apps/ui/src/features/shell/area-return-route.ts b/apps/ui/src/features/shell/area-return-route.ts index b2277ecd..5ae49818 100644 --- a/apps/ui/src/features/shell/area-return-route.ts +++ b/apps/ui/src/features/shell/area-return-route.ts @@ -7,6 +7,8 @@ * entry, cleared storage, tampered value) falls back to home. */ export interface AreaReturnRoute { + /** Forgets the recorded route; close then falls back to home. */ + clear(): void; /** The recorded route, sanitized; "/" when nothing usable is recorded. */ read(): string; /** Records the current route unless it already lies inside the area. */ @@ -29,6 +31,16 @@ export function createAreaReturnRoute(area: { return "/"; }; return { + clear() { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.removeItem(area.storageKey); + } catch { + // Nothing stored where storage is unavailable; see `record`. + } + }, read() { if (typeof window === "undefined") { return "/"; From af285bb39295207ed3eb79f95e71a9bfe352b2ed Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 15 Sep 2026 18:59:16 +0800 Subject: [PATCH 10/17] fix(billing): address review findings on Workspace Creation (AIM-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirm and retry buttons stay in their submitting state once the top window is handed to Stripe, so a second press during the unload cannot create a second Workspace. Only a creation's Stripe return forgets the Billing Area's return route — the page reads the pending-creation record, not the mere `stripeState` — so a plan change's top-level return still closes to where the user came from. The payment terms admit Stripe only: a balance payment would settle without a redirect, which the routes read as a failed payment and offer to retry. Cleanups: the two handlers share one authorize-then-parse preamble, the handler's payment fields derive from the zod schema instead of mirroring it, the client passes its credentials through, and the dev-mock's payment helper names what makes it fail. The retry route documents that account-service, not Brain, vouches for the Workspace it subscribes. Co-Authored-By: Claude Fable 5.1 --- .../billing/billing-plan.interaction.test.tsx | 4 + apps/ui/src/features/billing/billing-plan.tsx | 14 ++- .../billing/billing-return-route.test.ts | 19 +++- .../features/billing/billing-return-route.ts | 30 +++-- ...space-creation-dialog.interaction.test.tsx | 15 +++ .../billing-workspace-creation-dialog.tsx | 16 ++- .../billing/server/dev-fixtures/index.ts | 31 +++--- .../workspace-creation-handlers.test.ts | 2 + .../server/workspace-creation-handlers.ts | 104 +++++++++--------- .../billing/workspace-creation-client.ts | 5 +- .../billing/workspace-creation-return.ts | 12 ++ .../billing/workspace-creation-schema.ts | 28 +++-- 12 files changed, 178 insertions(+), 102 deletions(-) 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 4e82aa48..91626317 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -394,6 +394,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( {}, "", @@ -441,6 +443,8 @@ 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(() => { diff --git a/apps/ui/src/features/billing/billing-plan.tsx b/apps/ui/src/features/billing/billing-plan.tsx index 90409786..f4e051f4 100644 --- a/apps/ui/src/features/billing/billing-plan.tsx +++ b/apps/ui/src/features/billing/billing-plan.tsx @@ -218,13 +218,15 @@ 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, and the recorded return route belongs to the - // Workspace the creation left (spec §G.5) — close returns home. - clearBillingReturnRoute(); + // spent on the first read. Its 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 created = consumePendingWorkspaceCreation(stripeReturn.workspaceId); + if (created) { + clearBillingReturnRoute(); + } refresh = { - conclusion: consumePendingWorkspaceCreation(stripeReturn.workspaceId) - ? "created" - : "changed", + conclusion: created ? "created" : "changed", key, request: onRefreshSnapshot(stripeReturn.workspaceId), }; 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 1c3ef97b..3e7dd08e 100644 --- a/apps/ui/src/features/billing/billing-return-route.test.ts +++ b/apps/ui/src/features/billing/billing-return-route.test.ts @@ -6,6 +6,7 @@ import { recordBillingReturnRoute, sanitizeBillingReturnRoute, } from "./billing-return-route"; +import { recordPendingWorkspaceCreation } from "./workspace-creation-return"; function withWindow( location: { pathname: string; search: string }, @@ -53,18 +54,32 @@ test("sanitizeBillingReturnRoute falls back to home for unusable values", () => assert.equal(sanitizeBillingReturnRoute("/billing?mode=upgrade"), "/"); }); -test("a Stripe return voids the recorded entry point: it names the old Workspace's route", () => { +test("a creation's Stripe return voids the recorded entry point: it names the old Workspace's route", () => { withWindow({ pathname: "/project/abc", search: "" }, (storage) => { recordBillingReturnRoute(); + recordPendingWorkspaceCreation("ns-new"); assert.equal(readBillingReturnRoute(), "/project/abc"); window.location.pathname = "/billing"; window.location.search = "?stripeState=success&payId=p1&workspaceId=ns-new"; assert.equal(readBillingReturnRoute(), "/"); - assert.equal(storage.size, 0); + assert.equal(storage.has("billing-return-route"), false); // Once the return parameters are stripped, nothing recorded remains. window.location.search = ""; assert.equal(readBillingReturnRoute(), "/"); }); }); + +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 cf6dc364..4eb7342f 100644 --- a/apps/ui/src/features/billing/billing-return-route.ts +++ b/apps/ui/src/features/billing/billing-return-route.ts @@ -1,5 +1,7 @@ import { createAreaReturnRoute } from "@/features/shell/area-return-route"; +import { isPendingWorkspaceCreation } from "./workspace-creation-return"; + /** * 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 @@ -7,12 +9,13 @@ import { createAreaReturnRoute } from "@/features/shell/area-return-route"; * navigate into /billing (the App Sidebar entries); a click while already * inside the Billing Area keeps the original entry point. * - * A Stripe Checkout Round-Trip voids the record: the page arrives on - * `?stripeState=…` from outside, and after Workspace Creation the recorded - * route belongs to the Workspace the user left (spec §G.5). Reading through - * that arrival forgets the record, so the close button — which reads once, - * during hydration — lands on home rather than on a route from another - * Workspace. + * 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). + * Reading through that arrival forgets the record, so the close button — + * which reads once, during hydration — 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. */ const billingReturnRoute = createAreaReturnRoute({ prefix: "/billing", @@ -31,19 +34,22 @@ export function clearBillingReturnRoute(): void { billingReturnRoute.clear(); } -const STRIPE_RETURN_PARAMETER = "stripeState"; - -function arrivedFromStripe(): boolean { +/** Whether the page is the Stripe return of a Workspace this tab created. */ +function arrivedFromCreation(): boolean { if (typeof window === "undefined") { return false; } - return new URLSearchParams(window.location.search).has( - STRIPE_RETURN_PARAMETER + const query = new URLSearchParams(window.location.search); + const workspaceId = query.get("workspaceId"); + return ( + query.has("stripeState") && + workspaceId != null && + isPendingWorkspaceCreation(workspaceId) ); } export function readBillingReturnRoute(): string { - if (arrivedFromStripe()) { + if (arrivedFromCreation()) { billingReturnRoute.clear(); return "/"; } 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 index d97b215f..c18a6cb9 100644 --- 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 @@ -233,6 +233,15 @@ test("a valid name and plan confirm, create, and hand the top window to Stripe", ]); // 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()); } @@ -317,6 +326,12 @@ test("a failed first payment offers to retry it or leave the created Workspace a { 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()); } diff --git a/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx index 5e1e78f7..ced98cfa 100644 --- a/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx @@ -165,6 +165,10 @@ export function BillingWorkspaceCreationDialog({ } 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, @@ -174,7 +178,7 @@ export function BillingWorkspaceCreationDialog({ regionDomain, }); if (payment.status === "started") { - // The page is leaving; the button stays in its submitting state. + handedOff = true; handOffToStripe(workspace, payment); return; } @@ -193,7 +197,9 @@ export function BillingWorkspaceCreationDialog({ } setError(errorDescription(cause, "The Workspace could not be created.")); } finally { - setSubmitting(false); + if (!handedOff) { + setSubmitting(false); + } } }; @@ -203,6 +209,7 @@ export function BillingWorkspaceCreationDialog({ } setSubmitting(true); setError(null); + let handedOff = false; try { const payment = await services.retryPayment({ appToken: credentials.appToken, @@ -212,6 +219,7 @@ export function BillingWorkspaceCreationDialog({ workspaceId: workspace.id, }); if (payment.status === "started") { + handedOff = true; handOffToStripe(workspace, payment); return; } @@ -224,7 +232,9 @@ export function BillingWorkspaceCreationDialog({ ) ); } finally { - setSubmitting(false); + if (!handedOff) { + setSubmitting(false); + } } }; 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 5f90e221..8c1e84cb 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -935,10 +935,13 @@ const MOCK_TAKEN_WORKSPACE_NAME = "conflict"; */ function mockWorkspaceCreationPayment( context: FixtureContext, - workspaceId: string, - failureMark: string + input: { + /** Text whose "payfail" fails the payment: the creation's name; nothing on a retry. */ + failWhenMarked: string; + workspaceId: string; + } ): WorkspaceCreationPayment { - if (failureMark.toLowerCase().includes(MOCK_PAYMENT_FAILURE_MARK)) { + if (input.failWhenMarked.toLowerCase().includes(MOCK_PAYMENT_FAILURE_MARK)) { return { error: "Mock payment refused (the name says so).", status: "failed", @@ -947,7 +950,7 @@ function mockWorkspaceCreationPayment( const landing = new URL("/billing", context.origin); landing.searchParams.set("stripeState", "success"); landing.searchParams.set("payId", MOCK_CHECKOUT_PAY_ID); - landing.searchParams.set("workspaceId", workspaceId); + landing.searchParams.set("workspaceId", input.workspaceId); return { invoiceId: MOCK_CHECKOUT_INVOICE_ID, payId: MOCK_CHECKOUT_PAY_ID, @@ -1013,11 +1016,10 @@ const WRITE_FIXTURES: Record< return { nextScenario: context.scenario, payload: { - payment: mockWorkspaceCreationPayment( - context, - MOCK_CREATED_WORKSPACE_ID, - name - ), + payment: mockWorkspaceCreationPayment(context, { + failWhenMarked: name, + workspaceId: MOCK_CREATED_WORKSPACE_ID, + }), workspace: { id: MOCK_CREATED_WORKSPACE_ID, name, @@ -1035,15 +1037,14 @@ const WRITE_FIXTURES: Record< status: 400, }; } - const { workspaceId } = parsed.data; + // A retry always starts: the failed first payment was the name's doing. return { nextScenario: context.scenario, payload: { - payment: mockWorkspaceCreationPayment( - context, - workspaceId, - workspaceId - ), + payment: mockWorkspaceCreationPayment(context, { + failWhenMarked: "", + workspaceId: parsed.data.workspaceId, + }), }, }; }, 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 index 995c6ef0..f6d2820a 100644 --- a/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts @@ -260,6 +260,8 @@ describe(`POST ${CREATE_PATH}`, () => { { ...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 })); diff --git a/apps/ui/src/features/billing/server/workspace-creation-handlers.ts b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts index 344f0f52..6e332967 100644 --- a/apps/ui/src/features/billing/server/workspace-creation-handlers.ts +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts @@ -20,8 +20,7 @@ import { WORKSPACE_NAME_CONFLICT_CODE, WORKSPACE_NAME_CONFLICT_MESSAGE, type WorkspaceCreationPayment, - type WorkspaceCreationRequest, - type WorkspaceCreationRetryRequest, + type WorkspacePaymentTerms, workspaceCreationRequestSchema, workspaceCreationRetryRequestSchema, } from "../workspace-creation-schema"; @@ -58,13 +57,10 @@ export interface WorkspaceCreationRouteDependencies type RouteHandler = (request: Request) => Promise; -interface PaymentRequestFields { - cardId?: string; - payMethod: "balance" | "stripe"; - period: "1m" | "1y"; - planName: string; - promotionCode?: string; - regionDomain: string; +/** The account-service actor a Billing write runs as. */ +interface PayingActor { + userId: string; + userUid: string; } function errorResponse(error: string, status: number, code?: string): Response { @@ -78,18 +74,40 @@ function jsonResponse(payload: unknown): Response { return Response.json(payload, { headers: { "cache-control": "no-store" } }); } -async function parsedBody( +/** + * 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< - { body: T; response?: never } | { body?: never; response: Response } + | { 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); - return parsed.success - ? { body: parsed.data } - : { response: errorResponse(invalidMessage, 400) }; + if (!parsed.success) { + return { response: errorResponse(invalidMessage, 400) }; + } + return { + actor: { userId: actor.userId, userUid: actor.userUid }, + body: parsed.data, + }; } const DESKTOP_CODE_STATUSES: Record = { @@ -142,9 +160,9 @@ const PAYMENT_FAILED_FALLBACK = */ async function startWorkspacePayment( dependencies: WorkspaceCreationRouteDependencies, - actor: { userId: string; userUid: string }, + actor: PayingActor, workspaceId: string, - fields: PaymentRequestFields + fields: WorkspacePaymentTerms ): Promise { const body = { ...(fields.cardId == null ? {} : { cardId: fields.cardId }), @@ -230,23 +248,14 @@ export function createBillingWorkspaceCreateHandler( const entry = BILLING_ROUTES.workspaceCreate; return async function handler(request: Request): Promise { const log = routeLog(dependencies, entry.apiPath); - const actor = await authorizeBillingActor( - request, - dependencies.authorizeWorkspaceActor - ); - if (!actor.ok) { - return actor.response; - } - if (actor.userId === "") { - return errorResponse("Authentication is required.", 401); - } - const parsed = await parsedBody( + const { actor, body, response } = await authorizedCreationRequest( request, + dependencies, workspaceCreationRequestSchema, "Invalid workspace creation request." ); - if (parsed.response != null) { - return parsed.response; + if (response != null) { + return response; } const desktop = desktopForCreation(dependencies, log); if (!desktop.ok) { @@ -255,7 +264,7 @@ export function createBillingWorkspaceCreateHandler( const created = await desktop.desktop.namespaceCreate( appTokenFromRequest(request), - parsed.body.name + body.name ); if (!created.ok) { log( @@ -267,9 +276,9 @@ export function createBillingWorkspaceCreateHandler( const workspace: CreatedWorkspace = created.data; const payment = await startWorkspacePayment( dependencies, - { userId: actor.userId, userUid: actor.userUid }, + actor, workspace.id, - parsed.body + body ); if (payment.status === "failed") { log("Workspace created but its first payment did not start", { @@ -280,34 +289,31 @@ export function createBillingWorkspaceCreateHandler( }; } -/** `POST /api/billing/workspace-create/retry-payment`: Step 2 alone. */ +/** + * `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 actor = await authorizeBillingActor( - request, - dependencies.authorizeWorkspaceActor - ); - if (!actor.ok) { - return actor.response; - } - if (actor.userId === "") { - return errorResponse("Authentication is required.", 401); - } - const parsed = await parsedBody( + const { actor, body, response } = await authorizedCreationRequest( request, + dependencies, workspaceCreationRetryRequestSchema, "Invalid workspace payment retry request." ); - if (parsed.response != null) { - return parsed.response; + if (response != null) { + return response; } const payment = await startWorkspacePayment( dependencies, - { userId: actor.userId, userUid: actor.userUid }, - parsed.body.workspaceId, - parsed.body + actor, + body.workspaceId, + body ); return jsonResponse({ payment }); }; diff --git a/apps/ui/src/features/billing/workspace-creation-client.ts b/apps/ui/src/features/billing/workspace-creation-client.ts index b11ef02b..ebbae080 100644 --- a/apps/ui/src/features/billing/workspace-creation-client.ts +++ b/apps/ui/src/features/billing/workspace-creation-client.ts @@ -61,10 +61,7 @@ function creationRequester( dependencies: WorkspaceCreationDependencies ) { return createBillingJsonRequester({ - credentials: { - appToken: credentials.appToken, - kubeconfig: credentials.kubeconfig, - }, + credentials, fallbackErrorMessage, fetch: dependencies.fetch ?? globalThis.fetch, }); diff --git a/apps/ui/src/features/billing/workspace-creation-return.ts b/apps/ui/src/features/billing/workspace-creation-return.ts index 27b9ec6c..e121398a 100644 --- a/apps/ui/src/features/billing/workspace-creation-return.ts +++ b/apps/ui/src/features/billing/workspace-creation-return.ts @@ -21,6 +21,18 @@ export function recordPendingWorkspaceCreation(workspaceId: string): void { } } +/** Whether `workspaceId` is the Workspace this tab was creating; the record stays. */ +export function isPendingWorkspaceCreation(workspaceId: string): boolean { + if (typeof window === "undefined") { + return false; + } + try { + return window.sessionStorage.getItem(STORAGE_KEY) === workspaceId; + } catch { + return false; + } +} + /** Whether `workspaceId` is the Workspace this tab was creating; forgets the record either way. */ export function consumePendingWorkspaceCreation(workspaceId: string): boolean { if (typeof window === "undefined") { diff --git a/apps/ui/src/features/billing/workspace-creation-schema.ts b/apps/ui/src/features/billing/workspace-creation-schema.ts index ada69a19..f2c1ce53 100644 --- a/apps/ui/src/features/billing/workspace-creation-schema.ts +++ b/apps/ui/src/features/billing/workspace-creation-schema.ts @@ -11,30 +11,36 @@ import { workspaceNameSchema } from "@/features/workspace/workspace-write-schema * add, never the client's to choose. */ -const paymentRequestFields = { +/** + * 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.enum(["stripe", "balance"]), + 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 = z.object({ - ...paymentRequestFields, - name: workspaceNameSchema, -}); +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 = z.object({ - ...paymentRequestFields, - workspaceId: z.string().trim().min(1), -}); +export const workspaceCreationRetryRequestSchema = + workspacePaymentTermsSchema.extend({ + workspaceId: z.string().trim().min(1), + }); export type WorkspaceCreationRetryRequest = z.infer< typeof workspaceCreationRetryRequestSchema From 35c62c0b1b5043680615f59f09b15e98a9beec5a Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 18 Sep 2026 14:48:44 +0800 Subject: [PATCH 11/17] fix(session): close the CSRF, referrer, and wrong-Workspace holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/session refuses a foreign Origin (403 session_forbidden) and a JSON body that does not travel as application/json (400): a sibling page on the shared cloud domain could POST { nsid } as text/plain and trigger Desktop's namespace/switch — the mutation ADR-0083 refuses to perform in place — while being unable to read the answer. - The expired overlay's "Sign in again" no longer falls back to the embedding page's referrer: without a frame-ancestors policy that page is not proven Desktop, and the button could aim window.top at it. The sign-in URL comes only from the SDK host config; without a domain the button reloads. - Inside the Desktop iframe a missed SDK handshake raises the generic session error instead of silently landing in Personal: Desktop's shell is the only source of the current Workspace, and a guessed Personal kubeconfig would mint credentials for the wrong Workspace. Outside an iframe (local development) Personal remains the landing. --- .../session/server/session-handler.test.ts | 42 ++++- .../session/server/session-handler.ts | 35 ++++ .../session-bootstrap.shell-miss.test.tsx | 176 ++++++++++++++++++ .../features/session/session-bootstrap.tsx | 31 ++- .../session/session-expired-overlay.tsx | 23 +-- .../ui/src/features/session/session-schema.ts | 1 + 6 files changed, 284 insertions(+), 24 deletions(-) create mode 100644 apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx diff --git a/apps/ui/src/features/session/server/session-handler.test.ts b/apps/ui/src/features/session/server/session-handler.test.ts index c7842673..9d41bc99 100644 --- a/apps/ui/src/features/session/server/session-handler.test.ts +++ b/apps/ui/src/features/session/server/session-handler.test.ts @@ -25,11 +25,16 @@ const DEV_ENV = { function sessionRequest(input: { body?: unknown; + contentType?: string; cookie?: string | null; + origin?: string | null; }): Request { const headers: Record = { - "content-type": "application/json", + "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`; @@ -77,6 +82,41 @@ function expectNoTokenInLogs(logs: LogEntry[]) { } 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 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 } })); diff --git a/apps/ui/src/features/session/server/session-handler.ts b/apps/ui/src/features/session/server/session-handler.ts index 3ba12a46..8dc582e5 100644 --- a/apps/ui/src/features/session/server/session-handler.ts +++ b/apps/ui/src/features/session/server/session-handler.ts @@ -60,6 +60,8 @@ 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 @@ -68,6 +70,9 @@ async function requestPayload( 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 { @@ -75,6 +80,32 @@ async function requestPayload( } } +/** + * 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 match the request's own host (the `Host` header wins over + * `request.url` behind an ingress that rewrites the internal host). An + * absent `Origin` — a non-browser client such as the smoke script — still + * passes the content-type gate below. + */ +function originAllowed(request: Request): boolean { + const origin = request.headers.get("origin")?.trim() ?? ""; + if (origin === "" || origin === "null") { + return true; + } + const host = request.headers.get("host")?.trim() ?? ""; + try { + const parsed = new URL(origin); + 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 { @@ -84,6 +115,10 @@ export function createSessionHandler( ((message, fields) => console.warn(`[session] ${message}`, fields)); return async function handler(request: Request): Promise { + if (!originAllowed(request)) { + log("session request from a foreign origin", {}); + return errorResponse(SESSION_ERROR_CODES.forbidden, 403); + } const body = await requestPayload(request); const parsed = "invalid" in body 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..a3c7f8cd --- /dev/null +++ b/apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx @@ -0,0 +1,176 @@ +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.tsx b/apps/ui/src/features/session/session-bootstrap.tsx index aa8aefa7..98338c4f 100644 --- a/apps/ui/src/features/session/session-bootstrap.tsx +++ b/apps/ui/src/features/session/session-bootstrap.tsx @@ -4,7 +4,11 @@ import { useSetAtom, useStore } from "jotai"; import { useEffect } from "react"; import { toast } from "sonner"; -import { desktopDomainAtom, desktopLanguageAtom } from "@/lib/auth-store"; +import { + desktopDomainAtom, + desktopLanguageAtom, + sessionStatusAtom, +} from "@/lib/auth-store"; import { connectDesktopSdk, @@ -13,6 +17,7 @@ import { readDesktopLanguage, readDesktopShellState, } from "./desktop-sdk"; +import { SESSION_ERROR_CODES } from "./session-schema"; import { SessionExpiredOverlay } from "./session-expired-overlay"; import { establishSession } from "./session-store"; @@ -23,9 +28,11 @@ export const NOT_MEMBER_NOTICE = * 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. Until the session lands the - * shell keeps its existing empty-credentials state; a 401 raises the - * "session expired" overlay this component also mounts. + * `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. */ export function SessionBootstrap() { const store = useStore(); @@ -39,10 +46,11 @@ export function SessionBootstrap() { }); const run = async () => { + const insideIframe = isInsideDesktopIframe(); const [shell, language, domain] = await Promise.all([ readDesktopShellState(), readDesktopLanguage(), - isInsideDesktopIframe() ? readDesktopDomain() : Promise.resolve(null), + insideIframe ? readDesktopDomain() : Promise.resolve(null), ]); if (cancelled) { return; @@ -51,6 +59,19 @@ export function SessionBootstrap() { if (domain != null) { setDesktopDomain(domain); } + if (insideIframe && shell == null) { + // Desktop's shell is the only source of the current Workspace. A + // missed handshake is not "no shell": guessing Personal here would + // mint credentials for the wrong Workspace while Desktop's chrome + // still shows a Team one. The generic error overlay's reload + // retries the handshake; outside an iframe (local development) + // Personal remains the honest landing. + store.set(sessionStatusAtom, { + code: SESSION_ERROR_CODES.desktopUnavailable, + kind: "error", + }); + return; + } const result = await establishSession(store, { nsid: shell?.nsid ?? null, }); diff --git a/apps/ui/src/features/session/session-expired-overlay.tsx b/apps/ui/src/features/session/session-expired-overlay.tsx index bcb5a1b5..03ccc76b 100644 --- a/apps/ui/src/features/session/session-expired-overlay.tsx +++ b/apps/ui/src/features/session/session-expired-overlay.tsx @@ -23,20 +23,6 @@ export function desktopSigninUrl(domain: string): string | null { return `${origin}/signin`; } -/** - * The Desktop origin when the host config never answered: the page that - * embedded this iframe is Desktop, and the browser records it as the - * referrer. Null outside an iframe or without a referrer. - */ -function referrerOrigin(): string | null { - try { - const referrer = document.referrer.trim(); - return referrer === "" ? null : new URL(referrer).origin; - } catch { - return null; - } -} - /** Copy for the generic session error (spec §A.3): the code, never Desktop text. */ function sessionErrorDescription(code: string): string { if (code === "workspace_not_inited") { @@ -63,10 +49,11 @@ export function SessionExpiredOverlay() { const status = useAtomValue(sessionStatusAtom); const desktopDomain = useAtomValue(desktopDomainAtom); const inIframe = isInsideDesktopIframe(); - const signinUrl = inIframe - ? (desktopSigninUrl(desktopDomain) ?? - desktopSigninUrl(referrerOrigin() ?? "")) - : null; + // 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) { diff --git a/apps/ui/src/features/session/session-schema.ts b/apps/ui/src/features/session/session-schema.ts index c8733a38..bfead50a 100644 --- a/apps/ui/src/features/session/session-schema.ts +++ b/apps/ui/src/features/session/session-schema.ts @@ -77,6 +77,7 @@ export type SessionRequest = z.infer; 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", From 36fbfe59468ca920533b38bbe57523a03024e3fa Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 18 Sep 2026 14:54:12 +0800 Subject: [PATCH 12/17] fix(workspace): close the invite holes and the guard's failed-refresh bounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The invite dialog refuses before the POST when the Desktop domain is unknown: Desktop upserts the code per { inviter, workspace, role }, so minting without a domain to build the link on would replace a working link with one the user cannot see. The role select is disabled while a mint is in flight, so an in-flight Copy can no longer setLink a URL for a role the user switched away from. - A Manager invite link now requires an Owner actor, proven server-side against namespace/list — Desktop's getInviteCode rejects Owner but lets a Manager mint a Manager link, the same class of hole the schema's never-Owner rule closes. The dev-mock fixture enforces the same rule. - The /project/ guard only confirms its leave verdict when the revalidation came back with a list that still lacks the Project: a failed refresh (401, 5xx, offline) keeps the guard standing instead of bouncing a Project that may exist — the case the extra round-trip was added to prevent. --- .../projects/project-workspace-guard.test.tsx | 19 ++++- .../projects/project-workspace-guard.tsx | 44 ++++++----- .../session/server/dev-fixtures.test.ts | 12 +++ .../features/session/server/dev-fixtures.ts | 7 ++ .../server/workspace-write-handlers.test.ts | 75 +++++++++++++++++-- .../server/workspace-write-handlers.ts | 67 ++++++++++++++--- .../workspace/workspace-area.test.tsx | 19 ++++- .../workspace/workspace-invite-dialog.tsx | 8 ++ 8 files changed, 215 insertions(+), 36 deletions(-) diff --git a/apps/ui/src/features/projects/project-workspace-guard.test.tsx b/apps/ui/src/features/projects/project-workspace-guard.test.tsx index 204acd9e..e6ad3994 100644 --- a/apps/ui/src/features/projects/project-workspace-guard.test.tsx +++ b/apps/ui/src/features/projects/project-workspace-guard.test.tsx @@ -22,6 +22,8 @@ const explorer = { freshProjects: null as ProjectExplorerProject[] | null, projects: [] as ProjectExplorerProject[], projectsLoaded: false, + /** When set, the revalidation rejects — the SWR verdict never lands. */ + refreshFails: false, refreshes: 0, }; const toasts: string[] = []; @@ -43,11 +45,15 @@ mock.module("@/features/projects/explorer/use-projects-explorer", () => ({ projectsLoaded: explorer.projectsLoaded, refreshProjects: () => { explorer.refreshes += 1; + if (explorer.refreshFails) { + return Promise.reject(new Error("offline")); + } if (explorer.freshProjects != null) { explorer.projects = explorer.freshProjects; rerender((n) => n + 1); } - return Promise.resolve(undefined); + // SWR's mutate resolves with the fresh list on success. + return Promise.resolve(explorer.projects); }, states: { pinnedProjectIds: [], projects: explorer.projects }, }; @@ -89,6 +95,7 @@ beforeEach(() => { explorer.freshProjects = null; explorer.projects = []; explorer.projectsLoaded = false; + explorer.refreshFails = false; explorer.refreshes = 0; toasts.length = 0; }); @@ -133,6 +140,16 @@ test("a stale cached list that misses a Project created elsewhere stays once the 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 loaded list with the Project leaves the page alone", async () => { route.pathname = "/project/beta"; explorer.projects = [project("alpha"), project("beta")]; diff --git a/apps/ui/src/features/projects/project-workspace-guard.tsx b/apps/ui/src/features/projects/project-workspace-guard.tsx index 407b1d35..6f464605 100644 --- a/apps/ui/src/features/projects/project-workspace-guard.tsx +++ b/apps/ui/src/features/projects/project-workspace-guard.tsx @@ -23,9 +23,11 @@ export const PROJECT_NOT_IN_WORKSPACE_NOTICE = * * 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 if the - * fresh list still lacks the Project — a real Project is never bounced by - * a stale cache. + * first "leave" verdict therefore revalidates once and only acts when the + * refresh came back with a list that still lacks the Project — a real + * Project is never bounced by a stale cache, and a refresh that failed + * (401, 5xx, offline) confirms nothing, so the guard keeps standing rather + * than judging from the stale verdict. */ export function ProjectWorkspaceGuard() { const projectId = useProjectId(); @@ -41,29 +43,31 @@ export function ProjectWorkspaceGuard() { projectId, projectIds: states.projects.map((project) => project.id), }); - // The Project id whose absence a revalidation has confirmed. - const [verifiedMissing, setVerifiedMissing] = useState(null); + // 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 (verifiedMissing !== projectId) { - let cancelled = false; - refreshProjects() - .catch(() => undefined) - .then(() => { - if (!cancelled) { - setVerifiedMissing(projectId); - } - }); - return () => { - cancelled = true; - }; + if (revalidatedFor === projectId) { + router.replace("/project"); + toast(PROJECT_NOT_IN_WORKSPACE_NOTICE); + return; } - router.replace("/project"); - toast(PROJECT_NOT_IN_WORKSPACE_NOTICE); - }, [decision, projectId, refreshProjects, router, verifiedMissing]); + let cancelled = false; + refreshProjects() + .then((fresh) => { + if (!cancelled && fresh !== undefined) { + setRevalidatedFor(projectId); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [decision, projectId, refreshProjects, revalidatedFor, router]); return null; } diff --git a/apps/ui/src/features/session/server/dev-fixtures.test.ts b/apps/ui/src/features/session/server/dev-fixtures.test.ts index 4495463d..f92bd345 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.test.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.test.ts @@ -406,6 +406,18 @@ test("dev-mock writes: an invite link answers a code, and the role matrix holds" ).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( ( diff --git a/apps/ui/src/features/session/server/dev-fixtures.ts b/apps/ui/src/features/session/server/dev-fixtures.ts index 5975b6b0..00b85c00 100644 --- a/apps/ui/src/features/session/server/dev-fixtures.ts +++ b/apps/ui/src/features/session/server/dev-fixtures.ts @@ -488,6 +488,13 @@ const WORKSPACE_FIXTURES: Record< 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()}`, }; diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts index 4573bf71..c79147eb 100644 --- a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts @@ -21,7 +21,7 @@ const { createWorkspaceTransferHandler, } = await import("./workspace-write-handlers"); const { WORKSPACE_ROUTES } = await import("./workspace-route-table"); -const { createFakeDesktop, TEAM } = await import( +const { createFakeDesktop, PERSONAL, TEAM } = await import( "@/features/session/server/desktop-test-double" ); @@ -326,17 +326,82 @@ describe("POST /api/workspace/member/alias", () => { }); describe("POST /api/workspace/invite-link", () => { - it("sends Desktop the Manager code for a Manager link", async () => { + const managerLinkAnswers: FakeDesktopOptions["answers"] = { + [WORKSPACE_ROUTES.list.desktopPath]: { + code: 200, + data: { namespaces: [PERSONAL, TEAM] }, + }, + [WORKSPACE_ROUTES.inviteLink.desktopPath]: { + code: 200, + data: { code: "0f2c1a9e-invite-code" }, + }, + }; + + it("proves the actor is the Owner before sending Desktop the Manager code", async () => { + const ownedTeam = { + ...TEAM, + id: "ns-owned01", + role: 0, + uid: "33333333-3333-4333-8333-333333333333", + }; + const { calls, handler } = handlerWith(createWorkspaceInviteLinkHandler, { + ...managerLinkAnswers, + [WORKSPACE_ROUTES.list.desktopPath]: { + code: 200, + data: { namespaces: [PERSONAL, ownedTeam] }, + }, + }); + const response = await handler( + writeRequest(WORKSPACE_ROUTES.inviteLink.apiPath, { + body: { role: "Manager", uid: ownedTeam.uid }, + }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ code: "0f2c1a9e-invite-code" }); + expect(calls[0]?.path).toBe(WORKSPACE_ROUTES.list.desktopPath); + expect(calls[1]?.body).toEqual({ ns_uid: ownedTeam.uid, role: 1 }); + }); + + it("refuses a Manager link from a non-Owner actor — the hole Desktop leaves open", async () => { const { calls, handler } = handlerWith( createWorkspaceInviteLinkHandler, - successAnswer(WORKSPACE_ROUTES.inviteLink.desktopPath) + managerLinkAnswers ); - await handler( + const response = await handler( writeRequest(WORKSPACE_ROUTES.inviteLink.apiPath, { + // The double's TEAM carries this actor as a Manager. body: { role: "Manager", uid: TEAM.uid }, }) ); - expect(calls[0]?.body).toEqual({ ns_uid: TEAM.uid, role: 1 }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: WORKSPACE_ERROR_CODES.forbidden, + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.path).toBe(WORKSPACE_ROUTES.list.desktopPath); + }); + + it("mints a Developer link without the Owner proof call", async () => { + const { calls, handler } = handlerWith( + createWorkspaceInviteLinkHandler, + { + [WORKSPACE_ROUTES.inviteLink.desktopPath]: { + code: 200, + data: { code: "0f2c1a9e-invite-code" }, + }, + } + ); + const response = await handler( + writeRequest(WORKSPACE_ROUTES.inviteLink.apiPath, { + body: { role: "Developer", uid: TEAM.uid }, + }) + ); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ ns_uid: TEAM.uid, role: 2 }); }); it("answers 502 when Desktop's data carries no code", async () => { diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts index 3e852bb1..5b1a21cf 100644 --- a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts @@ -8,7 +8,6 @@ import type { DesktopCallResult } from "@/features/session/server/desktop-client import { WORKSPACE_ERROR_CODES } from "../workspace-errors"; import { WORKSPACE_WRITE_OK, - type WorkspaceInviteLinkResponse, workspaceDeleteRequestSchema, workspaceInviteLinkRequestSchema, workspaceMemberAliasRequestSchema, @@ -110,18 +109,68 @@ export function createWorkspaceDeleteHandler( /** * `POST /api/workspace/invite-link { uid, role }` → `namespace/getInviteCode`, * answered as `{ code }`; the client builds the Desktop link around it. + * Desktop's `getInviteCode` rejects Owner but lets a Manager mint a Manager + * link — the same class of hole the schema's never-Owner closes — so Brain + * closes it itself (spec §E.4): a Manager link requires an Owner actor, + * proven by the actor's own membership from `namespace/list`. Developer + * links need no extra call; Desktop gates them for Managers too. */ export function createWorkspaceInviteLinkHandler( dependencies: WorkspaceRouteDependencies = {} ): WorkspaceRouteHandler { - return createWorkspaceWriteHandler( - WORKSPACE_ROUTES.inviteLink, - workspaceInviteLinkRequestSchema, - (desktop, token, body) => - desktop.namespaceInviteCode(token, body.uid, body.role), - (data): WorkspaceInviteLinkResponse => ({ code: data.code }), - dependencies - ); + return async function handler(request: Request): Promise { + const context = workspaceRouteContext( + request, + dependencies, + WORKSPACE_ROUTES.inviteLink.apiPath + ); + if (!context.ok) { + return context.response; + } + const payload = await workspaceRequestPayload(request); + const parsed = + payload == null + ? null + : workspaceInviteLinkRequestSchema.safeParse(payload); + if (parsed == null || !parsed.success) { + return workspaceErrorResponse(WORKSPACE_ERROR_CODES.invalidRequest, 400); + } + const body = parsed.data; + if (body.role === "Manager") { + const listed = await context.desktop.namespaceList( + context.regionalToken + ); + if (!listed.ok) { + context.log("Desktop list failed", { + ...desktopFailureLogFields(listed), + desktopPath: WORKSPACE_ROUTES.list.desktopPath, + }); + return desktopFailureResponse(listed); + } + const actorRole = listed.data.find( + (workspace) => workspace.uid === body.uid + )?.role; + if (actorRole !== "Owner") { + context.log("non-Owner actor tried to mint a Manager invite", { + actorRole: actorRole ?? "none", + }); + return workspaceErrorResponse(WORKSPACE_ERROR_CODES.forbidden, 403); + } + } + const result = await context.desktop.namespaceInviteCode( + context.regionalToken, + body.uid, + body.role + ); + if (!result.ok) { + context.log("Desktop write failed", { + ...desktopFailureLogFields(result), + desktopPath: WORKSPACE_ROUTES.inviteLink.desktopPath, + }); + return desktopFailureResponse(result); + } + return workspaceJsonResponse({ code: result.data.code }); + }; } /** `POST /api/workspace/member/remove { uid, crUid }` → `namespace/removeUser`. */ diff --git a/apps/ui/src/features/workspace/workspace-area.test.tsx b/apps/ui/src/features/workspace/workspace-area.test.tsx index 85c78ecd..a69c0352 100644 --- a/apps/ui/src/features/workspace/workspace-area.test.tsx +++ b/apps/ui/src/features/workspace/workspace-area.test.tsx @@ -313,7 +313,10 @@ const { workspaceDeletedNotice, workspaceLeftNotice, } = await import("./use-workspace-actions"); -const { INVITE_LINK_COPIED_NOTICE } = await import("./workspace-invite-dialog"); +const { + INVITE_LINK_COPIED_NOTICE, + INVITE_LINK_NO_DESKTOP_NOTICE, +} = await import("./workspace-invite-dialog"); const { INVITE_LINK_VALIDITY_NOTE } = await import("./workspace-invite-core"); await moduleDom.restore(); @@ -1093,3 +1096,17 @@ test("Invite: a Manager is offered Developer only", async () => { ["Developer"] ); }); + +test("Invite: without a Desktop domain no code is minted — a working link is never replaced by one the user cannot see", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + getDefaultStore().set(desktopDomainAtom, ""); + await press(byLabel("Invite member"), "Invite member"); + await press( + dialogAction("workspace-invite-dialog", "Copy invite link"), + "Copy invite link" + ); + assert.equal(writesTo("/api/workspace/invite-link").length, 0); + assert.equal(bySlot("workspace-invite-link"), null); + assert.ok(toasts.includes(INVITE_LINK_NO_DESKTOP_NOTICE)); +}); diff --git a/apps/ui/src/features/workspace/workspace-invite-dialog.tsx b/apps/ui/src/features/workspace/workspace-invite-dialog.tsx index 197a6066..44ba9161 100644 --- a/apps/ui/src/features/workspace/workspace-invite-dialog.tsx +++ b/apps/ui/src/features/workspace/workspace-invite-dialog.tsx @@ -71,6 +71,13 @@ export function WorkspaceInviteDialog({ const selectId = useId(); const createLink = async () => { + if (workspaceInviteUrl({ cloudDomain, code: "x" }) == null) { + // Desktop upserts the code per { inviter, workspace, role }; minting + // without a domain to build the link on would replace a working link + // with one the user cannot see, so refuse before the POST. + toast(INVITE_LINK_NO_DESKTOP_NOTICE); + return; + } const code = await onCreateLink(role); if (code == null) { return; @@ -109,6 +116,7 @@ export function WorkspaceInviteDialog({ Role { setRole(next as AssignableRole); From 92e9f85225c5e7a6801ba1bcd696e3e255df03c4 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 18 Sep 2026 15:03:06 +0800 Subject: [PATCH 13/17] fix(billing): close Workspace Creation's unobserved-create and refresh holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Both creation routes validate planName against account-service's plan catalog and refuse a plan it does not price (400) — the picker hides Free, but a crafted POST did not — and fail closed (502) when the catalog cannot be read, before Desktop creates anything. - A pay answer that succeeds without a checkout URL (account-service's balance-style path, which the terms rule out) is read as settled, not failed: the schema gains a settled variant and the page closes as done instead of offering a retry for a payment that exists. - A pay call that throws after Desktop created the Workspace answers 200 with payment failed, so the Workspace id never disappears behind an error the page cannot act on. - A 409 on a name the actor already owns — Desktop created it but the answer never landed — re-reads the session list and offers Retry payment instead of the taken-name verdict that stranded a Workspace the user could not pay for. - Any create response carrying a Workspace refreshes /api/workspace/list into the session atoms (the duplicate check and the Switcher read it), so a payment-failed "Later" leaves the unpaid Workspace visible and a second create under a new name no longer slips past the check. Also applies the repo formatter's pass over the touched files. --- ...space-creation-dialog.interaction.test.tsx | 99 ++++++++++++++ .../billing-workspace-creation-dialog.tsx | 48 +++++++ .../workspace-creation-handlers.test.ts | 124 ++++++++++++++++-- .../server/workspace-creation-handlers.ts | 85 +++++++++++- .../billing/workspace-creation-schema.ts | 10 ++ .../session-bootstrap.shell-miss.test.tsx | 6 +- .../features/session/session-bootstrap.tsx | 56 +++++--- .../server/workspace-write-handlers.test.ts | 15 +-- .../server/workspace-write-handlers.ts | 4 +- .../workspace/workspace-area.test.tsx | 6 +- 10 files changed, 405 insertions(+), 48 deletions(-) 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 index c18a6cb9..5238811d 100644 --- 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 @@ -2,8 +2,10 @@ 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"; @@ -367,3 +369,100 @@ test("Later closes the whole dialog without touching the created Workspace", asy } }); }); + +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()); + } + }); +}); diff --git a/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx index ced98cfa..f4798c49 100644 --- a/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx @@ -3,6 +3,7 @@ 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 { useId, useMemo, useRef, useState } from "react"; @@ -11,7 +12,9 @@ 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 { @@ -122,6 +125,8 @@ export function BillingWorkspaceCreationDialog({ }: BillingWorkspaceCreationDialogProps) { const inputId = useId(); const inputRef = useRef(null); + const store = useStore(); + const refreshWorkspaces = useWorkspaceRefresh(); const [name, setName] = useState(""); const [nameIssue, setNameIssue] = useState(null); // Names Desktop already refused this session: the field says so on the @@ -177,11 +182,22 @@ export function BillingWorkspaceCreationDialog({ 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", @@ -190,6 +206,33 @@ export function BillingWorkspaceCreationDialog({ }); } 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 + // the name, offer the payment retry instead of "taken" — the user + // already owns a Workspace they cannot pay for otherwise. + await refreshWorkspaces({ details: false }).catch(() => undefined); + const owned = store + .get(workspacesAtom) + .find( + (candidate) => + 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; + } setTakenNames((names) => [...names, trimmedName]); setNameIssue("duplicate"); setStage({ kind: "pick" }); @@ -223,6 +266,11 @@ export function BillingWorkspaceCreationDialog({ handOffToStripe(workspace, payment); return; } + if (payment.status === "settled") { + handedOff = true; + onOpenChange(false); + return; + } setError(payment.error); } catch (cause) { setError( 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 index f6d2820a..d22c1fc1 100644 --- a/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.test.ts @@ -96,12 +96,40 @@ function paymentAnswer(): Response { }); } +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({ @@ -119,6 +147,9 @@ function harness( 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)); }, }; @@ -131,9 +162,6 @@ function harness( }; } -const CREATE_PATH = BILLING_ROUTES.workspaceCreate.apiPath; -const RETRY_PATH = BILLING_ROUTES.workspaceCreateRetryPayment.apiPath; - 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(); @@ -159,8 +187,11 @@ describe(`POST ${CREATE_PATH}`, () => { path: DESKTOP_CREATE_PATH, }, ]); - expect(accountRequests).toHaveLength(1); - const pay = accountRequests[0]; + 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"); @@ -194,7 +225,77 @@ describe(`POST ${CREATE_PATH}`, () => { code: WORKSPACE_NAME_CONFLICT_CODE, error: WORKSPACE_NAME_CONFLICT_MESSAGE, }); - expect(accountRequests).toEqual([]); + 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 () => { @@ -216,7 +317,9 @@ describe(`POST ${CREATE_PATH}`, () => { 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).toEqual([]); + 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"); } @@ -325,8 +428,11 @@ describe(`POST ${RETRY_PATH}`, () => { }, }); expect(desktopCalls).toEqual([]); - expect(accountRequests).toHaveLength(1); - expect(JSON.parse(String(accountRequests[0]?.init?.body))).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", diff --git a/apps/ui/src/features/billing/server/workspace-creation-handlers.ts b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts index 6e332967..f26ee833 100644 --- a/apps/ui/src/features/billing/server/workspace-creation-handlers.ts +++ b/apps/ui/src/features/billing/server/workspace-creation-handlers.ts @@ -15,6 +15,7 @@ import { 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, @@ -153,6 +154,43 @@ function upstreamErrorText(payload: unknown, fallback: string): string { 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 @@ -195,9 +233,20 @@ async function startWorkspacePayment( : {}; const redirectUrl = typeof checkout.redirectUrl === "string" ? checkout.redirectUrl.trim() : ""; - if (checkout.success !== true || redirectUrl === "") { + 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, @@ -257,6 +306,14 @@ export function createBillingWorkspaceCreateHandler( 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; @@ -274,11 +331,19 @@ export function createBillingWorkspaceCreateHandler( 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", { @@ -300,6 +365,10 @@ 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, @@ -309,11 +378,25 @@ export function createBillingWorkspaceCreateRetryPaymentHandler( 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/workspace-creation-schema.ts b/apps/ui/src/features/billing/workspace-creation-schema.ts index f2c1ce53..5193157c 100644 --- a/apps/ui/src/features/billing/workspace-creation-schema.ts +++ b/apps/ui/src/features/billing/workspace-creation-schema.ts @@ -71,6 +71,16 @@ export const workspaceCreationPaymentSchema = z.discriminatedUnion("status", [ 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< 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 index a3c7f8cd..db06cbc9 100644 --- a/apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx +++ b/apps/ui/src/features/session/session-bootstrap.shell-miss.test.tsx @@ -13,7 +13,11 @@ import { setActEnvironment, type TestDom, } from "@/features/project-canvas/react-test-harness"; -import { appTokenAtom, kubeconfigAtom, sessionStatusAtom } from "@/lib/auth-store"; +import { + appTokenAtom, + kubeconfigAtom, + sessionStatusAtom, +} from "@/lib/auth-store"; import type { BrainSession } from "./session-schema"; diff --git a/apps/ui/src/features/session/session-bootstrap.tsx b/apps/ui/src/features/session/session-bootstrap.tsx index 98338c4f..32cb302c 100644 --- a/apps/ui/src/features/session/session-bootstrap.tsx +++ b/apps/ui/src/features/session/session-bootstrap.tsx @@ -17,8 +17,8 @@ import { readDesktopLanguage, readDesktopShellState, } from "./desktop-sdk"; -import { SESSION_ERROR_CODES } from "./session-schema"; import { SessionExpiredOverlay } from "./session-expired-overlay"; +import { SESSION_ERROR_CODES } from "./session-schema"; import { establishSession } from "./session-store"; export const NOT_MEMBER_NOTICE = @@ -34,6 +34,37 @@ export const NOT_MEMBER_NOTICE = * 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); @@ -46,35 +77,18 @@ export function SessionBootstrap() { }); const run = async () => { - const insideIframe = isInsideDesktopIframe(); - const [shell, language, domain] = await Promise.all([ - readDesktopShellState(), - readDesktopLanguage(), - insideIframe ? readDesktopDomain() : Promise.resolve(null), - ]); + const facts = await readShellFacts(setDesktopLanguage, setDesktopDomain); if (cancelled) { return; } - setDesktopLanguage(language ?? "en"); - if (domain != null) { - setDesktopDomain(domain); - } - if (insideIframe && shell == null) { - // Desktop's shell is the only source of the current Workspace. A - // missed handshake is not "no shell": guessing Personal here would - // mint credentials for the wrong Workspace while Desktop's chrome - // still shows a Team one. The generic error overlay's reload - // retries the handshake; outside an iframe (local development) - // Personal remains the honest landing. + if ("error" in facts) { store.set(sessionStatusAtom, { code: SESSION_ERROR_CODES.desktopUnavailable, kind: "error", }); return; } - const result = await establishSession(store, { - nsid: shell?.nsid ?? null, - }); + const result = await establishSession(store, { nsid: facts.nsid }); if (cancelled) { return; } diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts index c79147eb..fc54f159 100644 --- a/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.test.ts @@ -384,15 +384,12 @@ describe("POST /api/workspace/invite-link", () => { }); it("mints a Developer link without the Owner proof call", async () => { - const { calls, handler } = handlerWith( - createWorkspaceInviteLinkHandler, - { - [WORKSPACE_ROUTES.inviteLink.desktopPath]: { - code: 200, - data: { code: "0f2c1a9e-invite-code" }, - }, - } - ); + const { calls, handler } = handlerWith(createWorkspaceInviteLinkHandler, { + [WORKSPACE_ROUTES.inviteLink.desktopPath]: { + code: 200, + data: { code: "0f2c1a9e-invite-code" }, + }, + }); const response = await handler( writeRequest(WORKSPACE_ROUTES.inviteLink.apiPath, { body: { role: "Developer", uid: TEAM.uid }, diff --git a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts index 5b1a21cf..001fd1dd 100644 --- a/apps/ui/src/features/workspace/server/workspace-write-handlers.ts +++ b/apps/ui/src/features/workspace/server/workspace-write-handlers.ts @@ -137,9 +137,7 @@ export function createWorkspaceInviteLinkHandler( } const body = parsed.data; if (body.role === "Manager") { - const listed = await context.desktop.namespaceList( - context.regionalToken - ); + const listed = await context.desktop.namespaceList(context.regionalToken); if (!listed.ok) { context.log("Desktop list failed", { ...desktopFailureLogFields(listed), diff --git a/apps/ui/src/features/workspace/workspace-area.test.tsx b/apps/ui/src/features/workspace/workspace-area.test.tsx index a69c0352..c33f5a55 100644 --- a/apps/ui/src/features/workspace/workspace-area.test.tsx +++ b/apps/ui/src/features/workspace/workspace-area.test.tsx @@ -313,10 +313,8 @@ const { workspaceDeletedNotice, workspaceLeftNotice, } = await import("./use-workspace-actions"); -const { - INVITE_LINK_COPIED_NOTICE, - INVITE_LINK_NO_DESKTOP_NOTICE, -} = await import("./workspace-invite-dialog"); +const { INVITE_LINK_COPIED_NOTICE, INVITE_LINK_NO_DESKTOP_NOTICE } = + await import("./workspace-invite-dialog"); const { INVITE_LINK_VALIDITY_NOTE } = await import("./workspace-invite-core"); await moduleDom.restore(); From 24ba9ec6e88b6053d869b899abfb684e36125bf4 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 18 Sep 2026 17:02:58 +0800 Subject: [PATCH 14/17] fix(session): refuse Origin null and plain-HTTP origins on the session route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Origin: null` is a browser (a sandboxed frame, some redirects), not a missing header — it now fails the origin parse and is refused, where an absent Origin (the smoke script, non-browser clients) still passes the content-type gate. The Host match no longer ignores scheme: an Origin must name this app over HTTPS, or over HTTP only outside production (local development), so http:// cannot stand in for the HTTPS app. --- .../session/server/session-handler.test.ts | 22 ++++++++++++++ .../session/server/session-handler.ts | 29 ++++++++++++++----- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/ui/src/features/session/server/session-handler.test.ts b/apps/ui/src/features/session/server/session-handler.test.ts index 9d41bc99..7a7f0a0c 100644 --- a/apps/ui/src/features/session/server/session-handler.test.ts +++ b/apps/ui/src/features/session/server/session-handler.test.ts @@ -103,6 +103,28 @@ describe("POST /api/session", () => { 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( diff --git a/apps/ui/src/features/session/server/session-handler.ts b/apps/ui/src/features/session/server/session-handler.ts index 8dc582e5..c7cd93e7 100644 --- a/apps/ui/src/features/session/server/session-handler.ts +++ b/apps/ui/src/features/session/server/session-handler.ts @@ -84,19 +84,32 @@ async function requestPayload( * 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 match the request's own host (the `Host` header wins over - * `request.url` behind an ingress that rewrites the internal host). An - * absent `Origin` — a non-browser client such as the smoke script — still - * passes the content-type gate below. + * `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): boolean { +function originAllowed( + request: Request, + env: Record +): boolean { const origin = request.headers.get("origin")?.trim() ?? ""; - if (origin === "" || origin === "null") { + if (origin === "") { return true; } - const host = request.headers.get("host")?.trim() ?? ""; 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; } @@ -115,7 +128,7 @@ export function createSessionHandler( ((message, fields) => console.warn(`[session] ${message}`, fields)); return async function handler(request: Request): Promise { - if (!originAllowed(request)) { + if (!originAllowed(request, env)) { log("session request from a foreign origin", {}); return errorResponse(SESSION_ERROR_CODES.forbidden, 403); } From d5f2dbc4b2b55aeb38508554f8cf6cd877431c14 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 18 Sep 2026 17:04:22 +0800 Subject: [PATCH 15/17] fix(workspace,projects): judge the guard on its payload; build invite links on Desktop only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The /project/ guard now inspects the revalidation's payload: only a raw /api/projects answer that came back as a list still lacking the Project confirms the leave verdict. A payload that carries the Project holds the guard even before the rendered snapshot paints — the case the extra round-trip was added to prevent — and an unusable shape confirms nothing. - The Workspace Area builds its invite links on the SDK host config's cloud domain only; the kubeconfig's apiserver host is not Desktop, and a link built on it both points nowhere and invalidates the previous {inviter, workspace, role} code. An empty SDK domain now really means "cannot build links yet" — the dialog refuses to mint — the same gate the Switcher applies. --- .../projects/project-workspace-guard.test.tsx | 27 +++++++++- .../projects/project-workspace-guard.tsx | 49 ++++++++++++++++--- .../workspace/workspace-area.test.tsx | 34 +++++++++++++ .../src/features/workspace/workspace-area.tsx | 19 +++---- 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/apps/ui/src/features/projects/project-workspace-guard.test.tsx b/apps/ui/src/features/projects/project-workspace-guard.test.tsx index e6ad3994..9ce63d14 100644 --- a/apps/ui/src/features/projects/project-workspace-guard.test.tsx +++ b/apps/ui/src/features/projects/project-workspace-guard.test.tsx @@ -20,6 +20,11 @@ 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. */ @@ -48,12 +53,15 @@ mock.module("@/features/projects/explorer/use-projects-explorer", () => ({ 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 fresh list on success. - return Promise.resolve(explorer.projects); + // SWR's mutate resolves with the raw `/api/projects` payload. + return Promise.resolve({ projects: explorer.projects }); }, states: { pinnedProjectIds: [], projects: explorer.projects }, }; @@ -93,6 +101,7 @@ beforeEach(() => { route.replaced = []; explorer.devMockActive = false; explorer.freshProjects = null; + explorer.freshPayloadOnly = null; explorer.projects = []; explorer.projectsLoaded = false; explorer.refreshFails = false; @@ -150,6 +159,20 @@ test("a refresh that fails confirms nothing: the page stays and no toast is serv 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")]; diff --git a/apps/ui/src/features/projects/project-workspace-guard.tsx b/apps/ui/src/features/projects/project-workspace-guard.tsx index 6f464605..307e671d 100644 --- a/apps/ui/src/features/projects/project-workspace-guard.tsx +++ b/apps/ui/src/features/projects/project-workspace-guard.tsx @@ -24,11 +24,40 @@ export const PROJECT_NOT_IN_WORKSPACE_NOTICE = * 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 - * refresh came back with a list that still lacks the Project — a real - * Project is never bounced by a stale cache, and a refresh that failed - * (401, 5xx, offline) confirms nothing, so the guard keeps standing rather - * than judging from the stale verdict. + * 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(); @@ -59,9 +88,17 @@ export function ProjectWorkspaceGuard() { let cancelled = false; refreshProjects() .then((fresh) => { - if (!cancelled && fresh !== undefined) { - setRevalidatedFor(projectId); + 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 () => { diff --git a/apps/ui/src/features/workspace/workspace-area.test.tsx b/apps/ui/src/features/workspace/workspace-area.test.tsx index c33f5a55..e6b8987f 100644 --- a/apps/ui/src/features/workspace/workspace-area.test.tsx +++ b/apps/ui/src/features/workspace/workspace-area.test.tsx @@ -1108,3 +1108,37 @@ test("Invite: without a Desktop domain no code is minted — a working link is n assert.equal(bySlot("workspace-invite-link"), null); assert.ok(toasts.includes(INVITE_LINK_NO_DESKTOP_NOTICE)); }); + +test("Invite: the kubeconfig's apiserver host is not Desktop — no link is built on it", async () => { + hydrate("owner", "uid-acme"); + await mountArea("uid-acme"); + getDefaultStore().set(desktopDomainAtom, ""); + getDefaultStore().set( + kubeconfigAtom, + [ + "apiVersion: v1", + "clusters:", + "- cluster:", + " server: https://apiserver.test:6443", + " name: c", + "contexts:", + "- context:", + " cluster: c", + " namespace: ns-acme", + " user: u", + " name: c", + "current-context: c", + "kind: Config", + "users:", + "- name: u", + ].join("\n") + ); + await press(byLabel("Invite member"), "Invite member"); + await press( + dialogAction("workspace-invite-dialog", "Copy invite link"), + "Copy invite link" + ); + assert.equal(writesTo("/api/workspace/invite-link").length, 0); + assert.equal(bySlot("workspace-invite-link"), null); + assert.ok(toasts.includes(INVITE_LINK_NO_DESKTOP_NOTICE)); +}); diff --git a/apps/ui/src/features/workspace/workspace-area.tsx b/apps/ui/src/features/workspace/workspace-area.tsx index 18d1292e..80d66f15 100644 --- a/apps/ui/src/features/workspace/workspace-area.tsx +++ b/apps/ui/src/features/workspace/workspace-area.tsx @@ -11,10 +11,8 @@ import { AreaShell } from "@/features/shell/area-shell"; import { currentWorkspaceAtom, desktopDomainAtom, - kubeconfigAtom, sessionUserAtom, } from "@/lib/auth-store"; -import { routingDomainFromKubeconfig } from "@/lib/kubeconfig-routing-domain"; import { useWorkspaceActions } from "./use-workspace-actions"; import { useWorkspaceDetails } from "./use-workspace-details"; @@ -44,19 +42,14 @@ function WorkspaceAreaIcon() { /** * Desktop's cloud domain for the links the area builds (spec §B.2): the - * SDK host config inside the iframe, else the kubeconfig's routing domain - * (the card-management route derives it the same way server-side). + * SDK host config only, never the kubeconfig's apiserver host — that is + * not Desktop, and an invite link built on it points nowhere while still + * invalidating the previous code. An empty SDK domain means "cannot build + * links yet": the invite dialog refuses to mint, the same gate the + * Switcher applies (spec §C.6). */ function useDesktopCloudDomain(): string { - const desktopDomain = useAtomValue(desktopDomainAtom).trim(); - const kubeconfig = useAtomValue(kubeconfigAtom); - return useMemo( - () => - desktopDomain === "" - ? routingDomainFromKubeconfig(kubeconfig) - : desktopDomain, - [desktopDomain, kubeconfig] - ); + return useAtomValue(desktopDomainAtom).trim(); } /** From 19988f31772d8f3a97b01e8cecd9c53b62b535da Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 18 Sep 2026 17:12:40 +0800 Subject: [PATCH 16/17] fix(billing): reset the creation dialog on close; spend the pending record on any Stripe return; scope 409 recovery to Team Workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The creation dialog resets its stage, name, and submitting state the moment `open` turns false: the workflow keeps it mounted, so a reopen after Later must not restack the failed-payment offer, and a reopen after a settled creation must not inherit the submitting freeze that exists for a real top-window Stripe hop. - The pending-creation record now carries the checkout's payId, and any Stripe return for the recorded Workspace — success or cancel — spends it: an abandoned Checkout can no longer reword a later plan change for that Workspace as a creation. A success return concludes "created" only when the recorded payId matches; a cancel keeps the recorded entry point (the user continues where they were) and draws no conclusion. readBillingReturnRoute is now a pure read — the workflow's return effect does the consuming and the entry-point voiding, never a snapshot-style read. - The 409 recovery matches only a Team Workspace the actor owns; a name shared with the Personal Workspace reads as taken on the field, never as a Retry-payment offer on the Personal one. The name stays retriable: nothing is pushed into a session-local taken list, so a list that has not caught up shows "taken" and the next attempt re-runs the ownership recovery. --- apps/ui/src/app/billing/page.tsx | 6 + .../billing/billing-plan.interaction.test.tsx | 108 +++++++++++ apps/ui/src/features/billing/billing-plan.tsx | 47 ++++- .../billing/billing-return-route.test.ts | 35 +++- .../features/billing/billing-return-route.ts | 48 +++-- ...space-creation-dialog.interaction.test.tsx | 174 ++++++++++++++++++ .../billing-workspace-creation-dialog.tsx | 39 ++-- .../billing/workspace-creation-return.ts | 81 ++++++-- .../session/server/session-handler.test.ts | 4 +- 9 files changed, 487 insertions(+), 55 deletions(-) diff --git a/apps/ui/src/app/billing/page.tsx b/apps/ui/src/app/billing/page.tsx index f8522411..91d04216 100644 --- a/apps/ui/src/app/billing/page.tsx +++ b/apps/ui/src/app/billing/page.tsx @@ -34,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/features/billing/billing-plan.interaction.test.tsx b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx index 91626317..f6b7a2b3 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -378,6 +378,114 @@ test("a Stripe return for the Workspace this tab created concludes as a creation }); }); +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("Stripe return refreshes before congratulations and clears on close", async () => { await withTestDom(async (act) => { const { BillingPlanWorkflow } = await import("./billing-plan"); diff --git a/apps/ui/src/features/billing/billing-plan.tsx b/apps/ui/src/features/billing/billing-plan.tsx index f4e051f4..71fd821c 100644 --- a/apps/ui/src/features/billing/billing-plan.tsx +++ b/apps/ui/src/features/billing/billing-plan.tsx @@ -66,7 +66,10 @@ import { submitCancellationSurvey } from "@/features/billing/cancellation-survey 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 } from "@/features/billing/workspace-creation-return"; +import { + consumePendingWorkspaceCreation, + readPendingWorkspaceCreation, +} from "@/features/billing/workspace-creation-return"; import { type FreeChatTurnsUsage, fetchFreeChatTurnsUsage, @@ -116,6 +119,7 @@ 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; @@ -152,6 +156,7 @@ export function BillingPlanWorkflow({ replaceUrl, schedulePoll, snapshot, + stripeCancelWorkspaceId = null, stripeReturn = null, viewerIsOwner = false, workspaceName = null, @@ -206,6 +211,17 @@ export function BillingPlanWorkflow({ } }, [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; @@ -217,11 +233,24 @@ 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. Its 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 created = consumePendingWorkspaceCreation(stripeReturn.workspaceId); + // 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 (when Desktop's answer carried one) + // is this return's; a plan change pays under its own. 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(); } @@ -437,12 +466,14 @@ export function BillingPlan({ gpuEnabled, initialMode = null, replaceUrl, + stripeCancelWorkspaceId = null, stripeReturn = null, }: { currency: BillingCurrency; gpuEnabled: boolean; initialMode?: BillingPlanMode | null; replaceUrl: (url: string) => void; + stripeCancelWorkspaceId?: string | null; stripeReturn?: BillingStripeReturn | null; }) { const appToken = useAtomValue(appTokenAtom); @@ -824,6 +855,7 @@ 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 @@ -843,11 +875,13 @@ export default function BillingPlanRoute({ currency, gpuEnabled, initialMode = null, + stripeCancelWorkspaceId = null, stripeReturn = null, }: { currency: BillingCurrency; gpuEnabled: boolean; initialMode?: BillingPlanMode | null; + stripeCancelWorkspaceId?: string | null; stripeReturn?: BillingStripeReturn | null; }) { const router = useRouter(); @@ -859,6 +893,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-return-route.test.ts b/apps/ui/src/features/billing/billing-return-route.test.ts index 3e7dd08e..fa3236b5 100644 --- a/apps/ui/src/features/billing/billing-return-route.test.ts +++ b/apps/ui/src/features/billing/billing-return-route.test.ts @@ -54,20 +54,45 @@ test("sanitizeBillingReturnRoute falls back to home for unusable values", () => assert.equal(sanitizeBillingReturnRoute("/billing?mode=upgrade"), "/"); }); -test("a creation's Stripe return voids the recorded entry point: it names the old Workspace's route", () => { +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"); + 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(), "/"); - assert.equal(storage.has("billing-return-route"), false); + // 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 recorded remains. + // 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(), "/"); + 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"); }); }); diff --git a/apps/ui/src/features/billing/billing-return-route.ts b/apps/ui/src/features/billing/billing-return-route.ts index 4eb7342f..8c34f66e 100644 --- a/apps/ui/src/features/billing/billing-return-route.ts +++ b/apps/ui/src/features/billing/billing-return-route.ts @@ -1,6 +1,6 @@ import { createAreaReturnRoute } from "@/features/shell/area-return-route"; -import { isPendingWorkspaceCreation } from "./workspace-creation-return"; +import { readPendingWorkspaceCreation } from "./workspace-creation-return"; /** * The Billing Area's return address: the close button returns to the in-app @@ -11,11 +11,14 @@ import { isPendingWorkspaceCreation } from "./workspace-creation-return"; * * 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). - * Reading through that arrival forgets the record, so the close button — - * which reads once, during hydration — 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. + * 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", @@ -34,24 +37,33 @@ export function clearBillingReturnRoute(): void { billingReturnRoute.clear(); } -/** Whether the page is the Stripe return of a Workspace this tab created. */ -function arrivedFromCreation(): boolean { +/** + * 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 — when Desktop's answer carried one — matches too, so a later plan + * change for the same Workspace never reads as a creation. + */ +function arrivedFromCreationLanding(): boolean { if (typeof window === "undefined") { return false; } const query = new URLSearchParams(window.location.search); + if (query.get("stripeState") !== "success") { + return false; + } + const pending = readPendingWorkspaceCreation(); const workspaceId = query.get("workspaceId"); - return ( - query.has("stripeState") && - workspaceId != null && - isPendingWorkspaceCreation(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 (arrivedFromCreation()) { - billingReturnRoute.clear(); - return "/"; - } - return billingReturnRoute.read(); + return arrivedFromCreationLanding() ? "/" : billingReturnRoute.read(); } 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 index 5238811d..37c55520 100644 --- 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 @@ -466,3 +466,177 @@ test("a payment that settles without a checkout hop closes the dialog as done", } }); }); + +/** 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 index f4798c49..a5a27810 100644 --- a/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx +++ b/apps/ui/src/features/billing/billing-workspace-creation-dialog.tsx @@ -5,7 +5,7 @@ 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 { useId, useMemo, useRef, useState } from "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"; @@ -129,20 +129,30 @@ export function BillingWorkspaceCreationDialog({ const refreshWorkspaces = useWorkspaceRefresh(); const [name, setName] = useState(""); const [nameIssue, setNameIssue] = useState(null); - // Names Desktop already refused this session: the field says so on the - // next attempt without another round-trip. - const [takenNames, setTakenNames] = useState([]); 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, - ...takenNames, - ]); + const issue = workspaceNameIssue(name, existingWorkspaceNames); setNameIssue(issue); if (issue != null) { inputRef.current?.focus(); @@ -160,7 +170,7 @@ export function BillingWorkspaceCreationDialog({ workspace: CreatedWorkspace, payment: Extract ) => { - recordPendingWorkspaceCreation(workspace.id); + recordPendingWorkspaceCreation(workspace.id, payment.payId); services.redirectTop(payment.redirectUrl); }; @@ -208,14 +218,18 @@ export function BillingWorkspaceCreationDialog({ 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 - // the name, offer the payment retry instead of "taken" — the user - // already owns a Workspace they cannot pay for otherwise. + // 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() ); @@ -233,7 +247,6 @@ export function BillingWorkspaceCreationDialog({ }); return; } - setTakenNames((names) => [...names, trimmedName]); setNameIssue("duplicate"); setStage({ kind: "pick" }); return; diff --git a/apps/ui/src/features/billing/workspace-creation-return.ts b/apps/ui/src/features/billing/workspace-creation-return.ts index e121398a..698319e4 100644 --- a/apps/ui/src/features/billing/workspace-creation-return.ts +++ b/apps/ui/src/features/billing/workspace-creation-return.ts @@ -6,43 +6,100 @@ * 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 function recordPendingWorkspaceCreation(workspaceId: string): void { +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, workspaceId); + window.sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ payId, workspaceId } satisfies PendingWorkspaceCreation) + ); } catch { // See above: the conclusion reads as a plan change instead. } } -/** Whether `workspaceId` is the Workspace this tab was creating; the record stays. */ -export function isPendingWorkspaceCreation(workspaceId: string): boolean { - if (typeof window === "undefined") { - return false; +export function readPendingWorkspaceCreation(): PendingWorkspaceCreation | null { + const stored = readStored(); + if (stored == null) { + return null; } try { - return window.sessionStorage.getItem(STORAGE_KEY) === workspaceId; + 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 { - return false; + // 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 either way. */ +/** + * 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 { - const recorded = window.sessionStorage.getItem(STORAGE_KEY); window.sessionStorage.removeItem(STORAGE_KEY); - return recorded != null && recorded === workspaceId; } catch { - return false; + // See above: the conclusion reads as a plan change instead. } + return true; } diff --git a/apps/ui/src/features/session/server/session-handler.test.ts b/apps/ui/src/features/session/server/session-handler.test.ts index 7a7f0a0c..9b9aa113 100644 --- a/apps/ui/src/features/session/server/session-handler.test.ts +++ b/apps/ui/src/features/session/server/session-handler.test.ts @@ -105,7 +105,9 @@ describe("POST /api/session", () => { 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" })); + const response = await handler( + sessionRequest({ body: {}, origin: "null" }) + ); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: "session_forbidden" }); From 0adbab4dfba0742e02dbd7e2f4760e398fdd2e69 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Sun, 20 Sep 2026 11:07:43 +0800 Subject: [PATCH 17/17] fix(billing): fail closed on a pending-creation record without a pay id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation conclusion required the recorded pay id to match only when one was recorded; a record without one (Desktop's checkout answer omitted it, or a legacy plain-string record) matched any success return for that Workspace — the abandoned-creation miswording this record exists to prevent, left open for "no pay id". "Created" now requires both pay ids to be non-empty and equal, in the workflow's return effect and in readBillingReturnRoute; the record is spent either way. The creation-landing test now records with its pay id instead of locking the wildcard, and both levels gained a no-pay-id fail-closed case. --- .../billing/billing-plan.interaction.test.tsx | 59 ++++++++++++++++++- apps/ui/src/features/billing/billing-plan.tsx | 14 +++-- .../billing/billing-return-route.test.ts | 11 ++++ .../features/billing/billing-return-route.ts | 7 ++- 4 files changed, 81 insertions(+), 10 deletions(-) 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 f6b7a2b3..2472e80c 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -325,7 +325,7 @@ test("a Stripe return for the Workspace this tab created concludes as a creation // a Workspace, and came back through Desktop's Stripe callback. window.history.replaceState({}, "", "/project/abc"); recordBillingReturnRoute(); - recordPendingWorkspaceCreation("ns-new00001"); + recordPendingWorkspaceCreation("ns-new00001", "payment-1"); window.history.replaceState( {}, "", @@ -486,6 +486,63 @@ test("a later plan change for an abandoned creation pays under its own id and re }); }); +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"); diff --git a/apps/ui/src/features/billing/billing-plan.tsx b/apps/ui/src/features/billing/billing-plan.tsx index 71fd821c..26f53df5 100644 --- a/apps/ui/src/features/billing/billing-plan.tsx +++ b/apps/ui/src/features/billing/billing-plan.tsx @@ -237,17 +237,19 @@ export function BillingPlanWorkflow({ // 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 (when Desktop's answer carried one) - // is this return's; a plan change pays under its own. 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. + // 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); + pending?.payId != null && + pending.payId === stripeReturn.payId; if (recordedHere) { consumePendingWorkspaceCreation(stripeReturn.workspaceId); } 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 fa3236b5..d9bd60cd 100644 --- a/apps/ui/src/features/billing/billing-return-route.test.ts +++ b/apps/ui/src/features/billing/billing-return-route.test.ts @@ -96,6 +96,17 @@ test("a later plan change for an abandoned creation pays under its own id: it re }); }); +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(); diff --git a/apps/ui/src/features/billing/billing-return-route.ts b/apps/ui/src/features/billing/billing-return-route.ts index 8c34f66e..1386459d 100644 --- a/apps/ui/src/features/billing/billing-return-route.ts +++ b/apps/ui/src/features/billing/billing-return-route.ts @@ -40,8 +40,9 @@ export function clearBillingReturnRoute(): void { /** * 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 — when Desktop's answer carried one — matches too, so a later plan - * change for the same Workspace never reads as a creation. + * 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. */ function arrivedFromCreationLanding(): boolean { if (typeof window === "undefined") { @@ -61,7 +62,7 @@ function arrivedFromCreationLanding(): boolean { return false; } const payId = query.get("payId"); - return pending.payId == null || pending.payId === payId; + return pending.payId != null && pending.payId === payId; } export function readBillingReturnRoute(): string {