Skip to content

Workspace Switcher, Workspace Area, and Workspace Creation on a Brain-owned session - #352

Open
aimeritething wants to merge 17 commits into
mainfrom
aimeritething/workspace
Open

aimeritething wants to merge 17 commits into
mainfrom
aimeritething/workspace

Conversation

@aimeritething

Copy link
Copy Markdown
Member

Brain owns its session and, on top of it, lets users see, switch, manage, and create Workspaces without leaving for Desktop's Team Center. Five steps, one branch, each step a feat commit followed by a fix commit that addresses its code review.

What changes

Brain Session (AIM-444). POST /api/session exchanges the Desktop login cookie server-side (regionTokennamespace/listnamespace/switchauth/info) and answers Brain's own zod-validated session; Desktop's "HTTP 200 + body.code" envelope becomes real statuses. Credentials live only in Jotai atoms; Workspace-management fetchers attach X-Sealos-Region-Token and run the 401 two-step. The self-signed dev credentials are gone; local dev runs the real path against a staging Desktop (DESKTOP_API_BASE_URL, DEV_GLOBAL_TOKEN). ADR-0083 (Proposed) records the model.

App Sidebar brand row and Workspace Switcher (AIM-445). The logo slot is the only collapse/expand control. The Switcher row shows the current Workspace (square avatar, name, plan badge or PAYG) and opens a popover with the current card, "Switch to", New Workspace, and Manage Workspaces. Switching hands the top window to Desktop's ?openapp=system-brain…&workspaceUid= deep link. Billing's canManage and the notification read dispatch read the session's Workspace Role; /project/<uid> gains a guard.

Workspace Area, read-only (AIM-446). /workspace/<uid>: the Workspace list beside the managed Workspace's detail (header, members). Gating is a pure module mirroring Desktop's vaildManage. The Billing Area's title bar becomes the shared AreaShell.

Workspace Area writes (AIM-447). Rename, delete, invite link, member remove / role / alias, transfer: seven routes in the route table, each validating with a shared schema (roles never Owner), calling Desktop through the auth API module, translating envelope codes into Brain's own. Three confirmation tiers in the UI; after a write the list and members are re-read.

Workspace Creation (AIM-448). /billing?mode=create opens the Billing Area's creation mode: name field plus Plan Picker on one screen, paid plans only, never gated by the current Workspace's role or lifecycle. POST /api/billing/workspace-create asks Desktop namespace/create with the raw app token, then starts the first payment through account-service as Brain (operator: created, payApp: system-brain); a taken name is a 409 on the field, a failed second step is an outcome with "Retry payment" / "Later" (…/retry-payment redoes Step 2 alone). The Stripe hop is a whole-page top-window redirect; on return the congratulations read "Workspace created" and the Billing Area's return route is forgotten, since it named the Workspace the user left.

Every new route joins its route table with an on-disk guard test and has dev-mock answers.

Deployment notes

  • New server variable DESKTOP_API_BASE_URL; the chart derives it to the in-cluster Desktop Service when left empty.
  • Desktop's postMessage allowedOrigins must include Brain's domain (chart README).
  • Removed: NEXT_PUBLIC_DEV_ENCODED_KUBECONFIG, NEXT_PUBLIC_DEV_APP_TOKEN, the mint script.

Verification

  • bun typecheck, bun check pass.
  • Focused suites pass per file: session, shell, workspace, billing (routes, fixtures, dialogs, workflow). Running the whole apps/ui suite in one bun test process trips Bun's "test() inside another test()" limitation and a next/navigation mock leak between files; a baseline run at main shows the same failing files and error kinds.
  • Manual: walk Workspace Creation on staging with a Stripe test card (create → Stripe → return in the new Workspace with "Workspace created"). Not done yet; this PR stays a draft until it is.

🤖 Generated with Claude Code

