+ >
+ );
+}
+
// ──────────────────────────────────────────────────────────────────────
// 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 08/17] 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 09/17] 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
From 53058b726e40a313b0d24314c851c17fb069799b Mon Sep 17 00:00:00 2001
From: Yuge Zhang
Date: Wed, 28 Oct 2026 17:53:35 +0800
Subject: [PATCH 10/17] feat(identity): administer people through /api/people
A Bubble administrator can list every principal with their system and
Workspace roles, admit or revoke a member, and give or remove a
Workspace role, without scripts against Bubble's API. Revoking one's
own access is refused. Changes apply on the next request.
Co-Authored-By: Claude Opus 5.5
---
apps/server/src/app.ts | 5 +
apps/server/src/modules/identity/bubble.ts | 98 ++++++++++++++++++-
apps/server/src/modules/identity/index.ts | 11 +++
.../modules/identity/multi-user.scenarios.ts | 66 ++++++++++++-
.../src/modules/identity/people.route.ts | 85 ++++++++++++++++
apps/server/src/modules/identity/service.ts | 27 ++++-
packages/shared/src/types/api/identity.ts | 30 ++++++
7 files changed, 316 insertions(+), 6 deletions(-)
create mode 100644 apps/server/src/modules/identity/people.route.ts
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index a0ddcdab5..c1c06a23c 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -42,6 +42,7 @@ import { resetExternalNoteSessions } from './modules/canvas/external-watcher.js'
import externalNoteRoutes from './modules/canvas/external.route.js';
import syncRoutes from './modules/canvas/sync.route.js';
import { createIdentity, registerIdentity } from './modules/identity/index.js';
+import peopleRoutes from './modules/identity/people.route.js';
import integrationsRoutes from './modules/integrations/integrations.route.js';
import interactiveViewRoutes from './modules/interactive-view/interactive-view.route.js';
import rfsRoutes from './modules/remote_fs/rfs.route.js';
@@ -145,6 +146,10 @@ registerIdentity(app, {
// An embedded Bubble serves its own API beside Huabu's, behind the same host,
// origin and rate-limit guards; clients address it as `/bubble`.
if (identity.bubble) app.register(identity.bubble, { prefix: '/bubble' });
+app.register(peopleRoutes, {
+ prefix: '/api/people',
+ service: identity.service,
+});
// Register @fastify/static to enable `reply.sendFile()`.
// Actual artifact serving uses a dynamic root resolved at request time
diff --git a/apps/server/src/modules/identity/bubble.ts b/apps/server/src/modules/identity/bubble.ts
index 9596cc1e9..c9da3b584 100644
--- a/apps/server/src/modules/identity/bubble.ts
+++ b/apps/server/src/modules/identity/bubble.ts
@@ -18,6 +18,7 @@ import {
import { identityPrincipalSchema } from '@huabu/shared';
+
import { getWorkspaceHandle } from '../workspace.js';
import { IdentityError } from './service.js';
import {
@@ -29,8 +30,10 @@ import type { WorkspacePreferences } from './preferences.js';
import type {
HuabuResource,
IdentityService,
+ PeopleDirectory,
ResourceDirectory,
} from './service.js';
+import type { WorkspaceRole } from '@huabu/shared';
import type {
BubbleAuthzOptions,
BubbleStore,
@@ -57,6 +60,92 @@ const CAPABILITY = {
const { read: READ, write: WRITE, manage: MANAGE } = CAPABILITY;
const TARGET = { workspace: 'huabu.workspace', space: 'huabu.space' } as const;
const SPACE_OWNER = 'huabu.space.owner';
+const WORKSPACE_ROLE = {
+ owner: 'huabu.workspace.owner',
+ editor: 'huabu.workspace.editor',
+ viewer: 'huabu.workspace.viewer',
+} as const satisfies Record;
+
+/** People administration over Bubble's principal and grant tables. */
+function bubblePeople(store: BubbleStore): PeopleDirectory {
+ const SYSTEM = { type: 'system' } as const;
+ const roleOf = (role: string): WorkspaceRole | null =>
+ (Object.keys(WORKSPACE_ROLE) as WorkspaceRole[]).find(
+ (key) => WORKSPACE_ROLE[key] === role,
+ ) ?? null;
+ return {
+ async list() {
+ const [principals, grants] = await Promise.all([
+ store.principals.list(),
+ store.grants.list(),
+ ]);
+ return principals.map((principal) => {
+ const held = grants.filter(
+ (grant) => grant.principalId === principal.principalId,
+ );
+ const system = held.filter((grant) => grant.target.type === 'system');
+ const workspaces = new Map();
+ for (const grant of held) {
+ const role = roleOf(grant.role);
+ if (
+ grant.target.type === TARGET.workspace &&
+ 'id' in grant.target &&
+ role
+ )
+ workspaces.set(grant.target.id, role);
+ }
+ return {
+ principal: identityPrincipalSchema.parse({
+ principalId: principal.principalId,
+ kind: principal.kind,
+ ...(principal.displayName
+ ? { displayName: principal.displayName }
+ : {}),
+ ...(principal.email ? { email: principal.email } : {}),
+ ...(principal.avatarUrl ? { avatarUrl: principal.avatarUrl } : {}),
+ }),
+ member: system.some((grant) => grant.role === 'member'),
+ admin: system.some((grant) => grant.role === 'owner'),
+ workspaces: [...workspaces].map(([workspaceId, role]) => ({
+ workspaceId,
+ role,
+ })),
+ };
+ });
+ },
+ async setMember(principalId, member, by) {
+ if (!(await store.principals.get(principalId))) return false;
+ const rows = await store.grants.list({ target: SYSTEM, principalId });
+ const current = rows.filter((row) => row.role === 'member');
+ if (member && current.length === 0)
+ await store.grants.add({
+ target: SYSTEM,
+ principalId,
+ role: 'member',
+ grantedByPrincipalId: by,
+ });
+ if (!member)
+ for (const row of current) await store.grants.remove(row.grantId);
+ return true;
+ },
+ async setWorkspaceRole(principalId, workspaceId, role, by) {
+ if (!(await store.principals.get(principalId))) return false;
+ const target = { type: TARGET.workspace, id: workspaceId };
+ const rows = await store.grants.list({ target, principalId });
+ for (const row of rows)
+ if (roleOf(row.role) && row.role !== (role && WORKSPACE_ROLE[role]))
+ await store.grants.remove(row.grantId);
+ if (role && !rows.some((row) => row.role === WORKSPACE_ROLE[role]))
+ await store.grants.add({
+ target,
+ principalId,
+ role: WORKSPACE_ROLE[role],
+ grantedByPrincipalId: by,
+ });
+ return true;
+ },
+ };
+}
/**
* A Space's Bubble target names its Workspace too, so a Space can later be
@@ -107,17 +196,17 @@ function huabuAuthz(resources: ResourceDirectory): BubbleAuthzOptions {
// 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: {
- 'huabu.workspace.owner': {
+ [WORKSPACE_ROLE.owner]: {
grantableOn: [TARGET.workspace],
capabilities: [READ, WRITE, MANAGE],
managementCapability: MANAGE,
},
- 'huabu.workspace.editor': {
+ [WORKSPACE_ROLE.editor]: {
grantableOn: [TARGET.workspace],
capabilities: [READ, WRITE],
managementCapability: MANAGE,
},
- 'huabu.workspace.viewer': {
+ [WORKSPACE_ROLE.viewer]: {
grantableOn: [TARGET.workspace],
capabilities: [READ],
managementCapability: MANAGE,
@@ -234,6 +323,7 @@ export async function openBubbleIdentity(options: {
service: {
provider: 'bubble',
challenge: 'Bearer realm="Huabu"',
+ people: bubblePeople(store),
selectWorkspace: (identity) =>
selectRequestWorkspace(identity, preferences, options.resources),
chooseWorkspace: (identity, workspaceId) =>
@@ -301,7 +391,7 @@ export async function openBubbleIdentity(options: {
role:
resource.type === 'space'
? SPACE_OWNER
- : 'huabu.workspace.owner',
+ : WORKSPACE_ROLE.owner,
grantedByPrincipalId: principalId,
});
},
diff --git a/apps/server/src/modules/identity/index.ts b/apps/server/src/modules/identity/index.ts
index 630f72a55..72477062d 100644
--- a/apps/server/src/modules/identity/index.ts
+++ b/apps/server/src/modules/identity/index.ts
@@ -50,6 +50,17 @@ export function createIdentity(
challenge: 'Bearer realm="Huabu"',
authenticate: async (credentials) =>
(await (opening ??= open())).service.authenticate(credentials),
+ // Present from the start so routes can tell Bubble mode by it; each call
+ // opens Bubble on demand like every other member here.
+ people: {
+ list: async () => (await (opening ??= open())).service.people!.list(),
+ setMember: async (...args) =>
+ (await (opening ??= open())).service.people!.setMember(...args),
+ setWorkspaceRole: async (...args) =>
+ (await (opening ??= open())).service.people!.setWorkspaceRole(
+ ...args,
+ ),
+ },
selectWorkspace: async (identity) =>
(await (opening ??= open())).service.selectWorkspace!(identity),
chooseWorkspace: async (identity, workspaceId) =>
diff --git a/apps/server/src/modules/identity/multi-user.scenarios.ts b/apps/server/src/modules/identity/multi-user.scenarios.ts
index 8fa141b25..152426824 100644
--- a/apps/server/src/modules/identity/multi-user.scenarios.ts
+++ b/apps/server/src/modules/identity/multi-user.scenarios.ts
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { openBubbleIdentity } from './bubble.js';
import { registerIdentity } from './http.js';
+import peopleRoutes from './people.route.js';
import { createMemoryWorkspacePreferences } from './preferences.js';
import { storageResourceDirectory } from './resources.js';
import canvasRoutes from '../canvas/canvas.route.js';
@@ -68,11 +69,15 @@ async function host() {
registerSpaceAccess(app);
await app.register(canvasRoutes, { prefix: '/api/canvas' });
await app.register(workspaceRoutes, { prefix: '/api/workspace' });
+ await app.register(peopleRoutes, {
+ prefix: '/api/people',
+ service: bubble.service,
+ });
await app.register(workspacesRoutes, { prefix: '/api/workspaces' });
const call = async (
principalId: string,
- method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
url: string,
payload?: object,
) => {
@@ -243,5 +248,64 @@ export function describeSeveralPeople(
).status,
).toBe(404);
});
+
+ it('lets an administrator admit, revoke and give Workspace roles', async () => {
+ const { call, store, workspaceOf, spacesOf } = await host();
+ await store.principals.upsert({ principalId: 'erin', kind: 'user' });
+ // Signed in, not admitted.
+ expect((await call('erin', 'GET', '/api/canvas')).status).toBe(403);
+ expect((await call('carol', 'GET', '/api/people')).status).toBe(403);
+
+ const listed = (await call('admin', 'GET', '/api/people')).body as Array<{
+ principal: { principalId: string };
+ member: boolean;
+ admin: boolean;
+ }>;
+ expect(
+ Object.fromEntries(
+ listed.map((p) => [p.principal.principalId, [p.member, p.admin]]),
+ ),
+ ).toMatchObject({
+ admin: [false, true],
+ carol: [true, false],
+ erin: [false, false],
+ });
+
+ const admit = (principalId: string, member: boolean) =>
+ call('admin', 'PUT', `/api/people/${principalId}/member`, { member });
+ expect((await admit('erin', true)).status).toBe(204);
+ expect((await call('erin', 'GET', '/api/canvas')).status).toBe(200);
+ expect((await admit('nobody', true)).status).toBe(404);
+ expect((await admit('admin', false)).status).toBe(409);
+
+ // A Workspace role lets Erin enter Carol's Workspace and edit there,
+ // beside the personal Workspace her first admitted request made.
+ const carolWs = await workspaceOf('carol');
+ await call('carol', 'POST', '/api/canvas', { title: 'plan' });
+ expect(
+ (
+ await call('admin', 'PUT', `/api/people/erin/workspaces/${carolWs}`, {
+ role: 'editor',
+ })
+ ).status,
+ ).toBe(204);
+ await call('erin', 'POST', `/api/workspaces/${carolWs}/activate`);
+ expect(await spacesOf('erin')).toEqual(['plan']);
+ const people = (await call('admin', 'GET', '/api/people')).body as Array<{
+ principal: { principalId: string };
+ workspaces: Array<{ workspaceId: string; role: string }>;
+ }>;
+ expect(
+ people.find((p) => p.principal.principalId === 'erin')?.workspaces,
+ ).toContainEqual({ workspaceId: carolWs, role: 'editor' });
+
+ // Removing it, then admission, takes effect on the next request.
+ await call('admin', 'PUT', `/api/people/erin/workspaces/${carolWs}`, {
+ role: null,
+ });
+ expect(await spacesOf('erin')).not.toContain('plan');
+ await admit('erin', false);
+ expect((await call('erin', 'GET', '/api/canvas')).status).toBe(403);
+ });
});
}
diff --git a/apps/server/src/modules/identity/people.route.ts b/apps/server/src/modules/identity/people.route.ts
new file mode 100644
index 000000000..4a0962948
--- /dev/null
+++ b/apps/server/src/modules/identity/people.route.ts
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import {
+ setPersonMemberSchema,
+ setPersonWorkspaceRoleSchema,
+} from '@huabu/shared';
+
+import { getRequestIdentity } from './request.js';
+import { getWorkspaceRepository } from '../storage/index.js';
+
+import type { IdentityService } from './service.js';
+import type { FastifyPluginAsync } from 'fastify';
+
+/**
+ * People administration for a Bubble deployment: who may use Huabu, and with
+ * which Workspace roles. An administrator's alone; absent in local mode, where
+ * there is one person. Revocation reaches open streams at their next
+ * revalidation, as every other grant change does.
+ */
+const peopleRoutes: FastifyPluginAsync<{ service: IdentityService }> = async (
+ app,
+ { service },
+) => {
+ const people = service.people;
+ app.addHook('preHandler', async (request, reply) => {
+ if (!people) return reply.code(404).send({ message: 'Not found' });
+ if (!getRequestIdentity(request)?.owner)
+ return reply.code(403).send({
+ message: 'Administering people requires an administrator',
+ code: 'ADMIN_REQUIRED',
+ });
+ });
+
+ app.get('/', async () => people!.list());
+
+ app.put<{ Params: { principalId: string } }>(
+ '/:principalId/member',
+ async (request, reply) => {
+ const body = setPersonMemberSchema.safeParse(request.body);
+ if (!body.success)
+ return reply.code(400).send({ message: 'Invalid request body' });
+ const by = getRequestIdentity(request)!.principal.principalId;
+ // An administrator cannot lock themselves out from here.
+ if (!body.data.member && request.params.principalId === by)
+ return reply
+ .code(409)
+ .send({ message: 'You cannot revoke your own access' });
+ if (
+ !(await people!.setMember(
+ request.params.principalId,
+ body.data.member,
+ by,
+ ))
+ )
+ return reply.code(404).send({ message: 'No such person' });
+ return reply.code(204).send();
+ },
+ );
+
+ app.put<{ Params: { principalId: string; workspaceId: string } }>(
+ '/:principalId/workspaces/:workspaceId',
+ async (request, reply) => {
+ const body = setPersonWorkspaceRoleSchema.safeParse(request.body);
+ if (!body.success)
+ return reply.code(400).send({ message: 'Invalid request body' });
+ const { principalId, workspaceId } = request.params;
+ if (body.data.role && !(await getWorkspaceRepository().get(workspaceId)))
+ return reply.code(404).send({ message: 'Workspace not found' });
+ const by = getRequestIdentity(request)!.principal.principalId;
+ if (
+ !(await people!.setWorkspaceRole(
+ principalId,
+ workspaceId,
+ body.data.role,
+ by,
+ ))
+ )
+ return reply.code(404).send({ message: 'No such person' });
+ return reply.code(204).send();
+ },
+ );
+};
+
+export default peopleRoutes;
diff --git a/apps/server/src/modules/identity/service.ts b/apps/server/src/modules/identity/service.ts
index 30fc16a28..e29768b21 100644
--- a/apps/server/src/modules/identity/service.ts
+++ b/apps/server/src/modules/identity/service.ts
@@ -2,7 +2,12 @@
// Licensed under the MIT license.
import type { WorkspaceScope } from '../workspace/context.js';
-import type { IdentityPrincipal, IdentityResponse } from '@huabu/shared';
+import type {
+ IdentityPrincipal,
+ IdentityResponse,
+ Person,
+ WorkspaceRole,
+} from '@huabu/shared';
/** Supplied by a trusted transport, never copied from a request body. */
export interface IdentityCredentials {
@@ -104,6 +109,8 @@ export interface IdentityService {
* mode).
*/
selectWorkspace?(identity: ResolvedIdentity): Promise;
+ /** Who may use Huabu and with which Workspace roles; Bubble mode only. */
+ readonly people?: PeopleDirectory;
/** Make a Workspace the caller's current one; `null` when refused. */
chooseWorkspace?(
identity: ResolvedIdentity,
@@ -120,3 +127,21 @@ export class IdentityError extends Error {
super(message);
}
}
+
+/**
+ * Administering people: admitting them to Huabu and giving them Workspace
+ * roles. Only an administrator's routes use it; the provider decides nothing
+ * here beyond storing the grants its policy then answers from.
+ */
+export interface PeopleDirectory {
+ list(): Promise;
+ /** `false` when there is no such principal. */
+ setMember(principalId: string, member: boolean, by: string): Promise;
+ /** `null` removes every Workspace role they hold there. */
+ setWorkspaceRole(
+ principalId: string,
+ workspaceId: string,
+ role: WorkspaceRole | null,
+ by: string,
+ ): Promise;
+}
diff --git a/packages/shared/src/types/api/identity.ts b/packages/shared/src/types/api/identity.ts
index c162ee2ba..a9add34dc 100644
--- a/packages/shared/src/types/api/identity.ts
+++ b/packages/shared/src/types/api/identity.ts
@@ -22,3 +22,33 @@ export const identityResponseSchema = z.object({
owner: z.boolean(),
});
export type IdentityResponse = z.infer;
+
+/** A Workspace role a person administration grant can give. */
+export const workspaceRoleSchema = z.enum(['owner', 'editor', 'viewer']);
+export type WorkspaceRole = z.infer;
+
+/** One principal, as `GET /api/people` lists them for an administrator. */
+export const personSchema = z.object({
+ principal: identityPrincipalSchema,
+ /** Holds system `member`: admitted to Huabu. */
+ member: z.boolean(),
+ /** Holds system `owner`: administers the deployment. */
+ admin: z.boolean(),
+ /** Workspace roles held directly, one entry per Workspace. */
+ workspaces: z.array(
+ z.object({ workspaceId: z.string().min(1), role: workspaceRoleSchema }),
+ ),
+});
+export type Person = z.infer;
+
+/** `PUT /api/people/:principalId/member`. */
+export const setPersonMemberSchema = z.object({ member: z.boolean() });
+export type SetPersonMemberRequest = z.infer;
+
+/** `PUT /api/people/:principalId/workspaces/:workspaceId`; `null` removes it. */
+export const setPersonWorkspaceRoleSchema = z.object({
+ role: workspaceRoleSchema.nullable(),
+});
+export type SetPersonWorkspaceRoleRequest = z.infer<
+ typeof setPersonWorkspaceRoleSchema
+>;
From cbe0a4450fd2228964047b55ce441ec580c392c0 Mon Sep 17 00:00:00 2001
From: Yuge Zhang
Date: Wed, 28 Oct 2026 18:06:44 +0800
Subject: [PATCH 11/17] feat(identity): give each member their own settings,
keys and credentials
A Bubble member's calls run in a personal settings namespace: the
secret store keeps their model keys, OAuth and connected credentials
under their own ids with no fallback to the deployment's environment,
and model settings and agent defaults live under data/people//.
Members may change their own settings; the deployment's stay with
administrators. A member's default agent is always the Built-In one.
Co-Authored-By: Claude Opus 5.5
---
.../src/modules/agent/agent-defaults.route.ts | 4 +-
.../src/modules/agent/agent-defaults.ts | 22 +++-
apps/server/src/modules/agent/llm.route.ts | 14 +--
apps/server/src/modules/agent/llm.ts | 103 +++++++++++-----
apps/server/src/modules/identity/http.ts | 9 +-
.../identity/personal-settings.test.ts | 115 ++++++++++++++++++
.../src/modules/identity/personal-settings.ts | 91 ++++++++++++++
.../integrations/ink-ocr-config.route.ts | 6 +-
.../integrations/integrations.route.ts | 4 +-
apps/server/src/modules/security/owner.ts | 10 ++
.../modules/services/services.route.test.ts | 1 +
.../src/modules/services/services.route.ts | 4 +-
apps/server/src/security/secret-store.ts | 45 ++++++-
13 files changed, 370 insertions(+), 58 deletions(-)
create mode 100644 apps/server/src/modules/identity/personal-settings.test.ts
create mode 100644 apps/server/src/modules/identity/personal-settings.ts
diff --git a/apps/server/src/modules/agent/agent-defaults.route.ts b/apps/server/src/modules/agent/agent-defaults.route.ts
index 9412298bd..92a2f3b73 100644
--- a/apps/server/src/modules/agent/agent-defaults.route.ts
+++ b/apps/server/src/modules/agent/agent-defaults.route.ts
@@ -14,7 +14,7 @@ import {
import { getProfileSchemaCache } from './acp/profile-schema-cache.js';
import { getAgentDefaults, setAgentDefaults } from './agent-defaults.js';
-import { isOwnerRequest } from '../security/owner.js';
+import { canChangeOwnSettings } from '../security/owner.js';
import type {
AgentDefaults,
@@ -75,7 +75,7 @@ function projectDefaults(defaults: AgentDefaults): AgentDefaultsResponse {
const agentDefaultsRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message:
'Forbidden: Utility Agent settings require owner authorization',
diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts
index 0accc5686..751a4dfc4 100644
--- a/apps/server/src/modules/agent/agent-defaults.ts
+++ b/apps/server/src/modules/agent/agent-defaults.ts
@@ -10,6 +10,10 @@ import { agentDefaultsSchema } from '@huabu/shared';
import { getDataDir } from '../../data-dir.js';
import { atomicWriteJson } from '../../utils/fs.js';
+import {
+ personalSettingsOwner,
+ settingsFilePath,
+} from '../identity/personal-settings.js';
import type { AgentDefaults, AgentProfileView } from '@huabu/shared';
@@ -35,7 +39,9 @@ interface AgentDefaultsStorage {
}
function configPath(): string {
- return join(getDataDir(), 'agent-defaults.json');
+ return personalSettingsOwner()
+ ? settingsFilePath('agent-defaults.json')
+ : join(getDataDir(), 'agent-defaults.json');
}
const diskStorage: AgentDefaultsStorage = {
@@ -125,9 +131,19 @@ export class AgentDefaultsService {
}
const service = new AgentDefaultsService();
-export const getAgentDefaults = (): AgentDefaults => service.getAgentDefaults();
+/**
+ * A member's own defaults always pick the Built-In agent: external agents run
+ * on the operator's machines under the operator's logins, so they stay the
+ * administrators'.
+ */
+export const getAgentDefaults = (): AgentDefaults => {
+ const defaults = service.getAgentDefaults();
+ return personalSettingsOwner()
+ ? { ...defaults, profileId: 'huabu' }
+ : defaults;
+};
export const requireDefaultAgentProfileId = (): string =>
- service.requireDefaultAgentProfileId();
+ personalSettingsOwner() ? 'huabu' : service.requireDefaultAgentProfileId();
export const setAgentDefaults = (config: AgentDefaults): AgentDefaults =>
service.setAgentDefaults(config);
export const initializeAgentDefaults = (
diff --git a/apps/server/src/modules/agent/llm.route.ts b/apps/server/src/modules/agent/llm.route.ts
index a12a17cb8..524a1709f 100644
--- a/apps/server/src/modules/agent/llm.route.ts
+++ b/apps/server/src/modules/agent/llm.route.ts
@@ -27,7 +27,7 @@ import {
verifyOAuthCredentials,
} from './oauth.js';
import { getRootErrorMessage } from '../../utils/error-message.js';
-import { isOwnerRequest } from '../security/owner.js';
+import { canChangeOwnSettings } from '../security/owner.js';
import type {
ApiResult,
@@ -59,7 +59,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
app.put<{ Body: LLMConfigUpdate; Reply: ApiResult }>(
'/config',
async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: LLM settings can only be changed from localhost',
});
@@ -86,7 +86,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
app.put<{ Body: LLMImageConfigUpdate; Reply: ApiResult }>(
'/image-config',
async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: LLM settings can only be changed from localhost',
});
@@ -116,7 +116,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
app.put<{ Body: LLMUtilityConfigUpdate; Reply: ApiResult }>(
'/utility-config',
async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: LLM settings can only be changed from localhost',
});
@@ -165,7 +165,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
Body: OAuthProviderBody;
Reply: ApiResult;
}>('/oauth/device-code', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({ message: 'Forbidden' });
}
@@ -191,7 +191,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
Body: OAuthProviderBody;
Reply: ApiResult;
}>('/oauth/poll', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({ message: 'Forbidden' });
}
@@ -234,7 +234,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
Body: OAuthProviderBody;
Reply: ApiResult;
}>('/oauth/logout', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({ message: 'Forbidden' });
}
diff --git a/apps/server/src/modules/agent/llm.ts b/apps/server/src/modules/agent/llm.ts
index ce7f6ba20..4dbe4464c 100644
--- a/apps/server/src/modules/agent/llm.ts
+++ b/apps/server/src/modules/agent/llm.ts
@@ -51,6 +51,10 @@ import {
setSecret,
} from '../../security/secret-store.js';
import { getLogger } from '../../utils/logger.js';
+import {
+ personalSettingsOwner,
+ settingsFilePath,
+} from '../identity/personal-settings.js';
import type {
Api,
@@ -198,7 +202,15 @@ function getDataFilePath(filename: string): string {
return join(getDataDir(), filename);
}
-const CONFIG_FILE = getDataFilePath('llm-config.json');
+/**
+ * The deployment's model settings, or a member's own in their personal
+ * settings namespace (`../identity/personal-settings.ts`).
+ */
+function configFile(): string {
+ return personalSettingsOwner()
+ ? settingsFilePath('llm-config.json')
+ : getDataFilePath('llm-config.json');
+}
/**
* Effective active configuration assembled from the persisted store —
@@ -304,8 +316,8 @@ interface UtilityConfigPersisted {
*/
function loadPersistedStore(): PersistedStore {
try {
- if (!existsSync(CONFIG_FILE)) return { providers: {} };
- const raw = readFileSync(CONFIG_FILE, 'utf-8');
+ if (!existsSync(configFile())) return { providers: {} };
+ const raw = readFileSync(configFile(), 'utf-8');
const parsed = JSON.parse(raw) as Record;
// Legacy shape — single active provider, fields at top level.
@@ -413,13 +425,13 @@ function extractLegacyImageConfig(
}
function savePersistedStore(store: PersistedStore): void {
- const dir = dirname(CONFIG_FILE);
+ const dir = dirname(configFile());
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
- writeFileSync(CONFIG_FILE, JSON.stringify(store, null, 2), 'utf-8');
+ writeFileSync(configFile(), JSON.stringify(store, null, 2), 'utf-8');
try {
- chmodSync(CONFIG_FILE, 0o600);
+ chmodSync(configFile(), 0o600);
} catch {
// Non-critical — best effort on platforms that support it
}
@@ -449,16 +461,30 @@ function loadPersistedConfig(): PersistedConfig | null {
// ==================== Runtime State ====================
-let cachedModel: Model | null = null;
-let activeConfig: PersistedConfig | null = null;
+interface RuntimeState {
+ cachedModel: Model | null;
+ activeConfig: PersistedConfig | null;
+ /**
+ * Cached utility-tier model. Built from `store.utilityConfig` and
+ * invalidated whenever {@link setUtilityConfig} writes. When utility is
+ * following the chat model (no utility config), the resolver returns the
+ * chat model directly and this stays null.
+ */
+ cachedUtilityModel: Model | null;
+}
-/**
- * Cached utility-tier model. Built from `store.utilityConfig` and
- * invalidated whenever {@link setUtilityConfig} writes. When utility is
- * following the chat model (no utility config), the resolver returns the
- * chat model directly and this stays null.
- */
-let cachedUtilityModel: Model | null = null;
+/** One runtime state per settings namespace: the deployment's, or a member's. */
+const runtimeStates = new Map();
+
+function runtime(): RuntimeState {
+ const key = personalSettingsOwner() ?? '';
+ let state = runtimeStates.get(key);
+ if (!state) {
+ state = { cachedModel: null, activeConfig: null, cachedUtilityModel: null };
+ runtimeStates.set(key, state);
+ }
+ return state;
+}
/**
* Resolve the API key for a provider.
@@ -565,26 +591,34 @@ function buildModel(cfg: PersistedConfig): Model {
/** Initialize the active configuration from persisted config or env vars. */
function ensureConfig(): PersistedConfig {
- if (activeConfig) return activeConfig;
+ const state = runtime();
+ if (state.activeConfig) return state.activeConfig;
// Try persisted config
const persisted = loadPersistedConfig();
if (persisted) {
- activeConfig = persisted;
- return activeConfig;
+ state.activeConfig = persisted;
+ return persisted;
}
+ // The deployment's environment is the operator's, never a member's.
+ if (personalSettingsOwner())
+ throw new Error(
+ 'No model configured. Choose a provider and add your key in Settings.',
+ );
+
// Fall back to legacy Azure OpenAI env vars
const azureKey = process.env.AZURE_OPENAI_API_KEY;
const azureEndpoint = process.env.AZURE_OPENAI_API_ENDPOINT;
const azureDeployment = process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME;
if (azureKey && azureEndpoint && azureDeployment) {
- activeConfig = {
+ const azure: PersistedConfig = {
provider: 'azure-openai',
model: azureDeployment,
};
- return activeConfig;
+ state.activeConfig = azure;
+ return azure;
}
// Try other providers from env vars
@@ -592,8 +626,9 @@ function ensureConfig(): PersistedConfig {
if (p.builtIn && getEnvApiKey(p.id as KnownProvider)) {
const models = getModelsForProvider(p.id);
if (models.length > 0) {
- activeConfig = { provider: p.id, model: models[0].id };
- return activeConfig;
+ const found: PersistedConfig = { provider: p.id, model: models[0].id };
+ state.activeConfig = found;
+ return found;
}
}
}
@@ -996,8 +1031,8 @@ export async function setLLMConfig(
provider: update.provider,
model: resolvedModel,
};
- activeConfig = persisted;
- cachedModel = null;
+ runtime().activeConfig = persisted;
+ runtime().cachedModel = null;
if (
persisted.provider === 'openai' &&
(update.apiKey !== undefined || update.baseUrl !== undefined)
@@ -1250,7 +1285,7 @@ export async function setUtilityConfig(
if (!update.provider) {
delete store.utilityConfig;
savePersistedStore(store);
- cachedUtilityModel = null;
+ runtime().cachedUtilityModel = null;
return getUtilityConfig();
}
@@ -1292,7 +1327,7 @@ export async function setUtilityConfig(
}
savePersistedStore(store);
- cachedUtilityModel = null;
+ runtime().cachedUtilityModel = null;
return getUtilityConfig();
}
@@ -1304,9 +1339,11 @@ export async function setUtilityConfig(
function getUtilityModel(): Model {
const cfg = loadUtilityPersistedConfig();
if (!cfg) return getLLMModel();
- if (cachedUtilityModel) return cachedUtilityModel;
- cachedUtilityModel = buildModel(cfg);
- return cachedUtilityModel;
+ const state = runtime();
+ if (state.cachedUtilityModel) return state.cachedUtilityModel;
+ const model = buildModel(cfg);
+ state.cachedUtilityModel = model;
+ return model;
}
/**
@@ -1580,7 +1617,8 @@ export async function ensureApiKeyForRole(
* Get a configured pi-ai Model instance for the active provider/model.
*/
export function getLLMModel(): Model {
- if (cachedModel) return cachedModel;
+ const state = runtime();
+ if (state.cachedModel) return state.cachedModel;
const cfg = ensureConfig();
const apiKey = resolveApiKey(cfg.provider);
@@ -1597,8 +1635,9 @@ export function getLLMModel(): Model {
}
}
- cachedModel = buildModel(cfg);
- return cachedModel;
+ const model = buildModel(cfg);
+ state.cachedModel = model;
+ return model;
}
/**
diff --git a/apps/server/src/modules/identity/http.ts b/apps/server/src/modules/identity/http.ts
index 66f5b7c7f..a6522b100 100644
--- a/apps/server/src/modules/identity/http.ts
+++ b/apps/server/src/modules/identity/http.ts
@@ -3,6 +3,7 @@
import { timingSafeEqual } from 'node:crypto';
+import { openPersonalSettings, useCaller } from './personal-settings.js';
import {
getRequestAuthorizations,
setRequestWorkspaceChooser,
@@ -80,10 +81,15 @@ export function registerIdentity(
// 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')
+ if (service.provider === 'bubble') {
app.addHook('onRequest', (_request, _reply, done) =>
openWorkspaceScope(done),
);
+ // Likewise each member's settings, model keys and credentials are their own.
+ app.addHook('onRequest', (_request, _reply, done) =>
+ openPersonalSettings(done),
+ );
+ }
app.addHook('onRequest', async (request, reply) => {
const path = request.url.split('?', 1)[0];
const identityRoute = path === '/api/identity' || path === SESSION_PATH;
@@ -174,6 +180,7 @@ export function registerIdentity(
}
// 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) useCaller(identity);
if (identity.member && service.selectWorkspace) {
const scope = await service.selectWorkspace(identity);
if (scope) fillWorkspaceScope(scope);
diff --git a/apps/server/src/modules/identity/personal-settings.test.ts b/apps/server/src/modules/identity/personal-settings.test.ts
new file mode 100644
index 000000000..964388176
--- /dev/null
+++ b/apps/server/src/modules/identity/personal-settings.test.ts
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ResolvedIdentity } from './service.js';
+
+/**
+ * A member's model keys and connected credentials are their own: the secret
+ * store and the settings files answer from a personal namespace, and the
+ * deployment's environment, which is the operator's, is never theirs.
+ */
+
+let tmp: string;
+const saved: Record = {};
+const ENV = ['HUABU_DATA_DIR', 'HUABU_SECRET_KEY', 'TAVILY_API_KEY'];
+
+beforeEach(() => {
+ tmp = mkdtempSync(join(tmpdir(), 'huabu-personal-settings-'));
+ for (const key of ENV) saved[key] = process.env[key];
+ process.env.HUABU_DATA_DIR = tmp;
+ process.env.TAVILY_API_KEY = 'operator-env-key';
+ delete process.env.HUABU_SECRET_KEY;
+ vi.resetModules();
+});
+afterEach(() => {
+ for (const key of ENV)
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ rmSync(tmp, { recursive: true, force: true });
+});
+
+const person = (principalId: string, owner = false): ResolvedIdentity => ({
+ principal: { principalId, kind: 'user' },
+ member: true,
+ owner,
+ access: {
+ can: async () => true,
+ claim: async () => {},
+ workspaces: async () => 'all',
+ },
+});
+
+describe('personal settings', () => {
+ it('keeps each member’s credentials apart from the deployment’s and each other’s', async () => {
+ const store = await import('../../security/secret-store.js');
+ const scope = await import('./personal-settings.js');
+ const { SECRET_IDS } = await import('../../security/secret-ids.js');
+ await store.initializeSecretStore();
+ const as = (identity: ResolvedIdentity | null, fn: () => T) =>
+ scope.openPersonalSettings(() => {
+ if (identity) scope.useCaller(identity);
+ return fn();
+ });
+ const tavily = SECRET_IDS.tavilyApiKey;
+
+ // The deployment falls back to its environment; a member does not.
+ expect(as(null, () => store.getSecret(tavily))).toBe('operator-env-key');
+ expect(as(person('carol'), () => store.getSecret(tavily))).toBeNull();
+
+ await as(person('carol'), () => store.setSecret(tavily, 'carol-key'));
+ await as(person('dave'), () => store.setSecrets({ [tavily]: 'dave-key' }));
+ expect(as(person('carol'), () => store.getSecret(tavily))).toBe(
+ 'carol-key',
+ );
+ expect(as(person('dave'), () => store.getSecret(tavily))).toBe('dave-key');
+ expect(as(null, () => store.getSecret(tavily))).toBe('operator-env-key');
+ // An administrator uses the deployment's.
+ expect(as(person('admin', true), () => store.getSecret(tavily))).toBe(
+ 'operator-env-key',
+ );
+
+ // Settings files go under the member's own directory.
+ expect(as(person('carol'), () => scope.settingsFilePath('x.json'))).toMatch(
+ new RegExp(`^${tmp}/people/[0-9a-f]{32}/x\\.json$`),
+ );
+ expect(as(null, () => scope.settingsFilePath('x.json'))).toBe(
+ join(tmp, 'x.json'),
+ );
+ });
+
+ it('never namespaces the deployment’s own credentials', async () => {
+ const store = await import('../../security/secret-store.js');
+ const scope = await import('./personal-settings.js');
+ const { SECRET_IDS } = await import('../../security/secret-ids.js');
+ await store.initializeSecretStore();
+ await store.setSecret(SECRET_IDS.managedConnectionToken, 'machine');
+ expect(
+ scope.openPersonalSettings(() => {
+ scope.useCaller(person('carol'));
+ return store.getSecret(SECRET_IDS.managedConnectionToken);
+ }),
+ ).toBe('machine');
+ });
+
+ it('gives a member the Built-In agent by default, whatever the deployment picks', async () => {
+ const scope = await import('./personal-settings.js');
+ const defaults = await import('../agent/agent-defaults.js');
+ defaults.setAgentDefaults({
+ profileId: 'external-x',
+ functionalModel: '',
+ });
+ expect(defaults.getAgentDefaults().profileId).toBe('external-x');
+ expect(
+ scope.openPersonalSettings(() => {
+ scope.useCaller(person('carol'));
+ return defaults.getAgentDefaults().profileId;
+ }),
+ ).toBe('huabu');
+ });
+});
diff --git a/apps/server/src/modules/identity/personal-settings.ts b/apps/server/src/modules/identity/personal-settings.ts
new file mode 100644
index 000000000..253bc77b6
--- /dev/null
+++ b/apps/server/src/modules/identity/personal-settings.ts
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+/**
+ * Whose settings a call reads and writes.
+ *
+ * The deployment's settings, model keys and connected credentials belong to
+ * its administrators. In Bubble mode every member has their own instead: a
+ * call made for a member, and everything it starts (an agent run included),
+ * runs in that member's personal settings namespace, and the secret store and
+ * settings files answer from it. Local mode, administrators and the machine
+ * credential never open one, so they read the deployment's, exactly as before.
+ */
+
+import { AsyncLocalStorage } from 'node:async_hooks';
+import { createHash } from 'node:crypto';
+import path from 'node:path';
+
+import { getDataDir } from '../../data-dir.js';
+
+import type { ResolvedIdentity, ResourceAccess } from './service.js';
+
+interface Slot {
+ /** Whose personal settings this call uses; `null` for the deployment's. */
+ principalId: string | null;
+ /**
+ * What the caller may reach, for work that outlives the request and no
+ * longer has it (an agent run's tools). `null` for an unrestricted caller.
+ */
+ access: ResourceAccess | null;
+}
+
+const storage = new AsyncLocalStorage();
+
+/** Run `fn` with an empty slot that {@link usePersonalSettings} may fill. */
+export function openPersonalSettings(fn: () => T): T {
+ return storage.run({ principalId: null, access: null }, fn);
+}
+
+/**
+ * Record who the call this runs inside is for: a member's own settings and
+ * access, or an administrator's access over the deployment's settings.
+ */
+export function useCaller(identity: ResolvedIdentity): void {
+ const slot = storage.getStore();
+ if (!slot) throw new Error('No settings scope is open for this call');
+ const personal = identity.member && !identity.owner;
+ const principalId = personal ? identity.principal.principalId : null;
+ if (slot.principalId && slot.principalId !== principalId)
+ throw new Error('This call already runs as another person');
+ slot.principalId = principalId;
+ slot.access = identity.access;
+}
+
+/**
+ * The access of the caller this call runs for, or `null` when it is
+ * unrestricted (local mode, the machine credential, startup work).
+ */
+export function callerAccess(): ResourceAccess | null {
+ return storage.getStore()?.access ?? null;
+}
+
+/** The member whose settings this call uses, or `null` for the deployment's. */
+export function personalSettingsOwner(): string | null {
+ return storage.getStore()?.principalId ?? null;
+}
+
+/** A name safe for a directory, the same for the same principal. */
+function personKey(principalId: string): string {
+ return createHash('sha256').update(principalId).digest('hex').slice(0, 32);
+}
+
+/**
+ * Where a settings file lives for this call: in the data directory for the
+ * deployment, under `people//` for a member.
+ */
+export function settingsFilePath(fileName: string): string {
+ const owner = personalSettingsOwner();
+ return owner
+ ? path.join(getDataDir(), 'people', personKey(owner), fileName)
+ : path.join(getDataDir(), fileName);
+}
+
+/**
+ * The secret id this call stores `id` under: itself for the deployment, and
+ * namespaced for a member, so one person's keys are never another's.
+ */
+export function personalSecretId(id: string): string {
+ const owner = personalSettingsOwner();
+ return owner ? `person:${personKey(owner)}:${id}` : id;
+}
diff --git a/apps/server/src/modules/integrations/ink-ocr-config.route.ts b/apps/server/src/modules/integrations/ink-ocr-config.route.ts
index 8e3b58d02..193cfb2d1 100644
--- a/apps/server/src/modules/integrations/ink-ocr-config.route.ts
+++ b/apps/server/src/modules/integrations/ink-ocr-config.route.ts
@@ -5,7 +5,7 @@ import { inkOcrConfigUpdateSchema } from '@huabu/shared';
import { getInkOcrConfig, setInkOcrConfig } from './ink-ocr-config.js';
import { isSecretStoreWritable } from '../../security/secret-store.js';
-import { isOwnerRequest } from '../security/owner.js';
+import { canChangeOwnSettings } from '../security/owner.js';
import type {
ApiResult,
@@ -18,7 +18,7 @@ const inkOcrConfigRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Reply: ApiResult }>(
'/config',
async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.code(403).send({
message:
'Forbidden: Ink OCR configuration requires owner authorization',
@@ -43,7 +43,7 @@ const inkOcrConfigRoutes: FastifyPluginAsync = async (app) => {
Body: InkOcrConfigUpdate;
Reply: ApiResult;
}>('/config', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.code(403).send({
message:
'Forbidden: Ink OCR configuration requires owner authorization',
diff --git a/apps/server/src/modules/integrations/integrations.route.ts b/apps/server/src/modules/integrations/integrations.route.ts
index 0e1acf421..f9a7598fe 100644
--- a/apps/server/src/modules/integrations/integrations.route.ts
+++ b/apps/server/src/modules/integrations/integrations.route.ts
@@ -18,7 +18,7 @@ import {
getIntegrationsConfig,
setIntegrationsConfig,
} from './integrations.js';
-import { isOwnerRequest } from '../security/owner.js';
+import { canChangeOwnSettings } from '../security/owner.js';
import type {
ApiResult,
@@ -39,7 +39,7 @@ const integrationsRoutes: FastifyPluginAsync = async (app) => {
Body: IntegrationsConfigUpdate;
Reply: ApiResult;
}>('/config', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: integration keys require owner authorization',
});
diff --git a/apps/server/src/modules/security/owner.ts b/apps/server/src/modules/security/owner.ts
index b829fc375..d73059ddf 100644
--- a/apps/server/src/modules/security/owner.ts
+++ b/apps/server/src/modules/security/owner.ts
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
+import { personalSettingsOwner } from '../identity/personal-settings.js';
import { getRequestIdentity } from '../identity/request.js';
import type { FastifyRequest } from 'fastify';
@@ -9,3 +10,12 @@ import type { FastifyRequest } from 'fastify';
export function isOwnerRequest(request: FastifyRequest): boolean {
return getRequestIdentity(request)?.owner === true;
}
+
+/**
+ * Whether the caller may change the settings this call reads: an owner the
+ * deployment's, a Bubble member their own personal ones (model keys, agent
+ * defaults, connected credentials).
+ */
+export function canChangeOwnSettings(request: FastifyRequest): boolean {
+ return isOwnerRequest(request) || personalSettingsOwner() !== null;
+}
diff --git a/apps/server/src/modules/services/services.route.test.ts b/apps/server/src/modules/services/services.route.test.ts
index e7f8c12da..8b2b03f0c 100644
--- a/apps/server/src/modules/services/services.route.test.ts
+++ b/apps/server/src/modules/services/services.route.test.ts
@@ -27,6 +27,7 @@ vi.mock('./index.js', async (importOriginal) => {
vi.mock('../security/owner.js', () => ({
isOwnerRequest: mocks.isOwner,
+ canChangeOwnSettings: mocks.isOwner,
}));
const { default: serviceRoutes } = await import('./services.route.js');
diff --git a/apps/server/src/modules/services/services.route.ts b/apps/server/src/modules/services/services.route.ts
index a31fd76d0..d1adbd389 100644
--- a/apps/server/src/modules/services/services.route.ts
+++ b/apps/server/src/modules/services/services.route.ts
@@ -3,7 +3,7 @@
import { serviceConfigUpdateSchema, serviceParamsSchema } from '@huabu/shared';
-import { isOwnerRequest } from '../security/owner.js';
+import { canChangeOwnSettings } from '../security/owner.js';
import { serviceProvisioner, ServiceProvisionError } from './index.js';
@@ -59,7 +59,7 @@ const serviceRoutes: FastifyPluginAsync = async (app) => {
Body: ServiceConfigUpdate;
Reply: ApiResult;
}>('/:serviceId', async (request, reply) => {
- if (!isOwnerRequest(request)) {
+ if (!canChangeOwnSettings(request)) {
return reply.code(403).send({
message: 'Forbidden: Service settings require owner authorization',
});
diff --git a/apps/server/src/security/secret-store.ts b/apps/server/src/security/secret-store.ts
index 16063af14..337c6e5e1 100644
--- a/apps/server/src/security/secret-store.ts
+++ b/apps/server/src/security/secret-store.ts
@@ -15,6 +15,8 @@ import {
} from './encrypted-file-secret-store.js';
import { EnvironmentSecretStore } from './environment-secret-store.js';
import { resolveStandaloneMasterKey } from './private-config.js';
+import { SECRET_IDS } from './secret-ids.js';
+import { personalSecretId } from '../modules/identity/personal-settings.js';
import type { SecretStore } from './secret-store-types.js';
@@ -56,16 +58,39 @@ function assertInitialized(): void {
}
}
-/** Read UI-persisted credentials first, then deployment-owned environment values. */
+/**
+ * The deployment's own credentials, never anyone's personal ones: a member
+ * running in their personal settings neither reads nor writes these as theirs.
+ */
+const DEPLOYMENT_SECRET_IDS: ReadonlySet = new Set([
+ SECRET_IDS.agentletConnectionToken,
+ SECRET_IDS.managedConnectionToken,
+ SECRET_IDS.remoteAccessPassword,
+]);
+
+/** Where `id` is kept for this call: a member's own, or the deployment's. */
+function scopedId(id: string): { id: string; personal: boolean } {
+ if (DEPLOYMENT_SECRET_IDS.has(id)) return { id, personal: false };
+ const personal = personalSecretId(id);
+ return { id: personal, personal: personal !== id };
+}
+
+/**
+ * Read UI-persisted credentials first, then deployment-owned environment
+ * values. A member's personal credentials have no environment fallback: the
+ * deployment's environment is the operator's.
+ */
export function getSecret(id: string): string | null {
assertInitialized();
+ const scoped = scopedId(id);
+ if (scoped.personal) return primaryStore?.get(scoped.id) ?? null;
return primaryStore?.get(id) ?? environmentStore.get(id);
}
/** Read only the writable backend, excluding environment fallbacks. */
export function getPersistedSecret(id: string): string | null {
assertInitialized();
- return primaryStore?.get(id) ?? null;
+ return primaryStore?.get(scopedId(id).id) ?? null;
}
/** Read saved credentials without creating storage for a new instance. */
@@ -90,8 +115,12 @@ export async function setSecret(
value: string | null,
): Promise {
assertInitialized();
- if (!primaryStore) await environmentStore.set(id, value);
- else await primaryStore.set(id, value);
+ const scoped = scopedId(id);
+ if (!primaryStore) {
+ if (scoped.personal)
+ throw new Error('Personal credentials need a writable secret store');
+ await environmentStore.set(id, value);
+ } else await primaryStore.set(scoped.id, value);
}
/**
@@ -106,11 +135,15 @@ export async function setSecrets(
assertInitialized();
if (!primaryStore) {
for (const [id, value] of Object.entries(updates)) {
- await environmentStore.set(id, value);
+ await setSecret(id, value);
}
return;
}
- await primaryStore.setMany(updates);
+ await primaryStore.setMany(
+ Object.fromEntries(
+ Object.entries(updates).map(([id, value]) => [scopedId(id).id, value]),
+ ),
+ );
}
export function getSecretStoreKind(): string {
From 2482eaf6cb86288d92b05e3f1737f12dc1d6d092 Mon Sep 17 00:00:00 2001
From: Yuge Zhang
Date: Wed, 28 Oct 2026 18:06:44 +0800
Subject: [PATCH 12/17] feat(agent): let members run the Built-In agent in
their own access
Members may now use /api/agent and conversations through a Space they
can use; external agents, RFS and change-review configuration stay with
administrators, since they act through the shared machine credential
on the operator's machines. The Built-In agent's tools check the
starter's access: World reads of another Space need read on it,
Workspace memory and Skills need manage on the Workspace, and Task Run
tools are refused.
Co-Authored-By: Claude Opus 5.5
---
apps/server/src/modules/agent/agent.route.ts | 9 +++
.../src/modules/agent/tools/executor.ts | 61 +++++++++++++++++--
apps/server/src/modules/identity/bubble.ts | 1 -
.../src/modules/security/space-access.test.ts | 34 ++++++++---
.../src/modules/security/space-access.ts | 34 ++++++-----
5 files changed, 110 insertions(+), 29 deletions(-)
diff --git a/apps/server/src/modules/agent/agent.route.ts b/apps/server/src/modules/agent/agent.route.ts
index cd4bb88c6..c758db9be 100644
--- a/apps/server/src/modules/agent/agent.route.ts
+++ b/apps/server/src/modules/agent/agent.route.ts
@@ -56,6 +56,7 @@ import {
import { buildHistoryFromTurns } from '../agent/conversation/transcript/history.js';
import { getLLMModel } from '../agent/llm.js';
import { hydrateCanvasNodes } from '../canvas/canvas-executor.js';
+import { personalSettingsOwner } from '../identity/personal-settings.js';
import { space } from '../storage/index.js';
import { canvasAcpNamespace } from '../workspace/paths.js';
@@ -831,6 +832,14 @@ const agentRoutes: FastifyPluginAsync = async (
agentTarget,
fixedTarget,
});
+ // External agents run on the operator's machines under the operator's
+ // logins and reach every Space through the shared machine credential, so
+ // a member runs the Built-In agent, with their own model keys.
+ if (effectiveBinding?.kind === 'external' && personalSettingsOwner())
+ return reply.code(403).send({
+ message: 'External agents are available to administrators only',
+ code: 'AGENT_ACCESS_REQUIRED',
+ });
if (effectiveBinding?.kind === 'external') {
request.log.info(
{
diff --git a/apps/server/src/modules/agent/tools/executor.ts b/apps/server/src/modules/agent/tools/executor.ts
index 1d25de984..94573a722 100644
--- a/apps/server/src/modules/agent/tools/executor.ts
+++ b/apps/server/src/modules/agent/tools/executor.ts
@@ -69,11 +69,24 @@ import {
} from './handlers/task.js';
import { handleWebSearch, type WebSearchArgs } from './handlers/web-search.js';
import { resolveWorldReadCanvasId } from '../../canvas/world-target-access.js';
-import { acquireWorkspaceOperationLease } from '../../workspace.js';
+import {
+ callerAccess,
+ personalSettingsOwner,
+} from '../../identity/personal-settings.js';
+import {
+ acquireWorkspaceOperationLease,
+ getWorkspaceHandle,
+} from '../../workspace.js';
import type { AgentToolResult } from '@earendil-works/pi-agent-core';
import type { NodeOrigin } from '@huabu/shared';
+const TASK_TOOLS: ReadonlySet = new Set([
+ 'create_task',
+ 'start_task_run',
+ 'complete_task_run',
+]);
+
/**
* Per-request context every handler call receives.
*
@@ -134,10 +147,16 @@ export async function executeTool(
throw new Error('targetCanvasId must be a string');
}
const { targetCanvasId: _targetCanvasId, ...toolArgs } = value;
- return {
- ...toolArgs,
- canvasId: await resolveWorldReadCanvasId(ownerCanvasId, requested),
- } as unknown as T;
+ const canvasId = await resolveWorldReadCanvasId(ownerCanvasId, requested);
+ // An agent run reaches no further than the person who started it.
+ const access = callerAccess();
+ if (
+ access &&
+ canvasId !== ownerCanvasId &&
+ !(await access.can('read', { type: 'space', id: canvasId }))
+ )
+ throw new Error(`Canvas ${canvasId} is not readable`);
+ return { ...toolArgs, canvasId } as unknown as T;
};
const withStableWorkspace = async (
operation: () => T | Promise,
@@ -153,6 +172,17 @@ export async function executeTool(
}
};
+ // Task Runs start agents through the shared machine credential, which
+ // reaches every Space; a member's run may not.
+ if (
+ TASK_TOOLS.has(name) &&
+ callerAccess() &&
+ personalSettingsOwner() !== null
+ )
+ throw new Error(
+ `${name} is not available to this agent: Task Runs are the administrators'`,
+ );
+
switch (name) {
case 'report_ink_intent':
return handleReportInkIntent(args as ReportInkIntentArgs, context);
@@ -233,6 +263,27 @@ export async function executeTool(
);
case 'fs_write':
+ {
+ // Workspace memory and Skills steer every agent in the Workspace, so
+ // changing them is the Workspace's managers'. A Space's own memory is
+ // checked like any other write to that Space.
+ const access = callerAccess();
+ const workspaceId = getWorkspaceHandle()?.workspaceId;
+ const target = (args as { path?: unknown }).path;
+ if (
+ access &&
+ typeof target === 'string' &&
+ target.replace(/^\.?\//, '') !== 'memory/space.md' &&
+ !(
+ workspaceId &&
+ (await access.can('manage', { type: 'workspace', id: workspaceId }))
+ )
+ )
+ throw new Error(
+ 'Changing Workspace memory or Skills needs manage access to the Workspace',
+ );
+ }
+ // falls through
// canvasId is conditionally needed (only for memory/canvas.md);
// the handler enforces that. Pass it through when present so the
// handler can resolve canvas-scoped paths, but do not require it
diff --git a/apps/server/src/modules/identity/bubble.ts b/apps/server/src/modules/identity/bubble.ts
index c9da3b584..ca015102a 100644
--- a/apps/server/src/modules/identity/bubble.ts
+++ b/apps/server/src/modules/identity/bubble.ts
@@ -18,7 +18,6 @@ import {
import { identityPrincipalSchema } from '@huabu/shared';
-
import { getWorkspaceHandle } from '../workspace.js';
import { IdentityError } from './service.js';
import {
diff --git a/apps/server/src/modules/security/space-access.test.ts b/apps/server/src/modules/security/space-access.test.ts
index d4446323e..3d271bb4a 100644
--- a/apps/server/src/modules/security/space-access.test.ts
+++ b/apps/server/src/modules/security/space-access.test.ts
@@ -158,23 +158,41 @@ describe('Space access', () => {
expect((await titles('', { canvasId: ['mine'] })).statusCode).toBe(400);
});
- it('keeps agents and conversations to owners, whatever Space a request names', async () => {
+ it('lets a member use the Built-In agent in their Spaces, and keeps external agents to administrators', async () => {
const { app } = host();
const as = (owner: 'yes' | 'no') => ({ 'x-owner': owner });
+ const member = async (request: {
+ method?: 'GET' | 'POST';
+ url: string;
+ payload?: object;
+ }) => (await app.inject({ ...request, headers: as('no') })).statusCode;
+ // Conversations, through a Space they may use.
+ expect(await member({ url: '/api/agent/history/t1?canvasId=mine' })).toBe(
+ 200,
+ );
+ expect(
+ await member({
+ method: 'POST',
+ url: '/api/agent',
+ payload: { canvasId: 'mine' },
+ }),
+ ).toBe(200);
+ expect(await member({ url: '/api/canvas/mine/threads/t1/changes' })).toBe(
+ 200,
+ );
+ // Not through one they may not, nor without naming one.
+ expect(await member({ url: '/api/agent/history/t1?canvasId=other' })).toBe(
+ 404,
+ );
+ expect(await member({ url: '/api/agent/history/t1' })).toBe(400);
for (const request of [
- { url: '/api/agent/history/t1?canvasId=mine' },
- { url: '/api/agent/history/t1' },
- { method: 'POST', url: '/api/agent', payload: { canvasId: 'mine' } },
{ method: 'POST', url: '/api/acp/threads/t1/permission?canvasId=mine' },
- { url: '/api/canvas/mine/threads/t1/changes' },
// The machine credential's own surface: agent launches, Task Runs and
// shared service credentials.
{ method: 'POST', url: '/api/rfs/mine/services/web-search/lease' },
{ method: 'POST', url: '/api/interactive-views/mine/n1/actions/a1' },
] as const) {
- expect(
- (await app.inject({ ...request, headers: as('no') })).statusCode,
- ).toBe(403);
+ expect(await member(request)).toBe(403);
}
expect(
(
diff --git a/apps/server/src/modules/security/space-access.ts b/apps/server/src/modules/security/space-access.ts
index eb421b171..82f903605 100644
--- a/apps/server/src/modules/security/space-access.ts
+++ b/apps/server/src/modules/security/space-access.ts
@@ -28,18 +28,16 @@ declare module 'fastify' {
type Fields = Record | undefined;
/**
- * Agents act through the shared machine credential, which reaches every
- * Space, so a member may not run or steer one until credentials carry the
- * caller's own authority. 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. Matched by route pattern, not URL spelling.
+ * External agents act through the shared machine credential, which reaches
+ * every Space, on the operator's machines and under the operator's logins, so
+ * a member may not run or steer one. 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. The change-review routes configure the
+ * deployment. A member runs the Built-In agent instead, through `/api/agent`,
+ * in their own access and with their own model keys. Matched by route
+ * pattern, not URL spelling.
*/
-const AGENT_ROUTES = [
- '/api/agent',
- '/api/acp',
- '/api/agent-change-review',
- '/api/rfs',
-];
+const ADMIN_AGENT_ROUTES = ['/api/acp', '/api/agent-change-review', '/api/rfs'];
/**
* The one value a request gives for `field` across its route parameters,
@@ -103,17 +101,23 @@ export function registerSpaceAccess(app: FastifyInstance): void {
const route = request.routeOptions.url ?? '';
if (
!identity.owner &&
- (threadId ||
- request.routeOptions.config?.invokesAgent ||
- AGENT_ROUTES.some(
+ (request.routeOptions.config?.invokesAgent ||
+ ADMIN_AGENT_ROUTES.some(
(prefix) => route === prefix || route.startsWith(`${prefix}/`),
))
) {
return reply.code(403).send({
- message: 'Agents and conversations require an owner',
+ message: 'External agents and their settings require an administrator',
code: 'AGENT_ACCESS_REQUIRED',
});
}
+ // A conversation lives in a Space, and is reached only through one the
+ // caller may use.
+ if (!identity.owner && threadId && !canvasId)
+ return reply.code(400).send({
+ message: 'A conversation is addressed through its Space',
+ code: 'SPACE_ACCESS_AMBIGUOUS',
+ });
const checks: Array<[AccessLevel, string]> = [];
if (canvasId) checks.push([level, canvasId]);
From 8a8d7b8696cd2b99c7f7fd254cbe19a98669e2e7 Mon Sep 17 00:00:00 2001
From: Yuge Zhang
Date: Wed, 28 Oct 2026 18:12:15 +0800
Subject: [PATCH 13/17] feat(web): renew a lapsing session without leaving the
page
Sign-in now asks for offline_access and keeps the refresh token in
page memory only. The page renews its session a minute before the
access token expires and on any 401; if it cannot, the sign-in screen
opens over the still-running app and signs in through a popup, then
resends the saves the lapse refused. With HUABU_SIGN_OUT_PROVIDER=true,
signing out also ends the identity provider's session through its
end_session_endpoint.
Co-Authored-By: Claude Opus 5.5
---
apps/server/src/modules/identity/config.ts | 5 +
apps/server/src/modules/identity/http.ts | 8 +-
apps/server/src/modules/identity/index.ts | 1 +
apps/server/src/modules/identity/service.ts | 2 +
apps/web/src/api/identity.ts | 20 +-
.../src/components/Panels/Header/AppMenu.tsx | 2 +-
.../src/components/Shell/SignInGate.test.tsx | 80 +++++-
apps/web/src/components/Shell/SignInGate.tsx | 85 +++++-
apps/web/src/store/canvasStore.ts | 11 +
apps/web/src/utils/oidcLogin.test.ts | 96 +++++++
apps/web/src/utils/oidcLogin.ts | 272 +++++++++++++++---
packages/shared/src/types/api/identity.ts | 4 +-
12 files changed, 515 insertions(+), 71 deletions(-)
create mode 100644 apps/web/src/utils/oidcLogin.test.ts
diff --git a/apps/server/src/modules/identity/config.ts b/apps/server/src/modules/identity/config.ts
index 9b1c0c661..dddf61ab7 100644
--- a/apps/server/src/modules/identity/config.ts
+++ b/apps/server/src/modules/identity/config.ts
@@ -23,6 +23,8 @@ export type IdentityConfig =
/** Principal provisioned as Bubble system owner at startup. */
owner?: string;
postgresUrl: string;
+ /** Signing out of Huabu also ends the identity provider's session. */
+ providerSignOut?: boolean;
};
export function resolveIdentityConfig(
@@ -67,6 +69,9 @@ export function resolveIdentityConfig(
authenticators,
...(owner ? { owner } : {}),
postgresUrl,
+ ...(env.HUABU_SIGN_OUT_PROVIDER === 'true'
+ ? { providerSignOut: true }
+ : {}),
};
}
diff --git a/apps/server/src/modules/identity/http.ts b/apps/server/src/modules/identity/http.ts
index a6522b100..9cd01138d 100644
--- a/apps/server/src/modules/identity/http.ts
+++ b/apps/server/src/modules/identity/http.ts
@@ -259,7 +259,13 @@ export function registerIdentity(
if (!identity)
throw new Error('Identity admission did not resolve a principal');
const { principal, member, owner } = identity;
- return { provider: service.provider, principal, member, owner };
+ return {
+ provider: service.provider,
+ principal,
+ member,
+ owner,
+ ...(service.providerSignOut ? { providerSignOut: true } : {}),
+ };
},
);
diff --git a/apps/server/src/modules/identity/index.ts b/apps/server/src/modules/identity/index.ts
index 72477062d..4f5db9fab 100644
--- a/apps/server/src/modules/identity/index.ts
+++ b/apps/server/src/modules/identity/index.ts
@@ -48,6 +48,7 @@ export function createIdentity(
service: {
provider: 'bubble',
challenge: 'Bearer realm="Huabu"',
+ ...(config.providerSignOut ? { providerSignOut: true } : {}),
authenticate: async (credentials) =>
(await (opening ??= open())).service.authenticate(credentials),
// Present from the start so routes can tell Bubble mode by it; each call
diff --git a/apps/server/src/modules/identity/service.ts b/apps/server/src/modules/identity/service.ts
index e29768b21..26e171a4c 100644
--- a/apps/server/src/modules/identity/service.ts
+++ b/apps/server/src/modules/identity/service.ts
@@ -88,6 +88,8 @@ export interface ResourceDirectory {
/** Resolve afresh for each request so revocation is not hidden by a host cache. */
export interface IdentityService {
readonly provider: IdentityResponse['provider'];
+ /** Signing out also ends the identity provider's own session. */
+ readonly providerSignOut?: boolean;
readonly challenge: string;
/**
* Admits callers without any credential, as local mode does without Basic
diff --git a/apps/web/src/api/identity.ts b/apps/web/src/api/identity.ts
index 66fd9ff74..88ab80120 100644
--- a/apps/web/src/api/identity.ts
+++ b/apps/web/src/api/identity.ts
@@ -87,7 +87,25 @@ export async function endSession(): Promise {
* staying put, when the Server did not confirm: the cookie would otherwise
* admit the same account again on arrival.
*/
-export async function signOut(): Promise {
+export async function signOut(
+ options: { providerSignOut?: boolean } = {},
+): Promise {
await endSession();
+ const { forgetTokens, providerSignOutUrl, heldAuthenticator } =
+ await import('../utils/oidcLogin');
+ forgetTokens();
+ // The provider's own session would otherwise sign the next person straight
+ // back in as this one, which matters on a shared computer.
+ if (options.providerSignOut) {
+ const authenticators = (await listInteractiveAuthenticators()) ?? [];
+ const authenticator =
+ heldAuthenticator() ??
+ (authenticators.length === 1 ? authenticators[0] : null);
+ const url = authenticator ? await providerSignOutUrl(authenticator) : null;
+ if (url) {
+ window.location.assign(url);
+ return;
+ }
+ }
window.location.assign('/');
}
diff --git a/apps/web/src/components/Panels/Header/AppMenu.tsx b/apps/web/src/components/Panels/Header/AppMenu.tsx
index c06da527a..eda5b7167 100644
--- a/apps/web/src/components/Panels/Header/AppMenu.tsx
+++ b/apps/web/src/components/Panels/Header/AppMenu.tsx
@@ -102,7 +102,7 @@ export const AppMenu: React.FC = ({
try {
if (!(await savedBeforeSignOut())) failure = 'auth.signOutUnsaved';
else {
- await signOut();
+ await signOut({ providerSignOut: identity?.providerSignOut });
return;
}
} catch {
diff --git a/apps/web/src/components/Shell/SignInGate.test.tsx b/apps/web/src/components/Shell/SignInGate.test.tsx
index 7a8594263..35d9ee69b 100644
--- a/apps/web/src/components/Shell/SignInGate.test.tsx
+++ b/apps/web/src/components/Shell/SignInGate.test.tsx
@@ -14,7 +14,11 @@ vi.mock('../../pages/WorkspaceLoadingScreen', () => ({
WorkspaceLoadingScreen: () => null,
}));
const unsaved = vi.hoisted(() => vi.fn(() => false));
-vi.mock('../../store/canvasStore', () => ({ hasUnsavedCanvasEdits: unsaved }));
+const resend = vi.hoisted(() => vi.fn(async () => {}));
+vi.mock('../../store/canvasStore', () => ({
+ hasUnsavedCanvasEdits: unsaved,
+ resendUnsavedCanvasEdits: resend,
+}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record) =>
@@ -136,7 +140,7 @@ describe('SignInGate', () => {
expect(container.textContent).toContain('Carol');
});
- it('warns that signing in again loses edits a lapsed session could not save', async () => {
+ it('keeps the app, and its unsaved edits, under the sign-in screen when a session lapses', async () => {
routes['GET /api/identity'] = () => json(MEMBER);
await mount();
expect(container.textContent).toBe('app');
@@ -144,20 +148,72 @@ describe('SignInGate', () => {
routes['GET /api/identity'] = () => json({}, 401);
routes['GET /bubble/v1/auth/authenticators'] = () =>
json({ authenticators: [ENTRA] });
- const lapse = async (hasUnsaved: boolean) => {
- unsaved.mockReturnValue(hasUnsaved);
- await act(async () => {
- window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
- });
- for (let i = 0; i < 5; i++) await act(async () => {});
- };
- await lapse(true);
+ unsaved.mockReturnValue(true);
+ await act(async () => {
+ window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
+ });
+ for (let i = 0; i < 5; i++) await act(async () => {});
+ // Signing in happens in a popup, so nothing is lost and nothing warns.
+ expect(container.textContent).toContain('app');
expect(container.textContent).toContain('auth.signInTitle');
- expect(container.textContent).toContain('auth.signInUnsaved');
- await lapse(false);
expect(container.textContent).not.toContain('auth.signInUnsaved');
});
+ it('renews a lapsing session in place with the refresh token the login gave', async () => {
+ sessionStorage.setItem(
+ 'huabu.oidc.pending',
+ JSON.stringify({
+ state: 'expected',
+ verifier: 'verifier',
+ authenticator: ENTRA,
+ returnTo: '/canvas/mine',
+ }),
+ );
+ window.history.replaceState(
+ null,
+ '',
+ '/auth/callback?code=the-code&state=expected',
+ );
+ routes[
+ 'GET https://login.example/tenant/v2.0/.well-known/openid-configuration'
+ ] = () => json({ token_endpoint: 'https://login.example/token' });
+ let issued = 0;
+ routes['POST https://login.example/token'] = () =>
+ json({
+ access_token: `access-${++issued}`,
+ refresh_token: `refresh-${issued}`,
+ });
+ routes['POST /api/identity/session'] = () =>
+ new Response(null, { status: 204 });
+ routes['GET /api/identity'] = () => json(MEMBER);
+ await mount();
+ expect(container.textContent).toBe('app');
+
+ await act(async () => {
+ window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
+ });
+ for (let i = 0; i < 5; i++) await act(async () => {});
+ const grants = calls
+ .filter((call) => call.url === 'https://login.example/token')
+ .map((call) =>
+ Object.fromEntries(new URLSearchParams(String(call.init?.body))),
+ );
+ expect(grants[1]).toMatchObject({
+ grant_type: 'refresh_token',
+ refresh_token: 'refresh-1',
+ client_id: 'spa',
+ });
+ const sessions = calls.filter(
+ (call) => call.url === '/api/identity/session',
+ );
+ expect(
+ new Headers(sessions.at(-1)?.init?.headers).get('Authorization'),
+ ).toBe('Bearer access-2');
+ // The page never left, and what the lapse refused goes out again.
+ expect(container.textContent).toBe('app');
+ expect(resend).toHaveBeenCalled();
+ });
+
it('completes a provider redirect by handing the token to the Server', async () => {
sessionStorage.setItem(
'huabu.oidc.pending',
diff --git a/apps/web/src/components/Shell/SignInGate.tsx b/apps/web/src/components/Shell/SignInGate.tsx
index 242fbaa10..07091d7be 100644
--- a/apps/web/src/components/Shell/SignInGate.tsx
+++ b/apps/web/src/components/Shell/SignInGate.tsx
@@ -17,7 +17,13 @@ import { API_CONFIG } from '../../config/api';
import { APP_NAME } from '../../config/app';
import { WorkspaceLoadingScreen } from '../../pages/WorkspaceLoadingScreen';
import { useIdentityStore } from '../../store/identityStore';
-import { beginLogin, completeLogin } from '../../utils/oidcLogin';
+import {
+ beginLogin,
+ completeLogin,
+ refreshTokens,
+ signInWithPopup,
+ type SignedInTokens,
+} from '../../utils/oidcLogin';
import { Button } from '../Common/Button';
import type { IdentityResponse } from '@huabu/shared';
@@ -73,6 +79,22 @@ export function SignInGate({ children }: { children: ReactNode }) {
const callback = useRef>(undefined);
// Once the app has run, a lapsed session can strand its unsaved edits.
const appStarted = useRef(false);
+ const renewalTimer = useRef(undefined);
+
+ /**
+ * Hand fresh tokens to the Server, and renew them a minute before they
+ * expire while this page holds a refresh token.
+ */
+ const adopt = useCallback(async (tokens: SignedInTokens) => {
+ await startSession(tokens.accessToken);
+ window.clearTimeout(renewalTimer.current);
+ if (tokens.expiresIn && tokens.expiresIn > 90)
+ renewalTimer.current = window.setTimeout(
+ () => void refreshTokens().then((next) => next && adopt(next)),
+ (tokens.expiresIn - 60) * 1000,
+ );
+ }, []);
+ useEffect(() => () => window.clearTimeout(renewalTimer.current), []);
const check = useCallback(
async (loginError?: string) => {
@@ -136,32 +158,74 @@ export function SignInGate({ children }: { children: ReactNode }) {
useEffect(() => {
callback.current ??= (async () => {
try {
- const token = await completeLogin();
- if (token) await startSession(token);
+ const tokens = await completeLogin();
+ if (tokens) await adopt(tokens);
return undefined;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
})();
void callback.current.then((loginError) => check(loginError));
- }, [check]);
+ }, [check, adopt]);
useEffect(() => {
- const recheck = () => void check();
+ // A lapsed session renews in place when this page holds a refresh token;
+ // whatever it refused is then sent again.
+ const recheck = () =>
+ void (async () => {
+ const tokens = appStarted.current ? await refreshTokens() : null;
+ if (tokens) {
+ try {
+ await adopt(tokens);
+ await resend();
+ } catch {
+ // Fall through to the sign-in check below.
+ }
+ }
+ await check();
+ })();
window.addEventListener(AUTH_REQUIRED_EVENT, recheck);
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, recheck);
- }, [check]);
+ }, [check, adopt]);
+
+ /** Sign in again without leaving the page, then resend what was refused. */
+ const signInInPlace = useCallback(
+ async (authenticator: InteractiveAuthenticator) => {
+ await adopt(await signInWithPopup(authenticator));
+ await check();
+ await resend();
+ },
+ [adopt, check],
+ );
if (state.kind === 'ready') return <>{children}>;
if (state.kind === 'checking') return ;
+ // The app keeps running under a lapsed session, so its edits survive.
+ if (state.kind === 'signIn' && appStarted.current)
+ return (
+ <>
+ {children}
+
+
+
+ >
+ );
return ;
}
+async function resend(): Promise {
+ const { resendUnsavedCanvasEdits } = await import('../../store/canvasStore');
+ await resendUnsavedCanvasEdits();
+}
+
/** The sign-in and no-access screens, in the Welcome page's frame. */
export function SignInScreen({
state,
+ onSignIn,
}: {
state: Exclude;
+ /** Sign in without leaving the page; the provider's redirect otherwise. */
+ onSignIn?: (authenticator: InteractiveAuthenticator) => Promise;
}) {
const { t } = useTranslation();
const [pending, setPending] = useState(null);
@@ -215,7 +279,12 @@ export function SignInScreen({
disabled={pending !== null}
onClick={() => {
setPending(authenticator.issuer);
- void beginLogin(authenticator).catch(() => setPending(null));
+ void (onSignIn ?? beginLogin)(authenticator).then(
+ () => {
+ if (onSignIn) setPending(null);
+ },
+ () => setPending(null),
+ );
}}
>
{pending === authenticator.issuer
@@ -253,7 +322,7 @@ export function SignInScreen({