Skip to content

feat: request-scoped Workspaces — each person works in their own - #356

Open
Yuge Zhang (ultmaster) wants to merge 9 commits into
microsoft:mainfrom
ultmaster:feat/228-request-scoped-workspaces
Open

Yuge Zhang (ultmaster) wants to merge 9 commits into
microsoft:mainfrom
ultmaster:feat/228-request-scoped-workspaces

Conversation

@ultmaster

@ultmaster Yuge Zhang (ultmaster) commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

First of two stacked PRs toward #228, following #237. The plan for both is in docs/proposals/multi-user-service.md.

After #237, several people could sign in, but they all shared the Server's single active Workspace, and only the administrator could switch it. With this PR each person works in their own Workspaces, at the same time, on one Server. A new member gets a personal Workspace on first sign-in, can create more and switch between them, and switching moves nobody else.

Nothing changes unless Bubble is enabled. In local mode no request carries a Workspace scope, so every getter answers from the process-active Workspace exactly as on main, with the same routes and the same UI.

Members still cannot run agents, as in #237. The stacked #357 lets them run the Built-In agent with their own model keys and credentials, and adds a People admin panel and session renewal.

How to use it

Configuration is #237's. In Bubble mode:

  • A member's first request, which is GET /api/identity right after sign-in, creates "'s Workspace", owned by them.
  • The header shows "Workspace: ". Clicking it opens the setup page, which lists the caller's Workspaces and creates new ones by name.
  • Sharing a Workspace is a grant by its owner, for example:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"target":{"type":"huabu.workspace","id":"<workspaceId>"},"principalId":"dave","role":"huabu.workspace.viewer"}' \
  https://huabu.example.com/bubble/v1/grants