aimeritething and others added 10 commits September 15, 2026 14:36
…kie (AIM-444)

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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…-445)

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/<uid>`, return address recorded). Switching
  hands the top window to Desktop's `?openapp=system-brain?<path>?<query>
  &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/<uid>` 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 <noreply@anthropic.com>
- The switch deep link takes its cloud domain only from the SDK host
  config (spec §C.6): the referrer fallback let the "Switch to" rows run
  under the local Dev Bridge, whose origin is not Desktop. Inside Desktop
  before the host config answers the rows wait with their own notice.
- The `/project/<uid>` guard revalidates the Project list once before
  leaving, so a Project created in another tab is never bounced by the
  stale SWR cache.
- The Workspace routes share the session's error codes for the codes
  they have in common; the plan-name rule is one module used by the
  route handler and the billing Dev Mock; `blur-none` replaces `blur-[0px]`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Workspace Area lands at `/workspace` and `/workspace/<uid>` (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 <noreply@anthropic.com>
…ea (AIM-446)

- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…(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 <noreply@anthropic.com>
`/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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@aimeritething
aimeritething marked this pull request as ready for review September 15, 2026 11:14
@norberia

Copy link
Copy Markdown
Contributor

@cursoragent review this

@cursor

cursor Bot commented Sep 16, 2026

Copy link
Copy Markdown

Posted the review on PR #352: a summary comment plus eight line comments.

The session model is the right one (server cookie exchange, in-memory credentials, Desktop handoff for switch). Before merge I’d close: CSRF on POST /api/session (no Origin check, can trigger namespace/switch), sign-in URL falling back to document.referrer, silent Personal fallback when the SDK misses inside the iframe, the project guard treating a failed refresh as “confirmed missing,” and creation’s payment-failed Later leaving the unpaid Workspace off the session list.

Invite still mints a Desktop code before the origin is known, and Manager→Manager is schema-legal even though the UI forbids it. Keep the PR draft until the Stripe create→return walk lands.

Open in Web Open in Cursor 

@cursor

cursor Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review (AIM-444–448)

The session model is the right one: Brain exchanges the Desktop login cookie on the server, holds credentials only in page memory, switches by handing window.top to Desktop, and treats the Workspace Area as a BFF over Desktop's envelope codes. The feat/fix pairing shows earlier rounds were absorbed. A few correctness holes should be closed before merge.

