From 504480665e0007f8e3ed952d8451a1dd37a8f5aa Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:26:52 +0800 Subject: [PATCH 1/9] docs: propose the multi-user service in two stacked PRs Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 1 + docs/proposals/multi-user-service.md | 145 +++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 docs/proposals/multi-user-service.md diff --git a/docs/README.md b/docs/README.md index 866d2ffbb..7a2fb305e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,6 +102,7 @@ docs/ | [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. | | [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. | | [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1–6: Blob and structured ports, SQLite, Postgres, Azure, async agents. | +| [multi-user-service.md](./proposals/multi-user-service.md) | Proposed | #228 follow-up: request-scoped Workspaces (PR A), then member agents, per-person credentials, grant admin and session renewal (PR B). | | [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. | | [space-prompt-topology-scoping.md](./proposals/space-prompt-topology-scoping.md) | Shipped | Topology-derived global/direct-Agent targeting for Prompt Frames. | diff --git a/docs/proposals/multi-user-service.md b/docs/proposals/multi-user-service.md new file mode 100644 index 000000000..dc783d726 --- /dev/null +++ b/docs/proposals/multi-user-service.md @@ -0,0 +1,145 @@ +# Multi-User Service + +Status: Proposed +Last updated: 2026-10-28 + +Follows PR #237 (Bubble identity and per-user Space access) toward issue #228. Two stacked pull requests take one Huabu Server from "several people sign in and each edits their own Spaces in one shared Workspace" to a service several people can use day to day: each person has their own Workspaces, uses agents in them, and an operator admits people without scripts. + +Nothing changes in local mode (`HUABU_IDENTITY_PROVIDER=local`). Desktop and single-owner deployments keep one process-active Workspace, the same routes and the same UI. + +## What "ready for several users" means + +After both PRs, in Bubble mode: + +| Outcome | PR | +| ----------------------------------------------------------------------------------------------------------------------------------------- | --- | +| Each admitted person gets a personal Workspace on first sign-in and can create, rename, switch and delete Workspaces they own. | A | +| Two people work in different Workspaces at the same time. One person switching never moves another person. | A | +| Members run agents and conversations. An agent reaches only what the person who started it may reach. | B | +| Connected credentials and service leases are per person. One person's agent never uses another's GitHub token or service key. | B | +| A system owner admits members, revokes them and grants Workspace ownership from Settings. | B | +| A session that expires mid-edit is renewed without leaving the page, so unsaved edits survive. Sign-out can end the provider session too. | B | + +Out of scope for both: sharing a Workspace or Space between people beyond operator grants, quotas and billing, and Desktop as a multi-user client. + +## PR A — Request-scoped Workspaces + +### The Workspace a request runs in + +Each request runs in exactly one Workspace, resolved once in admission and carried in an `AsyncLocalStorage` context (`modules/workspace/context.ts`, new). `getWorkspaceHandle()`, `getWorkspacePath()`, `getWorkspaceKey()` and `space()` read that context. The rest of the Server's ~145 `space(canvasId)` call sites stay as they are. + +How the Workspace is chosen: + +- **Local mode:** the process-active Workspace, as today. Switching still changes it for every client, because local mode has one person. +- **Bubble mode:** the principal's _current Workspace_, a per-principal preference stored in Huabu (`principal_preferences`, referencing Bubble's `principalId`, as #228 proposes). Switching changes only that preference. It must be a Workspace the principal can read (`huabu.workspace.read`, new), else admission falls back to their personal Workspace. + +A per-principal preference rather than a Workspace header keeps images, frames, downloads and EventSource working without changes, since they cannot set headers. The cost is that one person's tabs share one current Workspace, which is also the behavior today. Per-tab Workspaces would need the Workspace in every URL and are not planned. + +Work that outlives the request (agents, memory updates, Task Runs, preprocessing, World preview reconciliation) captures the Workspace when it starts and runs inside it via `runInWorkspace(handle, fn)`. This replaces today's failure mode, where a switch mid-run either retargets the work or fails it with "inactive workspace". + +### Storage + +- **SQL namespaces per call, not per connection.** `useWorkspace()` mutates the one Postgres/SQLite connection's namespace today, so concurrent Workspaces would read each other's Spaces. The structured store reads the Workspace id from the context per query, and transactions bind it when they open. The one database-wide write order (`pg_advisory_xact_lock(WRITE_LOCK)`) stays: it also covers name allocation and the Workspace registry. A per-Workspace lock is a later throughput change. +- **Per-Workspace state.** The World id, canvas-dirs index, user-skill cache, external-note sessions and the Space-create and hierarchy chains move into a `WorkspaceRuntime` (`modules/workspace/runtime.ts`, new), created on first use per Workspace and evicted when idle. The process-wide reset hooks (`refreshCanvasDirIndex`, `invalidateUserSkill`, `resetExternalNoteSessions`) become methods on it. +- **Leases.** `acquireWorkspaceOperationLease()` counts per Workspace. It still stops a local-mode switch or a Workspace delete from stranding in-flight work, and no longer refuses a second live Workspace. +- **Blob prefixes use the Workspace id.** Azure blobs are prefixed by `getWorkspaceKey()`, which on Disk is the folder _path_, so moving a folder orphans them. This becomes the Workspace id, with a one-time copy-forward on first open. +- **Maps keyed by Space id alone** (canvas mutex, SSE listeners, memory worker, dir handles, tombstones, Agenetes turn owners) stay as they are. Space ids are random UUIDs or `createId('canvas')` and imports mint new ids. Workspace creation refuses a Disk folder whose Space ids collide with a live Workspace's. + +### Roles and capabilities + +This replaces #237's model. Huabu checks four capabilities, and the same names apply on a Workspace and on a Space: + +| Capability | Allows | +| -------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `huabu.read` | Seeing and opening the Workspace or Space, every `GET`, search, live streams. | +| `huabu.write` | Editing Spaces and creating them. From PR B, also running agents and conversations in them. | +| `huabu.manage` | Renaming and deleting it, granting roles on it. On a Workspace, also its memory, Skills and Workspace settings. | +| `huabu.admin` | System target only: deployment, Canary, folder Workspaces on the Server's filesystem, and People administration (PR B). | + +Roles: + +| Role | Granted on | Gives | +| ------------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------- | +| `owner` (Bubble core) | system | `huabu.use`, `huabu.admin`. Bubble's system-owner rule also gives every capability on every Workspace and Space. | +| `member` (Bubble core) | system | `huabu.use`: admitted, and gets a personal Workspace. | +| `huabu.workspace.owner` | one Workspace | read, write, manage | +| `huabu.workspace.editor` | one Workspace | read, write | +| `huabu.workspace.viewer` | one Workspace | read | +| `huabu.space.owner` | one Space | read, write, manage on that Space and the Spaces inside it | + +- **Inheritance.** read, write and manage flow down Workspace → root Space → child Space, never up or sideways. +- **Who grants.** Granting a role on a Workspace or Space needs `huabu.manage` on it. Granting system roles needs `huabu.admin`. A member can therefore grant roles on their own Workspace. Neither PR adds a UI for that: sharing UI is a later step. +- **Admission.** Still system `member` or `owner`. A Workspace or Space grant alone does not admit anyone. +- **Space ownership on creation.** Creating a Space in a Workspace you can write claims `huabu.space.owner` on it, as in #237. This keeps a shared Space reachable by its creator if the Workspace grant is later narrowed. +- **Workspace-qualified Space targets.** `huabu.space` targets are `:`, and the parent of a root Space is its own Workspace, not "the active one". So a Space can be shared later without sharing its Workspace. +- **Migration from #237.** On first start, `huabu.space.owner` grants are rewritten to qualified targets in the Workspace that holds each Space. `huabu.workspace.owner` grants keep their meaning. A #237 grant that names no existing Space is dropped. +- **Later goal, not in these PRs.** Every request names its Workspace explicitly. Until then a request runs in its caller's current Workspace, and a Space shared from another Workspace is reached by switching to that Workspace. + +### Workspaces for members + +- **Personal Workspace.** On a member's first admitted request with no readable Workspace, Huabu creates "'s Workspace" and grants them `huabu.workspace.owner` on it, under a per-principal lock so two first requests create one. +- **Management.** `GET /api/workspaces` lists the Workspaces a caller can read. Any member may create one. Activating needs `huabu.read`; rename and delete need `huabu.manage`. Folder picking and path validation need `huabu.admin`, because they reach the Server's filesystem. On Disk storage, a member's new Workspace goes under the data directory. +- **System owner.** A system owner can reach every Workspace through Bubble's system-owner rule, and has a current Workspace like anyone else. + +### Agents in PR A + +Agents stay owner-only for members, as in #237. Each run now carries its Workspace: the reachback URL becomes `/api/rfs/w//`, and RFS runs the call in that Workspace. An owner's agent therefore keeps working when the owner switches away mid-run, instead of failing or reading the other Workspace. + +### Web + +- Boot no longer activates `registered[0]` in Bubble mode: the Server has already chosen the caller's current Workspace. +- The switcher lists the caller's Workspaces and is enabled for members. Switching posts to `/api/workspaces/:id/activate`, which in Bubble mode sets only the caller's preference. +- The Home folder name and `GET /api/workspace` path are withheld from members, closing a #237 limitation. + +### Proof + +The Postgres suite runs two principals in two Workspaces concurrently: creates, edits, SSE, search and uploads, with no cross-reads and no transaction aborts. It also checks that a switch in one browser leaves the other unchanged, that an owner's agent keeps its Workspace across the owner's switch, that #237 grants migrate, that a viewer cannot write and an editor cannot rename or delete, that personal Workspaces are created once under concurrent first requests, and that local mode's screenshots stay pixel-identical to `main`. + +## PR B — Members use agents, and the service is administrable + +### Per-run agent credentials + +Each agent run gets its own token, minted by the Server when the run starts. It names the requesting principal, the Workspace, the root Space and the thread, and expires with the run. The Agentlet passes it as the reachback credential instead of the machine token. RFS admits it as that principal, and Space access is checked as for that person's browser requests, so an agent can read or edit only what its starter can. The machine token keeps unrestricted reach only for the Agentlet's own control surface, never for Space content. + +This reopens `/api/agent`, `/api/acp`, `/api/agent-change-review` and Interactive View actions to members. It also covers the memory worker: a member's edits drive memory updates again, running in that person's Workspace under their authority. + +### Per-person credentials + +Model configuration and LLM keys, connected credentials, service-package leases and integration configuration move from data-directory scope to per principal in Bubble mode. Each person configures their own models and keys in Settings, and an agent run resolves models from its starter's configuration. A member with no model configured sees the Agent tool explain that, not a Server error. Operator-only settings (deployment, Canary, Workspaces on the Server's filesystem) stay system-owner-only. Settings shows a member only their own configuration. + +### Grant administration + +A Settings → People panel for system owners lists principals, and admits (`member`), revokes, and grants or removes `huabu.workspace.owner`. Huabu proxies these to Bubble's grant API in-process. Revoking `huabu.use` closes that person's open streams at the next revalidation, as #237 already does for Space grants. + +### Session lifecycle + +- **Refresh.** The sign-in flow requests a refresh token (`offline_access`), the Server holds it beside the access token in the session, and it renews the access token before it expires. +- **Re-sign-in in a popup.** When renewal is impossible, a lapsed session re-signs in through a popup, keeping the page and its unsaved edits. +- **Provider logout.** Sign-out optionally ends the provider session (RP-initiated logout) when the provider advertises `end_session_endpoint`. + +### Proof + +A member runs an agent that tries to read another member's Space through RFS and is refused. Their agent edits their own Space. Two members' connected credentials stay separate. An owner admits and revokes a member from the UI, and the revoked member's open page loses access within 30 seconds. A session that expires mid-edit renews or re-signs in without losing the edit. All of it is checked against real Auth0 and Entra sign-ins, as in #237. + +## Settled decisions + +Confirmed 2026-10-28: + +1. **Current Workspace per person, not per tab.** A Workspace in every URL was rejected as a much larger web and API change for a rare need. +2. **Workspace-qualified Space grants,** with an in-place migration of #237's grants, rather than relying on unique Space ids. Space grants stay so that one Space can be shared later. #237's roles are replaced by the model above. +3. **Automatic personal Workspace on first sign-in,** rather than no Workspace until an operator grants one. +4. **Per-person model keys** in PR B, rather than every member's agents using the operator's keys. + +## Code entry points + +| File/dir | Responsibility | +| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| [`modules/workspace.ts`](../../apps/server/src/modules/workspace.ts) | Today's process-active Workspace; becomes the local-mode default and the context reader. | +| [`modules/storage/storage.ts`](../../apps/server/src/modules/storage/storage.ts) | Storage facade, `space()`, World id, `activateWorkspace`. | +| [`modules/storage/backends/postgres/database.ts`](../../apps/server/src/modules/storage/backends/postgres/database.ts) | Connection namespace (`useWorkspace`) and write lock. | +| [`modules/identity/resources.ts`](../../apps/server/src/modules/identity/resources.ts) | Space parent resolution for Bubble inheritance. | +| [`modules/identity/bubble.ts`](../../apps/server/src/modules/identity/bubble.ts) | Huabu roles, capabilities and claims. | +| [`modules/security/space-access.ts`](../../apps/server/src/modules/security/space-access.ts) | Per-request Space guard and agent refusal. | +| [`modules/agent/acp/reachback-env.ts`](../../apps/server/src/modules/agent/acp/reachback-env.ts) | Agent reachback URL and credential. | +| [`modules/workspaces.route.ts`](../../apps/server/src/modules/workspaces.route.ts) | Workspace list, create, activate. | +| [`apps/web/src/store/workspaceStore.ts`](../../apps/web/src/store/workspaceStore.ts) | Web boot and switch. | From 6094f74db0065834ce848a07f69b0561a2dd267b Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:26:52 +0800 Subject: [PATCH 2/9] feat(workspace): run each call in its own Workspace scope 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 --- .../storage/backends/disk/canvas-dirs.ts | 84 ++++++++++---- .../storage/backends/postgres/database.ts | 26 ++++- .../storage/backends/sqlite/database.ts | 20 +++- apps/server/src/modules/storage/index.ts | 1 + apps/server/src/modules/storage/storage.ts | 44 ++++++- apps/server/src/modules/workspace.ts | 107 +++++++++++------- apps/server/src/modules/workspace/context.ts | 77 +++++++++++++ 7 files changed, 282 insertions(+), 77 deletions(-) create mode 100644 apps/server/src/modules/workspace/context.ts diff --git a/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts b/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts index bd886d2fe..a29851646 100644 --- a/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts +++ b/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts @@ -23,7 +23,7 @@ import { toSafeFilename, } from '../../../../utils/naming.js'; import { spaceHierarchyShapeError } from '../../../canvas/persistence-validation.js'; -import { getWorkspacePath } from '../../../workspace.js'; +import { getWorkspaceDirectory, getWorkspacePath } from '../../../workspace.js'; export interface CanvasDirEntry { id: string; @@ -42,9 +42,29 @@ export interface CanvasDirEntry { parentEntryNodeId?: string | null; } -const index = new NameIndex(); -let worldEntry: CanvasDirEntry | null = null; -let scanned = false; +/** One Workspace folder's index. Several are live at once in Bubble mode. */ +interface DirIndexState { + readonly index: NameIndex; + worldEntry: CanvasDirEntry | null; + scanned: boolean; +} + +const states = new Map(); + +/** The index of the Workspace this call runs in. */ +function dirState(): DirIndexState { + const workspacePath = getWorkspacePath(); + let state = states.get(workspacePath); + if (!state) { + state = { + index: new NameIndex(), + worldEntry: null, + scanned: false, + }; + states.set(workspacePath, state); + } + return state; +} function readCanvasDirEntry( fullPath: string, @@ -103,11 +123,12 @@ function readCanvasDirEntry( } function scanWorkspace(): void { - index.reset([]); - worldEntry = null; + const state = dirState(); + state.index.reset([]); + state.worldEntry = null; const ws = getWorkspacePath(); if (!existsSync(ws)) { - scanned = true; + state.scanned = true; return; } @@ -124,8 +145,12 @@ function scanWorkspace(): void { }`, ); } - worldEntry = readCanvasDirEntry(worldRoot, WORLD_CANVAS_DIR_NAME, true); - if (!worldEntry) { + state.worldEntry = readCanvasDirEntry( + worldRoot, + WORLD_CANVAS_DIR_NAME, + true, + ); + if (!state.worldEntry) { throw new Error( `World canvas is missing or malformed: ${path.join( worldRoot, @@ -160,46 +185,50 @@ function scanWorkspace(): void { ); } duplicateOf.set(canvasEntry.id, entry); - index.add(canvasEntry); + state.index.add(canvasEntry); } - if (worldEntry && index.has(worldEntry.id)) { + const worldEntry = state.worldEntry; + if (worldEntry && state.index.has(worldEntry.id)) { throw new Error( `World canvasId duplicates an ordinary Space: ${worldEntry.id}`, ); } - scanned = true; + state.scanned = true; } function ensureScanned(): void { - if (!scanned) scanWorkspace(); + if (!dirState().scanned) scanWorkspace(); } /** Force a re-scan on next access (call after migrations / imports). */ export function refreshCanvasDirIndex(): void { - scanned = false; + // Every folder rescans, not only this call's: a refresh is rare, and a + // stale index is worse than a second scan. + states.clear(); } export function canvasDirName(canvasId: string): string { ensureScanned(); - if (worldEntry?.id === canvasId) return WORLD_CANVAS_DIR_NAME; - return index.get(canvasId)?.filename ?? canvasId; + if (dirState().worldEntry?.id === canvasId) return WORLD_CANVAS_DIR_NAME; + return dirState().index.get(canvasId)?.filename ?? canvasId; } /** Ordinary user-visible Spaces only. */ export function listCanvasDirEntries(): CanvasDirEntry[] { ensureScanned(); - return index.list(); + return dirState().index.list(); } /** Every executable Canvas scope, including the hidden World. */ export function listAllCanvasDirEntries(): CanvasDirEntry[] { ensureScanned(); + const { index, worldEntry } = dirState(); return worldEntry ? [...index.list(), worldEntry] : index.list(); } export function getWorldCanvasId(): string | null { ensureScanned(); - return worldEntry?.id ?? null; + return dirState().worldEntry?.id ?? null; } export function requireWorldCanvasId(): string { @@ -212,7 +241,7 @@ export function requireWorldCanvasId(): string { export function isWorldCanvasId(canvasId: string): boolean { ensureScanned(); - return worldEntry?.id === canvasId; + return dirState().worldEntry?.id === canvasId; } /** @@ -226,7 +255,9 @@ export function suggestCanvasDir( ensureScanned(); const base = toSafeFilename(title, fallback); return dedupeName(base, [ - ...index.list().map((entry) => entry.filename), + ...dirState() + .index.list() + .map((entry) => entry.filename), WORLD_CANVAS_DIR_NAME, ]); } @@ -237,7 +268,7 @@ export function registerCanvasDir( title: string | null, ): NameIndexResult { ensureScanned(); - return index.add({ id: canvasId, filename: dirName, title }); + return dirState().index.add({ id: canvasId, filename: dirName, title }); } export function patchCanvasDirTitle( @@ -245,12 +276,12 @@ export function patchCanvasDirTitle( title: string | null, ): void { ensureScanned(); - index.patch(canvasId, { title }); + dirState().index.patch(canvasId, { title }); } export function unregisterCanvasDir(canvasId: string): void { ensureScanned(); - index.remove(canvasId); + dirState().index.remove(canvasId); } /** Result of a strict on-disk rename. */ @@ -299,6 +330,7 @@ export function renameCanvasDirOnDisk( newDirName: string, ): CanvasDirRenameResult { ensureScanned(); + const { index, worldEntry } = dirState(); const entry = index.get(canvasId); if (!entry) return { ok: false, reason: 'not-found' }; @@ -306,7 +338,11 @@ export function renameCanvasDirOnDisk( worldEntry && normalizeForCompare(worldEntry.filename) === normalizeForCompare(newDirName) ) { - return { ok: false, reason: 'conflict', conflictWith: worldEntry.filename }; + return { + ok: false, + reason: 'conflict', + conflictWith: worldEntry.filename, + }; } if (normalizeForCompare(entry.filename) === normalizeForCompare(newDirName)) { diff --git a/apps/server/src/modules/storage/backends/postgres/database.ts b/apps/server/src/modules/storage/backends/postgres/database.ts index 2e85fef1e..cdf38b8e2 100644 --- a/apps/server/src/modules/storage/backends/postgres/database.ts +++ b/apps/server/src/modules/storage/backends/postgres/database.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { Pool, type PoolClient, type PoolConfig } from 'pg'; +import { currentWorkspaceScope } from '../../../workspace/context.js'; import { POSTGRES_MIGRATIONS } from './schema.js'; import { createKeyedMutex } from '../../../../utils/keyed-mutex.js'; @@ -180,14 +181,27 @@ export class PostgresStoreContext { useWorkspace(workspaceId: string | null): void { this.#workspaceId = workspaceId; } - activeWorkspaceId(): string | null { + + /** + * The Workspace this call runs in: its request's or background work's own + * scope where one is open, else the process-active one set by + * {@link useWorkspace}. Concurrent calls in different Workspaces therefore + * never read each other's rows through the one shared connection. + */ + #currentWorkspaceId(): string | null { + const scope = currentWorkspaceScope(); + if (scope !== undefined) return scope?.handle.workspaceId ?? null; return this.#workspaceId; } + activeWorkspaceId(): string | null { + return this.#currentWorkspaceId(); + } workspaceId(): string { this.assertOpen(); - if (!this.#workspaceId) + const workspaceId = this.#currentWorkspaceId(); + if (!workspaceId) throw new Error('No Workspace is active on the Postgres backend'); - return this.#workspaceId; + return workspaceId; } assertBoundWorkspace(bound: string, what: string): string { if (this.workspaceId() !== bound) @@ -220,10 +234,10 @@ export class PostgresStoreContext { /** Never hold a SQL transaction while composition performs blob I/O. */ transaction(operation: (database: PgExecutor) => Promise): Promise { this.assertOpen(); - const bound = this.#workspaceId; + const bound = this.#currentWorkspaceId(); const result = serializeTransactions(this.#admissionScope, async () => { this.assertOpen(); - if (bound !== this.#workspaceId) + if (bound !== this.#currentWorkspaceId()) throw new Error('Postgres operation belongs to an inactive Workspace'); const client = await this.#pool.connect(); // pg-pool drops its own idle listener while a client is checked out and @@ -238,7 +252,7 @@ export class PostgresStoreContext { await client.query('BEGIN'); await client.query("SET LOCAL lock_timeout = '5s'"); await client.query('SELECT pg_advisory_xact_lock($1)', [WRITE_LOCK]); - if (bound !== this.#workspaceId) + if (bound !== this.#currentWorkspaceId()) throw new Error( 'Postgres operation belongs to an inactive Workspace', ); diff --git a/apps/server/src/modules/storage/backends/sqlite/database.ts b/apps/server/src/modules/storage/backends/sqlite/database.ts index 5740be4b4..106d504f8 100644 --- a/apps/server/src/modules/storage/backends/sqlite/database.ts +++ b/apps/server/src/modules/storage/backends/sqlite/database.ts @@ -20,6 +20,7 @@ import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; +import { currentWorkspaceScope } from '../../../workspace/context.js'; import { SQLITE_MIGRATIONS, type SqliteMigration } from './schema.js'; import { getDataDir } from '../../../../data-dir.js'; @@ -236,21 +237,34 @@ export class SqliteStoreContext { this.#workspaceId = workspaceId; } + /** + * The Workspace this call runs in: its request's or background work's own + * scope where one is open, else the process-active one set by + * {@link useWorkspace}. Concurrent calls in different Workspaces therefore + * never read each other's rows through the one shared connection. + */ + #currentWorkspaceId(): string | null { + const scope = currentWorkspaceScope(); + if (scope !== undefined) return scope?.handle.workspaceId ?? null; + return this.#workspaceId; + } + /** The active Workspace id, or `null` when none has been selected. */ activeWorkspaceId(): string | null { - return this.#workspaceId; + return this.#currentWorkspaceId(); } /** The active Workspace id, or a refusal when none has been selected. */ workspaceId(): string { this.assertOpen(); - if (this.#workspaceId === null) { + const workspaceId = this.#currentWorkspaceId(); + if (workspaceId === null) { throw new SqliteWorkspaceScopeError( 'No Workspace is active on the SQLite backend. Activate one before ' + 'reading or writing Spaces.', ); } - return this.#workspaceId; + return workspaceId; } /** diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 7e6cd62b2..00f4960a6 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -79,6 +79,7 @@ export type { export { activateWorkspace, + openWorkspace, adoptWorkspaceDirectory, closeStorage, composeStorage, diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index a959477ed..f91f3f876 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -67,7 +67,9 @@ import { type StorageProfile, } from './profile.js'; import { withSpacePutAdmission } from './space-lifecycle-admission.js'; +import { runInWorkspace } from '../workspace/context.js'; +import type { WorkspaceScope } from '../workspace/context.js'; import type { DiskSpaceImport } from './backends/disk/space-import.js'; import type { DiskSpaceTree } from './backends/disk/space-tree.js'; import type { SqliteSpaceSubstrate } from './backends/sqlite/space-extension.js'; @@ -370,7 +372,9 @@ let current: Storage | null = null; let workspaces: WorkspaceRepository | null = null; let sqlite: SqliteStoreContext | null = null; let postgres: PostgresStoreContext | null = null; -let activeWorldCanvasId: string | null = null; +// World ids per Workspace for backends without folders. Several Workspaces +// are open at once in Bubble mode, each with its own World. +const worldCanvasIds = new Map(); let spaceCreateTail: Promise = Promise.resolve(); /** @@ -699,7 +703,7 @@ export async function activateWorkspace( commitWorkspaceIdentity(workspace); if (sqlite) sqlite.useWorkspace(workspace.workspaceId); if (postgres) postgres.useWorkspace(workspace.workspaceId); - activeWorldCanvasId = null; + worldCanvasIds.delete(workspace.workspaceId); if ( workspaces instanceof SqliteWorkspaceRepository || workspaces instanceof PostgresWorkspaceRepository @@ -709,7 +713,35 @@ export async function activateWorkspace( // A Workspace with no World has no Portal target and no home view. On Disk // the World is written by workspace preparation; here the same step belongs // to activation, because activation is the whole of "open a Workspace". - activeWorldCanvasId = await ensure().structured.spaces().ensureWorld(); + worldCanvasIds.set( + workspace.workspaceId, + await ensure().structured.spaces().ensureWorld(), + ); +} + +/** + * Open a Workspace for the calls that run in it, without making it the + * process-active one. + * + * The scoped counterpart to {@link activateWorkspace}, for Bubble mode, where + * each request runs in its caller's Workspace. On Disk the folder is adopted + * and indexed on first use; elsewhere the World is ensured once per Workspace. + * Idempotent and cheap after the first call. + */ +export async function openWorkspace( + workspace: WorkspaceHandle, +): Promise { + const workspacePath = workspaceDirectory(workspace.workspaceId); + if (materializesWorkspaces() && !workspacePath) + throw new Error(`Workspace "${workspace.workspaceId}" has no folder`); + const scope: WorkspaceScope = { handle: workspace, path: workspacePath }; + if (!workspacePath && !worldCanvasIds.has(workspace.workspaceId)) { + const world = await runInWorkspace(scope, () => + ensure().structured.spaces().ensureWorld(), + ); + worldCanvasIds.set(workspace.workspaceId, world); + } + return scope; } /** @@ -722,7 +754,9 @@ export async function activateWorkspace( * it is synchronous in call sites that cannot await. */ export function getWorldCanvasId(): string | null { - return materializesWorkspaces() ? diskWorldCanvasId() : activeWorldCanvasId; + if (materializesWorkspaces()) return diskWorldCanvasId(); + const workspaceId = getWorkspaceHandle()?.workspaceId; + return workspaceId ? (worldCanvasIds.get(workspaceId) ?? null) : null; } export function requireWorldCanvasId(): string { @@ -762,7 +796,7 @@ export async function closeStorage(): Promise { workspaces = null; sqlite = null; postgres = null; - activeWorldCanvasId = null; + worldCanvasIds.clear(); try { if (storage) { const results = await Promise.allSettled([ diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index 4f2f9bbeb..3fd13a8b6 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -46,6 +46,7 @@ import { adoptWorkspaceDirectory, materializesWorkspaces, } from './storage/index.js'; +import { currentWorkspaceScope } from './workspace/context.js'; import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { invalidateUserSkill } from '../prompt/index.js'; @@ -60,8 +61,9 @@ const ENV_KEY = 'HUABU_WORKSPACE'; let _workspaceHandle: WorkspaceHandle | null = null; let _workspacePath: string | null = null; let _managed = false; -let _leasedWorkspaceKey: string | null = null; -let _workspaceOperationLeaseCount = 0; +// Leases per Workspace key. Several Workspaces can be live at once in Bubble +// mode, each with its own in-flight work. +const _workspaceOperationLeases = new Map(); let _activatingWorkspacePath: string | null = null; /** @@ -114,7 +116,7 @@ export function isManagedMode(): boolean { } export function isWorkspaceConfigured(): boolean { - return _workspaceHandle !== null; + return getWorkspaceHandle() !== null; } /** @@ -160,14 +162,15 @@ export function initWorkspaceFromEnv(): void { * Throws if no workspace has been activated yet. */ export function getWorkspacePath(): string { - if (!_workspacePath) { + const workspacePath = getWorkspaceDirectory(); + if (!workspacePath) { throw new Error( 'Workspace path has not been configured. ' + 'Activate a workspace first (PUT /api/workspace) or set ' + `${ENV_KEY} in the environment.`, ); } - return _workspacePath; + return workspacePath; } /** @@ -178,6 +181,8 @@ export function getWorkspacePath(): string { * a directory should keep calling {@link getWorkspacePath} and let it refuse. */ export function getWorkspaceDirectory(): string | null { + const scope = currentWorkspaceScope(); + if (scope !== undefined) return scope?.path ?? null; return _workspacePath; } @@ -190,45 +195,63 @@ export function getWorkspaceDirectory(): string | null { * is the Workspace identity. */ export function getWorkspaceKey(): string { - if (_workspacePath) return _workspacePath; - if (_workspaceHandle) return `workspace:${_workspaceHandle.workspaceId}`; + const workspacePath = getWorkspaceDirectory(); + if (workspacePath) return workspacePath; + const handle = getWorkspaceHandle(); + if (handle) return `workspace:${handle.workspaceId}`; throw new Error( 'Workspace has not been configured. Activate a workspace first ' + `(PUT /api/workspace) or set ${ENV_KEY} in the environment.`, ); } -/** The active immutable Workspace identity, or null before configuration. */ +/** + * The immutable identity of the Workspace this call runs in: the request's + * own in Bubble mode, else the process-active one. `null` before + * configuration. + */ export function getWorkspaceHandle(): WorkspaceHandle | null { + const scope = currentWorkspaceScope(); + if (scope !== undefined) return scope?.handle ?? null; + return _workspaceHandle; +} + +/** The process-active Workspace, whatever Workspace this call runs in. */ +export function getProcessWorkspaceHandle(): WorkspaceHandle | null { return _workspaceHandle; } +/** The process-active Workspace's directory, or `null`. */ +export function getProcessWorkspaceDirectory(): string | null { + return _workspacePath; +} + /** - * Keep the currently-active workspace stable for an async operation. + * Keep the Workspace this call runs in stable for an async operation. * - * Multiple operations may hold leases concurrently. Switching to another - * workspace is rejected until every lease has been released; recommitting the - * same Workspace remains allowed. + * Multiple operations may hold leases concurrently, on one Workspace or + * several. Switching the process-active Workspace away from a leased one is + * rejected until its leases are released; recommitting the same Workspace + * remains allowed. */ export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { const workspaceKey = getWorkspaceKey(); + const processKey = processWorkspaceKey(); + // A pending switch moves only the process-active Workspace, so it holds + // back new work there and nowhere else. if ( _activatingWorkspacePath !== null && + workspaceKey === processKey && _activatingWorkspacePath !== workspaceKey ) { throw new WorkspaceActivationInProgressError(); } - if ( - _workspaceOperationLeaseCount > 0 && - _leasedWorkspaceKey !== workspaceKey - ) { - throw new Error('Workspace operation lease invariant violated'); - } - - _leasedWorkspaceKey = workspaceKey; - _workspaceOperationLeaseCount += 1; + _workspaceOperationLeases.set( + workspaceKey, + (_workspaceOperationLeases.get(workspaceKey) ?? 0) + 1, + ); let released = false; return Object.freeze({ @@ -236,14 +259,24 @@ export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { release(): void { if (released) return; released = true; - _workspaceOperationLeaseCount -= 1; - if (_workspaceOperationLeaseCount === 0) { - _leasedWorkspaceKey = null; - } + const remaining = (_workspaceOperationLeases.get(workspaceKey) ?? 1) - 1; + if (remaining > 0) _workspaceOperationLeases.set(workspaceKey, remaining); + else _workspaceOperationLeases.delete(workspaceKey); }, }); } +/** Whether any operation holds a lease on this Workspace key. */ +export function isWorkspaceLeased(workspaceKey: string): boolean { + return _workspaceOperationLeases.has(workspaceKey); +} + +function processWorkspaceKey(): string | null { + if (_workspacePath) return _workspacePath; + if (_workspaceHandle) return `workspace:${_workspaceHandle.workspaceId}`; + return null; +} + /** * Reserve a namespace switch before asynchronous preparation can touch it. * @@ -356,14 +389,7 @@ function commitResolvedWorkspacePath(resolvedPath: string): void { */ export function commitWorkspaceIdentity(workspace: WorkspaceHandle): void { assertNoWorkspaceActivationInProgress(); - const key = `workspace:${workspace.workspaceId}`; - if ( - _workspaceOperationLeaseCount > 0 && - _leasedWorkspaceKey !== null && - _leasedWorkspaceKey !== key - ) { - throw new WorkspaceOperationInProgressError(); - } + assertProcessWorkspaceChangeAllowed(`workspace:${workspace.workspaceId}`); _workspaceHandle = workspace; _workspacePath = null; refreshCanvasDirIndex(); @@ -375,7 +401,8 @@ export function commitWorkspaceIdentity(workspace: WorkspaceHandle): void { export function updateActiveWorkspaceHandle( workspace: WorkspaceHandle, ): boolean { - if (_workspaceHandle?.workspaceId !== workspace.workspaceId) return false; + if (getProcessWorkspaceHandle()?.workspaceId !== workspace.workspaceId) + return false; _workspaceHandle = workspace; return true; } @@ -401,11 +428,13 @@ function validateAbsolutePath(p: string): void { } function assertWorkspacePathChangeAllowed(resolvedPath: string): void { - if ( - _workspaceOperationLeaseCount > 0 && - _leasedWorkspaceKey !== null && - _leasedWorkspaceKey !== resolvedPath - ) { + assertProcessWorkspaceChangeAllowed(resolvedPath); +} + +/** Refuse to move the process-active Workspace away while work holds it. */ +function assertProcessWorkspaceChangeAllowed(nextKey: string): void { + const key = processWorkspaceKey(); + if (key !== null && key !== nextKey && isWorkspaceLeased(key)) { throw new WorkspaceOperationInProgressError(); } } diff --git a/apps/server/src/modules/workspace/context.ts b/apps/server/src/modules/workspace/context.ts new file mode 100644 index 000000000..fdf4fd18f --- /dev/null +++ b/apps/server/src/modules/workspace/context.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * The Workspace one request, or one piece of background work, runs in. + * + * Local mode has one person and one process-active Workspace, and never fills + * a scope: every getter in `../workspace.ts` falls back to the process state, + * exactly as before. In Bubble mode each request runs in its caller's current + * Workspace, so two people can work in two Workspaces at once without either + * one moving the other. + * + * The scope travels with the async call chain. Work a request starts and that + * outlives it (an agent run, a memory update, a Task Run) keeps the Workspace + * it started in, even after its starter switches away. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { WorkspaceHandle } from '../storage/index.js'; + +/** One Workspace, as the getters in `../workspace.ts` answer for it. */ +export interface WorkspaceScope { + readonly handle: WorkspaceHandle; + /** The Disk materialization, or `null` when the Workspace is a row. */ + readonly path: string | null; +} + +/** + * A request's slot, opened before identity is known and filled once. + * + * Opening comes first because only a callback-style `onRequest` hook can wrap + * the rest of Fastify's lifecycle in `run()`; choosing comes later, after + * admission has found the principal. An open slot that was never filled is a + * request with no Workspace, not a request in the process's. + */ +interface ScopeSlot { + scope: WorkspaceScope | null; +} + +const storage = new AsyncLocalStorage(); + +/** Run `fn` with an empty slot that {@link fillWorkspaceScope} may fill. */ +export function openWorkspaceScope(fn: () => T): T { + return storage.run({ scope: null }, fn); +} + +/** + * Choose the Workspace for the request this call runs inside. + * + * Refuses a second choice: a request reads one Workspace from start to finish, + * so a handler can never act in a different one from the one authorized. + */ +export function fillWorkspaceScope(scope: WorkspaceScope): void { + const slot = storage.getStore(); + if (!slot) throw new Error('No Workspace scope is open for this call'); + if (slot.scope && slot.scope.handle.workspaceId !== scope.handle.workspaceId) + throw new Error('This request already runs in another Workspace'); + slot.scope = scope; +} + +/** Run `fn`, and everything it starts, in one Workspace. */ +export function runInWorkspace(scope: WorkspaceScope, fn: () => T): T { + return storage.run({ scope }, fn); +} + +/** + * The scope this call runs in. + * + * `undefined` when no slot is open (local mode, startup, process work), so the + * caller uses the process-active Workspace. `null` when a slot is open but + * nothing was chosen, which is a request with no Workspace. + */ +export function currentWorkspaceScope(): WorkspaceScope | null | undefined { + const slot = storage.getStore(); + return slot ? slot.scope : undefined; +} From adc4fc01c394885c9f881f97418dfd72507e3669 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:26:52 +0800 Subject: [PATCH 3/9] feat(identity): per-person current Workspace and a new role model 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 (:); #237's grants are rewritten at startup. Co-Authored-By: Claude Opus 5.5 --- .../agent/memory/op-counter-hook.test.ts | 6 +- .../src/modules/artifact/send-blob.test.ts | 16 +- .../canvas/member-spaces.route.test.ts | 8 +- .../canvas/preprocess-access.route.test.ts | 1 + .../modules/canvas/space-hierarchy.test.ts | 1 + .../src/modules/identity/bubble.test.ts | 332 +++++++++++++----- apps/server/src/modules/identity/bubble.ts | 201 +++++++++-- apps/server/src/modules/identity/http.ts | 17 + apps/server/src/modules/identity/index.ts | 9 + .../src/modules/identity/preferences.ts | 71 ++++ apps/server/src/modules/identity/resources.ts | 52 ++- apps/server/src/modules/identity/service.ts | 48 ++- .../modules/identity/workspace-selection.ts | 109 ++++++ .../src/modules/security/space-access.test.ts | 1 + .../src/modules/workspace-activation.ts | 30 ++ .../src/modules/workspace.route.test.ts | 1 + 16 files changed, 771 insertions(+), 132 deletions(-) create mode 100644 apps/server/src/modules/identity/preferences.ts create mode 100644 apps/server/src/modules/identity/workspace-selection.ts diff --git a/apps/server/src/modules/agent/memory/op-counter-hook.test.ts b/apps/server/src/modules/agent/memory/op-counter-hook.test.ts index e1a6bc55a..f4ab3e19f 100644 --- a/apps/server/src/modules/agent/memory/op-counter-hook.test.ts +++ b/apps/server/src/modules/agent/memory/op-counter-hook.test.ts @@ -34,7 +34,11 @@ app.addHook('onRequest', async (request) => { principal: { principalId: 'carol', kind: 'user' }, member: true, owner: false, - access: { can: async () => true, claim: async () => {} }, + access: { + can: async () => true, + claim: async () => {}, + workspaces: async () => 'all' as const, + }, }); }); registerOpCounterHook(app); diff --git a/apps/server/src/modules/artifact/send-blob.test.ts b/apps/server/src/modules/artifact/send-blob.test.ts index 354ede104..d6c88df54 100644 --- a/apps/server/src/modules/artifact/send-blob.test.ts +++ b/apps/server/src/modules/artifact/send-blob.test.ts @@ -297,7 +297,11 @@ describe('sendBlob under per-Space access', () => { 'public, max-age=0', ]); expect( - await cache({ can: async () => true, claim: async () => {} }), + await cache({ + can: async () => true, + claim: async () => {}, + workspaces: async () => 'all' as const, + }), ).toEqual(['private, no-cache', 304, 'private, no-cache']); }); @@ -340,8 +344,12 @@ describe('sendBlob under per-Space access', () => { // Bubble mode, even for a system owner; a route's own policy stays // enforced beside the sandbox. const sandbox = 'sandbox allow-scripts allow-forms allow-popups'; - expect(await csp({ can: async () => true, claim: async () => {} })).toEqual( - [sandbox, ["connect-src 'none'", sandbox]], - ); + expect( + await csp({ + can: async () => true, + claim: async () => {}, + workspaces: async () => 'all' as const, + }), + ).toEqual([sandbox, ["connect-src 'none'", sandbox]]); }); }); diff --git a/apps/server/src/modules/canvas/member-spaces.route.test.ts b/apps/server/src/modules/canvas/member-spaces.route.test.ts index 2572cfb4d..f328eec68 100644 --- a/apps/server/src/modules/canvas/member-spaces.route.test.ts +++ b/apps/server/src/modules/canvas/member-spaces.route.test.ts @@ -52,6 +52,7 @@ describe('a member’s Spaces', () => { const app = await host({ can: async (_level, { id }) => id === child.canvasId, claim: async () => {}, + workspaces: async () => 'all' as const, }); const listed = (await app.inject('/canvas')).json().canvases; await app.close(); @@ -77,7 +78,11 @@ describe('a member’s Spaces', () => { principal: { principalId: 'carol', kind: 'user' }, member: true, owner, - access: { can: async () => true, claim: async () => {} }, + access: { + can: async () => true, + claim: async () => {}, + workspaces: async () => 'all' as const, + }, }); }); await app.register(canvasRoutes, { prefix: '/canvas' }); @@ -115,6 +120,7 @@ describe('a member’s Spaces', () => { claim: async () => { throw new Error('grant store unavailable'); }, + workspaces: async () => 'all', }); const response = await app.inject({ method: 'POST', diff --git a/apps/server/src/modules/canvas/preprocess-access.route.test.ts b/apps/server/src/modules/canvas/preprocess-access.route.test.ts index 4e1f8841a..dcca8c9e0 100644 --- a/apps/server/src/modules/canvas/preprocess-access.route.test.ts +++ b/apps/server/src/modules/canvas/preprocess-access.route.test.ts @@ -70,6 +70,7 @@ const carol = (owner: boolean): ResolvedIdentity => ({ access: { can: async (_level, { id }) => id === 'mine', claim: async () => {}, + workspaces: async () => 'all' as const, }, }); diff --git a/apps/server/src/modules/canvas/space-hierarchy.test.ts b/apps/server/src/modules/canvas/space-hierarchy.test.ts index acbcb6a54..93992ab9f 100644 --- a/apps/server/src/modules/canvas/space-hierarchy.test.ts +++ b/apps/server/src/modules/canvas/space-hierarchy.test.ts @@ -878,6 +878,7 @@ describe('Space ownership', () => { ); if (failClaim) throw new Error('grant store unavailable'); }, + workspaces: async () => 'all', }, }); }); diff --git a/apps/server/src/modules/identity/bubble.test.ts b/apps/server/src/modules/identity/bubble.test.ts index 5b4602dc9..62f0ef333 100644 --- a/apps/server/src/modules/identity/bubble.test.ts +++ b/apps/server/src/modules/identity/bubble.test.ts @@ -7,10 +7,12 @@ import { InMemoryBubbleStore } from '@octostaff/bubble'; import Fastify from 'fastify'; import { afterEach, describe, expect, it } from 'vitest'; -import { openBubbleIdentity } from './bubble.js'; +import { migrateSpaceGrants, openBubbleIdentity } from './bubble.js'; import { registerIdentity } from './http.js'; +import { createMemoryWorkspacePreferences } from './preferences.js'; import { getRequestIdentity } from './request.js'; import { isOwnerRequest } from '../security/owner.js'; +import { getWorkspaceHandle } from '../workspace.js'; import { registerSpaceAccess } from '../security/space-access.js'; import type { HuabuResource, ResourceDirectory } from './service.js'; @@ -60,17 +62,43 @@ async function revokeAll( await store.grants.remove(row.grantId); } -/** Workspace `w1` holds Spaces `a` and `b`, `a` holds `a1`; `w2` holds `c`. */ -const PARENTS: Record = { - a: { type: 'workspace', id: 'w1' }, - a1: { type: 'space', id: 'a' }, - b: { type: 'workspace', id: 'w1' }, - c: { type: 'workspace', id: 'w2' }, -}; -const RESOURCES: ResourceDirectory = { - workspaceExists: async (id) => id === 'w1' || id === 'w2', - spaceParent: async (id) => PARENTS[id] ?? null, +/** + * Workspace `w1` holds Spaces `a` and `b`, `a` holds `a1`; `w2` holds `c`. + * Workspaces made at runtime hold nothing. + */ +const PARENTS: Record> = { + w1: { + a: { type: 'workspace', id: 'w1' }, + a1: { type: 'space', id: 'a' }, + b: { type: 'workspace', id: 'w1' }, + }, + w2: { c: { type: 'workspace', id: 'w2' } }, }; +const scopeOf = (workspaceId: string) => ({ + handle: { workspaceId, name: workspaceId }, + path: null, +}); +function directory(): ResourceDirectory & { created: string[] } { + const created: string[] = []; + const exists = (id: string) => id in PARENTS || created.includes(id); + return { + created, + workspaceExists: async (id) => exists(id), + spaceParent: async (workspaceId, id) => PARENTS[workspaceId]?.[id] ?? null, + workspaceOfSpace: async (id) => + Object.keys(PARENTS).find((w) => id in PARENTS[w]) ?? null, + openWorkspace: async (id) => (exists(id) ? scopeOf(id) : null), + defaultWorkspace: async () => scopeOf('w1'), + createWorkspace: async (name) => { + created.push(`made-${created.length + 1}`); + return { + ...scopeOf(created.at(-1)!), + handle: { workspaceId: created.at(-1)!, name }, + }; + }, + }; +} +const RESOURCES = directory(); const servers: FastifyInstance[] = []; afterEach(async () => { @@ -84,19 +112,22 @@ async function host( owner?: string; revalidationIntervalMs?: number; secureOrigin?: boolean; + store?: BubbleStore; } = {}, ) { - const store = new InMemoryBubbleStore(); + const store = options.store ?? new InMemoryBubbleStore(); const lookups: string[] = []; + const resources = directory(); const bubble = await openBubbleIdentity({ authenticator: options.authenticator ?? AUTHENTICATORS, owner: 'owner' in options ? options.owner : 'admin', store, + preferences: createMemoryWorkspacePreferences(), resources: { - ...RESOURCES, - spaceParent: async (id) => { - lookups.push(id); - return RESOURCES.spaceParent(id); + ...resources, + spaceParent: async (workspaceId, id) => { + lookups.push(`${workspaceId}:${id}`); + return resources.spaceParent(workspaceId, id); }, }, }); @@ -112,25 +143,43 @@ async function host( registerSpaceAccess(app); app.get('/', { config: { identity: 'shell' } }, async () => 'shell'); app.get('/api/data', async () => ({ private: true })); - // Answers what the caller may do with each Space, and claims one on POST. + app.get('/api/workspace', async () => ({ + workspaceId: getWorkspaceHandle()?.workspaceId ?? null, + })); + app.post<{ Params: { id: string } }>( + '/api/choose/:id', + async (request, reply) => + (await bubble.service.chooseWorkspace!( + getRequestIdentity(request)!, + request.params.id, + )) + ? {} + : reply.code(403).send({}), + ); + // Answers what the caller may do with each Space of the Workspace the + // request runs in, and with that Workspace; claims a Space on POST. app.get('/api/access', async (request) => { const { access } = getRequestIdentity(request)!; - const levels = async (id: string) => + const levels = async (resource: HuabuResource) => ( await Promise.all( - (['read', 'write'] as const).map(async (level) => - (await access.can(level, { type: 'space', id })) ? level : '', + (['read', 'write', 'manage'] as const).map(async (level) => + (await access.can(level, resource)) ? level[0] : '', ), ) ).join(''); - return Object.fromEntries( - await Promise.all( - ['a', 'a1', 'b', 'c', 'missing'].map(async (id) => [ - id, - await levels(id), - ]), + const workspaceId = getWorkspaceHandle()?.workspaceId ?? ''; + return { + workspace: await levels({ type: 'workspace', id: workspaceId }), + ...Object.fromEntries( + await Promise.all( + ['a', 'a1', 'b', 'c', 'missing'].map(async (id) => [ + id, + await levels({ type: 'space', id }), + ]), + ), ), - ); + }; }); app.post<{ Params: { id: string } }>('/api/claim/:id', async (request) => { await getRequestIdentity(request)!.access.claim({ @@ -149,7 +198,7 @@ async function host( }; app.get('/api/events', events); app.get('/api/canvas/:canvasId/events', events); - return { app, store, lookups }; + return { app, store, lookups, resources }; } /** Whether a WebSocket on `path` opens and, when asked, carries a clean reply. */ @@ -374,7 +423,7 @@ describe('Bubble identity', () => { const { app, store } = await host({ revalidationIntervalMs: 20 }); const origin = await app.listen({ port: 0, host: '127.0.0.1' }); await grant(store, 'carol', 'member'); - const space = { type: 'huabu.space', id: 'b' } as const; + const space = { type: 'huabu.space', id: 'w1:b' } as const; await grant(store, 'carol', 'huabu.space.owner', space); const response = await fetch(`${origin}/api/canvas/b/events`, { headers: as('carol'), @@ -394,8 +443,8 @@ describe('Bubble identity', () => { it('answers every check in a request from one policy scope', async () => { const { app, lookups } = await host(); await app.inject({ url: '/api/access', headers: MASTER }); - // Ten questions about five Spaces, each Space resolved once. - expect(lookups.filter((id) => id === 'a')).toHaveLength(1); + // Eighteen questions, each Space resolved once. + expect(lookups.filter((id) => id === 'w1:a')).toHaveLength(1); }); it('serves the application shell to a browser that has not signed in', async () => { @@ -405,75 +454,199 @@ describe('Bubble identity', () => { expect((await app.inject('/api/data')).statusCode).toBe(401); }); - it('lets a member reach only the Spaces they own, and a Workspace owner every Space in it', async () => { + it('gives a new member one personal Workspace they own, however many first requests race', async () => { + const { app, store, resources } = await host(); + await grant(store, 'carol', 'member'); + const workspaces = await Promise.all( + Array.from({ length: 5 }, async () => + ( + await app.inject({ url: '/api/workspace', headers: as('carol') }) + ).json(), + ), + ); + expect(resources.created).toEqual(['made-1']); + expect(new Set(workspaces.map((w) => w.workspaceId))).toEqual( + new Set(['made-1']), + ); + const access = await app.inject({ + url: '/api/access', + headers: as('carol'), + }); + expect(access.json()).toMatchObject({ workspace: 'rwm', a: '', c: '' }); + + // A login without `member` gets no Workspace at all. + await app.inject({ url: '/api/identity', headers: as('erin') }); + expect(resources.created).toEqual(['made-1']); + }); + + it('gives each Workspace role its capabilities on every Space inside', async () => { const { app, store } = await host(); + const w1 = { type: 'huabu.workspace', id: 'w1' } as const; + const roles = { + olive: 'huabu.workspace.owner', + eddie: 'huabu.workspace.editor', + vera: 'huabu.workspace.viewer', + } as const; + for (const [principalId, role] of Object.entries(roles)) { + await grant(store, principalId, 'member'); + await grant(store, principalId, role, w1); + } const access = async (principalId: string) => ( await app.inject({ url: '/api/access', headers: as(principalId) }) ).json(); - for (const principalId of ['carol', 'dave']) - await grant(store, principalId, 'member'); - const none = { a: '', a1: '', b: '', c: '', missing: '' }; - expect(await access('carol')).toEqual(none); - - // Creating a Space makes its creator the owner of it and the Spaces inside - // it, and nobody else. - const claimed = await app.inject({ - method: 'POST', - url: '/api/claim/a', - headers: as('carol'), - }); - expect(claimed.statusCode).toBe(200); - expect(await access('carol')).toEqual({ - ...none, - a: 'readwrite', - a1: 'readwrite', + const of = (level: string) => ({ + workspace: level, + a: level, + a1: level, + b: level, + c: '', + missing: '', }); - expect(await access('dave')).toEqual(none); + expect(await access('olive')).toEqual(of('rwm')); + expect(await access('eddie')).toEqual(of('rw')); + expect(await access('vera')).toEqual(of('r')); + // The system owner reaches every Space that exists. + expect(await access('admin')).toEqual(of('rwm')); + }); - // A Workspace owner reaches its Spaces, not another Workspace's. - await grant(store, 'dave', 'huabu.workspace.owner', { - type: 'huabu.workspace', - id: 'w1', + it('scopes a Space grant to the Workspace it names', async () => { + const { app, store } = await host(); + await grant(store, 'carol', 'member'); + // Owning `a` in w1 enters w1, and reaches `a` and the Spaces inside it. + await grant(store, 'carol', 'huabu.space.owner', { + type: 'huabu.space', + id: 'w1:a', + }); + const access = async () => + (await app.inject({ url: '/api/access', headers: as('carol') })).json(); + expect(await access()).toEqual({ + workspace: '', + a: 'rwm', + a1: 'rwm', + b: '', + c: '', + missing: '', }); - expect(await access('dave')).toEqual({ - ...none, - a: 'readwrite', - a1: 'readwrite', - b: 'readwrite', + // The same id in another Workspace is another Space. + await grant(store, 'dave', 'member'); + await grant(store, 'dave', 'huabu.space.owner', { + type: 'huabu.space', + id: 'w2:a', }); + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/choose/w1', + headers: as('dave'), + }) + ).statusCode, + ).toBe(403); - // The system owner reaches every Space that exists. - expect(await access('admin')).toEqual({ - a: 'readwrite', - a1: 'readwrite', - b: 'readwrite', - c: 'readwrite', - missing: '', + // Creating a Space claims it in the Workspace the request runs in. + await app.inject({ + method: 'POST', + url: '/api/claim/b', + headers: as('carol'), }); + expect( + (await store.grants.list({ principalId: 'carol' })).map( + (row) => row.target, + ), + ).toContainEqual({ type: 'huabu.space', id: 'w1:b' }); }); - it('leaves granting Space ownership to system owners', async () => { + it('switches only its caller, and only into a Workspace they may enter', async () => { const { app, store } = await host(); await grant(store, 'carol', 'member'); - await app.inject({ - method: 'POST', - url: '/api/claim/a', - headers: as('carol'), + await grant(store, 'dave', 'member'); + await grant(store, 'carol', 'huabu.workspace.viewer', { + type: 'huabu.workspace', + id: 'w2', + }); + const current = async (principalId: string) => + ( + await app.inject({ url: '/api/workspace', headers: as(principalId) }) + ).json().workspaceId; + expect(await current('carol')).toBe('w2'); + expect(await current('dave')).toBe('made-1'); + const choose = (principalId: string, id: string) => + app.inject({ + method: 'POST', + url: `/api/choose/${id}`, + headers: as(principalId), + }); + expect((await choose('carol', 'w1')).statusCode).toBe(403); + expect((await choose('carol', 'made-1')).statusCode).toBe(403); + expect((await choose('admin', 'w2')).statusCode).toBe(200); + expect([await current('admin'), await current('carol')]).toEqual([ + 'w2', + 'w2', + ]); + expect((await choose('admin', 'w1')).statusCode).toBe(200); + expect([await current('admin'), await current('carol')]).toEqual([ + 'w1', + 'w2', + ]); + }); + + it('lets whoever manages a Workspace or Space share it, and nobody else', async () => { + const { app, store } = await host(); + for (const principalId of ['olive', 'eddie']) + await grant(store, principalId, 'member'); + await grant(store, 'olive', 'huabu.workspace.owner', { + type: 'huabu.workspace', + id: 'w1', + }); + await grant(store, 'eddie', 'huabu.workspace.editor', { + type: 'huabu.workspace', + id: 'w1', }); - const share = (principalId: string) => + const share = (principalId: string, target: AuthzTarget, role: string) => app.inject({ method: 'POST', url: '/bubble/v1/grants', headers: as(principalId), - payload: { - target: { type: 'huabu.space', id: 'a' }, - principalId: 'dave', - role: 'huabu.space.owner', - }, + payload: { target, principalId: 'frank', role }, + }); + const space = { type: 'huabu.space', id: 'w1:a' } as const; + const workspace = { type: 'huabu.workspace', id: 'w1' } as const; + expect((await share('eddie', space, 'huabu.space.owner')).statusCode).toBe( + 403, + ); + expect( + (await share('eddie', workspace, 'huabu.workspace.viewer')).statusCode, + ).toBe(403); + expect( + (await share('olive', space, 'huabu.space.owner')).statusCode, + ).toBeLessThan(300); + expect( + (await share('olive', workspace, 'huabu.workspace.viewer')).statusCode, + ).toBeLessThan(300); + // System roles stay the administrator's. + expect( + (await share('olive', { type: 'system' }, 'member')).statusCode, + ).toBe(403); + }); + + it("rewrites #237's Space grants to name their Workspace, once", async () => { + const store = new InMemoryBubbleStore(); + for (const id of ['a', 'c', 'gone']) + await grant(store, 'carol', 'huabu.space.owner', { + type: 'huabu.space', + id, }); - expect((await share('carol')).statusCode).toBe(403); - expect((await share('admin')).statusCode).toBeLessThan(300); + await migrateSpaceGrants(store, RESOURCES); + await migrateSpaceGrants(store, RESOURCES); + expect( + (await store.grants.list({ principalId: 'carol' })).map( + (row) => row.target, + ), + ).toEqual([ + { type: 'huabu.space', id: 'w1:a' }, + { type: 'huabu.space', id: 'w2:c' }, + ]); }); it('carries a validated Bearer credential in an HttpOnly session cookie', async () => { @@ -557,6 +730,7 @@ describe('Bubble identity', () => { openBubbleIdentity({ authenticator: [{ driver: 'no-such-driver' }] as never, store: new InMemoryBubbleStore(), + preferences: createMemoryWorkspacePreferences(), resources: RESOURCES, }), ).rejects.toThrow(); diff --git a/apps/server/src/modules/identity/bubble.ts b/apps/server/src/modules/identity/bubble.ts index b67296c55..9596cc1e9 100644 --- a/apps/server/src/modules/identity/bubble.ts +++ b/apps/server/src/modules/identity/bubble.ts @@ -18,9 +18,19 @@ import { import { identityPrincipalSchema } from '@huabu/shared'; +import { getWorkspaceHandle } from '../workspace.js'; import { IdentityError } from './service.js'; +import { + chooseRequestWorkspace, + selectRequestWorkspace, +} from './workspace-selection.js'; -import type { IdentityService, ResourceDirectory } from './service.js'; +import type { WorkspacePreferences } from './preferences.js'; +import type { + HuabuResource, + IdentityService, + ResourceDirectory, +} from './service.js'; import type { BubbleAuthzOptions, BubbleStore, @@ -30,26 +40,44 @@ import type { FastifyPluginAsync } from 'fastify'; /** * Huabu's permission model, kept in Bubble's grant table and answered by - * Bubble's policy. On Bubble's system target, a `member` may use Huabu and an - * `owner` may also manage it; a system owner also holds every Huabu capability - * on every Workspace and Space. A Workspace owner may read and write every - * Space in that Workspace; a Space owner, that Space and the Spaces inside it. - * Only a system owner grants the ownership roles, so there is no sharing - * between members. + * Bubble's policy (`docs/proposals/multi-user-service.md`, "Roles and + * capabilities"). On the system target a `member` may use Huabu and an `owner` + * may also administer the deployment; Bubble's system-owner rule gives the + * latter every capability on every Workspace and Space too. On a Workspace or + * a Space the same three capability names apply, and they flow down from a + * Workspace to its root Spaces and from a Space to the Spaces inside it. */ const USE = 'huabu.use'; -const MANAGE = 'huabu.manage'; -const READ = 'huabu.space.read'; -const WRITE = 'huabu.space.write'; -const TARGET = { workspace: 'huabu.workspace', space: 'huabu.space' } as const; -const OWNER_ROLE = { - workspace: 'huabu.workspace.owner', - space: 'huabu.space.owner', +const ADMIN = 'huabu.admin'; +const CAPABILITY = { + read: 'huabu.read', + write: 'huabu.write', + manage: 'huabu.manage', } as const; +const { read: READ, write: WRITE, manage: MANAGE } = CAPABILITY; +const TARGET = { workspace: 'huabu.workspace', space: 'huabu.space' } as const; +const SPACE_OWNER = 'huabu.space.owner'; + +/** + * A Space's Bubble target names its Workspace too, so a Space can later be + * shared on its own and two Workspaces can never share a grant by id. + */ +export function spaceTargetId(workspaceId: string, canvasId: string): string { + return `${workspaceId}:${canvasId}`; +} + +function parseSpaceTargetId( + id: string, +): { workspaceId: string; canvasId: string } | null { + const at = id.indexOf(':'); + if (at <= 0 || at === id.length - 1) return null; + return { workspaceId: id.slice(0, at), canvasId: id.slice(at + 1) }; +} function huabuAuthz(resources: ResourceDirectory): BubbleAuthzOptions { + const flowing = [READ, WRITE, MANAGE]; return { - coreRoleCapabilities: { member: [USE], owner: [USE, MANAGE] }, + coreRoleCapabilities: { member: [USE], owner: [USE, ADMIN] }, targets: { [TARGET.workspace]: { resolve: async (id) => @@ -57,32 +85,84 @@ function huabuAuthz(resources: ResourceDirectory): BubbleAuthzOptions { }, [TARGET.space]: { resolve: async (id) => { - const parent = await resources.spaceParent(id); - return parent - ? { parent: { type: TARGET[parent.type], id: parent.id } } - : null; + const target = parseSpaceTargetId(id); + if (!target) return null; + const parent = await resources.spaceParent( + target.workspaceId, + target.canvasId, + ); + if (!parent) return null; + return { + parent: + parent.type === 'space' + ? { + type: TARGET.space, + id: spaceTargetId(target.workspaceId, parent.id), + } + : { type: TARGET.workspace, id: parent.id }, + }; }, }, }, + // Granting a role on a Workspace or Space takes `huabu.manage` there, so + // an owner can share what they own; nothing here grants system roles. roles: { - [OWNER_ROLE.workspace]: { + 'huabu.workspace.owner': { + grantableOn: [TARGET.workspace], + capabilities: [READ, WRITE, MANAGE], + managementCapability: MANAGE, + }, + 'huabu.workspace.editor': { grantableOn: [TARGET.workspace], capabilities: [READ, WRITE], managementCapability: MANAGE, }, - [OWNER_ROLE.space]: { + 'huabu.workspace.viewer': { + grantableOn: [TARGET.workspace], + capabilities: [READ], + managementCapability: MANAGE, + }, + [SPACE_OWNER]: { grantableOn: [TARGET.space], - capabilities: [READ, WRITE], + capabilities: [READ, WRITE, MANAGE], managementCapability: MANAGE, }, }, inheritance: [ - { from: TARGET.workspace, to: TARGET.space, capabilities: [READ, WRITE] }, - { from: TARGET.space, to: TARGET.space, capabilities: [READ, WRITE] }, + { from: TARGET.workspace, to: TARGET.space, capabilities: flowing }, + { from: TARGET.space, to: TARGET.space, capabilities: flowing }, ], }; } +/** + * Rewrite #237's Space grants, which named a Space by id alone, to name the + * Workspace holding it. A grant on a Space no Workspace holds is dropped. + * Idempotent: qualified grants are left alone. + */ +export async function migrateSpaceGrants( + store: BubbleStore, + resources: ResourceDirectory, +): Promise { + for (const grant of await store.grants.list()) { + const { target } = grant; + if (target.type !== TARGET.space || !('id' in target)) continue; + if (parseSpaceTargetId(target.id)) continue; + const workspaceId = await resources.workspaceOfSpace(target.id); + if (workspaceId) + await store.grants.add({ + target: { + type: TARGET.space, + id: spaceTargetId(workspaceId, target.id), + }, + principalId: grant.principalId, + role: grant.role, + grantedByPrincipalId: grant.grantedByPrincipalId ?? null, + }); + await store.grants.remove(grant.grantId); + } +} + /** Bubble's records live beside Huabu's, in a schema of their own. */ export async function openBubblePostgresStore( url: string, @@ -109,9 +189,10 @@ export async function openBubbleIdentity(options: { /** Principal provisioned as Bubble system owner. */ owner?: string; store: BubbleStore; + preferences: WorkspacePreferences; resources: ResourceDirectory; }): Promise<{ service: IdentityService; plugin: FastifyPluginAsync }> { - const { store, owner } = options; + const { store, owner, preferences } = options; const runtime = await createBubbleRuntime({ store, authenticator: options.authenticator, @@ -122,6 +203,12 @@ export async function openBubbleIdentity(options: { await store.close?.(); throw error; }); + await migrateSpaceGrants(store, options.resources).catch( + async (error: unknown) => { + await store.close?.(); + throw error; + }, + ); if (owner) { // Created without an external identity, which suits master-key callers; a // federated user is granted ownership through Bubble after first login. @@ -147,6 +234,15 @@ export async function openBubbleIdentity(options: { service: { provider: 'bubble', challenge: 'Bearer realm="Huabu"', + selectWorkspace: (identity) => + selectRequestWorkspace(identity, preferences, options.resources), + chooseWorkspace: (identity, workspaceId) => + chooseRequestWorkspace( + identity, + preferences, + options.resources, + workspaceId, + ), async authenticate({ authorization, header }) { if (!authorization || !/^Bearer\s+\S+$/i.test(authorization)) return null; @@ -169,24 +265,62 @@ export async function openBubbleIdentity(options: { // One grant load and ancestry walk per request; a later request or // stream revalidation authenticates again and gets a fresh scope. const policy = runtime.policy.scoped(); + const admin = await policy.can(ADMIN, principalId, { + type: 'system', + }); + // A Space is the one of that id in the Workspace this call runs in. + const target = (resource: HuabuResource) => { + if (resource.type === 'workspace') + return { type: TARGET.workspace, id: resource.id }; + const workspaceId = getWorkspaceHandle()?.workspaceId; + return workspaceId + ? { + type: TARGET.space, + id: spaceTargetId(workspaceId, resource.id), + } + : null; + }; return { principal: identityPrincipalSchema.parse(whoami.principal), member: await policy.can(USE, principalId, { type: 'system' }), - owner: await policy.can(MANAGE, principalId, { type: 'system' }), + owner: admin, access: { - can: (level, resource) => - policy.can(level === 'read' ? READ : WRITE, principalId, { - type: TARGET[resource.type], - id: resource.id, - }), + can: async (level, resource) => { + const resolved = target(resource); + return resolved + ? policy.can(CAPABILITY[level], principalId, resolved) + : false; + }, claim: async (resource) => { + const resolved = target(resource); + if (!resolved) + throw new Error('A Space is claimed inside a Workspace'); await store.grants.add({ - target: { type: TARGET[resource.type], id: resource.id }, + target: resolved, principalId, - role: OWNER_ROLE[resource.type], + role: + resource.type === 'space' + ? SPACE_OWNER + : 'huabu.workspace.owner', grantedByPrincipalId: principalId, }); }, + // A Space shared on its own makes its Workspace enterable; what + // the caller sees there is still decided Space by Space. + workspaces: async () => { + if (admin) return 'all'; + const ids = new Set(); + for (const grant of await store.grants.list({ principalId })) { + const granted = grant.target; + if (!('id' in granted)) continue; + if (granted.type === TARGET.workspace) ids.add(granted.id); + if (granted.type === TARGET.space) { + const space = parseSpaceTargetId(granted.id); + if (space) ids.add(space.workspaceId); + } + } + return [...ids]; + }, }, }; } catch (error) { @@ -237,6 +371,7 @@ export async function openBubbleIdentity(options: { app.server.off('upgrade', forward); await runtime.stop(); await store.close?.(); + await preferences.close(); }); }, }; diff --git a/apps/server/src/modules/identity/http.ts b/apps/server/src/modules/identity/http.ts index 31f40e518..4c0018e23 100644 --- a/apps/server/src/modules/identity/http.ts +++ b/apps/server/src/modules/identity/http.ts @@ -11,6 +11,10 @@ import { import { IdentityError, UNRESTRICTED_ACCESS } from './service.js'; import { isPublicRfsSkillBootstrapRequest } from '../remote_fs/public-skill.js'; import { isLoopbackRequest } from '../security/peer.js'; +import { + fillWorkspaceScope, + openWorkspaceScope, +} from '../workspace/context.js'; import type { IdentityCredentials, IdentityService } from './service.js'; import type { IdentityResponse } from '@huabu/shared'; @@ -72,6 +76,13 @@ export function registerIdentity( app.addHook('preClose', async () => { for (const close of activeResponses) close(); }); + // In Bubble mode every request runs in its caller's own Workspace. The slot + // must wrap the rest of the lifecycle, which only a callback hook can do; + // admission fills it below once it knows who is calling. + if (service.provider === 'bubble') + app.addHook('onRequest', (_request, _reply, done) => + openWorkspaceScope(done), + ); app.addHook('onRequest', async (request, reply) => { const path = request.url.split('?', 1)[0]; const identityRoute = path === '/api/identity' || path === SESSION_PATH; @@ -160,6 +171,12 @@ export function registerIdentity( code: 'ACCESS_REQUIRED', }); } + // A member's first request, `GET /api/identity` after sign-in included, + // is also where their personal Workspace is created. + if (identity.member && service.selectWorkspace) { + const scope = await service.selectWorkspace(identity); + if (scope) fillWorkspaceScope(scope); + } if ( service.provider === 'bubble' && !reply.raw.destroyed && diff --git a/apps/server/src/modules/identity/index.ts b/apps/server/src/modules/identity/index.ts index 01cbe29f4..630f72a55 100644 --- a/apps/server/src/modules/identity/index.ts +++ b/apps/server/src/modules/identity/index.ts @@ -3,6 +3,7 @@ import { resolveIdentityConfig } from './config.js'; import { createLocalIdentityService } from './local.js'; +import { openPostgresWorkspacePreferences } from './preferences.js'; import { storageResourceDirectory } from './resources.js'; import type { IdentityService, ResourceDirectory } from './service.js'; @@ -39,6 +40,7 @@ export function createIdentity( authenticator: authenticators as BubbleAuthConfig, owner, store: await bubble.openBubblePostgresStore(postgresUrl), + preferences: await openPostgresWorkspacePreferences(postgresUrl), resources, }); } @@ -48,6 +50,13 @@ export function createIdentity( challenge: 'Bearer realm="Huabu"', authenticate: async (credentials) => (await (opening ??= open())).service.authenticate(credentials), + selectWorkspace: async (identity) => + (await (opening ??= open())).service.selectWorkspace!(identity), + chooseWorkspace: async (identity, workspaceId) => + (await (opening ??= open())).service.chooseWorkspace!( + identity, + workspaceId, + ), }, bubble: async (app, options) => (await (opening ??= open())).plugin(app, options), diff --git a/apps/server/src/modules/identity/preferences.ts b/apps/server/src/modules/identity/preferences.ts new file mode 100644 index 000000000..a41526c2a --- /dev/null +++ b/apps/server/src/modules/identity/preferences.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Pool } from 'pg'; + +/** + * Each person's current Workspace, in Bubble mode. + * + * Huabu's own record about a Bubble principal, referencing its `principalId` + * as #228 proposes, so Bubble stays the only account directory. It lives in + * the Postgres Bubble already requires, in a schema of its own, whatever + * backend holds Huabu's content. + */ +export interface WorkspacePreferences { + current(principalId: string): Promise; + select(principalId: string, workspaceId: string): Promise; + close(): Promise; +} + +const SCHEMA = 'huabu_identity'; + +export async function openPostgresWorkspacePreferences( + url: string, +): Promise { + const pool = new Pool({ connectionString: url, max: 2 }); + pool.on('error', () => {}); + try { + await pool.query(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA}`); + await pool.query( + `CREATE TABLE IF NOT EXISTS ${SCHEMA}.current_workspaces ( + principal_id text PRIMARY KEY, + workspace_id text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() + )`, + ); + } catch (error) { + await pool.end(); + throw error; + } + return { + async current(principalId) { + const { rows } = await pool.query<{ workspace_id: string }>( + `SELECT workspace_id FROM ${SCHEMA}.current_workspaces WHERE principal_id = $1`, + [principalId], + ); + return rows[0]?.workspace_id ?? null; + }, + async select(principalId, workspaceId) { + await pool.query( + `INSERT INTO ${SCHEMA}.current_workspaces (principal_id, workspace_id) + VALUES ($1, $2) + ON CONFLICT (principal_id) + DO UPDATE SET workspace_id = EXCLUDED.workspace_id, updated_at = now()`, + [principalId, workspaceId], + ); + }, + close: () => pool.end(), + }; +} + +/** For tests and single-process use without a database. */ +export function createMemoryWorkspacePreferences(): WorkspacePreferences { + const current = new Map(); + return { + current: async (principalId) => current.get(principalId) ?? null, + select: async (principalId, workspaceId) => { + current.set(principalId, workspaceId); + }, + close: async () => {}, + }; +} diff --git a/apps/server/src/modules/identity/resources.ts b/apps/server/src/modules/identity/resources.ts index 07f31d1bd..9163f09fc 100644 --- a/apps/server/src/modules/identity/resources.ts +++ b/apps/server/src/modules/identity/resources.ts @@ -1,27 +1,59 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { getWorkspaceRepository, space } from '../storage/index.js'; -import { getWorkspaceHandle } from '../workspace.js'; +import { + getWorkspaceRepository, + openWorkspace, + space, +} from '../storage/index.js'; +import { runInWorkspace } from '../workspace/context.js'; +import { createWorkspace } from '../workspace-activation.js'; +import { getProcessWorkspaceHandle } from '../workspace.js'; import type { ResourceDirectory } from './service.js'; /** - * Huabu storage as the identity provider sees it. Only the active Workspace's - * Spaces exist here, World included, so a Space in any other Workspace is - * denied until Workspaces are served side by side. A child Space sits under - * its parent Space, so whoever may use a Space may use the Spaces inside it. + * Huabu storage as the identity provider sees it. A Space is found in the + * Workspace its grant names, World included, whichever Workspace the caller is + * in. A child Space sits under its parent Space, so whoever may use a Space + * may use the Spaces inside it. */ export const storageResourceDirectory: ResourceDirectory = { async workspaceExists(workspaceId) { return (await getWorkspaceRepository().get(workspaceId)) !== null; }, - async spaceParent(canvasId) { - const workspaceId = getWorkspaceHandle()?.workspaceId; - const record = workspaceId ? await space(canvasId).read() : null; - if (!workspaceId || !record) return null; + async spaceParent(workspaceId, canvasId) { + const workspace = await getWorkspaceRepository().get(workspaceId); + if (!workspace) return null; + const scope = await openWorkspace(workspace); + const record = await runInWorkspace(scope, () => space(canvasId).read()); + if (!record) return null; return record.parentSpaceId ? { type: 'space', id: record.parentSpaceId } : { type: 'workspace', id: workspaceId }; }, + async workspaceOfSpace(canvasId) { + for (const workspace of await getWorkspaceRepository().list()) { + try { + const scope = await openWorkspace(workspace); + if (await runInWorkspace(scope, () => space(canvasId).read())) + return workspace.workspaceId; + } catch { + // A Workspace whose folder is gone holds nothing to find. + } + } + return null; + }, + async openWorkspace(workspaceId) { + const workspace = await getWorkspaceRepository().get(workspaceId); + return workspace ? openWorkspace(workspace) : null; + }, + async defaultWorkspace() { + const workspace = + getProcessWorkspaceHandle() ?? (await getWorkspaceRepository().list())[0]; + return workspace ? openWorkspace(workspace) : null; + }, + async createWorkspace(name) { + return openWorkspace(await createWorkspace(name)); + }, }; diff --git a/apps/server/src/modules/identity/service.ts b/apps/server/src/modules/identity/service.ts index 2a2e6fe17..30fc16a28 100644 --- a/apps/server/src/modules/identity/service.ts +++ b/apps/server/src/modules/identity/service.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import type { WorkspaceScope } from '../workspace/context.js'; import type { IdentityPrincipal, IdentityResponse } from '@huabu/shared'; /** Supplied by a trusted transport, never copied from a request body. */ @@ -15,7 +16,10 @@ export interface ResolvedIdentity { principal: IdentityPrincipal; /** May use Huabu; every owner is a member. */ member: boolean; - /** May also change settings, credentials and agent configuration. */ + /** + * Administers the deployment (`huabu.admin`): settings, credentials, agent + * configuration and folder Workspaces. Every Workspace and Space, too. + */ owner: boolean; /** What this caller may do with individual Workspaces and Spaces. */ access: ResourceAccess; @@ -27,10 +31,22 @@ export interface HuabuResource { id: string; } +/** + * A Space is named by its id alone: it is always the Space of that id in the + * Workspace the call runs in (`../workspace/context.ts`). + */ +/** `manage`: rename, delete, grant on it; for a Workspace, its memory and Skills. */ +export type AccessLevel = 'read' | 'write' | 'manage'; + export interface ResourceAccess { - can(level: 'read' | 'write', resource: HuabuResource): Promise; + can(level: AccessLevel, resource: HuabuResource): Promise; /** Make the caller the owner of a resource it has just created. */ claim(resource: HuabuResource): Promise; + /** + * The Workspaces the caller may enter, or `'all'` for a caller who may + * enter any registered one. + */ + workspaces(): Promise; } /** @@ -40,6 +56,7 @@ export interface ResourceAccess { export const UNRESTRICTED_ACCESS: ResourceAccess = { can: async () => true, claim: async () => {}, + workspaces: async () => 'all', }; /** Where Huabu's resources sit, as the provider needs to know to authorize them. */ @@ -47,9 +64,20 @@ export interface ResourceDirectory { workspaceExists(workspaceId: string): Promise; /** * The Space's parent Space, or its Workspace when it is a root; `null` when - * there is no such Space. + * there is no such Space in that Workspace. */ - spaceParent(canvasId: string): Promise; + spaceParent( + workspaceId: string, + canvasId: string, + ): Promise; + /** The Workspace holding a Space, for migrating grants that named none. */ + workspaceOfSpace(canvasId: string): Promise; + /** A registered Workspace, opened for calls to run in; `null` if none. */ + openWorkspace(workspaceId: string): Promise; + /** Where a caller who may enter any Workspace lands first. */ + defaultWorkspace(): Promise; + /** A new Workspace, wherever this backend keeps one, opened. */ + createWorkspace(name: string): Promise; } /** Resolve afresh for each request so revocation is not hidden by a host cache. */ @@ -69,6 +97,18 @@ export interface IdentityService { * keeps its loopback rule for it; without this the machine is never an owner. */ machineOwner?(loopback: boolean): boolean; + /** + * The Workspace an admitted member's request runs in: their current one. + * Present when each request runs in its caller's own Workspace (Bubble + * mode); absent when the process-active Workspace serves everyone (local + * mode). + */ + selectWorkspace?(identity: ResolvedIdentity): Promise; + /** Make a Workspace the caller's current one; `null` when refused. */ + chooseWorkspace?( + identity: ResolvedIdentity, + workspaceId: string, + ): Promise; } export class IdentityError extends Error { diff --git a/apps/server/src/modules/identity/workspace-selection.ts b/apps/server/src/modules/identity/workspace-selection.ts new file mode 100644 index 000000000..2c8d63894 --- /dev/null +++ b/apps/server/src/modules/identity/workspace-selection.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { WorkspacePreferences } from './preferences.js'; +import type { ResolvedIdentity, ResourceDirectory } from './service.js'; +import type { WorkspaceScope } from '../workspace/context.js'; + +/** + * One personal-Workspace creation per principal, however many first requests + * race. Keyed by directory too, so separate hosts never share a creation. + */ +const provisioning = new WeakMap< + ResourceDirectory, + Map> +>(); + +/** + * The Workspace a Bubble request runs in: its caller's current one, while they + * may still enter it. Otherwise the first Workspace they may enter, which + * becomes current. A member who may enter none gets a personal Workspace that + * they own. `null` for a caller with no Workspace at all, such as an + * administrator before any exists. + */ +export async function selectRequestWorkspace( + identity: ResolvedIdentity, + preferences: WorkspacePreferences, + resources: ResourceDirectory, +): Promise { + const { principalId } = identity.principal; + const allowed = await identity.access.workspaces(); + const may = (workspaceId: string) => + allowed === 'all' || allowed.includes(workspaceId); + + const preferred = await preferences.current(principalId); + if (preferred && may(preferred)) { + const scope = await resources.openWorkspace(preferred); + if (scope) return scope; + } + + const chosen = await firstEnterable(allowed, resources); + if (chosen) { + await preferences.select(principalId, chosen.handle.workspaceId); + return chosen; + } + if (allowed === 'all') return null; + return provisionPersonalWorkspace(identity, preferences, resources); +} + +/** + * Make one Workspace its caller's current one. Refused, with nothing changed, + * for a Workspace the caller may not enter or that does not exist. + */ +export async function chooseRequestWorkspace( + identity: ResolvedIdentity, + preferences: WorkspacePreferences, + resources: ResourceDirectory, + workspaceId: string, +): Promise { + const allowed = await identity.access.workspaces(); + if (allowed !== 'all' && !allowed.includes(workspaceId)) return null; + const scope = await resources.openWorkspace(workspaceId); + if (scope) + await preferences.select(identity.principal.principalId, workspaceId); + return scope; +} + +async function firstEnterable( + allowed: readonly string[] | 'all', + resources: ResourceDirectory, +): Promise { + // An administrator lands where the Server already is, as before. + if (allowed === 'all') return resources.defaultWorkspace(); + for (const workspaceId of allowed) { + const scope = await resources.openWorkspace(workspaceId); + if (scope) return scope; + } + return null; +} + +function provisionPersonalWorkspace( + identity: ResolvedIdentity, + preferences: WorkspacePreferences, + resources: ResourceDirectory, +): Promise { + const { principalId, displayName } = identity.principal; + let pending = provisioning.get(resources); + if (!pending) provisioning.set(resources, (pending = new Map())); + const running = pending.get(principalId); + if (running) return running; + const creating = (async () => { + // A request that read this principal's grants before a racing first + // request claimed its Workspace still finds that Workspace here: the + // preference is only ever set to one the principal may enter. + const already = await preferences.current(principalId); + const existing = already ? await resources.openWorkspace(already) : null; + if (existing) return existing; + const scope = await resources.createWorkspace( + `${displayName?.trim() || principalId}'s Workspace`, + ); + await identity.access.claim({ + type: 'workspace', + id: scope.handle.workspaceId, + }); + await preferences.select(principalId, scope.handle.workspaceId); + return scope; + })().finally(() => pending.delete(principalId)); + pending.set(principalId, creating); + return creating; +} diff --git a/apps/server/src/modules/security/space-access.test.ts b/apps/server/src/modules/security/space-access.test.ts index e47461b5a..5aa6e4717 100644 --- a/apps/server/src/modules/security/space-access.test.ts +++ b/apps/server/src/modules/security/space-access.test.ts @@ -38,6 +38,7 @@ function host() { claim: async ({ id }) => { claimed.push(id); }, + workspaces: async () => 'all', }, }); }); diff --git a/apps/server/src/modules/workspace-activation.ts b/apps/server/src/modules/workspace-activation.ts index f78634902..c283e87cb 100644 --- a/apps/server/src/modules/workspace-activation.ts +++ b/apps/server/src/modules/workspace-activation.ts @@ -11,7 +11,9 @@ */ import { fork, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -21,6 +23,14 @@ import { resolveWorkspacePath, WorkspaceActivationInProgressError, } from './workspace.js'; +import { getDataDir } from '../data-dir.js'; +import { + adoptWorkspaceDirectory, + createNamedWorkspace, + getWorkspaceRepository, + materializesWorkspaces, + type WorkspaceHandle, +} from './storage/index.js'; import { getLogger } from '../utils/logger.js'; const DEFAULT_ACTIVATION_TIMEOUT_MS = 70_000; @@ -209,3 +219,23 @@ export async function activateWorkspacePath( reservation.release(); } } + +/** + * Create a Workspace by name, on any backend, without activating it. + * + * Where a Workspace is a row this is the repository's own create. On Disk it + * is a new folder under the data directory, prepared in the isolated worker + * like any other: the creator never chooses where it lives, which is what lets + * someone without access to the Server's filesystem have one. + */ +export async function createWorkspace(name: string): Promise { + if (!materializesWorkspaces()) return createNamedWorkspace(name); + const workspacePath = path.join(getDataDir(), 'workspaces', randomUUID()); + await mkdir(workspacePath, { recursive: true }); + await runWorkspacePreparation(workspacePath); + const adopted = adoptWorkspaceDirectory(workspacePath); + return ( + (await getWorkspaceRepository().rename(adopted.workspaceId, name)) ?? + adopted + ); +} diff --git a/apps/server/src/modules/workspace.route.test.ts b/apps/server/src/modules/workspace.route.test.ts index 0211a8cc8..ea4321bfc 100644 --- a/apps/server/src/modules/workspace.route.test.ts +++ b/apps/server/src/modules/workspace.route.test.ts @@ -101,6 +101,7 @@ describe('workspace Space repository integration', () => { access: { can: async () => request.headers['x-world'] === 'yes', claim: async () => {}, + workspaces: async () => 'all' as const, }, }); }); From 2c15df61a4f174e056f1e71f434d8b0608368e05 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:34:12 +0800 Subject: [PATCH 4/9] feat(security): require manage to rename or delete a Space 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 --- .../server/src/modules/canvas/canvas.route.ts | 67 +++++---- .../src/modules/canvas/space-card.route.ts | 140 +++++++++--------- .../modules/canvas/space-hierarchy.route.ts | 32 ++-- apps/server/src/modules/identity/request.ts | 35 ++++- .../src/modules/security/space-access.test.ts | 20 ++- .../src/modules/security/space-access.ts | 14 +- 6 files changed, 188 insertions(+), 120 deletions(-) diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts index 29d3d6be0..b5efbfb6f 100644 --- a/apps/server/src/modules/canvas/canvas.route.ts +++ b/apps/server/src/modules/canvas/canvas.route.ts @@ -713,38 +713,43 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { fastify.delete<{ Params: { canvasId: string }; Reply: ApiResult; - }>('/:canvasId', async function (request, reply) { - const { canvasId } = request.params; - return withSpaceHierarchyLock(async () => { - if (isWorldCanvasId(canvasId)) { - return reply - .code(403) - .send({ message: 'World canvas cannot be deleted' }); - } - try { - const preview = deletionPreview( - await getStructuredStore().spaces().list(), - canvasId, - ); - if (preview.spaces.length > 1) - return reply.code(409).send({ - code: 'SPACE_TREE_CONFIRMATION_REQUIRED', - message: 'Confirm the complete subtree before deleting this Space', - }); - await deleteSpaceTree(canvasId, preview.token); - } catch (error) { - if (error instanceof SpaceHierarchyError) - return reply.code(error.status).send({ - code: error.code, - message: error.message, - details: error.details, - }); - throw error; - } + }>( + '/:canvasId', + { config: { spaceAccess: 'manage' } }, + async function (request, reply) { + const { canvasId } = request.params; + return withSpaceHierarchyLock(async () => { + if (isWorldCanvasId(canvasId)) { + return reply + .code(403) + .send({ message: 'World canvas cannot be deleted' }); + } + try { + const preview = deletionPreview( + await getStructuredStore().spaces().list(), + canvasId, + ); + if (preview.spaces.length > 1) + return reply.code(409).send({ + code: 'SPACE_TREE_CONFIRMATION_REQUIRED', + message: + 'Confirm the complete subtree before deleting this Space', + }); + await deleteSpaceTree(canvasId, preview.token); + } catch (error) { + if (error instanceof SpaceHierarchyError) + return reply.code(error.status).send({ + code: error.code, + message: error.message, + details: error.details, + }); + throw error; + } - return reply.send({ success: true }); - }); - }); + return reply.send({ success: true }); + }); + }, + ); // Delete a node — removes its markdown sidecar. // diff --git a/apps/server/src/modules/canvas/space-card.route.ts b/apps/server/src/modules/canvas/space-card.route.ts index cbac72fa5..67a222f32 100644 --- a/apps/server/src/modules/canvas/space-card.route.ts +++ b/apps/server/src/modules/canvas/space-card.route.ts @@ -128,73 +128,81 @@ export function registerSpaceCardRoutes(app: FastifyInstance): void { } }); - app.patch('/:canvasId/title', async (request, reply) => { - const params = recentCanvasConversationParamsSchema.safeParse( - request.params, - ); - const body = renameSpaceBodySchema.safeParse(request.body); - if (!params.success || !body.success) - return reply.code(400).send({ message: 'Invalid Space rename' }); - const { canvasId } = params.data; - const lease = acquireWorkspaceOperationLease(); - try { - return await withCanvasMutex(canvasId, async () => { - const handle = space(canvasId); - const current = await handle.read(); - if (!current) - return reply.code(404).send({ message: 'Space not found' }); - if (current.title !== body.data.expectedTitle) - return reply.code(409).send({ - code: 'SPACE_TITLE_CHANGED', - message: 'The Space name changed. Refresh and try again.', - }); - const renamed = await getStructuredStore() - .spaces() - .rename({ canvasId, title: body.data.title }); - if (!renamed.ok) - return reply - .code( - renamed.reason === 'not-found' - ? 404 - : renamed.reason === 'world-forbidden' - ? 403 - : 409, - ) - .send({ - code: - renamed.reason === 'title-conflict' - ? 'CANVAS_TITLE_CONFLICT' - : 'SPACE_RENAME_REJECTED', - message: 'Unable to rename this Space', + app.patch( + '/:canvasId/title', + { config: { spaceAccess: 'manage' } }, + async (request, reply) => { + const params = recentCanvasConversationParamsSchema.safeParse( + request.params, + ); + const body = renameSpaceBodySchema.safeParse(request.body); + if (!params.success || !body.success) + return reply.code(400).send({ message: 'Invalid Space rename' }); + const { canvasId } = params.data; + const lease = acquireWorkspaceOperationLease(); + try { + return await withCanvasMutex(canvasId, async () => { + const handle = space(canvasId); + const current = await handle.read(); + if (!current) + return reply.code(404).send({ message: 'Space not found' }); + if (current.title !== body.data.expectedTitle) + return reply.code(409).send({ + code: 'SPACE_TITLE_CHANGED', + message: 'The Space name changed. Refresh and try again.', }); - const record = { - ...renamed.record, - version: current.version + 1, - updatedAt: Date.now(), - }; - const written = await handle.write({ - expectedVersion: current.version, - nextRecord: record, - nodeMutations: [], + const renamed = await getStructuredStore() + .spaces() + .rename({ canvasId, title: body.data.title }); + if (!renamed.ok) + return reply + .code( + renamed.reason === 'not-found' + ? 404 + : renamed.reason === 'world-forbidden' + ? 403 + : 409, + ) + .send({ + code: + renamed.reason === 'title-conflict' + ? 'CANVAS_TITLE_CONFLICT' + : 'SPACE_RENAME_REJECTED', + message: 'Unable to rename this Space', + }); + const record = { + ...renamed.record, + version: current.version + 1, + updatedAt: Date.now(), + }; + const written = await handle.write({ + expectedVersion: current.version, + nextRecord: record, + nodeMutations: [], + }); + if (!written.ok) + throw new Error( + `Space rename metadata write failed: ${written.reason}`, + ); + publishCanvasUpdate(canvasId, { + type: 'snapshot', + data: { version: record.version }, + }); + return reply.send({ + title: record.title, + updatedAt: record.updatedAt, + }); }); - if (!written.ok) - throw new Error( - `Space rename metadata write failed: ${written.reason}`, - ); - publishCanvasUpdate(canvasId, { - type: 'snapshot', - data: { version: record.version }, + } catch (error) { + request.log.error({ err: error, canvasId }, 'Space rename failed'); + return reply.code(500).send({ + code: 'SPACE_RENAME_FAILED', + message: + 'Unable to confirm the Space rename. Refresh before retrying.', }); - return reply.send({ title: record.title, updatedAt: record.updatedAt }); - }); - } catch (error) { - request.log.error({ err: error, canvasId }, 'Space rename failed'); - return reply.code(500).send({ - code: 'SPACE_RENAME_FAILED', - message: 'Unable to confirm the Space rename. Refresh before retrying.', - }); - } finally { - lease.release(); - } - }); + } finally { + lease.release(); + } + }, + ); } diff --git a/apps/server/src/modules/canvas/space-hierarchy.route.ts b/apps/server/src/modules/canvas/space-hierarchy.route.ts index 666ca5092..37167f195 100644 --- a/apps/server/src/modules/canvas/space-hierarchy.route.ts +++ b/apps/server/src/modules/canvas/space-hierarchy.route.ts @@ -105,18 +105,22 @@ export function registerSpaceHierarchyRoutes(fastify: FastifyInstance): void { ), ); }); - fastify.post('/:canvasId/delete-tree', async (request, reply) => { - const params = spaceHierarchyParamsSchema.safeParse(request.params); - const body = deleteSpaceTreeBodySchema.safeParse(request.body); - if (!params.success || !body.success) - return reply - .code(400) - .send({ message: 'Invalid subtree deletion request' }); - return respond(reply, async () => ({ - deletedCanvasIds: await deleteSpaceTree( - params.data.canvasId, - body.data.token, - ), - })); - }); + fastify.post( + '/:canvasId/delete-tree', + { config: { spaceAccess: 'manage' } }, + async (request, reply) => { + const params = spaceHierarchyParamsSchema.safeParse(request.params); + const body = deleteSpaceTreeBodySchema.safeParse(request.body); + if (!params.success || !body.success) + return reply + .code(400) + .send({ message: 'Invalid subtree deletion request' }); + return respond(reply, async () => ({ + deletedCanvasIds: await deleteSpaceTree( + params.data.canvasId, + body.data.token, + ), + })); + }, + ); } diff --git a/apps/server/src/modules/identity/request.ts b/apps/server/src/modules/identity/request.ts index c24d41e2a..1cf01a998 100644 --- a/apps/server/src/modules/identity/request.ts +++ b/apps/server/src/modules/identity/request.ts @@ -1,13 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import type { HuabuResource, ResolvedIdentity } from './service.js'; +import type { + AccessLevel, + HuabuResource, + ResolvedIdentity, +} from './service.js'; import type { FastifyRequest } from 'fastify'; const identities = new WeakMap(); const authorizations = new WeakMap< FastifyRequest, - Array<{ level: 'read' | 'write'; resource: HuabuResource }> + Array<{ level: AccessLevel; resource: HuabuResource }> >(); export function setRequestIdentity( @@ -27,7 +31,7 @@ export function getRequestIdentity( /** Record a resource access the request was granted, to recheck while it lasts. */ export function recordRequestAuthorization( request: FastifyRequest, - level: 'read' | 'write', + level: AccessLevel, resource: HuabuResource, ): void { const granted = authorizations.get(request) ?? []; @@ -37,6 +41,29 @@ export function recordRequestAuthorization( export function getRequestAuthorizations( request: FastifyRequest, -): ReadonlyArray<{ level: 'read' | 'write'; resource: HuabuResource }> { +): ReadonlyArray<{ level: AccessLevel; resource: HuabuResource }> { return authorizations.get(request) ?? []; } + +const workspaceChoosers = new WeakMap< + FastifyRequest, + (workspaceId: string) => Promise +>(); + +/** How this request's caller changes their own current Workspace. */ +export function setRequestWorkspaceChooser( + request: FastifyRequest, + choose: (workspaceId: string) => Promise, +): void { + workspaceChoosers.set(request, choose); +} + +/** + * Present when the caller has a current Workspace of their own (Bubble mode); + * absent when switching moves the process-active Workspace (local mode). + */ +export function getRequestWorkspaceChooser( + request: FastifyRequest, +): ((workspaceId: string) => Promise) | undefined { + return workspaceChoosers.get(request); +} diff --git a/apps/server/src/modules/security/space-access.test.ts b/apps/server/src/modules/security/space-access.test.ts index 5aa6e4717..d4446323e 100644 --- a/apps/server/src/modules/security/space-access.test.ts +++ b/apps/server/src/modules/security/space-access.test.ts @@ -34,7 +34,9 @@ function host() { owner: request.headers['x-owner'] === 'yes', access: { can: async (level, { id }) => - id === 'mine' || (id === 'shared' && level === 'read'), + id === 'mine' || + (id === 'shared' && level === 'read') || + (id === 'edit' && level !== 'manage'), claim: async ({ id }) => { claimed.push(id); }, @@ -46,6 +48,11 @@ function host() { const ok = async () => ({ ok: true }); app.get('/api/canvas/:canvasId', ok); app.put('/api/canvas/:canvasId', ok); + app.delete( + '/api/canvas/:canvasId', + { config: { spaceAccess: 'manage' } }, + ok, + ); app.post( '/api/canvas/:canvasId/search', { config: { spaceAccess: 'read' } }, @@ -80,6 +87,17 @@ function host() { } describe('Space access', () => { + it('needs manage, not only write, on a route that declares it', async () => { + const { app } = host(); + const remove = async (id: string) => + (await app.inject({ method: 'DELETE', url: `/api/canvas/${id}` })) + .statusCode; + expect(await remove('mine')).toBe(200); + expect(await remove('edit')).toBe(403); + expect(await remove('shared')).toBe(403); + expect(await remove('other')).toBe(404); + }); + it('reads and writes only the Spaces the identity provider allows', async () => { const { app } = host(); const status = async (method: 'GET' | 'PUT', id: string) => diff --git a/apps/server/src/modules/security/space-access.ts b/apps/server/src/modules/security/space-access.ts index 37daa7802..9ce61c387 100644 --- a/apps/server/src/modules/security/space-access.ts +++ b/apps/server/src/modules/security/space-access.ts @@ -9,12 +9,18 @@ import { } from '../identity/request.js'; import { UNRESTRICTED_ACCESS } from '../identity/service.js'; +import type { AccessLevel } from '../identity/service.js'; + import type { FastifyInstance, FastifyRequest } from 'fastify'; declare module 'fastify' { interface FastifyContextConfig { - /** A non-GET route that only reads the Space it names. */ - spaceAccess?: 'read'; + /** + * What a route needs on the Space it names, when not the method's + * default: `read` for a non-GET route that only reads it, `manage` for + * renaming or deleting it. + */ + spaceAccess?: 'read' | 'manage'; /** Starts or steers an agent outside the agent route prefixes. */ invokesAgent?: true; } @@ -110,7 +116,7 @@ export function registerSpaceAccess(app: FastifyInstance): void { }); } - const checks: Array<['read' | 'write', string]> = []; + const checks: Array<[AccessLevel, string]> = []; if (canvasId) checks.push([level, canvasId]); const destination = body?.destination as Fields; for (const [needed, value] of [ @@ -135,7 +141,7 @@ export function registerSpaceAccess(app: FastifyInstance): void { continue; } // Without read access the Space is indistinguishable from a missing one. - if (needed === 'write' && (await identity.access.can('read', space))) + if (needed !== 'read' && (await identity.access.can('read', space))) return reply.code(403).send({ message: 'Space is read-only for this principal', code: 'SPACE_ACCESS_REQUIRED', From a97596639cebeb767e4de4f962436575efdbfa2f Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:34:12 +0800 Subject: [PATCH 5/9] feat(workspaces): let each person list, create and switch their own Workspaces 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 --- .../src/modules/identity/bubble.test.ts | 2 +- apps/server/src/modules/identity/http.ts | 8 + .../identity/multi-user.remote.test.ts | 53 ++++ .../modules/identity/multi-user.scenarios.ts | 247 ++++++++++++++++++ .../src/modules/identity/multi-user.test.ts | 33 +++ .../src/modules/workspace-activation.ts | 11 +- apps/server/src/modules/workspace.route.ts | 42 ++- apps/server/src/modules/workspaces.route.ts | 149 +++++++++-- packages/shared/src/types/api/workspace.ts | 6 + 9 files changed, 515 insertions(+), 36 deletions(-) create mode 100644 apps/server/src/modules/identity/multi-user.remote.test.ts create mode 100644 apps/server/src/modules/identity/multi-user.scenarios.ts create mode 100644 apps/server/src/modules/identity/multi-user.test.ts diff --git a/apps/server/src/modules/identity/bubble.test.ts b/apps/server/src/modules/identity/bubble.test.ts index 62f0ef333..1ff91bda6 100644 --- a/apps/server/src/modules/identity/bubble.test.ts +++ b/apps/server/src/modules/identity/bubble.test.ts @@ -12,8 +12,8 @@ import { registerIdentity } from './http.js'; import { createMemoryWorkspacePreferences } from './preferences.js'; import { getRequestIdentity } from './request.js'; import { isOwnerRequest } from '../security/owner.js'; -import { getWorkspaceHandle } from '../workspace.js'; import { registerSpaceAccess } from '../security/space-access.js'; +import { getWorkspaceHandle } from '../workspace.js'; import type { HuabuResource, ResourceDirectory } from './service.js'; import type { BubbleStore } from '@octostaff/bubble'; diff --git a/apps/server/src/modules/identity/http.ts b/apps/server/src/modules/identity/http.ts index 4c0018e23..66f5b7c7f 100644 --- a/apps/server/src/modules/identity/http.ts +++ b/apps/server/src/modules/identity/http.ts @@ -5,6 +5,7 @@ import { timingSafeEqual } from 'node:crypto'; import { getRequestAuthorizations, + setRequestWorkspaceChooser, getRequestIdentity, setRequestIdentity, } from './request.js'; @@ -176,6 +177,13 @@ export function registerIdentity( if (identity.member && service.selectWorkspace) { const scope = await service.selectWorkspace(identity); if (scope) fillWorkspaceScope(scope); + const { chooseWorkspace } = service; + if (chooseWorkspace) + setRequestWorkspaceChooser( + request, + async (workspaceId) => + (await chooseWorkspace(identity, workspaceId)) !== null, + ); } if ( service.provider === 'bubble' && diff --git a/apps/server/src/modules/identity/multi-user.remote.test.ts b/apps/server/src/modules/identity/multi-user.remote.test.ts new file mode 100644 index 000000000..88220676f --- /dev/null +++ b/apps/server/src/modules/identity/multi-user.remote.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach } from 'vitest'; + +import { describeSeveralPeople } from './multi-user.scenarios.js'; +import { openPostgresTestDatabase } from '../storage/backends/postgres/test-support.js'; +import { closeStorage, initStorage } from '../storage/index.js'; + +/** + * The same people, on Postgres: every request goes through the one shared + * connection pool, so this is where a namespace leaking between concurrent + * Workspaces would show. + */ + +let tmp: string; +const restores: Array<() => Promise | void> = []; + +function setEnv(key: string, value: string): void { + const previous = process.env[key]; + restores.push(() => { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + }); + process.env[key] = value; +} + +beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), 'huabu-multi-user-pg-')); + setEnv('HUABU_DATA_DIR', join(tmp, 'data')); + const database = await openPostgresTestDatabase(); + restores.push(database.cleanup); + const url = new URL(database.config.connectionString); + url.searchParams.set('options', database.config.options); + url.searchParams.set('application_name', `huabu-multi-user-${randomUUID()}`); + setEnv('HUABU_POSTGRES_URL', url.toString()); + await initStorage({ + structured: { kind: 'postgres' }, + blobs: { kind: 'disk' }, + }); +}); +afterEach(async () => { + await closeStorage(); + for (const restore of restores.splice(0).reverse()) await restore(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describeSeveralPeople('Postgres', () => tmp); diff --git a/apps/server/src/modules/identity/multi-user.scenarios.ts b/apps/server/src/modules/identity/multi-user.scenarios.ts new file mode 100644 index 000000000..8fa141b25 --- /dev/null +++ b/apps/server/src/modules/identity/multi-user.scenarios.ts @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { join } from 'node:path'; + +import { InMemoryBubbleStore } from '@octostaff/bubble'; +import Fastify from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { openBubbleIdentity } from './bubble.js'; +import { registerIdentity } from './http.js'; +import { createMemoryWorkspacePreferences } from './preferences.js'; +import { storageResourceDirectory } from './resources.js'; +import canvasRoutes from '../canvas/canvas.route.js'; +import { registerSpaceAccess } from '../security/space-access.js'; +import workspaceRoutes from '../workspace.route.js'; +import workspacesRoutes from '../workspaces.route.js'; + +import type { FastifyInstance } from 'fastify'; + +/** + * Several people on one Server, end to end: Bubble admission, the Space guard + * and the real routes, over real Disk storage. Each person works in their own + * Workspace at the same time, and one person's switch moves nobody else. + */ + +const MASTER = 'Bearer master-secret'; +const as = (principalId: string) => ({ + authorization: MASTER, + 'x-bubble-principal-id': principalId, +}); + +const servers: FastifyInstance[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((app) => app.close())); +}); + +async function host() { + const store = new InMemoryBubbleStore(); + const bubble = await openBubbleIdentity({ + authenticator: [ + { + driver: 'master-key', + masterKey: 'master-secret', + defaultPrincipalId: 'admin', + }, + ], + owner: 'admin', + store, + preferences: createMemoryWorkspacePreferences(), + resources: storageResourceDirectory, + }); + for (const principalId of ['carol', 'dave']) { + await store.principals.upsert({ principalId, kind: 'user' }); + await store.grants.add({ + target: { type: 'system' }, + principalId, + role: 'member', + grantedByPrincipalId: null, + }); + } + const app = Fastify(); + servers.push(app); + registerIdentity(app, { + service: bubble.service, + getConnectionToken: () => 'machine-secret', + }); + registerSpaceAccess(app); + await app.register(canvasRoutes, { prefix: '/api/canvas' }); + await app.register(workspaceRoutes, { prefix: '/api/workspace' }); + await app.register(workspacesRoutes, { prefix: '/api/workspaces' }); + + const call = async ( + principalId: string, + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + url: string, + payload?: object, + ) => { + const response = await app.inject({ + method, + url, + headers: as(principalId), + payload, + }); + return { + status: response.statusCode, + body: response.body ? response.json() : undefined, + }; + }; + const workspaceOf = async (principalId: string) => + (await call(principalId, 'GET', '/api/workspace')).body.workspaceId; + const spacesOf = async (principalId: string) => + ( + (await call(principalId, 'GET', '/api/canvas')).body.canvases as Array<{ + title: string; + }> + ) + .map((space) => space.title) + .sort(); + return { app, store, call, workspaceOf, spacesOf }; +} + +/** + * The scenarios, for one storage backend. `tmp` is a scratch directory the + * backend's setup owns; a folder path a member must not be able to adopt. + */ +export function describeSeveralPeople( + backend: string, + scratch: () => string, +): void { + describe(`several people on one Server (${backend})`, () => { + it('gives each member a Workspace of their own and keeps their Spaces apart', async () => { + const { call, workspaceOf, spacesOf } = await host(); + const [carolWs, daveWs, adminWs] = await Promise.all([ + workspaceOf('carol'), + workspaceOf('dave'), + workspaceOf('admin'), + ]); + expect(new Set([carolWs, daveWs, adminWs]).size).toBe(3); + + // Interleaved creates land in each caller's own Workspace. + const created = await Promise.all( + ['carol', 'dave', 'carol', 'dave'].map((principalId, index) => + call(principalId, 'POST', '/api/canvas', { + title: `${principalId} ${index}`, + }), + ), + ); + expect(created.map((response) => response.status)).toEqual([ + 201, 201, 201, 201, + ]); + expect(await spacesOf('carol')).toEqual(['carol 0', 'carol 2']); + expect(await spacesOf('dave')).toEqual(['dave 1', 'dave 3']); + expect(await spacesOf('admin')).toEqual([]); + + // Another person's Space is missing, not forbidden. + const carols = created[0].body.canvasId as string; + expect((await call('dave', 'GET', `/api/canvas/${carols}`)).status).toBe( + 404, + ); + expect((await call('carol', 'GET', `/api/canvas/${carols}`)).status).toBe( + 200, + ); + + // Members never learn where the Server keeps their folder. + const info = (await call('carol', 'GET', '/api/workspace')).body; + expect(info).toMatchObject({ + path: null, + capabilities: { + canChangeWorkspace: true, + nativePicker: false, + namedWorkspaces: true, + }, + }); + }); + + it('switches only its caller, among Workspaces they may enter', async () => { + const { call, workspaceOf, spacesOf } = await host(); + const daveWs = await workspaceOf('dave'); + const carolWs = await workspaceOf('carol'); + await call('carol', 'POST', '/api/canvas', { title: 'first' }); + + const made = await call('carol', 'POST', '/api/workspaces', { + name: 'Side project', + }); + expect(made.status).toBe(201); + expect(made.body).toMatchObject({ name: 'Side project', path: null }); + const listed = (await call('carol', 'GET', '/api/workspaces')) + .body as Array<{ + workspaceId: string; + active: boolean; + }>; + expect(listed.map((w) => w.workspaceId).sort()).toEqual( + [carolWs, made.body.workspaceId].sort(), + ); + + const switched = await call( + 'carol', + 'POST', + `/api/workspaces/${made.body.workspaceId}/activate`, + ); + expect(switched.body).toMatchObject({ active: true }); + expect(await workspaceOf('carol')).toBe(made.body.workspaceId); + expect(await spacesOf('carol')).toEqual([]); + expect(await workspaceOf('dave')).toBe(daveWs); + + // Someone else's Workspace is not there to switch to, rename or remove. + for (const [method, url] of [ + ['POST', `/api/workspaces/${daveWs}/activate`], + ['PATCH', `/api/workspaces/${daveWs}`], + ['DELETE', `/api/workspaces/${daveWs}`], + ] as const) { + const response = await call('carol', method, url, { name: 'mine now' }); + expect(response.status).toBe(404); + } + expect(await workspaceOf('dave')).toBe(daveWs); + + // Folder operations stay with the administrator. + expect( + ( + await call('carol', 'POST', '/api/workspaces', { + path: join(scratch(), 'elsewhere'), + }) + ).status, + ).toBe(400); + expect( + ( + await call('carol', 'POST', '/api/workspace/validate-path', { + path: '/', + }) + ).status, + ).toBe(403); + + // Back to the first Workspace; its Space is still there. + await call('carol', 'POST', `/api/workspaces/${carolWs}/activate`); + expect(await spacesOf('carol')).toEqual(['first']); + }); + + it('lets a viewer read a shared Workspace but not change it', async () => { + const { call, store, workspaceOf } = await host(); + const carolWs = await workspaceOf('carol'); + const space = await call('carol', 'POST', '/api/canvas', { + title: 'notes', + }); + await store.grants.add({ + target: { type: 'huabu.workspace', id: carolWs }, + principalId: 'dave', + role: 'huabu.workspace.viewer', + grantedByPrincipalId: 'carol', + }); + await workspaceOf('dave'); + await call('dave', 'POST', `/api/workspaces/${carolWs}/activate`); + const id = space.body.canvasId as string; + expect((await call('dave', 'GET', `/api/canvas/${id}`)).status).toBe(200); + expect((await call('dave', 'DELETE', `/api/canvas/${id}`)).status).toBe( + 403, + ); + expect( + ( + await call('dave', 'PATCH', `/api/workspaces/${carolWs}`, { + name: 'x', + }) + ).status, + ).toBe(404); + }); + }); +} diff --git a/apps/server/src/modules/identity/multi-user.test.ts b/apps/server/src/modules/identity/multi-user.test.ts new file mode 100644 index 000000000..1d212af9b --- /dev/null +++ b/apps/server/src/modules/identity/multi-user.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach } from 'vitest'; + +import { describeSeveralPeople } from './multi-user.scenarios.js'; +import { resetStorageCache } from '../storage/index.js'; +import { setWorkspacePath } from '../workspace.js'; + +let tmp: string; +let previousDataDir: string | undefined; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'huabu-multi-user-')); + previousDataDir = process.env.HUABU_DATA_DIR; + process.env.HUABU_DATA_DIR = join(tmp, 'data'); + mkdirSync(join(tmp, 'server')); + resetStorageCache(); + // The Server's own Workspace, where the administrator lands. + setWorkspacePath(join(tmp, 'server')); +}); +afterEach(() => { + if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR; + else process.env.HUABU_DATA_DIR = previousDataDir; + resetStorageCache(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describeSeveralPeople('Disk', () => tmp); diff --git a/apps/server/src/modules/workspace-activation.ts b/apps/server/src/modules/workspace-activation.ts index c283e87cb..4a340e1b5 100644 --- a/apps/server/src/modules/workspace-activation.ts +++ b/apps/server/src/modules/workspace-activation.ts @@ -17,6 +17,7 @@ import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { beginWorkspaceActivation, isManagedMode, @@ -224,15 +225,17 @@ export async function activateWorkspacePath( * Create a Workspace by name, on any backend, without activating it. * * Where a Workspace is a row this is the repository's own create. On Disk it - * is a new folder under the data directory, prepared in the isolated worker - * like any other: the creator never chooses where it lives, which is what lets - * someone without access to the Server's filesystem have one. + * is a new folder under the data directory: the creator never chooses where + * it lives, which is what lets someone without access to the Server's + * filesystem have one. An empty folder the Server just made is not the + * unresponsive cloud or network drive the preparation worker isolates, so it + * is prepared in-process. */ export async function createWorkspace(name: string): Promise { if (!materializesWorkspaces()) return createNamedWorkspace(name); const workspacePath = path.join(getDataDir(), 'workspaces', randomUUID()); await mkdir(workspacePath, { recursive: true }); - await runWorkspacePreparation(workspacePath); + prepareWorkspaceOnDisk(workspacePath); const adopted = adoptWorkspaceDirectory(workspacePath); return ( (await getWorkspaceRepository().rename(adopted.workspaceId, name)) ?? diff --git a/apps/server/src/modules/workspace.route.ts b/apps/server/src/modules/workspace.route.ts index a2f5cba3c..8187cf7a4 100644 --- a/apps/server/src/modules/workspace.route.ts +++ b/apps/server/src/modules/workspace.route.ts @@ -160,7 +160,9 @@ function sendError( } /** Build the canonical success payload describing the current workspace. */ -async function buildWorkspaceState(): Promise { +async function buildWorkspaceState( + personal: { admin: boolean } | null = null, +): Promise { const managed = isManagedMode(); const workspace = getWorkspaceHandle(); const configured = workspace !== null; @@ -171,26 +173,38 @@ async function buildWorkspaceState(): Promise { // Free-mode active absolute path. Never exposed in managed mode. // Null in managed mode, and null wherever a Workspace has no folder at // all. The client already renders a Workspace with no path. - path: workspace && !managed ? getWorkspaceDirectory() : null, + path: + workspace && !managed && (!personal || personal.admin) + ? getWorkspaceDirectory() + : null, // Persisted display label. Safe to send in either mode. name: workspace?.name ?? null, worldCanvasId: configured ? await getStructuredStore().spaces().worldId() : null, - capabilities: { - // Switching Workspaces means picking a folder in this API. A backend - // that keeps Workspaces as rows has one already open and no folder to - // offer, so the client stops showing a picker it could not honour. - canChangeWorkspace: !managed && materializesWorkspaces(), - nativePicker: - !managed && materializesWorkspaces() && canShowNativePicker(), - }, + capabilities: personal + ? // Each person switches among, and creates, Workspaces of their own + // by name; folders are not theirs to pick. + { + canChangeWorkspace: !managed, + nativePicker: false, + namedWorkspaces: true, + } + : { + // Switching Workspaces means picking a folder in this API. A backend + // that keeps Workspaces as rows has one already open and no folder to + // offer, so the client stops showing a picker it could not honour. + canChangeWorkspace: !managed && materializesWorkspaces(), + nativePicker: + !managed && materializesWorkspaces() && canShowNativePicker(), + }, }; } const workspaceRoutes: FastifyPluginAsync = async (app) => { - // The active Workspace is the whole Server's, so a principal with - // per-Space access (a Bubble member) may not change it. + // These routes pick folders on the Server and move the process-active + // Workspace, so in Bubble mode only an administrator reaches them; a person + // switches their own Workspace through `/api/workspaces`. app.addHook('preHandler', async (request, reply) => { if ( request.method !== 'GET' && @@ -208,7 +222,9 @@ const workspaceRoutes: FastifyPluginAsync = async (app) => { // Always available (clients need to know the mode). // ──────────────────────────────────────────────────────────────── app.get<{ Reply: ApiResult }>('/', async (request) => { - const state = await buildWorkspaceState(); + const state = await buildWorkspaceState( + isRestrictedRequest(request) ? { admin: isOwnerRequest(request) } : null, + ); // World is an ordinary Space here, advertised only to who may read it. if ( state.worldCanvasId && diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts index 094a851a4..0bae50e69 100644 --- a/apps/server/src/modules/workspaces.route.ts +++ b/apps/server/src/modules/workspaces.route.ts @@ -7,6 +7,10 @@ import { z } from 'zod'; import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; +import { + getRequestIdentity, + getRequestWorkspaceChooser, +} from './identity/request.js'; import { migrateLegacyDesktopWorkspaceStore } from './legacy-desktop-workspace-store.js'; import { resetPreprocessDispatcher } from './preprocessing/index.js'; import { isOwnerRequest } from './security/owner.js'; @@ -26,16 +30,19 @@ import { } from './storage/index.js'; import { activateWorkspacePath, + createWorkspace, prepareWorkspacePath, WorkspaceActivationInProgressError, WorkspaceActivationTimeoutError, } from './workspace-activation.js'; import { commitWorkspacePath, + getProcessWorkspaceHandle, getWorkspaceDirectory, getWorkspaceHandle, getWorkspacePath, isManagedMode, + isWorkspaceLeased, resolveWorkspacePath, updateActiveWorkspaceHandle, } from './workspace.js'; @@ -87,7 +94,10 @@ function rejectReadOnlyMutation( return null; } -function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { +function descriptor( + workspace: WorkspaceHandle, + showPath = true, +): WorkspaceDescriptor { const workspacePath = workspaceDirectory(workspace.workspaceId); const activeHandle = getWorkspaceHandle(); const activeDirectory = getWorkspaceDirectory(); @@ -105,7 +115,7 @@ function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { name: workspace.name, // A Workspace's directory is a materialization fact the handle does not // carry, so it is resolved separately — and never sent in managed mode. - path: isManagedMode() ? null : workspacePath, + path: isManagedMode() || !showPath ? null : workspacePath, active, }; } @@ -188,17 +198,39 @@ interface WorkspaceParams { workspaceId: string; } -const workspacesRoutes: FastifyPluginAsync = async (app) => { - // Listing, creating and switching Workspaces act for the whole Server, so - // a principal with per-Space access (a Bubble member) may not. - app.addHook('preHandler', async (request, reply) => { - if (!isOwnerRequest(request) && isRestrictedRequest(request)) { - return reply.status(403).send({ - message: 'Forbidden: managing Workspaces requires an owner', - }); - } +/** + * In Bubble mode each person has Workspaces of their own and a current one, + * and these routes act for the caller alone: they list what the caller may + * enter, switch only the caller, and create, rename or unregister by the + * caller's grants. Folder Workspaces stay with administrators, because they + * name places on the Server's filesystem. Local mode acts for the Server, as + * it always has. + */ +function personal(request: FastifyRequest): boolean { + return isRestrictedRequest(request); +} + +async function enterable( + request: FastifyRequest, +): Promise { + const allowed = await getRequestIdentity(request)!.access.workspaces(); + const all = await getWorkspaceRepository().list(); + return allowed === 'all' + ? all + : all.filter((workspace) => allowed.includes(workspace.workspaceId)); +} + +async function canManage( + request: FastifyRequest, + workspaceId: string, +): Promise { + return getRequestIdentity(request)!.access.can('manage', { + type: 'workspace', + id: workspaceId, }); +} +const workspacesRoutes: FastifyPluginAsync = async (app) => { let legacyDesktopStoreImported = false; /** @@ -233,13 +265,39 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { importLegacyDesktopStore(); }); - app.get('/', async () => (await visibleWorkspaces()).map(descriptor)); + app.get('/', async (request) => { + if (!personal(request)) + return (await visibleWorkspaces()).map((w) => descriptor(w)); + const showPath = isOwnerRequest(request); + return (await enterable(request)).map((w) => descriptor(w, showPath)); + }); app.post<{ Body: WorkspaceCreateRequest }>('/', async (request, reply) => { + const parsed = workspaceCreateSchema.safeParse(request.body); + // A person's Workspace is made by name, wherever the backend keeps one, + // and is theirs. Choosing a folder is an administrator's, from localhost. + if (personal(request) && !(isOwnerRequest(request) && parsed.data?.path)) { + if (isManagedMode()) + return sendError(reply, 403, 'Workspace collection is read-only'); + if (!parsed.success || !parsed.data.name || parsed.data.path) + return sendError(reply, 400, 'Workspace name is required'); + try { + const workspace = await createWorkspace(parsed.data.name); + await getRequestIdentity(request)!.access.claim({ + type: 'workspace', + id: workspace.workspaceId, + }); + return reply + .status(201) + .send(descriptor(workspace, isOwnerRequest(request))); + } catch (error) { + return sendPreparationError(reply, error); + } + } + const rejected = rejectReadOnlyMutation(request, reply); if (rejected) return rejected; - const parsed = workspaceCreateSchema.safeParse(request.body); if (!parsed.success) { return sendError( reply, @@ -321,6 +379,13 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { async (request, reply) => { const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; + if (personal(request)) { + const workspace = (await enterable(request)).find( + (w) => w.workspaceId === parsedId, + ); + if (!workspace) return sendError(reply, 404, 'Workspace not found'); + return reply.send(descriptor(workspace, isOwnerRequest(request))); + } const workspace = await findVisible(parsedId); if (!workspace) return sendError(reply, 404, 'Workspace not found'); return reply.send(descriptor(workspace)); @@ -330,6 +395,19 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { app.post<{ Params: WorkspaceParams }>( '/:workspaceId/activate', async (request, reply) => { + const choose = getRequestWorkspaceChooser(request); + if (personal(request)) { + const parsedId = parseWorkspaceId(request.params.workspaceId, reply); + if (typeof parsedId !== 'string') return parsedId; + // Switching moves the caller alone; the request itself still runs in + // the Workspace it was admitted to, so it answers for the new one. + if (!choose || !(await choose(parsedId))) + return sendError(reply, 404, 'Workspace not found'); + const workspace = await getWorkspaceRepository().get(parsedId); + if (!workspace) return sendError(reply, 404, 'Workspace not found'); + const chosen = descriptor(workspace, isOwnerRequest(request)); + return reply.send({ ...chosen, active: true }); + } const rejected = rejectReadOnlyMutation(request, reply); if (rejected) return rejected; const parsedId = parseWorkspaceId(request.params.workspaceId, reply); @@ -367,10 +445,17 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { app.patch<{ Params: WorkspaceParams; Body: WorkspaceRenameRequest }>( '/:workspaceId', async (request, reply) => { - const rejected = rejectReadOnlyMutation(request, reply); - if (rejected) return rejected; const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; + if (personal(request)) { + if (isManagedMode()) + return sendError(reply, 403, 'Workspace collection is read-only'); + if (!(await canManage(request, parsedId))) + return sendError(reply, 404, 'Workspace not found'); + } else { + const rejected = rejectReadOnlyMutation(request, reply); + if (rejected) return rejected; + } const parsed = workspaceRenameSchema.safeParse(request.body); if (!parsed.success) { return sendError( @@ -387,7 +472,9 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { ); if (!workspace) return sendError(reply, 404, 'Workspace not found'); updateActiveWorkspaceHandle(workspace); - return reply.send(descriptor(workspace)); + return reply.send( + descriptor(workspace, !personal(request) || isOwnerRequest(request)), + ); } catch (error) { return sendError(reply, 400, (error as Error).message); } @@ -397,10 +484,36 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { app.delete<{ Params: WorkspaceParams }>( '/:workspaceId', async (request, reply) => { - const rejected = rejectReadOnlyMutation(request, reply); - if (rejected) return rejected; const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; + if (personal(request)) { + if (isManagedMode()) + return sendError(reply, 403, 'Workspace collection is read-only'); + if (!(await canManage(request, parsedId))) + return sendError(reply, 404, 'Workspace not found'); + // Someone else's current Workspace moves them on at their next + // request; work in flight there is not stranded. + const workspace = await getWorkspaceRepository().get(parsedId); + const key = workspace + ? (workspaceDirectory(parsedId) ?? `workspace:${parsedId}`) + : null; + if (key && isWorkspaceLeased(key)) + return sendError( + reply, + 409, + 'Cannot unregister a Workspace while an operation is using it', + ); + // The process-active Workspace serves local callers and startup work. + if (getProcessWorkspaceHandle()?.workspaceId === parsedId) + return sendError( + reply, + 409, + 'Cannot unregister the active Workspace', + ); + } else { + const rejected = rejectReadOnlyMutation(request, reply); + if (rejected) return rejected; + } if (getWorkspaceHandle()?.workspaceId === parsedId) { return sendError(reply, 409, 'Cannot unregister the active Workspace'); } diff --git a/packages/shared/src/types/api/workspace.ts b/packages/shared/src/types/api/workspace.ts index 9ffc9dc5e..d5554f0a9 100644 --- a/packages/shared/src/types/api/workspace.ts +++ b/packages/shared/src/types/api/workspace.ts @@ -26,6 +26,12 @@ export const workspaceCapabilitiesSchema = z.object({ canChangeWorkspace: z.boolean(), /** Whether the server can show a native folder picker. */ nativePicker: z.boolean(), + /** + * Whether Workspaces are switched and created by name rather than by + * folder: each signed-in person has their own (Bubble mode). Absent from + * older servers. + */ + namedWorkspaces: z.boolean().optional(), }); export type WorkspaceCapabilities = z.infer; From b1b527e3c76ea99a9b1282aba64cbde52899035d Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:42:18 +0800 Subject: [PATCH 6/9] feat(rfs): keep an agent run in the Workspace it started in An agent's reachback URL now names its Workspace (/api/rfs/w//), 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 --- apps/server/src/app.ts | 5 ++ .../src/modules/agent/acp/reachback-env.ts | 8 +- .../src/modules/remote_fs/public-skill.ts | 3 +- .../modules/workspace/scoped-routes.test.ts | 79 +++++++++++++++++++ .../src/modules/workspace/scoped-routes.ts | 37 +++++++++ 5 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/modules/workspace/scoped-routes.test.ts create mode 100644 apps/server/src/modules/workspace/scoped-routes.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 638ea2dbe..a0ddcdab5 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -59,6 +59,7 @@ import { registerSpaceAccess } from './modules/security/space-access.js'; import serviceRoutes from './modules/services/services.route.js'; import { closeStorage } from './modules/storage/index.js'; import webRoutes from './modules/web/web.route.js'; +import { workspaceScopedRoutes } from './modules/workspace/scoped-routes.js'; import { registerWorkspaceRequiredGuard } from './modules/workspace-required.js'; import { initWorkspaceFromEnv } from './modules/workspace.js'; import workspaceRoutes from './modules/workspace.route.js'; @@ -183,6 +184,10 @@ app.register(skillsRoutes, { prefix: '/api/skills' }); app.register(workspaceRoutes, { prefix: '/api/workspace' }); app.register(workspacesRoutes, { prefix: '/api/workspaces' }); app.register(rfsRoutes, { prefix: '/api/rfs' }); +// An agent's reachback names the Workspace its run started in. +app.register(workspaceScopedRoutes(rfsRoutes), { + prefix: '/api/rfs/w/:workspaceId', +}); // ── External agent (ACP) transport host ─────────────────────────────── // Mount the Agenetes agentlet transport host (`@agenetes/agentlet-host`). diff --git a/apps/server/src/modules/agent/acp/reachback-env.ts b/apps/server/src/modules/agent/acp/reachback-env.ts index a33a7c1a6..db3c4c45b 100644 --- a/apps/server/src/modules/agent/acp/reachback-env.ts +++ b/apps/server/src/modules/agent/acp/reachback-env.ts @@ -9,7 +9,7 @@ * host over the canvas-scoped Remote File System (RFS) and attribute its * change-review activity to the right conversation: * - * - `HUABU_RFS_URL` — `/api/rfs/`, + * - `HUABU_RFS_URL` — `/api/rfs/w//`, * the canvas-scoped RFS base (capabilities / query / * download / upload / execute / agent / skill). Bakes the canvasId into the path * so the agent needs no separate canvas variable. @@ -27,6 +27,7 @@ import { getHostServerPort } from '../../../host-port.js'; import { resolveConfiguredPublicOrigin } from '../../security/public-origin.js'; +import { getWorkspaceHandle } from '../../workspace.js'; /** * Build the reachback env for an agent serving `threadId` in `canvasId`. @@ -45,8 +46,11 @@ export function buildReachbackEnv( if (port > 0) origin = `http://127.0.0.1:${port}`; } if (origin) { + // Named so the run keeps its Workspace after its starter switches. + const workspaceId = getWorkspaceHandle()?.workspaceId; + const scope = workspaceId ? `w/${encodeURIComponent(workspaceId)}/` : ''; env.HUABU_RFS_URL = new URL( - `/api/rfs/${encodeURIComponent(canvasId)}`, + `/api/rfs/${scope}${encodeURIComponent(canvasId)}`, origin, ).toString(); } diff --git a/apps/server/src/modules/remote_fs/public-skill.ts b/apps/server/src/modules/remote_fs/public-skill.ts index 21b504c41..83d435b71 100644 --- a/apps/server/src/modules/remote_fs/public-skill.ts +++ b/apps/server/src/modules/remote_fs/public-skill.ts @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const PUBLIC_ROOT_SKILL_PATTERN = /^\/api\/rfs\/[^/?]+\/skill(?:\?.*)?$/; +const PUBLIC_ROOT_SKILL_PATTERN = + /^\/api\/rfs\/(?:w\/[^/?]+\/)?[^/?]+\/skill(?:\?.*)?$/; /** Only a credential-free root skill request may bypass the RFS auth gate. */ export function isPublicRfsSkillBootstrapRequest(input: { diff --git a/apps/server/src/modules/workspace/scoped-routes.test.ts b/apps/server/src/modules/workspace/scoped-routes.test.ts new file mode 100644 index 000000000..2580c2957 --- /dev/null +++ b/apps/server/src/modules/workspace/scoped-routes.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { runInWorkspace } from './context.js'; +import { workspaceScopedRoutes } from './scoped-routes.js'; +import { buildReachbackEnv } from '../agent/acp/reachback-env.js'; +import { resetStorageCache } from '../storage/index.js'; +import { createWorkspace } from '../workspace-activation.js'; +import { getWorkspaceHandle, setWorkspacePath } from '../workspace.js'; + +let tmp: string; +let previousDataDir: string | undefined; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'huabu-scoped-routes-')); + previousDataDir = process.env.HUABU_DATA_DIR; + process.env.HUABU_DATA_DIR = join(tmp, 'data'); + mkdirSync(join(tmp, 'server')); + resetStorageCache(); + setWorkspacePath(join(tmp, 'server')); +}); +afterEach(() => { + if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR; + else process.env.HUABU_DATA_DIR = previousDataDir; + resetStorageCache(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('Workspace-scoped routes', () => { + it('run each call in the Workspace their prefix names', async () => { + const other = await createWorkspace('Other'); + const app = Fastify(); + const routes = async (scoped: typeof app) => { + scoped.get('/where', async () => ({ + workspaceId: getWorkspaceHandle()?.workspaceId, + })); + }; + await app.register(routes, { prefix: '/plain' }); + await app.register(workspaceScopedRoutes(routes), { + prefix: '/w/:workspaceId', + }); + + const processActive = getWorkspaceHandle()?.workspaceId; + expect((await app.inject('/plain/where')).json().workspaceId).toBe( + processActive, + ); + expect( + (await app.inject(`/w/${other.workspaceId}/where`)).json().workspaceId, + ).toBe(other.workspaceId); + expect((await app.inject('/w/no-such-workspace/where')).statusCode).toBe( + 404, + ); + await app.close(); + }); + + it('names the Workspace a run starts in in its reachback URL', async () => { + const other = await createWorkspace('Other'); + const previous = process.env.HUABU_PUBLIC_ORIGIN; + process.env.HUABU_PUBLIC_ORIGIN = 'https://huabu.example.com'; + try { + const env = runInWorkspace({ handle: other, path: null }, () => + buildReachbackEnv('thread-1', 'canvas-1'), + ); + expect(env).toEqual({ + HUABU_RFS_URL: `https://huabu.example.com/api/rfs/w/${other.workspaceId}/canvas-1`, + HUABU_THREAD_ID: 'thread-1', + }); + } finally { + if (previous === undefined) delete process.env.HUABU_PUBLIC_ORIGIN; + else process.env.HUABU_PUBLIC_ORIGIN = previous; + } + }); +}); diff --git a/apps/server/src/modules/workspace/scoped-routes.ts b/apps/server/src/modules/workspace/scoped-routes.ts new file mode 100644 index 000000000..6bdd12011 --- /dev/null +++ b/apps/server/src/modules/workspace/scoped-routes.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { fillWorkspaceScope, openWorkspaceScope } from './context.js'; +import { getWorkspaceRepository, openWorkspace } from '../storage/index.js'; + +import type { FastifyPluginAsync } from 'fastify'; + +/** + * Serve `routes` under a `:workspaceId` prefix, each call running in that + * Workspace whatever the caller's current one or the process-active one is. + * + * For callers whose Workspace was fixed when they started: an agent run keeps + * the Workspace it was launched in for its whole life, so its reachback names + * it rather than following its starter's later switches. + */ +export function workspaceScopedRoutes( + routes: FastifyPluginAsync, +): FastifyPluginAsync { + return async (app) => { + // Own slot, wrapping the rest of the lifecycle; admission's, if any, was + // for the caller's current Workspace and does not apply here. + app.addHook('onRequest', (_request, _reply, done) => + openWorkspaceScope(done), + ); + app.addHook('onRequest', async (request, reply) => { + const { workspaceId } = request.params as { workspaceId?: string }; + const workspace = workspaceId + ? await getWorkspaceRepository().get(workspaceId) + : null; + if (!workspace) + return reply.code(404).send({ message: 'Workspace not found' }); + fillWorkspaceScope(await openWorkspace(workspace)); + }); + await app.register(routes); + }; +} From 4a58f0cda9c150dbeabcdce787008f1554bd8cae Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:42:18 +0800 Subject: [PATCH 7/9] feat(web): switch and create named Workspaces 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 --- apps/web/src/api/workspace.ts | 14 ++ apps/web/src/i18n/resources/en/common.json | 10 +- apps/web/src/i18n/resources/zh-CN/common.json | 10 +- apps/web/src/pages/CanvasListPage.tsx | 11 +- apps/web/src/pages/WorkspaceSetupPage.tsx | 141 ++++++++++++++++-- apps/web/src/store/workspaceStore.test.ts | 39 +++++ apps/web/src/store/workspaceStore.ts | 22 ++- 7 files changed, 230 insertions(+), 17 deletions(-) diff --git a/apps/web/src/api/workspace.ts b/apps/web/src/api/workspace.ts index 1984e2c69..fc5781a9b 100644 --- a/apps/web/src/api/workspace.ts +++ b/apps/web/src/api/workspace.ts @@ -60,6 +60,20 @@ export async function listWorkspaces(): Promise { }); } +/** + * Create a Workspace by name, for a Server where each person keeps their own + * (`capabilities.namedWorkspaces`). The caller owns it; it is not activated. + */ +export async function createNamedWorkspace( + name: string, +): Promise { + return apiFetch(routes.workspaces, { + method: 'POST', + json: { name }, + fallbackMessage: 'Failed to create Workspace', + }); +} + /** Activate a registered Workspace by stable identity. */ export async function activateWorkspace( workspaceId: string, diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 4ee5149fa..98be7fa91 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -665,7 +665,15 @@ "clickToSwitch": "Click to switch", "openPathFailed": "Failed to open path", "activationTimeout": "Opening Home folder “{{path}}” took longer than {{seconds}} seconds. Check that the cloud or network drive is available, then try again.", - "activationInProgress": "Another Home folder is still opening. Please wait for it to finish." + "activationInProgress": "Another Home folder is still opening. Please wait for it to finish.", + "yourWorkspaces": "Your Workspaces", + "introNamed": "Choose a Workspace for your Spaces, notes, and artifacts, or create one.", + "newWorkspace": "New Workspace", + "namePlaceholder": "Name, then press Enter", + "create": "Create", + "current": "Current", + "named": "Workspace: {{workspace}}", + "namedPrefix": "Workspace: " }, "spaceHierarchy": { "collectAction": "Collect into child Space", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 1774c4f3d..4a806e6ae 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -665,7 +665,15 @@ "clickToSwitch": "点击切换", "openPathFailed": "打开路径失败", "activationTimeout": "打开“{{path}}”已超过 {{seconds}} 秒。请检查云盘或网络磁盘是否可用,然后重试。", - "activationInProgress": "另一个主目录仍在打开,请等待其完成。" + "activationInProgress": "另一个主目录仍在打开,请等待其完成。", + "yourWorkspaces": "你的工作区", + "introNamed": "选择一个工作区来保存你的 Space、笔记和产物,或新建一个。", + "newWorkspace": "新建工作区", + "namePlaceholder": "输入名称,然后按 Enter", + "create": "创建", + "current": "当前", + "named": "工作区:{{workspace}}", + "namedPrefix": "工作区:" }, "spaceHierarchy": { "collectAction": "收纳为子 Space", diff --git a/apps/web/src/pages/CanvasListPage.tsx b/apps/web/src/pages/CanvasListPage.tsx index 9ad63d815..92bedab33 100644 --- a/apps/web/src/pages/CanvasListPage.tsx +++ b/apps/web/src/pages/CanvasListPage.tsx @@ -77,6 +77,9 @@ export default function CanvasListPage() { const canChangeWorkspace = useWorkspaceStore( (s) => s.capabilities?.canChangeWorkspace ?? true, ); + const namedWorkspaces = useWorkspaceStore( + (s) => s.capabilities?.namedWorkspaces === true, + ); const setCanvasCount = useWorkspaceStore((s) => s.setCanvasCount); const setSpaceTitle = useWorkspaceStore((s) => s.setSpaceTitle); const workspaceSyncing = useWorkspaceStore((s) => s.isSyncing); @@ -284,7 +287,9 @@ export default function CanvasListPage() { {workspacePath ? t('workspace.pathPrefix') - : t('workspace.workspacePrefix')} + : namedWorkspaces + ? t('workspace.namedPrefix') + : t('workspace.workspacePrefix')} {workspaceLabel} @@ -297,7 +302,9 @@ export default function CanvasListPage() {
{workspacePath ? t('workspace.path', { path: workspacePath }) - : t('workspace.workspace', { workspace: workspaceLabel })} + : namedWorkspaces + ? t('workspace.named', { workspace: workspaceLabel }) + : t('workspace.workspace', { workspace: workspaceLabel })}
{t('canvasList.canvasCount', { count: canvases.length })}
{canChangeWorkspace && ( diff --git a/apps/web/src/pages/WorkspaceSetupPage.tsx b/apps/web/src/pages/WorkspaceSetupPage.tsx index 1d6648aa4..bdd64fb8b 100644 --- a/apps/web/src/pages/WorkspaceSetupPage.tsx +++ b/apps/web/src/pages/WorkspaceSetupPage.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { FolderOpen, X } from 'lucide-react'; +import { Check, FolderOpen, Layers, X } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate } from 'react-router-dom'; @@ -9,6 +9,7 @@ import { Navigate, useNavigate } from 'react-router-dom'; import { WorkspaceLoadingScreen } from './WorkspaceLoadingScreen'; import { Button } from '../components/Common/Button'; import { PathInput } from '../components/Common/PathInput'; +import { TextInput } from '../components/Common/TextInput'; import { toast } from '../components/Common/Toast'; import { APP_NAME } from '../config/app'; import { useWorkspaceStore } from '../store/workspaceStore'; @@ -40,6 +41,10 @@ export default function WorkspaceSetupPage() { (s) => s.activateRecentWorkspace, ); const selectWorkspace = useWorkspaceStore((s) => s.selectWorkspace); + const createWorkspace = useWorkspaceStore((s) => s.createWorkspace); + const named = useWorkspaceStore( + (s) => s.capabilities?.namedWorkspaces === true, + ); const storeError = useWorkspaceStore((s) => s.error); // Managed mode: workspace is locked by the server, nothing to choose. @@ -64,23 +69,137 @@ export default function WorkspaceSetupPage() {

{t('workspace.welcome', { appName: APP_NAME })}

-

{t('workspace.intro')}

+

+ {t(named ? 'workspace.introNamed' : 'workspace.intro')} +

- navigate('/', { replace: true })} - /> + {named ? ( + navigate('/', { replace: true })} + /> + ) : ( + navigate('/', { replace: true })} + /> + )} ); } +// ────────────────────────────────────────────────────────────────────── +// Named Workspaces: each signed-in person's own, chosen and created by name +// ────────────────────────────────────────────────────────────────────── + +interface NamedSetupProps { + isSyncing: boolean; + workspaces: WorkspaceDescriptor[]; + activateWorkspace: (workspaceId: string) => Promise; + createWorkspace: (name: string) => Promise; + onActivated: () => void; +} + +function NamedSetup({ + isSyncing, + workspaces, + activateWorkspace, + createWorkspace, + onActivated, +}: NamedSetupProps) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + + const run = async (action: () => Promise) => { + try { + await action(); + onActivated(); + } catch (error) { + console.error('[workspace-setup] Could not open Workspace:', error); + toast(useWorkspaceStore.getState().error ?? t('workspace.switchFailed'), { + tone: 'danger', + }); + } + }; + + const create = () => { + const trimmed = name.trim(); + if (trimmed) void run(() => createWorkspace(trimmed)); + }; + + return ( + <> + {workspaces.length > 0 && ( +
+
+ {t('workspace.yourWorkspaces')} +
+
    + {workspaces.map((workspace) => ( +
  • + +
  • + ))} +