Roles and capabilities (replacing #237's)

The same three capabilities apply on a Workspace and on a Space. They flow down Workspace → root Space → child Space, never up or sideways.

Capability Allows
huabu.read Seeing and opening it, every GET, search, live streams
huabu.write Editing Spaces and creating them
huabu.manage Renaming and deleting it, and granting roles on it
huabu.admin (system) Deployment, settings, credentials, agent configuration and folder Workspaces. Replaces #237's system huabu.manage.
Role On Gives
owner / member (Bubble core, system) system as in #237, plus huabu.admin for owner
huabu.workspace.owner Workspace read, write, manage
huabu.workspace.editor Workspace read, write
huabu.workspace.viewer Workspace read
huabu.space.owner Space read, write, manage on it and the Spaces inside it
  • Space grant targets. A Space grant names <workspaceId>:<canvasId>, so it can never reach a Space with the same id in another Workspace, and a single Space can be shared later. At startup, feat: per-user Workspace and Space access through Bubble #237's grants are rewritten to the Workspace that holds each Space; grants on Spaces that no longer exist are dropped.
  • Who shares. Whoever has huabu.manage on a Workspace or Space may grant roles on it. System roles stay with the administrator.
  • Admission. It still needs huabu.use.
  • Space ownership. Creating a Space still claims huabu.space.owner on it.
  • Rename and delete. Renaming or deleting a Space now needs manage, so an editor can change Spaces but not remove them.

Design

  • The Workspace a call runs in.
    • A per-request AsyncLocalStorage scope (modules/workspace/context.ts) is opened by a callback onRequest hook and filled by admission once the caller is known.
    • The Workspace getters (getWorkspaceHandle, getWorkspacePath, getWorkspaceKey) read it first, so the ~145 space(canvasId) call sites are unchanged.
    • Work a request starts keeps its scope through promise chains. I checked the queues that outlive a request (memory passes, World-preview reconciliation, preprocessing): each runs an item in its enqueuer's scope.
  • Storage.
    • The Postgres and SQLite stores read the Workspace per call instead of from one mutable connection field, so concurrent Workspaces share the pool without sharing a namespace.
    • The database-wide write lock stays, because it also covers name allocation and the Workspace registry.
    • Operation leases count per Workspace.
    • The Disk directory index and the SQL World id are kept per Workspace.
  • Current Workspace.
    • A per-principal row, huabu_identity.current_workspaces, lives in the Postgres Bubble already requires.
    • Admission uses it while the caller may still enter it. Otherwise it uses the first Workspace they may enter, or creates a personal Workspace, once per principal even when first requests race.
    • A system owner lands where the Server already is.
    • modules/identity/workspace-selection.ts, behind ResourceDirectory.
  • Workspace routes for people.
    • In Bubble mode /api/workspaces lists what the caller may enter. Create by name works on any backend; on Disk it makes a folder under the data directory. Activate sets only the caller's current Workspace. Rename and unregister need huabu.manage.
    • Folder picking and paths stay administrator-only and are never sent to members.
    • GET /api/workspace reports capabilities.namedWorkspaces.
  • Agents keep their Workspace.
    • The reachback URL is now /api/rfs/w/<workspaceId>/<canvasId>. RFS is also mounted under that prefix (modules/workspace/scoped-routes.ts) and runs in that Workspace, so a run neither follows its starter's later switches nor falls back to the process-active Workspace.
    • Old URLs keep working.
  • Web.
    • Boot keeps the Workspace the Server chose instead of activating the first registered one.
    • The setup page has a named mode: the caller's Workspaces, plus "New Workspace".

Proof

Automated. These suites run Bubble admission, the Space guard and the real routes over real storage, with two members and an administrator working at once:

  • modules/identity/multi-user.test.ts, on Disk
  • modules/identity/multi-user.remote.test.ts, on Postgres

They cover:

  • three distinct Workspaces
  • interleaved creates landing in each caller's own Workspace, with no cross-listing
  • another member's Space answering 404
  • a member creating, listing and switching Workspaces while the other member stays put
  • 404 on another member's Workspace for activate, rename and delete
  • folder operations refused to members
  • a viewer who can read but not delete or rename

bubble.test.ts covers each role's capabilities, Workspace-scoped Space grants, who may share, a personal Workspace created once under five racing first requests, switching only into enterable Workspaces, and the #237 grant migration. scoped-routes.test.ts covers the Workspace-scoped RFS mount and reachback URL.

Live, production-like. Dev Server in Bubble mode, Postgres content and Bubble records in a throwaway Postgres 18, with two bot members holding Bubble local tokens. Browser rows use a session cookie made from Carol's token.

Check Result
GET /api/workspace for Carol, Dave, admin "Carol's Workspace", "Dave's Workspace", "Workspace": three ids, members with namedWorkspaces: true and path: null
Browser, Carol, / Space list of her empty Workspace, header "Workspace: Carol's Workspace"
Browser, Carol, /setup "Your Workspaces" lists only hers, marked Current; "New Workspace" field
Browser, Carol creates "Side project" lands on its empty Space list, header "Workspace: Side project"
Afterwards over the API Carol → Side project, Dave → Dave's Workspace, admin → Workspace; Dave lists only his own

Limitations

  • Members cannot run agents. This is still the case from feat: per-user Workspace and Space access through Bubble #237 and is what feat: members use the Built-In agent with their own keys; People admin; session renewal #357 fixes.
  • Settings and credentials stay global to the deployment. LLM keys, connected credentials and service packages are not per Workspace or per person. feat: members use the Built-In agent with their own keys; People admin; session renewal #357 makes them per person.
  • Memory and Skills are per Workspace. Everyone who can enter a Workspace shares them.
  • One person's tabs share one current Workspace. There is no Workspace in the URL. Making every request name its Workspace is a later step.
  • A copied Disk folder can repeat Space ids. If an administrator adopts a copy of a Disk folder as a second Workspace, its Space ids repeat. In-memory maps keyed by Space id (canvas mutex, SSE listeners) would then mix the two. This is not guarded; members cannot do it.
  • Unregistering doesn't delete content. It forgets the Workspace, as before; its data stays.
  • Azure blob prefix unchanged. Azure blobs are still prefixed by the Disk folder path. That predates this PR.
  • Local-mode screenshots were not re-captured. The local branches are covered by the unchanged existing tests, and every new web path is gated on namedWorkspaces.

Validation (on 0116f51f)

  • pnpm typecheck: pass
  • pnpm format: applied
  • pnpm lint:fix: 0 errors; the 359 warnings were already there
  • Server vitest run: 243 files, 3050 passed, 28 skipped
  • pnpm test:storage (Postgres and Azurite containers): 17 files, 509 passed
  • Postgres multi-user.remote.test.ts: 3 passed
  • Web vitest run: 357 files, 4756 passed

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request or background job can now carry the Workspace it runs in
through AsyncLocalStorage. The Workspace getters, the SQL stores, the
Disk folder index and the SQL World id read that scope before the
process-active Workspace, and operation leases count per Workspace.
Local mode never opens a scope, so it behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bubble mode selects each admitted request's Workspace from the
caller's stored current Workspace, creating a personal one for a new
member. Roles become Workspace owner/editor/viewer plus Space owner,
with read/write/manage flowing down Workspace -> Space -> child Space
and huabu.admin on the system. Space grants name their Workspace
(<workspaceId>:<canvasId>); microsoft#237's grants are rewritten at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Routes can declare spaceAccess: 'manage'; the Space delete, delete-tree
and rename routes now do, so a Workspace editor can change Spaces but
not remove them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orkspaces

In Bubble mode the Workspace routes act for the caller alone: they list
the Workspaces the caller may enter, create one by name (on Disk, a
folder under the data directory) owned by its creator, switch only the
caller's current Workspace, and rename or unregister by huabu.manage.
Folder picking and paths stay with administrators. GET /api/workspace
reports namedWorkspaces so the web app can offer a name-based switcher.

A Disk and a Postgres suite run two members and an administrator
through the real routes at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent's reachback URL now names its Workspace
(/api/rfs/w/<workspaceId>/<canvasId>), and the RFS routes are also
served under that prefix in that Workspace. A run no longer follows its
starter's later switches or the process-active Workspace. The old
unscoped URL keeps working for runs started before the upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the Server reports namedWorkspaces, boot keeps the Workspace the
Server chose for the caller instead of activating the first registered
one, the setup page lists the caller's Workspaces and creates one by
name instead of picking a folder, and the header calls it a Workspace.
Local mode renders as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fold PR A into the architecture docs: the Workspace/Space role model,
Workspace-qualified Space grants and their migration, per-person
current Workspaces, the Workspace routes for members, the per-call
storage scope and the Workspace-scoped RFS URL. The proposal records
what PR A built and what it deliberately left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 09:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved authorization flaws allow revoked access and privilege escalation, while migration can permanently delete ownership grants when Workspaces are temporarily unreadable.

6 open issues
What changed in this PR

This PR extends Bubble identity toward #228 by giving each person an independent current Workspace on one server. It introduces request-scoped storage and authorization, while retaining the process-active Workspace model for local mode.

Changes:

  • Adds per-person Workspace selection, provisioning, management, and Workspace-qualified Space grants.
  • Makes storage namespaces and agent reachback Workspace-aware.
  • Adds named Workspace selection in the web app, integration tests, and architecture documentation.
File Description
packages/​shared/​src/​types/​api/​workspace.ts Adds the named-Workspaces capability.
docs/​README.md Indexes the multi-user proposal.
docs/​proposals/​multi-user-service.md Records the two-stage multi-user design.
docs/​architecture/​deployment-security.md Documents Workspace selection and authorization.
docs/​architecture/​canvas-storage.md Documents request-scoped storage behavior.
docs/​architecture/​agent-reachback.md Documents Workspace-qualified RFS URLs.
apps/​web/​src/​store/​workspaceStore.ts Preserves server selection and creates named Workspaces.
apps/​web/​src/​store/​workspaceStore.test.ts Tests named Workspace initialization and creation.
apps/​web/​src/​pages/​WorkspaceSetupPage.tsx Adds named Workspace selection and creation UI.
apps/​web/​src/​pages/​CanvasListPage.tsx Displays named Workspace labels.
apps/​web/​src/​i18n/​resources/​zh-CN/​common.json Adds Chinese Workspace UI strings.
apps/​web/​src/​i18n/​resources/​en/​common.json Adds English Workspace UI strings.
apps/​web/​src/​api/​workspace.ts Adds the named Workspace creation client.
apps/​server/​src/​modules/​workspaces.route.ts Adds caller-specific Workspace management.
apps/​server/​src/​modules/​workspace/​scoped-routes.ts Binds routes to an explicit Workspace.
apps/​server/​src/​modules/​workspace/​scoped-routes.test.ts Tests scoped routes and reachback URLs.
apps/​server/​src/​modules/​workspace/​context.ts Introduces asynchronous Workspace context.
apps/​server/​src/​modules/​workspace.ts Makes getters scope-aware and leases per Workspace.
apps/​server/​src/​modules/​workspace.route.ts Reports personal capabilities and hides member paths.
apps/​server/​src/​modules/​workspace.route.test.ts Updates the identity test fixture.
apps/​server/​src/​modules/​workspace-activation.ts Creates named Workspaces across backends.
apps/​server/​src/​modules/​storage/​storage.ts Opens scoped Workspaces and caches their World IDs.
apps/​server/​src/​modules/​storage/​index.ts Exports scoped Workspace opening.
apps/​server/​src/​modules/​storage/​backends/​sqlite/​database.ts Resolves SQLite namespaces from request scope.
apps/​server/​src/​modules/​storage/​backends/​postgres/​database.ts Resolves PostgreSQL namespaces from request scope.
apps/​server/​src/​modules/​storage/​backends/​disk/​canvas-dirs.ts Keeps directory indexes per Workspace.
apps/​server/​src/​modules/​security/​space-access.ts Supports manage-level Space checks.
apps/​server/​src/​modules/​security/​space-access.test.ts Tests manage versus write permissions.
apps/​server/​src/​modules/​remote_fs/​public-skill.ts Recognizes scoped public bootstrap URLs.
apps/​server/​src/​modules/​identity/​workspace-selection.ts Selects and provisions personal Workspaces.
apps/​server/​src/​modules/​identity/​service.ts Extends Workspace and permission interfaces.
apps/​server/​src/​modules/​identity/​resources.ts Resolves resources across Workspaces.
apps/​server/​src/​modules/​identity/​request.ts Stores request-level Workspace selection callbacks.
apps/​server/​src/​modules/​identity/​preferences.ts Persists each principal’s current Workspace.
apps/​server/​src/​modules/​identity/​multi-user.test.ts Runs multi-user scenarios on Disk.
apps/​server/​src/​modules/​identity/​multi-user.scenarios.ts Tests isolation, switching, and viewer restrictions.
apps/​server/​src/​modules/​identity/​multi-user.remote.test.ts Runs multi-user scenarios on PostgreSQL.
apps/​server/​src/​modules/​identity/​index.ts Wires preferences and Workspace selection.
apps/​server/​src/​modules/​identity/​http.ts Opens and fills request Workspace scopes.
apps/​server/​src/​modules/​identity/​bubble.ts Defines roles, qualified grants, and migration.
apps/​server/​src/​modules/​identity/​bubble.test.ts Tests provisioning, roles, sharing, and migration.
apps/​server/​src/​modules/​canvas/​space-hierarchy.test.ts Updates the ownership test fixture.
apps/​server/​src/​modules/​canvas/​space-hierarchy.route.ts Requires manage for subtree deletion.
apps/​server/​src/​modules/​canvas/​space-card.route.ts Requires manage for the title endpoint.
apps/​server/​src/​modules/​canvas/​preprocess-access.route.test.ts Updates the preprocessing identity fixture.
apps/​server/​src/​modules/​canvas/​member-spaces.route.test.ts Updates member access fixtures.
apps/​server/​src/​modules/​canvas/​canvas.route.ts Requires manage for Space deletion.
apps/​server/​src/​modules/​artifact/​send-blob.test.ts Updates artifact access fixtures.
apps/​server/​src/​modules/​agent/​memory/​op-counter-hook.test.ts Updates the memory-hook identity fixture.
apps/​server/​src/​modules/​agent/​acp/​reachback-env.ts Includes Workspace identity in reachback URLs.
apps/​server/​src/​app.ts Mounts Workspace-qualified RFS routes.

Review effort: Balanced

message: 'Unable to rename this Space',
app.patch(
'/:canvasId/title',
{ config: { spaceAccess: 'manage' } },
Comment on lines +115 to 118
'huabu.workspace.editor': {
grantableOn: [TARGET.workspace],
capabilities: [READ, WRITE],
managementCapability: MANAGE,
role: grant.role,
grantedByPrincipalId: grant.grantedByPrincipalId ?? null,
});
await store.grants.remove(grant.grantId);
Comment on lines +94 to +96
const already = await preferences.current(principalId);
const existing = already ? await resources.openWorkspace(already) : null;
if (existing) return existing;
}

const checks: Array<['read' | 'write', string]> = [];
const checks: Array<[AccessLevel, string]> = [];
Comment on lines +83 to +86
if (service.provider === 'bubble')
app.addHook('onRequest', (_request, _reply, done) =>
openWorkspaceScope(done),
);

This branch has not been deployed

No deployments
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.

2 participants