Should fix

  1. POST /api/session is cookie-authenticated and has no Origin / Content-Type check. The handler JSON-parses any body. A same-site sibling on *.<cloudDomain> can POST { nsid } as text/plain (no preflight) and trigger Desktop namespace/switch — the mutation ADR-0083 refuses to let Brain perform in-place specifically because it reloads every tab. CSRF cannot read the session JSON (no CORS ACAO), but it can switch the user's Workspace. Reject unless Origin is Brain's own origin; require Content-Type: application/json.

  2. "Sign in again" can navigate to the embedder. SessionExpiredOverlay falls back to document.referrer when the SDK host config is empty. There is no frame-ancestors policy. A hostile iframe of Brain that never gets the cookie will show the expired overlay; the button then assigns window.top to {referrer}/signin. Only build a sign-in URL from the SDK cloud.domain; otherwise reload.

  3. Inside the Desktop iframe, a missed SDK nsid silently lands in Personal. establishSession(..., { nsid: shell?.nsid ?? null }) treats timeout/empty the same as local-dev "no shell". Brain then mints a Personal kubeconfig while Desktop's chrome is still on a Team Workspace. If isInsideDesktopIframe() and shell is null, surface the generic session error instead of guessing Personal.

  4. /project/<uid> guard treats a failed revalidation as "confirmed missing". refreshProjects().catch(() => undefined).then(() => setVerifiedMissing(projectId)) always confirms. A 401/5xx/offline refresh then replace(\"/project\") off a Project that may exist — the case the extra round-trip was added to prevent. Only confirm after a successful mutate whose payload still lacks the id.

  5. Workspace Creation's payment-failed "Later" never refreshes the session list. existingWorkspaceNames and the Switcher both read workspacesAtom / SWR with revalidateOnFocus: false. After Step 1 succeeds and Step 2 fails, Later just closes the dialog. The unpaid Workspace is invisible; a second create with a new name goes through. After any successful Step 1, mutate /api/workspace/list and write workspacesAtom the way Workspace Area writes already do.

  6. Invite mints (and replaces) a Desktop code before the origin is known; Manager→Manager is schema-legal. createLink calls onCreateLink first, then workspaceInviteUrl; an empty cloudDomain toasts and still invalidates the previous {inviter, workspace, role} code. Refuse before the POST. Separately, inviteRoleOptions limits Managers to Developer, but workspaceInviteLinkRequestSchema accepts Manager for any caller and Desktop's getInviteCode does not close that hole — the same class of Desktop hole the schema already closes for Owner.

  7. An unobserved Desktop create (timeout / malformed after success) is a 409 "taken", not Retry payment. If Step 1 succeeded on Desktop and Brain's 30s client timed out, the page stays on confirm. Retrying the same name is Desktop 409 → picker "name taken" for a Workspace the user now owns and cannot pay for. Once Desktop has answered, always return { workspace, payment }; on 409 after a create attempt, refresh the list and offer retry when the caller already owns that name as PAYG.

Looks good

  • Envelope 401/409/502/504 mapping, 401 two-step, DEV_GLOBAL_TOKEN server-only and production-gated.
  • Switcher vs CONTEXT (brand slot is the only collapse control; plan lives on the Workspace row; deep link encodes openapp once).
  • Owner never assignable on invite/role; Personal delete/transfer hidden; current-Workspace delete disabled in UI and 403 on Desktop.
  • Stripe confirm/retry stay submitting after the top-window hop; return route is forgotten only for a creation's Stripe return.

Still unchecked

The PR's own Stripe create → Checkout → "Workspace created" walk. I would keep this draft until that lands, and until the session CSRF / wrong-Workspace fallbacks above are closed.

Comment on lines +41 to +44
export const workspaceInviteLinkRequestSchema = z.object({
role: assignableRoleSchema,
uid: workspaceUidSchema,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema comment says it closes holes Desktop leaves open (never Owner). inviteRoleOptions already limits Managers to Developer, but this still accepts role: "Manager" for any caller, the handler does not look up the actor, and Desktop's getInviteCode only rejects Owner.

A Manager can mint a Manager link through the API / mock. Resolve the actor in uid and reject role: "Manager" unless they are Owner — same class of hole already closed for Owner. Add a handler/mock test for Manager + role: "Manager" → 403.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in 36fbfe59. The handler looks up the actor on namespace/list for body.uid and 403s unless Owner; list miss and list failure do not mint. Handler + mock tests cover Manager + role: "Manager" → 403. The schema still accepts Manager on the wire so an Owner can mint it — that is the right split.

Comment on lines +73 to +81
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop upserts invite codes on {inviter, workspaceUid, role} — a new code invalidates the previous one. This still POSTs first; if cloudDomain is empty the URL cannot be built, the toast fires, and the working link is gone.

If workspaceInviteUrl({ cloudDomain, code: "x" }) would be null, refuse before onCreateLink. Also: the role select stays live while pending, so an in-flight Copy can setLink a URL for the role the user already switched away from — disable the select (or ignore stale replies) while the mint is in flight.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-domain POST is closed in 36fbfe59 (refuse before onCreateLink; role select disabled={pending}). Remaining: useDesktopCloudDomain fills an empty SDK domain with the kubeconfig apiserver host, so this check almost never fires and the copied URL is still not Desktop — see the new comment on workspace-area.tsx.

Comment on lines +265 to +275
const created = await desktop.desktop.namespaceCreate(
appTokenFromRequest(request),
body.name
);
if (!created.ok) {
log(
"Desktop workspace creation failed",
desktopFailureLogFields(created)
);
return desktopCreateFailureResponse(created);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Desktop created the Workspace and then this call timed out (30s) or returned a malformed envelope, Brain answers 504/502 with no workspace id. The client stays on confirm ("could not be created"). Same-name retry is Desktop 409 → picker "taken" for a Workspace the user now owns and cannot pay for.

Once Desktop has succeeded, always return { workspace, payment } (wrap Step 2 so a pay throw cannot hide the id). On 409 after a create attempt, refresh the list and offer Retry payment when the caller already owns that name as PAYG.

Related: planName is any non-empty string (workspace-creation-schema.ts). The picker hides Free; a crafted POST does not. account-service's $0 created path can succeed with no redirectUrl, which this handler maps to payment.failed. Reject unpriced/Free on the route; if pay returns success with no URL, treat as settled, not failed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server path closed in 92e9f852: pay throw returns { workspace, payment: failed }; Free/unpriced 400; catalog unread 502; success with no URL is settled. Remaining: 409 recovery matches any Owner name (including Personal), not PAYG — see the new comment on the dialog.

Comment on lines +31 to +69
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 inIframe = isInsideDesktopIframe();
const signinUrl = inIframe
? (desktopSigninUrl(desktopDomain) ??
desktopSigninUrl(referrerOrigin() ?? ""))
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document.referrer is not Desktop. isInsideDesktopIframe() is true for any parent, and there is no frame-ancestors policy. If the SDK host config never answers (typical in a hostile iframe), "Sign in again" assigns window.top to {referrer}/signin.

Only build a sign-in URL from the SDK cloud.domain. If it is missing, reload (or show copy with no navigation).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in 35c62c0b. Sign-in URL is only desktopSigninUrl(desktopDomainAtom) from the SDK host config; without a domain the button reloads. document.referrer is gone.

Comment on lines +51 to +59
if (verifiedMissing !== projectId) {
let cancelled = false;
refreshProjects()
.catch(() => undefined)
.then(() => {
if (!cancelled) {
setVerifiedMissing(projectId);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says: revalidate once, and only leave if the fresh list still lacks the Project. .catch(() => undefined).then(() => setVerifiedMissing(projectId)) confirms on failure too. A 401/5xx/offline mutate then replace("/project") using the stale leave verdict — the case this extra round-trip was added to prevent.

Only set verifiedMissing when refresh succeeds and the resolved list still lacks the id. On failure, leave the guard in place and do not toast. The unit tests never reject refreshProjects, so they lock the happy path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failure path closed in 36fbfe59 (reject → no bounce, no toast; test now actually rejects). Remaining: any defined mutate() result still confirms, without inspecting fresh.projects — see the new comment on this file.

Comment on lines +54 to +56
const result = await establishSession(store, {
nsid: shell?.nsid ?? null,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null nsid means Personal, with no not_member toast. Inside the Desktop iframe a timed-out / empty getSession() is not "no shell" — Desktop's chrome can still be on a Team Workspace while Brain mints a Personal kubeconfig and the user creates resources in the wrong place.

If isInsideDesktopIframe() and shell is null, treat it as a session error (generic overlay / retry), not as Personal. Local-dev (no iframe) can keep the current fallback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in 35c62c0b. Inside the iframe a missed SDK handshake sets desktop_unavailable and does not POST /api/session. Local-dev (no iframe) still lands in Personal. Locked by session-bootstrap.shell-miss.test.tsx.

Comment on lines +241 to +254
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" });
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After Step 1 succeeded and Step 2 failed, Later just closes. existingWorkspaceNames and the Switcher both read workspacesAtom / SWR with revalidateOnFocus: false. The unpaid Workspace is invisible; a second create with a new name goes through. Spec's "PAYG, subscribe like any other" assumes it is findable.

After any successful Step 1 (including payment-failed), mutate /api/workspace/list and write workspacesAtom, same as Workspace Area writes (useWorkspaceRefresh).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed on the 200-create path in 92e9f852. After started / settled / failed the dialog calls useWorkspaceRefresh (mutate /api/workspace/list + workspacesAtom), same as Workspace Area writes. Remaining: the dialog does not reset on close, so Later still leaves the payment-failed stage mounted — see the new comment on this file.

Comment on lines +63 to +94
/** 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<Response> {
const env = dependencies.env ?? process.env;
const log: SessionLog =
dependencies.log ??
((message, fields) => console.warn(`[session] ${message}`, fields));

return async function handler(request: Request): Promise<Response> {
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookie-authenticated POST with no Origin check, and any body is JSON-parsed (so Content-Type: text/plain still works). A same-site sibling on *.<cloudDomain> can CSRF { "nsid": "ns-…" } and trigger Desktop namespace/switch — the mutation ADR-0083 refuses to let Brain do in-place because it reloads every tab.

CSRF cannot read the session JSON (no CORS ACAO). It can still switch the user's Workspace.

Reject unless Origin is Brain's own origin, and require Content-Type: application/json so a simple request cannot carry nsid.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sibling text/plain+nsid switch is closed in 35c62c0b (foreign Origin 403, non-JSON body 400). Remaining: Origin: null and scheme-ignoring Host match are still treated as Brain — see the new comment on originAllowed.

- 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.
… bounce

- 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/<uid> 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.
…h holes

- 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.
@norberia

Copy link
Copy Markdown
Contributor

@cursoragent review again

@cursor

cursor Bot commented Sep 18, 2026

Copy link
Copy Markdown

Posted the follow-up on PR #352: a summary plus six line comments, and replies on the eight previous threads.

The three fix commits closed the last round’s attack stories: sibling text/plain CSRF, referrer sign-in, iframe→Personal, Manager invite, mint-before-origin, pay-throw hiding the id, Free/catalog refusal, and the 200-create list refresh.

Still open: the creation dialog does not reset on close (Later / settled leave it stuck); the project guard still does not inspect the resolved list; Origin: null is still allowed; invite URLs fall back to the kubeconfig apiserver host; the Stripe pending record is spent only on success; 409 recovery treats any owned name as Retry payment. I would not merge until the Stripe create → Checkout → “Workspace created” walk lands — that box is still unchecked and the PR is no longer a draft.

Open in Web Open in Cursor 

@cursor

cursor Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review again (after the three fix commits)

The session model is still the right one, and the last round's attack stories are mostly gone: the sibling text/plain+nsid switch, the referrer sign-in, the iframe→Personal guess, Manager invite, mint-before-origin, pay-throw hiding the Workspace id, Free/catalog refusal, and the 200-create list refresh.

A few of the required checks are still half-done, and the creation dialog now has a close/reopen leak the last round did not catch.

Closed

  1. CSRF sibling switch. Foreign Origin → 403 before Desktop; a non-JSON body is not parsed. The original text/plain { nsid } gadget is dead.
  2. Sign-in URL. Only sealosApp.getHostConfig().cloud.domain; otherwise Reload. document.referrer is gone.
  3. Iframe + missed SDK nsid. desktop_unavailable, no POST /api/session. Local-dev (no iframe) still lands in Personal.
  4. Payment-failed list. A 200 create (started / settled / failed) mutates /api/workspace/list into workspacesAtom the way Workspace Area writes do.
    6a. Invite mint. The dialog refuses before onCreateLink when the URL cannot be built; the role select is disabled while pending.
    6b. Manager invite. Handler looks up the actor on namespace/list and 403s unless Owner; the mock does the same.
  5. Unobserved pay / Free. Pay throw after Desktop create returns { workspace, payment: failed }. Unpriced/Free is 400; catalog unread is 502. Success with no checkout URL is settled.

Still open

  1. Creation dialog state survives close. BillingPlanWorkflow keeps the dialog mounted. Later leaves stage: "payment-failed"; settled sets handedOff and skips setSubmitting(false). Reopening New Workspace shows the old overlay, or Create & Pay stuck on "Creating…". Reset stage / submitting when open becomes false; only keep submitting across a real top-window hop.
  2. Guard still does not inspect the resolved list. Failed refresh no longer confirms (good). Any defined mutate() result still sets revalidatedFor, then the next effect trusts hook decision. Last review: confirm missing only when the payload still lacks the id. refreshProjects is SWR mutate and returns { projects: [...] }, not explorer rows — the tests never exercise that shape.
  3. Origin: null is still treated as Brain. Empty Origin is documented for the smoke script. Origin: null is a browser (sandbox / some redirects), not a missing header. Reject it. Matching Origin host to Host also ignores scheme (http:// vs HTTPS).
  4. Invite origin still falls back to the kubeconfig apiserver host. The dialog's empty-domain check never fires when useDesktopCloudDomain fills in routingDomainFromKubeconfig. Copied links become https://<apiserver>/WorkspaceInvite/… and the mint still invalidates the previous code. Same gate as the Switcher: no SDK/dev Desktop origin → don't mint.
  5. Creation pending record is keyed only on workspaceId and spent only on stripeState=success. Cancel / abandon leaves it. The next Plan-change return for that Workspace is worded and routed as a creation. Consume on any Stripe return for that id (or record payId). readBillingReturnRoute also mutates storage on any stripeState, including cancel.
  6. 409 recovery treats any owned name as Retry payment, including Personal. Require !isPersonal (and PAYG if the list can say so). Do not push the name into takenNames until ownership is ruled out.

Still unchecked

The PR's own Stripe create → Checkout → "Workspace created" walk. The description still has that box unchecked, and the PR is no longer a draft. I would not merge until that walk lands.

Comment on lines +194 to +246
if (payment.status === "settled") {
// Paid without a checkout hop (the terms rule the path out; read
// it as done): close rather than offer a payment that exists.
handedOff = true;
onOpenChange(false);
return;
}
setStage({
error: payment.error,
kind: "payment-failed",
plan,
workspace,
});
} catch (cause) {
if (cause instanceof WorkspaceNameConflictError) {
// A 409 on a name this dialog just submitted can be Desktop having
// created the Workspace while its answer never landed (a timeout,
// a malformed envelope). Re-read the list: if the actor now owns
// 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" });
return;
}
setError(errorDescription(cause, "The Workspace could not be created."));
} finally {
if (!handedOff) {
setSubmitting(false);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BillingPlanWorkflow always mounts this dialog (open only hides it). settled sets handedOff and returns without setSubmitting(false); Later leaves stage.kind === "payment-failed". Reopening New Workspace on the same Billing Plan tree then either sticks Create & Pay on "Creating…" (Cancel is a no-op while submitting) or immediately restacks Workspace created / Retry payment.

handedOff is the right freeze for a real window.top hop. It is the wrong freeze for close. Reset submitting / stage / name when open becomes false. Tests never reopen after Later or settled.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in 19988f31. When open turns false the dialog resets stage, name, and submitting. Reopen tests cover Later (no stacked offer) and settled (not stuck on "Creating…"). handedOff still freezes settled until the parent flips open; the effect then clears it — the user-visible leak is gone.

Comment on lines +207 to +235
} 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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required 409 recovery was "the caller already owns that name as PAYG." This matches any role === "Owner", including Personal and an already-subscribed Team of the same name.

A Personal collision would offer Retry payment (operator: "created") on the Personal Workspace. A 409 whose list lag misses the new row still pushes the name into takenNames, so this session cannot pay for a Workspace the user now owns.

Require !isPersonal (and plan null if you can see it). Do not add to takenNames until ownership is ruled out — retry the list once, same as a 504 after an unobserved Desktop create.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed for Personal in 19988f31: match is !isPersonal && role === "Owner". Personal-name collision stays a field verdict; nothing is pushed into takenNames. Still no PAYG/subscription check on Retry — a subscribed Team of the same name would still get operator: "created". Follow-up, not blocking.

Comment on lines +50 to +59
function useDesktopCloudDomain(): string {
const desktopDomain = useAtomValue(desktopDomainAtom).trim();
const kubeconfig = useAtomValue(kubeconfigAtom);
return useMemo(
() =>
desktopDomain === ""
? routingDomainFromKubeconfig(kubeconfig)
: desktopDomain,
[desktopDomain, kubeconfig]
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invite dialog now refuses before POST when workspaceInviteUrl would be null — but this hook almost never hands it an empty domain. Empty SDK cloud.domain becomes routingDomainFromKubeconfig: the apiserver hostname (apiserver.test in fixtures; often kubernetes.default.svc in cluster), truncated to a 63-char label.

The dialog then mints (and invalidates the previous {inviter, workspace, role} code) and copies https://<apiserver>/WorkspaceInvite/?code=…. The Switcher already waits for a real Desktop origin (desktop-pending). Invite should too: no SDK/dev Desktop domain → don't mint. Do not treat the kubeconfig server URL as Desktop.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in d5f2dbc4. useDesktopCloudDomain is desktopDomainAtom only. The kubeconfig apiserver host no longer yields a link; the area test asserts no mint.

Comment on lines +60 to +66
refreshProjects()
.then((fresh) => {
if (!cancelled && fresh !== undefined) {
setRevalidatedFor(projectId);
}
})
.catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failure path is closed (reject → no revalidatedFor, no toast). The required rule is not: any defined mutate() result confirms, and the bounce still keys off hook decision (states.projects).

Production refreshProjects is SWR mutate and resolves { projects: BrainProject[] }. A successful revalidation whose payload contains the id can still replace("/project") if that snapshot has not painted yet — the case the extra round-trip was added to prevent.

Parse fresh.projects[].id; set the flag only if that list is present and still lacks projectId. Unparseable / undefined = failure: do not bounce, do not toast. The tests still resolve explorer rows and update hook state before resolve, so they lock the happy path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in d5f2dbc4. The guard parses the raw /api/projects payload and confirms only when that list still lacks the id. A payload that carries the id holds even before the rendered snapshot paints; reject confirms nothing. Tests now resolve { projects: [...] } and cover the before-paint case.

Comment on lines +92 to +107
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sibling text/plain+nsid switch is closed when the browser sends a foreign Origin. This still allowlists two values that are not Brain:

  • Origin: null — a browser (sandboxed iframe, some 307s), not the smoke script. Reject it.
  • parsed.host === host — scheme is ignored, so http://brain.example matches an HTTPS app.

Empty Origin for a non-browser client is a documented tradeoff; null is not. Happy-path tests omit Origin and lock the empty-Origin allow; there is no Origin: null case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed in 24ba9ec6. Origin: null fails the URL parse and is refused (tested). Host match requires HTTPS; HTTP is allowed only outside production. Empty Origin remains the documented smoke-script hole.

Comment on lines 219 to +227
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);
if (created) {
clearBillingReturnRoute();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consumePendingWorkspaceCreation is keyed only on workspaceId and runs only when stripeState === "success" (apps/ui/src/app/billing/page.tsx). Cancel / abandon leaves sessionStorage["billing-workspace-creation"].

CONTEXT's model is: the Workspace exists before payment, so an abandoned Checkout is PAYG and "subscribes like any other." The next Plan-change return for that same Workspace then reads as a creation ("Workspace created" + forgotten Billing entry point).

Record { workspaceId, payId } (or consume on any Stripe return for that Workspace, including cancel). readBillingReturnRoute currently treats any stripeState + matching id as a creation landing and mutates storage inside getSnapshot — do not clear on cancel, and do not mutate from a snapshot read.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly closed in 19988f31: the record carries payId; any success or cancel for that Workspace spends it; cancel keeps the entry point; "created" requires a pay-id match when one was recorded; readBillingReturnRoute is a pure read.

Residual: pending.payId == null is still a wildcard match (this file and readBillingReturnRoute). Fail closed — "created" only when both pay ids are non-empty and equal. See the new comment.

…n route

`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://<host> cannot stand in for the HTTPS app.
… links on Desktop only

- The /project/<uid> 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.
…ecord on any Stripe return; scope 409 recovery to Team Workspaces

- 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.
@zjy365

zjy365 commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

The six open findings from the follow-up review are addressed in three commits (24ba9ec6, d5f2dbc4, 19988f31):

  1. Dialog close/reopen leak — the creation dialog resets stage, name, and submitting the moment open turns false; handedOff's freeze remains only for a real top-window Stripe hop. Reopen tests cover both the Later and settled paths.
  2. Guard payload — the /project/<uid> guard now parses the revalidation's raw /api/projects payload and confirms only when it came back as a list that still lacks the id; a payload carrying the id holds the guard even before the rendered snapshot paints, and an unusable shape confirms nothing.
  3. Origin: null / schemenull now fails the origin parse and is refused (an absent header still passes for non-browser clients), and the Host match requires HTTPS (HTTP only outside production).
  4. Invite origin — the Workspace Area builds invite links on the SDK host config's domain only; the kubeconfig apiserver fallback is gone, with a regression test that a kubeconfig server URL never yields a link.
  5. Pending record — it now carries the checkout's payId, is spent by any Stripe return for the recorded Workspace (a new stripeState=cancel leg consumes it while keeping the entry point and drawing no conclusion), and a success return concludes "created" only when the pay ids match. readBillingReturnRoute is a pure read; the workflow's return effect does the consuming and voiding.
  6. 409 recovery — matches only a Team Workspace the actor owns; a Personal-name collision stays a field verdict, and nothing is pushed into a session-local taken list, so a lagging list never locks out the ownership recovery.

All focused suites pass per file (14 files, 173 tests), plus bun typecheck and bun check. One assumption worth confirming on staging: that Desktop's cancel callback carries workspaceId (the payId key already prevents the miswording even if it does not).

@cursoragent review again

@norberia

Copy link
Copy Markdown
Contributor

@cursoragent review again

@cursor

cursor Bot commented Sep 18, 2026

Copy link
Copy Markdown

Posted the follow-up on PR #352. The six findings from last time are closed in 24ba9ec6, d5f2dbc4, and 19988f31 — I checked the current files and the new tests.

One residual, not blocking: pending.payId == null is still a wildcard, so a later Plan-change success can still read as “Workspace created” if the original checkout had no payID. Fail closed — match only when both pay ids are non-empty and equal.

I would not merge until the Stripe create → Checkout → “Workspace created” walk lands; that box is still unchecked.

Open in Web Open in Cursor 

@cursor

cursor Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review again (HEAD 19988f31)

The six findings from the last follow-up are closed in 24ba9ec6, d5f2dbc4, and 19988f31. I read the current files and the new tests; the commit messages match the code.

Closed

  1. Dialog close/reopen. Reset-on-open === false clears stage, name, and submitting. Reopen tests cover Later and settled. handedOff still freezes settled until the parent flips open; the effect then clears it.
  2. Guard payload. freshProjectIdsOf judges { projects: [{ id }] }. Confirms leave only when that list still lacks the id; a payload that carries the id holds even before paint; reject / unusable shape confirm nothing.
  3. Origin: null / scheme. new URL("null") throws and is refused. Host match runs only after HTTPS (HTTP only outside production). Empty Origin remains the documented smoke-script hole.
  4. Invite origin. desktopDomainAtom only. Kubeconfig apiserver regression test asserts no mint.
  5. Pending record. { workspaceId, payId }. Success or cancel for that Workspace spends it. Cancel keeps the entry point and draws no conclusion. "created" requires a pay-id match when one was recorded. readBillingReturnRoute is a pure read.
  6. 409 recovery. Team Owner only (!isPersonal); Personal stays a field verdict. No session-local takenNames lockout.

Residual (cheap, not blocking)

pending.payId == null is still treated as a match (billing-plan.tsx and readBillingReturnRoute). If account-service starts Checkout without a payID, or a legacy string record is in storage, a later Plan-change success for that Workspace still reads as "Workspace created" and voids the Billing entry point. Fail closed: "created" only when both pay ids are non-empty and equal; spend the record either way. The creation-landing test currently records without a pay id, so it would not catch this.

Cancel without workspaceId still does not spend (the assumption on staging). Harmless when a pay id was recorded; same hole as above when it was not.

Still unchecked

Stripe create → Checkout → "Workspace created" on staging, including whether Desktop's cancel callback carries workspaceId. The PR description still has that box unchecked. I would not merge until that walk lands.

Comment on lines +245 to +250
const pending = readPendingWorkspaceCreation();
const recordedHere =
pending != null && pending.workspaceId === stripeReturn.workspaceId;
const created =
recordedHere &&
(pending?.payId == null || pending.payId === stripeReturn.payId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending.payId == null is still treated as a match. If the recorded checkout had no payID (account-service omitted it, "" stored then read as null, or a legacy string record), a later Plan-change success for that Workspace still concludes "Workspace created" and clearBillingReturnRoute().

That is the abandoned-creation miswording this change was meant to kill, with a hole left for "Desktop didn't carry a pay id." Fail closed: "created" only when pending.payId is a non-empty string and equals the return payId. Spend the record either way.

The creation-landing test records without a pay id (recordPendingWorkspaceCreation("ns-new00001")), so it currently locks the wildcard. Record "payment-1" there.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants