Skip to content

feat(workspace): workspace isolation + multi-workspace (switch + manage)#202

Merged
Fullstop000 merged 19 commits into
mainfrom
feat/workspace-isolation
May 22, 2026
Merged

feat(workspace): workspace isolation + multi-workspace (switch + manage)#202
Fullstop000 merged 19 commits into
mainfrom
feat/workspace-isolation

Conversation

@Fullstop000
Copy link
Copy Markdown
Owner

Implements per-account workspace isolation + multi-workspace (switch + manage). Spec/plan: docs/superpowers/{specs,plans}/2026-05-20-workspace-isolation* (local). 13 commits.

What this does

  • A workspace is now a real, membership-gated boundary: each account's channels/agents/teams/decisions/tasks are private to its members.
  • An account owns N workspaces, switchable via the URL (/w/{slug}), with a quick-switch dropdown and a Settings management page.

Backend

  • Routing: workspace collection routes moved under /api/w/{workspace_id}/… behind a new require_workspace middleware (membership check; 404 for unknown workspace AND non-member — enumeration guard, never 403). The server's global active_workspace_id cache was deleted (compile-gate forced converting all read sites).
  • By-id item routes (/api/{resource}/{id}) stay put but each handler now does a per-handler membership check (require_resource_in_workspace → 404; conversation routes use require_channel_membership). Attachments scoped via their linking message's channel membership.
  • Provisioning: a fresh account gets a personal default workspace "{name}'s Space" at OAuth sign-in; the local operator gets one at chorus start.
  • Workspaces CRUD (account-scoped): list (actor-scoped), create, rename, delete (refuses deleting the last workspace). The global /api/workspaces/switch + /current endpoints were removed (switching is navigation).
  • CLI: chorus workspace is now a direct-store command (--data-dir); chorus channel/agent resolve the target workspace via GET /api/workspaces.

Security

A cross-account IDOR was found and closed during review: the initial route move left ~24 by-id routes (and the message-send route) reachable cross-account. Both were caught by independent review passes, fixed with per-handler membership checks, and covered by IDOR tests (assert 404 + no over-blocking, including a proven message-send case).

Frontend

  • Current workspace lives in uiStore (currentWorkspaceId/currentWorkspaceSlug), set from the /w/{slug} route; a single wsPath() helper in client.ts builds /api/w/{id}/… for the ~8 collection calls (by-id/account calls unchanged).
  • Quick-switch dropdown in the sidebar workspace title; Settings ▸ Workspaces management page (create/rename/delete, delete-last disabled).

Tests / gates

  • cargo fmt --check + cargo clippy --all-targets -- -D warnings clean
  • cargo test green (all suites 0 failed; 12 pre-existing ignored live tests) — incl. middleware authz, cross-account isolation, by-id IDOR (channels/agents/agent-file/decisions/attachments/message-send), CRUD (list-scoping, delete-last)
  • cd ui && npx tsc --noEmit clean · npm run test 124 pass · npm run build OK