+
+ )} + + +
+ setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') create(); + }} + placeholder={t('workspace.namePlaceholder')} + disabled={isSyncing} + className="min-w-0 flex-1" + /> + +
+ + ); +} + // ────────────────────────────────────────────────────────────────────── // Free-mode UI // ────────────────────────────────────────────────────────────────────── diff --git a/apps/web/src/store/workspaceStore.test.ts b/apps/web/src/store/workspaceStore.test.ts index fd23fa5b3..9da059f15 100644 --- a/apps/web/src/store/workspaceStore.test.ts +++ b/apps/web/src/store/workspaceStore.test.ts @@ -39,6 +39,16 @@ vi.mock('../hooks/useElectron', async (importOriginal) => ({ })); const apiMocks = vi.hoisted(() => ({ + createNamedWorkspace: vi.fn(async (name: string) => { + const created = { + workspaceId: '33333333-3333-4333-8333-333333333333', + name, + path: null, + active: false, + }; + apiState.workspaces = [created, ...apiState.workspaces]; + return created; + }), getWorkspaceInfo: vi.fn(async () => apiState.info as WorkspaceInfo), listWorkspaces: vi.fn(async () => apiState.workspaces), activateWorkspace: vi.fn(async (workspaceId: string) => { @@ -75,6 +85,7 @@ vi.mock('../api/workspace', () => ({ getWorkspaceInfo: apiMocks.getWorkspaceInfo, listWorkspaces: apiMocks.listWorkspaces, activateWorkspace: apiMocks.activateWorkspace, + createNamedWorkspace: apiMocks.createNamedWorkspace, putWorkspacePath: apiMocks.putWorkspacePath, removeWorkspace: apiMocks.removeWorkspace, })); @@ -559,6 +570,34 @@ describe('workspaceStore registry persistence', () => { }); }); + it('keeps the Workspace the Server chose for this person', async () => { + apiState.info = { + ...unconfiguredInfo(), + configured: true, + workspaceId: FIRST_ID, + name: 'First', + capabilities: { + canChangeWorkspace: true, + nativePicker: false, + namedWorkspaces: true, + }, + }; + await expect(useWorkspaceStore.getState().init()).resolves.toBe(true); + // Their current Workspace has no path, and is already theirs. + expect(apiMocks.activateWorkspace).not.toHaveBeenCalled(); + expect(useWorkspaceStore.getState()).toMatchObject({ + workspaceId: FIRST_ID, + workspaceName: 'First', + isReady: true, + }); + + await useWorkspaceStore.getState().createWorkspace('Side project'); + expect(apiMocks.createNamedWorkspace).toHaveBeenCalledWith('Side project'); + expect(apiMocks.activateWorkspace).toHaveBeenCalledWith( + '33333333-3333-4333-8333-333333333333', + ); + }); + it('restores the first Workspace from server-owned MRU order', async () => { await expect(useWorkspaceStore.getState().init()).resolves.toBe(true); diff --git a/apps/web/src/store/workspaceStore.ts b/apps/web/src/store/workspaceStore.ts index 7a9a66e8e..fe440ccbb 100644 --- a/apps/web/src/store/workspaceStore.ts +++ b/apps/web/src/store/workspaceStore.ts @@ -8,6 +8,7 @@ import { ApiError } from '../api/_client'; import { listCanvases } from '../api/canvas'; import { activateWorkspace, + createNamedWorkspace, getWorkspaceInfo, listWorkspaces, putWorkspacePath, @@ -109,6 +110,8 @@ interface WorkspaceState { /** (Free mode) Activate a registered Workspace by stable identity. */ activateRecentWorkspace: (workspaceId: string) => Promise; + /** Create a Workspace by name and switch to it (`namedWorkspaces`). */ + createWorkspace: (name: string) => Promise; /** (Free mode) Unregister an inactive Workspace. */ removeRecentWorkspace: (workspaceId: string) => void; @@ -383,8 +386,12 @@ export const useWorkspaceStore = create()((set, get) => ({ } // ── Free mode ── - // Server already activated (e.g. another tab beat us to it). - const activated = info.configured && Boolean(info.path); + // Server already activated (e.g. another tab beat us to it). Where each + // person has their own Workspaces, the Server has already chosen the + // caller's current one, which carries no path. + const activated = + info.configured && + (Boolean(info.path) || info.capabilities.namedWorkspaces === true); let registered: WorkspaceDescriptor[] = []; try { @@ -484,6 +491,17 @@ export const useWorkspaceStore = create()((set, get) => ({ ); }, + createWorkspace: async (name: string) => { + if (get().mode === 'managed') { + throw new Error('Workspace is locked by the server (managed mode)'); + } + await changeWorkspace(async () => { + const created = await createNamedWorkspace(name); + await activateWorkspace(created.workspaceId); + return getWorkspaceInfo(); + }, name); + }, + removeRecentWorkspace: (workspaceId: string) => { void removeWorkspace(workspaceId) .then(() => From 6a79a200c8def09c22a37ed99dd9a888295a96be Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:48:01 +0800 Subject: [PATCH 8/9] chore: tidy imports and a test type after repository checks Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/security/space-access.ts | 1 - apps/server/src/modules/storage/backends/disk/canvas-dirs.ts | 2 +- apps/server/src/modules/storage/backends/postgres/database.ts | 2 +- apps/server/src/modules/storage/backends/sqlite/database.ts | 2 +- apps/server/src/modules/workspace/scoped-routes.test.ts | 4 +++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/server/src/modules/security/space-access.ts b/apps/server/src/modules/security/space-access.ts index 9ce61c387..eb421b171 100644 --- a/apps/server/src/modules/security/space-access.ts +++ b/apps/server/src/modules/security/space-access.ts @@ -10,7 +10,6 @@ import { import { UNRESTRICTED_ACCESS } from '../identity/service.js'; import type { AccessLevel } from '../identity/service.js'; - import type { FastifyInstance, FastifyRequest } from 'fastify'; declare module 'fastify' { diff --git a/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts b/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts index a29851646..3df520055 100644 --- a/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts +++ b/apps/server/src/modules/storage/backends/disk/canvas-dirs.ts @@ -23,7 +23,7 @@ import { toSafeFilename, } from '../../../../utils/naming.js'; import { spaceHierarchyShapeError } from '../../../canvas/persistence-validation.js'; -import { getWorkspaceDirectory, getWorkspacePath } from '../../../workspace.js'; +import { getWorkspacePath } from '../../../workspace.js'; export interface CanvasDirEntry { id: string; diff --git a/apps/server/src/modules/storage/backends/postgres/database.ts b/apps/server/src/modules/storage/backends/postgres/database.ts index cdf38b8e2..df312c9d8 100644 --- a/apps/server/src/modules/storage/backends/postgres/database.ts +++ b/apps/server/src/modules/storage/backends/postgres/database.ts @@ -2,10 +2,10 @@ // Licensed under the MIT license. import { Pool, type PoolClient, type PoolConfig } from 'pg'; -import { currentWorkspaceScope } from '../../../workspace/context.js'; import { POSTGRES_MIGRATIONS } from './schema.js'; import { createKeyedMutex } from '../../../../utils/keyed-mutex.js'; +import { currentWorkspaceScope } from '../../../workspace/context.js'; import { assertSpaceMutationAllowed, beginSpaceDeleteAdmission, diff --git a/apps/server/src/modules/storage/backends/sqlite/database.ts b/apps/server/src/modules/storage/backends/sqlite/database.ts index 106d504f8..92eac9b2f 100644 --- a/apps/server/src/modules/storage/backends/sqlite/database.ts +++ b/apps/server/src/modules/storage/backends/sqlite/database.ts @@ -20,10 +20,10 @@ import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; -import { currentWorkspaceScope } from '../../../workspace/context.js'; import { SQLITE_MIGRATIONS, type SqliteMigration } from './schema.js'; import { getDataDir } from '../../../../data-dir.js'; +import { currentWorkspaceScope } from '../../../workspace/context.js'; import { assertSpaceMutationAllowed, beginSpaceDeleteAdmission, diff --git a/apps/server/src/modules/workspace/scoped-routes.test.ts b/apps/server/src/modules/workspace/scoped-routes.test.ts index 2580c2957..534b945e9 100644 --- a/apps/server/src/modules/workspace/scoped-routes.test.ts +++ b/apps/server/src/modules/workspace/scoped-routes.test.ts @@ -15,6 +15,8 @@ import { resetStorageCache } from '../storage/index.js'; import { createWorkspace } from '../workspace-activation.js'; import { getWorkspaceHandle, setWorkspacePath } from '../workspace.js'; +import type { FastifyPluginAsync } from 'fastify'; + let tmp: string; let previousDataDir: string | undefined; beforeEach(() => { @@ -36,7 +38,7 @@ describe('Workspace-scoped routes', () => { it('run each call in the Workspace their prefix names', async () => { const other = await createWorkspace('Other'); const app = Fastify(); - const routes = async (scoped: typeof app) => { + const routes: FastifyPluginAsync = async (scoped) => { scoped.get('/where', async () => ({ workspaceId: getWorkspaceHandle()?.workspaceId, })); From 0116f51f5ef25393ecbb3b89d08c2fe583c20a1b Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 28 Oct 2026 17:48:01 +0800 Subject: [PATCH 9/9] docs: describe per-person Workspaces and the new roles 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 --- docs/architecture/agent-reachback.md | 4 +-- docs/architecture/canvas-storage.md | 5 ++-- docs/architecture/deployment-security.md | 35 ++++++++++++++++++++---- docs/proposals/multi-user-service.md | 11 ++++---- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/docs/architecture/agent-reachback.md b/docs/architecture/agent-reachback.md index 7b9e9a4be..d268ce8f0 100644 --- a/docs/architecture/agent-reachback.md +++ b/docs/architecture/agent-reachback.md @@ -20,7 +20,7 @@ The shipped design record is [`agent-reachback-rfs.md`](../proposals/agent-reach ## Canvas-scoped HTTP surface -All endpoints are mounted under `/api/rfs/:canvasId`; `HUABU_RFS_URL` already contains that canvas-scoped base. +All endpoints are mounted under `/api/rfs/:canvasId`, and again under `/api/rfs/w/:workspaceId/:canvasId`, where they run in that Workspace whatever the caller's current one; `HUABU_RFS_URL` already contains the Workspace- and canvas-scoped base. | Endpoint | Responsibility | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | @@ -112,7 +112,7 @@ Parent lineage is best effort. The route resolves `parentThreadId` or `X-Huabu-H ## External-agent bootstrap -Huabu injects `HUABU_RFS_URL` and `AGENTLET_TOKEN` into the external agent environment. `HUABU_RFS_URL` is derived from the deployment-owned root `HUABU_PUBLIC_ORIGIN` plus `/api/rfs/`; a loopback deployment with no configured public origin uses its actual bound port as the explicit local fallback. Network binds require a non-loopback public origin before the Server listens, so a remote Agentlet never receives a success-shaped Server-local loopback URL. Every external-agent Deployment persists `external-agent/system_prompt.md` as its initial preamble, including Deployments first created by mode, model, or configuration control requests; startup repair backfills older undelivered records that omitted it. Bootstrap owns authentication and entry routing: prefer `node "$HUABU_RFS_ENTRY" get-skill` when the Agent has compatible Node and an advertised entry; otherwise fetch authenticated HTTP `/skill`. Node is not provisioned or required for Space access. Public `/skill` is bootstrap documentation, not a substitute for authenticated Space instructions. Focused workflows remain authenticated `/skill/:id` resources and are loaded through `get-skill ID` or native HTTP. +Huabu injects `HUABU_RFS_URL` and `AGENTLET_TOKEN` into the external agent environment. `HUABU_RFS_URL` is derived from the deployment-owned root `HUABU_PUBLIC_ORIGIN` plus `/api/rfs/w//`, naming the Workspace the run started in so it keeps it after its starter switches; a loopback deployment with no configured public origin uses its actual bound port as the explicit local fallback. Network binds require a non-loopback public origin before the Server listens, so a remote Agentlet never receives a success-shaped Server-local loopback URL. Every external-agent Deployment persists `external-agent/system_prompt.md` as its initial preamble, including Deployments first created by mode, model, or configuration control requests; startup repair backfills older undelivered records that omitted it. Bootstrap owns authentication and entry routing: prefer `node "$HUABU_RFS_ENTRY" get-skill` when the Agent has compatible Node and an advertised entry; otherwise fetch authenticated HTTP `/skill`. Node is not provisioned or required for Space access. Public `/skill` is bootstrap documentation, not a substitute for authenticated Space instructions. Focused workflows remain authenticated `/skill/:id` resources and are loaded through `get-skill ID` or native HTTP. Huabu injects `HUABU_SERVICE_SDK_URL`, an absolute `file://` URL for its own SDK in `packages/client-runtime/dist/service-sdk.js` or the installed `client-runtime/service-sdk.js`. Generic Agentlet protects this explicit bootstrap key against session overrides but ships no SDK itself. No old `AGENTLET_SERVICE_SDK_URL` alias or fallback exists; refresh old downloaded entries and restart Agents. Entries obtain only current configuration leases, not Package code, and provider credentials remain absent from startup environment until requested. diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index ee5c840e7..3383275d0 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -183,7 +183,7 @@ All six structured/blob pairings are selectable: `validateStorageProfile()` admi `backends/postgres/database.ts` owns a `pg.Pool` per context, shared by `PostgresStructuredStore` and `PostgresWorkspaceRepository`. Defaults are four connections, a five-second connection timeout, and a thirty-second idle timeout; callers may supply `PoolConfig` overrides. Initialization applies contiguous versioned migrations in a transaction under a database advisory lock, recording the version in `huabu_schema_version`. A future schema fails without modification, and migration failure rolls back so initialization can be retried. Health runs a liveness query. Closing drains the context's queued transactions and ends its pool; operations on a closed or uninitialized context fail. A structured store closes a context it constructed, while an injected context remains its caller's responsibility. -The v1 schema has the same record families shown in the SQLite table above, using PostgreSQL identity columns, foreign-key cascades, uniqueness constraints, and JSON-validating text columns. Shared `backends/sql/` codecs and rules own JSON serialization, identity/name validation, collision allocation, and Task/Run transitions; SQL dialect and migration history stay adapter-owned. Parameter values are bound. Workspace activation changes the context's active identity without reopening its pool; retained Space handles and queued operations reject an inactive Workspace. Forgetting a Workspace marks its membership row without deleting data. `ensureDefault()` reuses a registered Workspace or creates one if no registered Workspace remains, including after the last one was forgotten. +The v1 schema has the same record families shown in the SQLite table above, using PostgreSQL identity columns, foreign-key cascades, uniqueness constraints, and JSON-validating text columns. Shared `backends/sql/` codecs and rules own JSON serialization, identity/name validation, collision allocation, and Task/Run transitions; SQL dialect and migration history stay adapter-owned. Parameter values are bound. Workspace activation changes the context's active identity without reopening its pool; retained Space handles and queued operations reject an inactive Workspace. A call running in a Workspace scope (`modules/workspace/context.ts`, used in Bubble mode) reads that Workspace instead, per call, so concurrent calls in different Workspaces share the pool without sharing a namespace; transactions bind it when they open, and the one database-wide write order still applies. Forgetting a Workspace marks its membership row without deleting data. `ensureDefault()` reuses a registered Workspace or creates one if no registered Workspace remains, including after the last one was forgotten. Structured mutations run in native transactions with a five-second lock timeout and a database-wide transaction advisory lock. This conservative write order makes CAS, label allocation, Task/Run mutation, and node → Space record → delta batches atomic across independent pools. SQL failures roll back; failed rollback discards the connection, and a failed operation does not poison the next queued writer. Reads use the pool directly; bulk node reads use bounded batches, and streaming supports cancellation. @@ -241,7 +241,7 @@ Space deletion is serialized against composed blob puts by a writer-preferring a Cross-Space Move is an application-level compensated operation, not a new storage transaction. It holds an active-Workspace lease and both Canvas write mutexes, optionally creates a named destination Space, copies every referenced Blob to a fresh destination key before structural mutation, commits the destination Space, closes and rehomes eligible dormant Agent conversations under their turn leases, then deletes the source selection and optionally creates a `spacePreview` breadcrumb in one source executor batch. A determinate later failure rehomes completed conversations back before applying inverse source and destination deltas; after successful compensation, a request-created destination and its blobs are removed. Unknown Agenetes outcomes or compensation failures retain both Spaces for reconciliation and never trigger automatic destination deletion. Failure to remove a new destination after a determinate failure is surfaced explicitly rather than reported as an ordinary rejected move. BlobScope intentionally has no per-key deletion, so a failed move into an existing Space may leave freshly copied unreferenced blobs until that Space is deleted; referenced source blobs are retained because the source Space may still contain other references. The guarantee remains all-or-compensated while the Server continues running and receives determinate backend outcomes, matching the existing `SpaceHandle.write()` boundary. -Retained Disk Space repository and handle instances, blob scopes, and legacy `CanvasStore` instances reject use after the active Workspace changes. Each `spaces()` call returns a fresh Workspace-bound handle and each read rescans current Disk state. The Workspace-qualified LRU is cleared and rebuilt on the next lookup after a switch. The delete-session contract covers overlapping operations through one configured backend instance. Disk realizes it with the shared process-local coordinator; it is not a multi-process transaction or distributed lock, and a SQL adapter must supply an equivalent backend-instance fence using its own mechanisms. +Retained Disk Space repository and handle instances, blob scopes, and legacy `CanvasStore` instances reject use after the active Workspace changes. Every Workspace getter answers for the Workspace the call runs in: its scope where one is open, else the process-active one. The Disk directory index is kept per Workspace folder and the SQL World id per Workspace, so several Workspaces are served side by side; operation leases count per Workspace. Each `spaces()` call returns a fresh Workspace-bound handle and each read rescans current Disk state. The Workspace-qualified LRU is cleared and rebuilt on the next lookup after a switch. The delete-session contract covers overlapping operations through one configured backend instance. Disk realizes it with the shared process-local coordinator; it is not a multi-process transaction or distributed lock, and a SQL adapter must supply an equivalent backend-instance fence using its own mechanisms. The `chat_v2/` two-tier log and `threads.json` remain owned by Agenetes L2 (`FileEventLogStore`, SQLite-backed `FileTurnStore`, and `FileThreadStore`), wired in [agenetes/drivers.ts](../../apps/server/src/modules/agent/agenetes/drivers.ts); see [agent-architecture.md](./agent-architecture.md) §5. Workspace activation is coordinated by `apps/server/src/modules/workspace-activation.ts`; the isolated child entry is `workspace-prepare.worker.ts`, and the ordered migration sequence is centralized in `workspace-prepare.ts` with migration implementations under `modules/workspace/migrations/`. @@ -319,6 +319,7 @@ An explicit title rename is resolved before the protected node → record → de | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | [storage/index.ts](../../apps/server/src/modules/storage/index.ts) | Portable Space facade and storage composition exports | | [structured.ts](../../apps/server/src/modules/storage/ports/structured.ts) | Space, node, and ordered-write ports | +| [workspace/context.ts](../../apps/server/src/modules/workspace/context.ts) | The Workspace a request or background job runs in | | [write-coordinator.ts](../../apps/server/src/modules/canvas/write-coordinator.ts) | Per-Canvas non-reentrant application mutex | | [canvas-executor.ts](../../apps/server/src/modules/canvas/canvas-executor.ts) | Editable command/inverse composition and ordered persistence | | [import-space-identity.ts](../../apps/server/src/modules/canvas/import-space-identity.ts) | Staged bundle identity discovery and storage migration | diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md index 728bd19a9..505fbabfd 100644 --- a/docs/architecture/deployment-security.md +++ b/docs/architecture/deployment-security.md @@ -74,7 +74,7 @@ Bubble owns credential validation, its durable principal directory, issuer/subje `HUABU_BUBBLE_AUTH` is Bubble's ordered authenticator list as JSON, passed to Bubble unchanged; Bubble validates it at startup and a rejected list stops the Server. Every driver Bubble ships is therefore available: `local-token`, `master-key`, `auth0`, `entra`, and `default`. When unset, it is `[{"driver":"local-token"}]`. `master-key` must come last because it claims every Bearer token. Its key is a system-owner credential, and the embedded Agentlet and its agents inherit every environment variable outside the `HUABU_` namespace, so Huabu refuses to start when `BUBBLE_MASTER_KEY` is set or a `master-key` entry would read it: give the entry `"masterKeyEnvVar": "HUABU_BUBBLE_MASTER_KEY"` (any `HUABU_` variable) or an inline `masterKey`. `default` treats the Bearer text as the principal ID without verifying anything; Huabu refuses it when `HUABU_BIND_HOST` is not loopback. -Huabu authorizes its own routes with two permissions, which it adds to Bubble's core roles through the `coreRoleCapabilities` of Bubble's host `authz` model and checks on Bubble's `system` target with Bubble's policy. `huabu.use` admits a principal to Huabu and is carried by the `member` and `owner` roles. `huabu.manage` is the owner authority for Settings, OAuth, credential, External Agent Profile and similar routes, and is carried by `owner` only. So a system `member` grant makes a Huabu member, a system `owner` grant a Huabu owner; no other role, and no grant on a thread or principal, confers either. A principal with neither can inspect only `GET /api/identity`; other routes return 403 `ACCESS_REQUIRED`. A member reaches every route that does not itself require an owner. The local provider's principal always holds both. In Bubble a system grant also carries that role's Bubble authority over every thread. Bubble's first-user owner auto-grant is always off. `HUABU_BUBBLE_OWNER` names one principal that startup provisions as system owner, creating it when missing without an external identity. That suits a master-key caller: set the `master-key` entry's `defaultPrincipalId` to the same ID. Provisioning only adds; removing the variable does not revoke a stored grant. Owners authenticated by Auth0 or Entra receive their principal at first login, so an existing owner grants them `owner` on `system` through Bubble afterwards. +Huabu authorizes its own routes with two system permissions, which it adds to Bubble's core roles through the `coreRoleCapabilities` of Bubble's host `authz` model and checks on Bubble's `system` target with Bubble's policy. `huabu.use` admits a principal to Huabu and is carried by the `member` and `owner` roles. `huabu.admin` is the owner authority for Settings, OAuth, credential, External Agent Profile, folder Workspace and similar routes, and is carried by `owner` only. So a system `member` grant makes a Huabu member, a system `owner` grant a Huabu owner; no other role, and no grant on a thread or principal, confers either. A principal with neither can inspect only `GET /api/identity`; other routes return 403 `ACCESS_REQUIRED`. A member reaches every route that does not itself require an owner. The local provider's principal always holds both. In Bubble a system grant also carries that role's Bubble authority over every thread. Bubble's first-user owner auto-grant is always off. `HUABU_BUBBLE_OWNER` names one principal that startup provisions as system owner, creating it when missing without an external identity. That suits a master-key caller: set the `master-key` entry's `defaultPrincipalId` to the same ID. Provisioning only adds; removing the variable does not revoke a stored grant. Owners authenticated by Auth0 or Entra receive their principal at first login, so an existing owner grants them `owner` on `system` through Bubble afterwards. Bubble issues local tokens to bot principals only. An owner creates the bot and its token through Bubble's API, and the bot reaches Huabu's routes once it also holds the system-owner grant: @@ -93,15 +93,36 @@ Long-lived Bubble-authenticated HTTP responses, including existing SSE streams, ### Workspace and Space access -Huabu declares its own permission model to Bubble as host targets and roles, so grants live in Bubble's table and Bubble's policy answers every check. There are two target kinds: `huabu.workspace`, and `huabu.space`, whose parent is its parent Space or, for a root Space, its Workspace. Two roles carry `huabu.space.read` and `huabu.space.write`: `huabu.workspace.owner`, granted on a Workspace, and `huabu.space.owner`, granted on a Space. Both capabilities inherit downward along that parent chain, so a Workspace owner may use every Space in it and a Space owner may use that Space and every Space inside it. A system `owner` holds every Huabu capability everywhere, which is Bubble's root rule. Both ownership roles are managed by `huabu.manage`, so only a system owner grants or revokes them, for example `{"target":{"type":"huabu.workspace","id":""},"principalId":"dave","role":"huabu.workspace.owner"}` posted to `/bubble/v1/grants`. A member cannot share a Space with another member; that is deliberate for this stage. +Huabu declares its own permission model to Bubble as host targets and roles, so grants live in Bubble's table and Bubble's policy answers every check. There are two target kinds: `huabu.workspace`, named by Workspace id, and `huabu.space`, named `:` so that a grant never reaches a Space of the same id in another Workspace. A Space's parent is its parent Space or, for a root Space, its own Workspace. Three capabilities apply on both kinds and flow down that chain, never up or sideways: -Creating a Space makes its creator its `huabu.space.owner`. A selection move into a new Space claims it right after creating it, before any content leaves the source, so a failed claim is compensated like any other move failure. A move also requires read access to every Space whose artifact a moved node cites before copying it into the destination. That covers `POST /api/canvas`, bundle import, moving a selection into a new Space, adding a child Space, and detaching a Space to the root, where it no longer inherits from a parent. A Space created by an agent through the machine credential is claimed by nobody and is reachable only through Workspace or system ownership. +| Capability | Allows | +| -------------- | ----------------------------------------------------------------------------- | +| `huabu.read` | Seeing and opening the Workspace or Space, every `GET`, search, live streams. | +| `huabu.write` | Editing Spaces and creating them. | +| `huabu.manage` | Renaming and deleting it, and granting roles on it. | -One `preHandler` authorizes every request that names a Space before its route runs. The Space is the `canvasId` route parameter, query field or body field; when more than one is given they must agree, and conflicting, repeated or non-string ids answer 400 `SPACE_ACCESS_AMBIGUOUS`, so a handler never reads a different Space from the one authorized. A request that also names another Space is checked against both: the destination of a selection move, the new parent of a re-parent, the target of a conversation fork (write), and the source of an artifact clone (read). Safe methods need read and other methods need write; the few `POST` routes that only read declare it. A Space the caller may not read answers 404, exactly like a missing one, and one it may only read answers 403 `SPACE_ACCESS_REQUIRED` to a write. Agents and conversations are owner-only for now: a member gets 403 `AGENT_ACCESS_REQUIRED` from every `/api/agent`, `/api/acp`, `/api/agent-change-review` and `/api/rfs` route, from any route that declares `invokesAgent` (Interactive View actions), and from any request that names a thread, because an agent acts through the shared machine credential. RFS is that credential's own surface — it launches agents and Task Runs and leases shared service credentials — and the web app never calls it. For the same reason a member's edits never count towards the memory worker, which runs an agent over Workspace-wide memory and Skills, and a member's node preprocessing runs without LLM enrichment. That preprocessing also fetches only from public addresses: inside its run every outbound request, each redirect hop included, refuses at connect time any loopback, private, link-local, carrier-grade NAT or other non-public address (`modules/security/outbound.ts`), and the desktop fetch fallback is skipped. The Space list and Space cards omit or report as missing what the caller may not read. World is an ordinary Space under its Workspace, so only Workspace and system owners reach it. +| Role | Granted on | Gives | +| ------------------------ | ----------- | -------------------------------------------------- | +| `huabu.workspace.owner` | a Workspace | read, write, manage | +| `huabu.workspace.editor` | a Workspace | read, write | +| `huabu.workspace.viewer` | a Workspace | read | +| `huabu.space.owner` | a Space | read, write, manage on it and the Spaces inside it | -Workspaces are still activated one at a time for the whole Server. Listing, creating, renaming, removing or activating Workspaces, and changing the active one, is therefore owner-only, and Bubble mode's Postgres backend offers no Workspace switching in the UI. Only Spaces of the active Workspace exist for authorization; a Space of another Workspace is denied. Serving several Workspaces side by side is the next phase. +A system `owner` holds every Huabu capability everywhere, which is Bubble's root rule. Every role on a Workspace or Space is granted with `huabu.manage` there, so whoever owns a Workspace or Space may share it, for example `{"target":{"type":"huabu.workspace","id":""},"principalId":"dave","role":"huabu.workspace.viewer"}` posted to `/bubble/v1/grants`; system roles stay with system owners. A Workspace or Space grant alone does not admit anyone: admission still needs `huabu.use`. On startup, Space grants that name a Space by id alone, as the first Bubble release wrote them, are rewritten to name the Workspace holding that Space, and dropped when no Workspace does. -The local provider's owner and the Agentlet machine credential pass every Space check. An agent therefore acts with the machine credential's full RFS reach, which is why only owners may run one until credentials carry the caller's own authority. Workspace-level state such as `setting/user.md` memory and user Skills is shared by everyone in the Workspace. Each request authenticates once and answers all its checks from one Bubble policy scope, one grant load and one ancestry walk per Space. +Creating a Space makes its creator its `huabu.space.owner`. A selection move into a new Space claims it right after creating it, before any content leaves the source, so a failed claim is compensated like any other move failure. A move also requires read access to every Space whose artifact a moved node cites before copying it into the destination. That covers `POST /api/canvas`, bundle import, moving a selection into a new Space, adding a child Space, and detaching a Space to the root, where it no longer inherits from a parent. A Space created by an agent through the machine credential is claimed by nobody and is reachable through its Workspace or system ownership. + +One `preHandler` authorizes every request that names a Space before its route runs. The Space is the `canvasId` route parameter, query field or body field, in the Workspace the request runs in; when more than one is given they must agree, and conflicting, repeated or non-string ids answer 400 `SPACE_ACCESS_AMBIGUOUS`, so a handler never reads a different Space from the one authorized. A request that also names another Space is checked against both: the destination of a selection move, the new parent of a re-parent, the target of a conversation fork (write), and the source of an artifact clone (read). Safe methods need read and other methods need write; the few `POST` routes that only read declare it, and renaming or deleting a Space declares manage. A Space the caller may not read answers 404, exactly like a missing one, and one it may read but not change answers 403 `SPACE_ACCESS_REQUIRED`. Agents and conversations are owner-only for now: a member gets 403 `AGENT_ACCESS_REQUIRED` from every `/api/agent`, `/api/acp`, `/api/agent-change-review` and `/api/rfs` route, from any route that declares `invokesAgent` (Interactive View actions), and from any request that names a thread, because an agent acts through the shared machine credential. RFS is that credential's own surface — it launches agents and Task Runs and leases shared service credentials — and the web app never calls it. For the same reason a member's edits never count towards the memory worker, which runs an agent over Workspace-wide memory and Skills, and a member's node preprocessing runs without LLM enrichment. That preprocessing also fetches only from public addresses: inside its run every outbound request, each redirect hop included, refuses at connect time any loopback, private, link-local, carrier-grade NAT or other non-public address (`modules/security/outbound.ts`), and the desktop fetch fallback is skipped. The Space list and Space cards omit or report as missing what the caller may not read. World is an ordinary Space under its Workspace, so only those who may read the Workspace reach it. + +The local provider's owner and the Agentlet machine credential pass every Space check. An agent therefore acts with the machine credential's full RFS reach, which is why only owners may run one until credentials carry the caller's own authority. Workspace-level state such as `setting/user.md` memory and user Skills is shared by everyone in a Workspace. Each request authenticates once and answers all its checks from one Bubble policy scope, one grant load and one ancestry walk per Space. + +### Workspaces per person + +In Bubble mode each request runs in its caller's own current Workspace, so several people work in different Workspaces of one Server at once; local mode keeps one process-active Workspace for everyone. Admission chooses it after authentication: the caller's stored current Workspace while they may still enter it, else the first Workspace they may enter, which becomes current. The current Workspace is a per-principal row in the `huabu_identity.current_workspaces` table of `HUABU_POSTGRES_URL`, referencing Bubble's principal ID. A member who may enter no Workspace gets a personal one, "'s Workspace", owned by them, on their first admitted request; concurrent first requests in one process create one. A caller may enter a Workspace they hold a Workspace role on, or one holding a Space they hold a role on; a system owner may enter any, and lands first on the process-active one. The choice is carried through the request and everything it starts in an `AsyncLocalStorage` scope (`modules/workspace/context.ts`), which the Workspace getters, the storage backends' per-call namespace and the identity checks read; see [Multi-backend storage](./canvas-storage.md). + +`/api/workspaces` then acts for the caller alone. It lists the Workspaces they may enter, `active` marking their current one. `POST` with a `name` creates a Workspace they own, wherever the backend keeps one: a row on SQL backends, a folder under the data directory on Disk. Activating sets only their current Workspace. Renaming and unregistering need `huabu.manage` on that Workspace, and unregistering refuses a Workspace with work in flight or the process-active one; a person whose current Workspace was unregistered moves on at their next request. Folder Workspaces stay with system owners, from localhost, as do `/api/workspace` folder picking and path validation, because they name places on the Server's filesystem; members never receive a Workspace path. `GET /api/workspace` reports `capabilities.namedWorkspaces`, and the web app then switches and creates Workspaces by name. + +An agent run keeps the Workspace it started in: its reachback URL is `/api/rfs/w//`, and the RFS routes under that prefix run in that Workspace whatever its starter or the process does later. ### Browser sign-in @@ -154,6 +175,8 @@ The Desktop remote-client path uses the operating system's normal certificate va | [`apps/server/src/security/private-config.ts`](../../apps/server/src/security/private-config.ts) | Instance-specific private directory and automatic standalone master-key lifecycle. | | [`apps/server/src/modules/security/deployment-config.ts`](../../apps/server/src/modules/security/deployment-config.ts) | Resolve and fail closed on invalid bind, allowed-host, and Basic Auth combinations. | | [`apps/server/src/modules/identity/bubble.ts`](../../apps/server/src/modules/identity/bubble.ts) | Declare Huabu's Workspace and Space targets, roles and inheritance to Bubble. | +| [`apps/server/src/modules/identity/workspace-selection.ts`](../../apps/server/src/modules/identity/workspace-selection.ts) | Each Bubble request's Workspace: the caller's current one, or a personal one. | +| [`apps/server/src/modules/workspace/scoped-routes.ts`](../../apps/server/src/modules/workspace/scoped-routes.ts) | RFS served in the Workspace an agent run started in. | | [`apps/server/src/modules/security/space-access.ts`](../../apps/server/src/modules/security/space-access.ts) | Authorize every Space a request names; claim created Spaces. | | [`apps/server/src/modules/identity/http.ts`](../../apps/server/src/modules/identity/http.ts) | Admit callers and carry a browser's Bearer credential in its session cookie. | | [`apps/web/src/components/Shell/SignInGate.tsx`](../../apps/web/src/components/Shell/SignInGate.tsx) | Sign the browser in through Bubble's discovered provider before the app mounts. | diff --git a/docs/proposals/multi-user-service.md b/docs/proposals/multi-user-service.md index dc783d726..7e3ae3147 100644 --- a/docs/proposals/multi-user-service.md +++ b/docs/proposals/multi-user-service.md @@ -1,6 +1,6 @@ # Multi-User Service -Status: Proposed +Status: In-Progress — PR A implemented, PR B proposed Last updated: 2026-10-28 Follows PR #237 (Bubble identity and per-user Space access) toward issue #228. Two stacked pull requests take one Huabu Server from "several people sign in and each edits their own Spaces in one shared Workspace" to a service several people can use day to day: each person has their own Workspaces, uses agents in them, and an operator admits people without scripts. @@ -31,7 +31,7 @@ Each request runs in exactly one Workspace, resolved once in admission and carri How the Workspace is chosen: - **Local mode:** the process-active Workspace, as today. Switching still changes it for every client, because local mode has one person. -- **Bubble mode:** the principal's _current Workspace_, a per-principal preference stored in Huabu (`principal_preferences`, referencing Bubble's `principalId`, as #228 proposes). Switching changes only that preference. It must be a Workspace the principal can read (`huabu.workspace.read`, new), else admission falls back to their personal Workspace. +- **Bubble mode:** the principal's _current Workspace_, a per-principal row stored in Huabu (`huabu_identity.current_workspaces`, referencing Bubble's `principalId`, as #228 proposes). Switching changes only that row. It must be a Workspace the principal may enter, else admission falls back to the first one they may enter, or a new personal Workspace. A per-principal preference rather than a Workspace header keeps images, frames, downloads and EventSource working without changes, since they cannot set headers. The cost is that one person's tabs share one current Workspace, which is also the behavior today. Per-tab Workspaces would need the Workspace in every URL and are not planned. @@ -40,10 +40,11 @@ Work that outlives the request (agents, memory updates, Task Runs, preprocessing ### Storage - **SQL namespaces per call, not per connection.** `useWorkspace()` mutates the one Postgres/SQLite connection's namespace today, so concurrent Workspaces would read each other's Spaces. The structured store reads the Workspace id from the context per query, and transactions bind it when they open. The one database-wide write order (`pg_advisory_xact_lock(WRITE_LOCK)`) stays: it also covers name allocation and the Workspace registry. A per-Workspace lock is a later throughput change. -- **Per-Workspace state.** The World id, canvas-dirs index, user-skill cache, external-note sessions and the Space-create and hierarchy chains move into a `WorkspaceRuntime` (`modules/workspace/runtime.ts`, new), created on first use per Workspace and evicted when idle. The process-wide reset hooks (`refreshCanvasDirIndex`, `invalidateUserSkill`, `resetExternalNoteSessions`) become methods on it. +- **Per-Workspace state.** The SQL World id and the Disk directory index are kept per Workspace. The user-skill cache rebuilds whenever the calling Workspace differs, which is correct and costs only a rescan. The Space-create and hierarchy chains stay process-wide: they serialize, which costs throughput, not correctness. Queues that outlive a request (memory passes, World-preview reconciliation, preprocessing) run each item in its enqueuer's scope, because they chain promises from the enqueuing call. - **Leases.** `acquireWorkspaceOperationLease()` counts per Workspace. It still stops a local-mode switch or a Workspace delete from stranding in-flight work, and no longer refuses a second live Workspace. -- **Blob prefixes use the Workspace id.** Azure blobs are prefixed by `getWorkspaceKey()`, which on Disk is the folder _path_, so moving a folder orphans them. This becomes the Workspace id, with a one-time copy-forward on first open. -- **Maps keyed by Space id alone** (canvas mutex, SSE listeners, memory worker, dir handles, tombstones, Agenetes turn owners) stay as they are. Space ids are random UUIDs or `createId('canvas')` and imports mint new ids. Workspace creation refuses a Disk folder whose Space ids collide with a live Workspace's. +- **Maps keyed by Space id alone** (canvas mutex, SSE listeners, memory worker, dir handles, tombstones, Agenetes turn owners) stay as they are. Space ids are random UUIDs or `createId('canvas')`, imports mint new ids, and personal Workspaces start empty. The one way to repeat an id is an administrator adopting a copy of a Disk folder as a second Workspace; that is a known limitation, not guarded. + +Not changed: Azure blobs are still prefixed by the Workspace key, which on Disk is the folder path. That predates this work and does not affect several people working at once. ### Roles and capabilities