+ >
+ );
+}
+
// ──────────────────────────────────────────────────────────────────────
// Free-mode UI
// ──────────────────────────────────────────────────────────────────────
diff --git a/apps/web/src/store/canvasStore.ts b/apps/web/src/store/canvasStore.ts
index 10837a9c5..e84081ccc 100644
--- a/apps/web/src/store/canvasStore.ts
+++ b/apps/web/src/store/canvasStore.ts
@@ -1134,6 +1134,17 @@ export function hasUnsavedCanvasEdits(): boolean {
);
}
+/**
+ * Resend what a lapsed session refused, once it has been renewed. Best
+ * effort: a save that fails again is reported as any other save failure.
+ */
+export async function resendUnsavedCanvasEdits(): Promise {
+ const { canvasId } = useCanvasStore.getState();
+ if (!canvasId) return;
+ if (structureSaveErrors.has(canvasId)) structureScheduler.schedule();
+ await drainPendingSaves();
+}
+
export class CanvasSubmissionSaveError extends Error {
readonly code = 'canvas_save_failed';
diff --git a/apps/web/src/store/settingsUiStore.ts b/apps/web/src/store/settingsUiStore.ts
index 4f01610e1..64324df63 100644
--- a/apps/web/src/store/settingsUiStore.ts
+++ b/apps/web/src/store/settingsUiStore.ts
@@ -19,7 +19,12 @@ import { create } from 'zustand';
* open and then cleared so a later plain `open()` reopens on the last
* tab the user was viewing rather than snapping back.
*/
-export type SettingsTabId = 'general' | 'agent' | 'services' | 'builtIn';
+export type SettingsTabId =
+ | 'general'
+ | 'agent'
+ | 'services'
+ | 'builtIn'
+ | 'people';
interface SettingsUiState {
isOpen: boolean;
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(() =>
diff --git a/apps/web/src/utils/oidcLogin.test.ts b/apps/web/src/utils/oidcLogin.test.ts
new file mode 100644
index 000000000..e99345ce6
--- /dev/null
+++ b/apps/web/src/utils/oidcLogin.test.ts
@@ -0,0 +1,96 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { refreshTokens, signInWithPopup } from './oidcLogin';
+
+const ENTRA = {
+ driver: 'entra',
+ interactive: true as const,
+ issuer: 'https://login.example/tenant/v2.0',
+ authorizationUrl: 'https://login.example/tenant/authorize',
+ clientId: 'spa',
+ scopes: ['api://huabu/access'],
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('signing in again in a popup', () => {
+ it('redeems the code the popup posts back, with a verifier only this page held', async () => {
+ const popup = { closed: false, location: { href: '' } };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+ const bodies: Record[] = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith('/.well-known/openid-configuration'))
+ return Response.json({ token_endpoint: 'https://login.example/t' });
+ bodies.push(
+ Object.fromEntries(new URLSearchParams(String(init?.body))),
+ );
+ return Response.json({
+ access_token: 'fresh',
+ refresh_token: 'refresh',
+ expires_in: 3600,
+ });
+ }),
+ );
+
+ const signingIn = signInWithPopup(ENTRA);
+ await vi.waitFor(() => expect(popup.location.href).not.toBe(''));
+ const sent = new URL(popup.location.href);
+ expect(sent.searchParams.get('scope')).toContain('offline_access');
+ const state = sent.searchParams.get('state')!;
+
+ // Another origin, or another login's state, is ignored.
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'https://evil.example',
+ data: { type: 'huabu.oidc.popup', state, code: 'stolen' },
+ }),
+ );
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'huabu.oidc.popup', state: 'popup.other', code: 'x' },
+ }),
+ );
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'huabu.oidc.popup', state, code: 'the-code' },
+ }),
+ );
+
+ await expect(signingIn).resolves.toEqual({
+ accessToken: 'fresh',
+ expiresIn: 3600,
+ });
+ expect(bodies).toHaveLength(1);
+ expect(bodies[0]).toMatchObject({
+ grant_type: 'authorization_code',
+ code: 'the-code',
+ client_id: 'spa',
+ });
+ expect(bodies[0].code_verifier).toBeTruthy();
+
+ // The refresh token it brought renews the next session.
+ await expect(refreshTokens()).resolves.toMatchObject({
+ accessToken: 'fresh',
+ });
+ expect(bodies[1]).toMatchObject({
+ grant_type: 'refresh_token',
+ refresh_token: 'refresh',
+ });
+ });
+
+ it('fails plainly when the browser blocks the window', async () => {
+ vi.spyOn(window, 'open').mockReturnValue(null);
+ await expect(signInWithPopup(ENTRA)).rejects.toThrow(/blocked/);
+ });
+});
diff --git a/apps/web/src/utils/oidcLogin.ts b/apps/web/src/utils/oidcLogin.ts
index aea32786d..186503aa4 100644
--- a/apps/web/src/utils/oidcLogin.ts
+++ b/apps/web/src/utils/oidcLogin.ts
@@ -6,11 +6,15 @@ import type { InteractiveAuthenticator } from '../api/identity';
/**
* Browser login against whichever identity provider Bubble discovery names:
* an OAuth authorization-code flow with PKCE, so no client secret is needed.
- * Only the access token's handover to the Server outlives the redirect; the
- * Server keeps it, and this module keeps nothing once the login completes.
+ * The Server keeps the access token in its session cookie. This page keeps a
+ * refresh token, when the provider issues one, in memory only: it renews the
+ * session in place while the page lives, and is gone with it.
*/
const PENDING_KEY = 'huabu.oidc.pending';
+/** A login run in a popup reports back to its opener instead of finishing. */
+const POPUP_STATE_PREFIX = 'popup.';
+const POPUP_MESSAGE = 'huabu.oidc.popup';
interface PendingLogin {
state: string;
@@ -19,6 +23,20 @@ interface PendingLogin {
returnTo: string;
}
+/** What a token endpoint answers, as far as this module reads it. */
+export interface SignedInTokens {
+ accessToken: string;
+ /** Seconds until the access token expires, when the provider says. */
+ expiresIn?: number;
+}
+
+interface HeldRefresh {
+ authenticator: InteractiveAuthenticator;
+ refreshToken: string;
+}
+
+let held: HeldRefresh | null = null;
+
function base64Url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
@@ -37,46 +55,139 @@ function redirectUri(): string {
return `${window.location.origin}${CALLBACK_PATH}`;
}
-/** Leave for the provider's login page; the browser returns to the callback. */
-export async function beginLogin(
+async function authorizationUrl(
authenticator: InteractiveAuthenticator,
-): Promise {
- const verifier = randomToken();
+ state: string,
+ verifier: string,
+): Promise {
const challenge = base64Url(
new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)),
),
);
- const pending: PendingLogin = {
- state: randomToken(),
- verifier,
- authenticator,
- returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`,
- };
- sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
const params = new URLSearchParams({
response_type: 'code',
client_id: authenticator.clientId,
redirect_uri: redirectUri(),
- scope: [...new Set([...authenticator.scopes, 'openid', 'profile'])].join(
- ' ',
- ),
- state: pending.state,
+ // `offline_access` asks for a refresh token, so a lapsing session can be
+ // renewed without leaving the page.
+ scope: [
+ ...new Set([
+ ...authenticator.scopes,
+ 'openid',
+ 'profile',
+ 'offline_access',
+ ]),
+ ].join(' '),
+ state,
code_challenge: challenge,
code_challenge_method: 'S256',
...(authenticator.audience ? { audience: authenticator.audience } : {}),
});
- window.location.assign(`${authenticator.authorizationUrl}?${params}`);
+ return `${authenticator.authorizationUrl}?${params}`;
+}
+
+/** The provider's OpenID metadata, as far as this module reads it. */
+async function providerMetadata(
+ authenticator: InteractiveAuthenticator,
+): Promise<{ token_endpoint?: string; end_session_endpoint?: string }> {
+ const metadata = await fetch(
+ `${authenticator.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
+ );
+ if (!metadata.ok) throw new Error('Could not reach the sign-in provider');
+ return (await metadata.json()) as {
+ token_endpoint?: string;
+ end_session_endpoint?: string;
+ };
+}
+
+async function requestTokens(
+ authenticator: InteractiveAuthenticator,
+ grant: Record,
+): Promise {
+ const { token_endpoint: tokenEndpoint } =
+ await providerMetadata(authenticator);
+ if (!tokenEndpoint)
+ throw new Error('The sign-in provider has no token endpoint');
+ const response = await fetch(tokenEndpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ client_id: authenticator.clientId, ...grant }),
+ });
+ const token = (await response.json().catch(() => ({}))) as {
+ access_token?: string;
+ refresh_token?: string;
+ expires_in?: number;
+ error_description?: string;
+ };
+ if (!response.ok || !token.access_token)
+ throw new Error(
+ token.error_description ?? 'Sign-in could not be completed',
+ );
+ // Providers that rotate refresh tokens send a new one with each use.
+ if (token.refresh_token)
+ held = { authenticator, refreshToken: token.refresh_token };
+ return {
+ accessToken: token.access_token,
+ ...(typeof token.expires_in === 'number'
+ ? { expiresIn: token.expires_in }
+ : {}),
+ };
+}
+
+function exchangeCode(
+ authenticator: InteractiveAuthenticator,
+ code: string,
+ verifier: string,
+): Promise {
+ return requestTokens(authenticator, {
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: redirectUri(),
+ code_verifier: verifier,
+ });
+}
+
+/** Leave for the provider's login page; the browser returns to the callback. */
+export async function beginLogin(
+ authenticator: InteractiveAuthenticator,
+): Promise {
+ const pending: PendingLogin = {
+ state: randomToken(),
+ verifier: randomToken(),
+ authenticator,
+ returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`,
+ };
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
+ window.location.assign(
+ await authorizationUrl(authenticator, pending.state, pending.verifier),
+ );
}
/**
* Finish a login the provider has just redirected back from, returning its
- * access token, or `null` when this page load is not such a return.
+ * tokens, or `null` when this page load is not such a return. In a sign-in
+ * popup, hand the provider's answer to the page that opened it and close.
*/
-export async function completeLogin(): Promise {
+export async function completeLogin(): Promise {
const url = new URL(window.location.href);
if (url.pathname !== CALLBACK_PATH) return null;
const state = url.searchParams.get('state');
+ if (state?.startsWith(POPUP_STATE_PREFIX) && window.opener) {
+ (window.opener as Window).postMessage(
+ {
+ type: POPUP_MESSAGE,
+ state,
+ code: url.searchParams.get('code'),
+ error:
+ url.searchParams.get('error_description') ??
+ url.searchParams.get('error'),
+ },
+ window.location.origin,
+ );
+ window.close();
+ return null;
+ }
const raw = sessionStorage.getItem(PENDING_KEY);
if (!state || !raw) return null;
const pending = JSON.parse(raw) as PendingLogin;
@@ -90,35 +201,102 @@ export async function completeLogin(): Promise {
throw new Error(url.searchParams.get('error_description') ?? error);
const code = url.searchParams.get('code');
if (!code) throw new Error('The sign-in response carried no code');
+ return exchangeCode(pending.authenticator, code, pending.verifier);
+}
- const metadata = await fetch(
- `${pending.authenticator.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
+/**
+ * Sign in again in a popup, leaving this page and its unsaved edits in
+ * place. The verifier never leaves this page's memory; the popup only carries
+ * the provider's code back.
+ */
+export async function signInWithPopup(
+ authenticator: InteractiveAuthenticator,
+): Promise {
+ const state = `${POPUP_STATE_PREFIX}${randomToken()}`;
+ const verifier = randomToken();
+ const popup = window.open(
+ 'about:blank',
+ 'huabu-sign-in',
+ 'popup,width=520,height=680',
);
- if (!metadata.ok) throw new Error('Could not reach the sign-in provider');
- const { token_endpoint: tokenEndpoint } = (await metadata.json()) as {
- token_endpoint?: string;
- };
- if (!tokenEndpoint)
- throw new Error('The sign-in provider has no token endpoint');
+ if (!popup) throw new Error('The browser blocked the sign-in window');
+ popup.location.href = await authorizationUrl(authenticator, state, verifier);
+ const code = await new Promise((resolve, reject) => {
+ const closedPoll = window.setInterval(() => {
+ if (!popup.closed) return;
+ cleanup();
+ reject(new Error('The sign-in window was closed'));
+ }, 500);
+ const onMessage = (event: MessageEvent) => {
+ const data = event.data as {
+ type?: string;
+ state?: string;
+ code?: string | null;
+ error?: string | null;
+ };
+ if (
+ event.origin !== window.location.origin ||
+ data?.type !== POPUP_MESSAGE ||
+ data.state !== state
+ )
+ return;
+ cleanup();
+ if (data.code) resolve(data.code);
+ else reject(new Error(data.error ?? 'Sign-in could not be completed'));
+ };
+ const cleanup = () => {
+ window.clearInterval(closedPoll);
+ window.removeEventListener('message', onMessage);
+ };
+ window.addEventListener('message', onMessage);
+ });
+ return exchangeCode(authenticator, code, verifier);
+}
- const response = await fetch(tokenEndpoint, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: new URLSearchParams({
- grant_type: 'authorization_code',
- code,
- redirect_uri: redirectUri(),
- client_id: pending.authenticator.clientId,
- code_verifier: pending.verifier,
- }),
+/**
+ * A fresh access token from the refresh token this page holds, or `null`
+ * when it holds none or the provider refuses it.
+ */
+export async function refreshTokens(): Promise {
+ const current = held;
+ if (!current) return null;
+ try {
+ return await requestTokens(current.authenticator, {
+ grant_type: 'refresh_token',
+ refresh_token: current.refreshToken,
+ });
+ } catch {
+ if (held === current) held = null;
+ return null;
+ }
+}
+
+/** The provider this page signed in with, while it holds a refresh token. */
+export function heldAuthenticator(): InteractiveAuthenticator | null {
+ return held?.authenticator ?? null;
+}
+
+/** Forget the refresh token, as signing out must. */
+export function forgetTokens(): void {
+ held = null;
+}
+
+/**
+ * Where to send the browser to end the provider's own session, or `null`
+ * when the provider advertises no logout endpoint.
+ */
+export async function providerSignOutUrl(
+ authenticator: InteractiveAuthenticator,
+): Promise {
+ const { end_session_endpoint: endpoint } = await providerMetadata(
+ authenticator,
+ ).catch(() => ({
+ end_session_endpoint: undefined,
+ }));
+ if (!endpoint) return null;
+ const params = new URLSearchParams({
+ client_id: authenticator.clientId,
+ post_logout_redirect_uri: `${window.location.origin}/`,
});
- const token = (await response.json().catch(() => ({}))) as {
- access_token?: string;
- error_description?: string;
- };
- if (!response.ok || !token.access_token)
- throw new Error(
- token.error_description ?? 'Sign-in could not be completed',
- );
- return token.access_token;
+ return `${endpoint}?${params}`;
}
diff --git a/docs/README.md b/docs/README.md
index 866d2ffbb..fc1712d2e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -102,6 +102,7 @@ docs/
| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. |
| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. |
| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1–6: Blob and structured ports, SQLite, Postgres, Azure, async agents. |
+| [multi-user-service.md](./proposals/multi-user-service.md) | Implemented | #228 follow-up: request-scoped Workspaces (PR A), then member agents, per-person credentials, grant admin and session renewal (PR B). |
| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. |
| [space-prompt-topology-scoping.md](./proposals/space-prompt-topology-scoping.md) | Shipped | Topology-derived global/direct-Agent targeting for Prompt Frames. |
diff --git a/docs/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..3dde74056 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,44 @@ 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`. A member runs the Built-In agent and its conversations through `/api/agent`, in a Space they may use; a request naming a conversation must name its Space too (400 otherwise). External agents stay with administrators: a member gets 403 `AGENT_ACCESS_REQUIRED` from every `/api/acp`, `/api/agent-change-review` and `/api/rfs` route except `GET /api/acp/profiles`, which lists Built-In alone for them, from any route that declares `invokesAgent` (Interactive View actions), and from a run bound to an external agent. External agents act through the shared machine credential, which reaches every Space, on the operator's machines and under the operator's own CLI logins. 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 Built-In agent runs in-process, in the request's Workspace and caller scope, and its tools check the person who started it: a World conversation reads another Space only if they may read it, Workspace memory and Skills change only with `huabu.manage` on the Workspace, and the Task Run tools are refused. A member's edits still 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 external agent therefore acts with the machine credential's full RFS reach, which is why only administrators may run one. 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.
+
+### Personal settings
+
+In Bubble mode a member's calls run in their own settings namespace (`modules/identity/personal-settings.ts`), carried through the request and the agent runs it starts. The secret store keeps their model keys, OAuth credentials and connected-service keys under ids of their own, and gives them no fallback to the deployment's environment variables, which are the operator's. Their model settings and agent defaults live under `/people//`, and their default agent is always the Built-In one. So `/api/llm`, `/api/agent/defaults`, `/api/integrations`, `/api/services` and Ink OCR settings change the caller's own settings for a member and the deployment's for an administrator; the deployment's connection token and access password are never anyone's personal ones. A member with no model configured is told to add one in Settings.
+
+### People
+
+`/api/people` lets a system owner administer people without Bubble's API: list every principal Bubble knows with their system and Workspace roles, admit or revoke `member` (`PUT /api/people/:principalId/member`), and give or remove a Workspace role (`PUT /api/people/:principalId/workspaces/:workspaceId`). A principal appears after their first sign-in. An administrator cannot revoke their own access there. Settings shows it as the People tab.
### Browser sign-in
@@ -115,6 +144,8 @@ API clients can still send the Bearer token themselves:
curl -H "Authorization: Bearer $BUBBLE_TOKEN" https://huabu.example/api/identity
```
+Sign-in asks for `offline_access`, and the page keeps the refresh token in memory only, never in storage. It renews the session a minute before the access token expires and on any `401`; when it cannot, the sign-in screen opens over the still-running app and signs in through a popup, whose code returns to the page by `postMessage` and is redeemed with a verifier only that page held, and the saves the lapse refused are sent again. With `HUABU_SIGN_OUT_PROVIDER=true`, `GET /api/identity` reports `providerSignOut`, and signing out also sends the browser to the provider's `end_session_endpoint`; register `/` as a post-logout redirect URI.
+
Changing providers does not migrate or link `local-owner` to a Bubble principal. Existing conversation records remain untouched.
`pnpm start:desktop --server ` is a native client for the same remote deployment boundary. It accepts a root HTTP or HTTPS origin but does not weaken the Server's Host, Origin, CORS, or Basic Auth policy and does not inject allowed hosts. A `401` challenge from the selected exact origin opens a sandboxed Electron credential prompt; credentials remain in Chromium's current network session and are not placed in the command line, exposed to the renderer, or persisted by Huabu Desktop.
@@ -154,6 +185,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
new file mode 100644
index 000000000..d8e87f0c3
--- /dev/null
+++ b/docs/proposals/multi-user-service.md
@@ -0,0 +1,154 @@
+# Multi-User Service
+
+Status: Implemented — PR A (#356) and PR B
+Last updated: 2026-10-28
+
+Follows PR #237 (Bubble identity and per-user Space access) toward issue #228. Two stacked pull requests take one Huabu Server from "several people sign in and each edits their own Spaces in one shared Workspace" to a service several people can use day to day: each person has their own Workspaces, uses agents in them, and an operator admits people without scripts.
+
+Nothing changes in local mode (`HUABU_IDENTITY_PROVIDER=local`). Desktop and single-owner deployments keep one process-active Workspace, the same routes and the same UI.
+
+## What "ready for several users" means
+
+After both PRs, in Bubble mode:
+
+| Outcome | PR |
+| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
+| Each admitted person gets a personal Workspace on first sign-in and can create, rename, switch and delete Workspaces they own. | A |
+| Two people work in different Workspaces at the same time. One person switching never moves another person. | A |
+| Members run the Built-In agent and conversations, with their own model keys. It reaches only what the person who started it may reach. External agents stay with administrators. | B |
+| Connected credentials and service leases are per person. One person's agent never uses another's GitHub token or service key. | B |
+| A system owner admits members, revokes them and grants Workspace ownership from Settings. | B |
+| A session that expires mid-edit is renewed without leaving the page, so unsaved edits survive. Sign-out can end the provider session too. | B |
+
+Out of scope for both: sharing a Workspace or Space between people beyond operator grants, quotas and billing, and Desktop as a multi-user client.
+
+## PR A — Request-scoped Workspaces
+
+### The Workspace a request runs in
+
+Each request runs in exactly one Workspace, resolved once in admission and carried in an `AsyncLocalStorage` context (`modules/workspace/context.ts`, new). `getWorkspaceHandle()`, `getWorkspacePath()`, `getWorkspaceKey()` and `space()` read that context. The rest of the Server's ~145 `space(canvasId)` call sites stay as they are.
+
+How the Workspace is chosen:
+
+- **Local mode:** the process-active Workspace, as today. Switching still changes it for every client, because local mode has one person.
+- **Bubble mode:** the principal's _current Workspace_, a per-principal 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.
+
+Work that outlives the request (agents, memory updates, Task Runs, preprocessing, World preview reconciliation) captures the Workspace when it starts and runs inside it via `runInWorkspace(handle, fn)`. This replaces today's failure mode, where a switch mid-run either retargets the work or fails it with "inactive workspace".
+
+### Storage
+
+- **SQL namespaces per call, not per connection.** `useWorkspace()` mutates the one Postgres/SQLite connection's namespace today, so concurrent Workspaces would read each other's Spaces. The structured store reads the Workspace id from the context per query, and transactions bind it when they open. The one database-wide write order (`pg_advisory_xact_lock(WRITE_LOCK)`) stays: it also covers name allocation and the Workspace registry. A per-Workspace lock is a later throughput change.
+- **Per-Workspace state.** The 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.
+- **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
+
+This replaces #237's model. Huabu checks four capabilities, and the same names apply on a Workspace and on a Space:
+
+| Capability | Allows |
+| -------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `huabu.read` | Seeing and opening the Workspace or Space, every `GET`, search, live streams. |
+| `huabu.write` | Editing Spaces and creating them. From PR B, also running agents and conversations in them. |
+| `huabu.manage` | Renaming and deleting it, granting roles on it. On a Workspace, also its memory, Skills and Workspace settings. |
+| `huabu.admin` | System target only: deployment, Canary, folder Workspaces on the Server's filesystem, and People administration (PR B). |
+
+Roles:
+
+| Role | Granted on | Gives |
+| ------------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------- |
+| `owner` (Bubble core) | system | `huabu.use`, `huabu.admin`. Bubble's system-owner rule also gives every capability on every Workspace and Space. |
+| `member` (Bubble core) | system | `huabu.use`: admitted, and gets a personal Workspace. |
+| `huabu.workspace.owner` | one Workspace | read, write, manage |
+| `huabu.workspace.editor` | one Workspace | read, write |
+| `huabu.workspace.viewer` | one Workspace | read |
+| `huabu.space.owner` | one Space | read, write, manage on that Space and the Spaces inside it |
+
+- **Inheritance.** read, write and manage flow down Workspace → root Space → child Space, never up or sideways.
+- **Who grants.** Granting a role on a Workspace or Space needs `huabu.manage` on it. Granting system roles needs `huabu.admin`. A member can therefore grant roles on their own Workspace. Neither PR adds a UI for that: sharing UI is a later step.
+- **Admission.** Still system `member` or `owner`. A Workspace or Space grant alone does not admit anyone.
+- **Space ownership on creation.** Creating a Space in a Workspace you can write claims `huabu.space.owner` on it, as in #237. This keeps a shared Space reachable by its creator if the Workspace grant is later narrowed.
+- **Workspace-qualified Space targets.** `huabu.space` targets are `:`, and the parent of a root Space is its own Workspace, not "the active one". So a Space can be shared later without sharing its Workspace.
+- **Migration from #237.** On first start, `huabu.space.owner` grants are rewritten to qualified targets in the Workspace that holds each Space. `huabu.workspace.owner` grants keep their meaning. A #237 grant that names no existing Space is dropped.
+- **Later goal, not in these PRs.** Every request names its Workspace explicitly. Until then a request runs in its caller's current Workspace, and a Space shared from another Workspace is reached by switching to that Workspace.
+
+### Workspaces for members
+
+- **Personal Workspace.** On a member's first admitted request with no readable Workspace, Huabu creates "'s Workspace" and grants them `huabu.workspace.owner` on it, under a per-principal lock so two first requests create one.
+- **Management.** `GET /api/workspaces` lists the Workspaces a caller can read. Any member may create one. Activating needs `huabu.read`; rename and delete need `huabu.manage`. Folder picking and path validation need `huabu.admin`, because they reach the Server's filesystem. On Disk storage, a member's new Workspace goes under the data directory.
+- **System owner.** A system owner can reach every Workspace through Bubble's system-owner rule, and has a current Workspace like anyone else.
+
+### Agents in PR A
+
+Agents stay owner-only for members, as in #237. Each run now carries its Workspace: the reachback URL becomes `/api/rfs/w//`, and RFS runs the call in that Workspace. An owner's agent therefore keeps working when the owner switches away mid-run, instead of failing or reading the other Workspace.
+
+### Web
+
+- Boot no longer activates `registered[0]` in Bubble mode: the Server has already chosen the caller's current Workspace.
+- The switcher lists the caller's Workspaces and is enabled for members. Switching posts to `/api/workspaces/:id/activate`, which in Bubble mode sets only the caller's preference.
+- The Home folder name and `GET /api/workspace` path are withheld from members, closing a #237 limitation.
+
+### Proof
+
+The Postgres suite runs two principals in two Workspaces concurrently: creates, edits, SSE, search and uploads, with no cross-reads and no transaction aborts. It also checks that a switch in one browser leaves the other unchanged, that an owner's agent keeps its Workspace across the owner's switch, that #237 grants migrate, that a viewer cannot write and an editor cannot rename or delete, that personal Workspaces are created once under concurrent first requests, and that local mode's screenshots stay pixel-identical to `main`.
+
+## PR B — Members use agents, and the service is administrable
+
+### Members run the Built-In agent
+
+The first design gave each external agent run its own RFS token. Agentlet hands every agent process the daemon's own machine token (`AGENTLET_TOKEN`) by design, and refuses a session that sets it. So a member's external agent would hold an unrestricted credential beside any run token, and a prompt-injected agent could use it. External agents also run on the operator's machines under the operator's own CLI logins, so they cannot honour per-person keys either. So:
+
+- **Members run the Built-In agent.** It runs in-process, never calls RFS, and its tools execute in the request's Workspace and caller scope. `/api/agent` and conversations are open to members through a Space they may use.
+- **Its tools check the starter.** A World conversation reads another Space only if the starter may read it. Workspace memory and Skills change only with `huabu.manage` on the Workspace. The Task Run tools are refused, because Task Runs launch external agents through the machine credential.
+- **External agents stay with administrators.** That covers `/api/acp`, RFS, change-review configuration, Interactive View actions and an external binding on `/api/agent`. A member's agent listing shows Built-In alone.
+
+Per-run credentials for external agents remain the way to open them to members later, and need Agentlet to stop giving agent processes the daemon token.
+
+### Personal settings
+
+A Bubble member's calls run in a personal settings namespace (`modules/identity/personal-settings.ts`), which also carries their access to the agent tools.
+
+- **Secrets.** The secret store namespaces their model keys, OAuth credentials and connected-service keys, with no fallback to the deployment's environment.
+- **Settings files.** Model settings and agent defaults live under `/people//`.
+- **Routes.** Members may change their own settings; the deployment's stay with administrators. Settings shows a member only Built-In setup and services.
+
+### Grant administration
+
+`/api/people`, and a People tab for system owners, list principals, admit and revoke `member`, and give or remove Workspace roles. Revocation applies on the next request, and to open streams within 30 seconds.
+
+### Session lifecycle
+
+- **Refresh.** Sign-in requests `offline_access`. The page holds the refresh token in memory only, and renews the session before expiry and on a 401.
+- **Re-sign-in in a popup.** When renewal is impossible, the sign-in screen opens over the running app and signs in through a popup, then resends the refused saves.
+- **Provider logout.** `HUABU_SIGN_OUT_PROVIDER=true` makes sign-out also end the provider session through `end_session_endpoint`.
+
+### Proof
+
+A dev Server in Bubble mode, with Postgres content and Bubble records, runs two members whose model settings point at a local OpenAI-compatible mock that records each request's key. Each member's Built-In agent answers using only their own key. A member is refused in another member's Space, for an external binding and on `/api/acp`. The People tab admits people and gives Workspace roles. Renewal and the popup are unit-tested. They were not tried against real Auth0 or Entra.
+
+## Settled decisions
+
+Confirmed 2026-10-28:
+
+1. **Current Workspace per person, not per tab.** A Workspace in every URL was rejected as a much larger web and API change for a rare need.
+2. **Workspace-qualified Space grants,** with an in-place migration of #237's grants, rather than relying on unique Space ids. Space grants stay so that one Space can be shared later. #237's roles are replaced by the model above.
+3. **Automatic personal Workspace on first sign-in,** rather than no Workspace until an operator grants one.
+4. **Per-person model keys** in PR B, rather than every member's agents using the operator's keys. Members therefore run the Built-In agent; external agents, which use the operator's CLI logins, stay with administrators.
+
+## Code entry points
+
+| File/dir | Responsibility |
+| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
+| [`modules/workspace.ts`](../../apps/server/src/modules/workspace.ts) | Today's process-active Workspace; becomes the local-mode default and the context reader. |
+| [`modules/storage/storage.ts`](../../apps/server/src/modules/storage/storage.ts) | Storage facade, `space()`, World id, `activateWorkspace`. |
+| [`modules/storage/backends/postgres/database.ts`](../../apps/server/src/modules/storage/backends/postgres/database.ts) | Connection namespace (`useWorkspace`) and write lock. |
+| [`modules/identity/resources.ts`](../../apps/server/src/modules/identity/resources.ts) | Space parent resolution for Bubble inheritance. |
+| [`modules/identity/bubble.ts`](../../apps/server/src/modules/identity/bubble.ts) | Huabu roles, capabilities and claims. |
+| [`modules/security/space-access.ts`](../../apps/server/src/modules/security/space-access.ts) | Per-request Space guard and agent refusal. |
+| [`modules/agent/acp/reachback-env.ts`](../../apps/server/src/modules/agent/acp/reachback-env.ts) | Agent reachback URL and credential. |
+| [`modules/workspaces.route.ts`](../../apps/server/src/modules/workspaces.route.ts) | Workspace list, create, activate. |
+| [`apps/web/src/store/workspaceStore.ts`](../../apps/web/src/store/workspaceStore.ts) | Web boot and switch. |
diff --git a/packages/shared/src/types/api/identity.ts b/packages/shared/src/types/api/identity.ts
index c162ee2ba..1faa7dc4b 100644
--- a/packages/shared/src/types/api/identity.ts
+++ b/packages/shared/src/types/api/identity.ts
@@ -18,7 +18,39 @@ export const identityResponseSchema = z.object({
principal: identityPrincipalSchema,
/** May use Huabu at all. */
member: z.boolean(),
- /** May also change settings, credentials and agent configuration. */
+ /** Administers the deployment: its settings, agents and people. */
owner: z.boolean(),
+ /** Signing out should also end the identity provider's own session. */
+ providerSignOut: z.boolean().optional(),
});
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
+>;
diff --git a/packages/shared/src/types/api/workspace.ts b/packages/shared/src/types/api/workspace.ts
index 9ffc9dc5e..d5554f0a9 100644
--- a/packages/shared/src/types/api/workspace.ts
+++ b/packages/shared/src/types/api/workspace.ts
@@ -26,6 +26,12 @@ export const workspaceCapabilitiesSchema = z.object({
canChangeWorkspace: z.boolean(),
/** Whether the server can show a native folder picker. */
nativePicker: z.boolean(),
+ /**
+ * Whether Workspaces are switched and created by name rather than by
+ * folder: each signed-in person has their own (Bubble mode). Absent from
+ * older servers.
+ */
+ namedWorkspaces: z.boolean().optional(),
});
export type WorkspaceCapabilities = z.infer;