NOT done / follow-ups

  • Browser QA not run (no live server in the build env) — recommend /gstack-qa + /ultrareview before merge.
  • Deferred (per spec): invites/roles (hard-gated behind per-workspace OS-level agent isolation), realtime socket workspace-scoping (correctness; same-user, fix(realtime): authenticate /api/events/ws; derive viewer from session (#200) #201 fixed auth), limits/billing.
  • Minor: freshly-uploaded-but-unlinked attachments 404 to everyone (intentional; UI links before fetch); chorus workspace flag changed --server-url--data-dir (docs/CLI.md follow-up); a few unused WorkspaceInfo TS fields could be trimmed.

🤖 Generated with Claude Code

Fullstop000 and others added 16 commits May 20, 2026 23:40
…elpers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ion guard)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…dlers + workspaces CRUD + test migration

Move workspace-scoped collection routes (channels, agents, teams,
decisions list, ui server-info, launch-trio) under /api/w/{workspace_id}
behind require_workspace, reading WorkspaceContext instead of the deleted
global active_workspace_id accessor. /internal agent routes derive their
workspace from the sender record (sender_workspace_id) since they carry no
WorkspaceContext. Add account-scoped workspaces CRUD (list/create/rename/
delete with last-workspace guard, 404-on-non-member). Keep CLI-backing
current/switch/rename routes (local_workspace_state) and migrate the
chorus channel/agent CLI to resolve the active workspace and target the
path-carried routes. Migrate the test harness (alice owns/joins one
workspace; ws_uri helper) and ~40 call sites across the suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add require_resource_in_workspace membership gate (404, never 403) and
apply it to every by-id route still on require_auth-only: channels,
agents, agent workspace file reads, teams, decision resolve, and agent
runs. Public task board routes use the sibling channel_member_exists
check. Attachments are scoped through the linking message's channel
membership via a new attachment_visible_to_member store helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove /api/workspaces/current and /api/workspaces/switch (and the
rename-current handler) — a server-global active workspace is
incompatible with multi-tenant isolation. The channel/agent CLI now
resolves its target workspace from the account-scoped GET /api/workspaces
list (active marker, else single, else first). `chorus workspace`
becomes a direct-store command group (--data-dir) that reads/writes the
per-machine local_workspace_state, matching `chorus setup`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
As account A (alice), assert 404 on B's channel PATCH, agent GET/activity/
runs/workspace/file, decision resolve, and attachment fetch — and assert
the mutations did not land. Add a mirror test proving the gate does not
over-block A's own resources. Join the e2e HTTP actor to #general so the
public task-board routes (now channel-membership-gated) keep passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ount write IDOR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ce CRUD isolation tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation for multi-workspace frontend (T8):
- uiStore: currentWorkspaceId/currentWorkspaceSlug + setCurrentWorkspace,
  cleared on identity reset.
- client.ts: wsPath() prefixes /api/w/{id} for scoped collection calls (T9
  will adopt it); throws if no workspace selected.
- routes.ts: all path builders carry the /w/{slug} prefix from the store;
  resolveWorkspaceBySlug() resolves the URL slug to a workspace (matched +
  first-as-fallback).
- App.tsx: AuthedApp syncs whoami + loads workspaces, then routes
  /w/:slug/* -> WorkspaceShell (resolves slug, sets current workspace,
  redirects unknown slugs to first) with scoped data bootstrap moved inside
  so nothing scoped fetches before the workspace id is set; any other authed
  path redirects to the first workspace. Public/sign-in gating unchanged.
- useRouteSubject/TabBar useMatch patterns prefixed with /w/:slug.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…/current client code

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rent client code

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…count QA)

dev-login minted a session but never provisioned a workspace (only OAuth +
startup-seed did), so a second dev account landed workspaceless and
require_workspace 404'd it. Provision on dev-login like the OAuth path, so
`CHORUS_DEV_AUTH_USERS=a,b` yields two usable, isolated accounts for QA
automation. Test asserts two dev logins get distinct workspaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n spec

Per-worker server now runs with the dev-auth bypass (CHORUS_DEV_AUTH); a new
`accounts` fixture mints two dev-logged-in contexts (alice, bob) via
/api/auth/dev-login, each in its own provisioned workspace — no OAuth/login
dance. WRK-002 uses it to assert distinct workspaces, non-member redirect, and
actor-scoped GET /api/workspaces. helpers/accounts.ts holds openDevAccount +
workspaceSlugFromUrl. Validated via `playwright test --list` (transpile +
collection); a live run needs the UI bundle rebuilt + chromium.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The route move to path-carried workspace routes broke the e2e helper layer
(helpers/api.ts) and two specs that still hit /api/{channels,agents,teams}.
Add workspaceApiBase(request) (resolves the actor's workspace, caches per
worker) and route the collection calls through it; CHN-002 inline posts use it
too; NAV-002 counts the new /api/w/{id}/… paths. Smoke: 8/9 green
(ENV/AUTH/NAV-001/CHN-001-003/AGT-001/WRK-002); NAV-002 flags an intermittent
double agents-fetch on bootstrap (tracked separately).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fullstop000 and others added 3 commits May 21, 2026 19:10
)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…gs mgmt

WRK-003 drives the sidebar workspace-title switcher (list/create/switch);
WRK-004 drives Settings ▸ Workspaces (create/rename/delete + last-workspace
guard). Both run green against a live build (chromium). Cataloged in agents.md.
Automates the manual UI click-through for the T10/T11 workspace UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The workspace-isolation bootstrap (`ensure_builtin_channels`, run by
`build_router_with_lifecycle`) migrates a legacy channel literally named
"general" into the system "#all" channel. Both live-runtime test
harnesses seeded a channel named "general", so the bootstrap renamed it
out from under them — every later "general" lookup hit
`QueryReturnedNoRows`. These tests are `#[ignore]` so CI never caught it.

Seed a neutral "lobby" channel the migration leaves alone.

Verified real LLM round-trips through the shared bridge on the new code:
  - live_runtime_tests: kimi (7.2s), codex (22.3s) post to #lobby → store
  - live_multi_session_tests: kimi bootstrap (3.8s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Fullstop000 Fullstop000 merged commit 13e27e4 into main May 22, 2026
3 checks passed
@Fullstop000 Fullstop000 deleted the feat/workspace-isolation branch May 22, 2026 03:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant