Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 17 additions & 48 deletions src/api/auth-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,15 @@ export class WorkspaceResolutionError extends Error {
/**
* Resolve the workspace for a request.
*
* Resolution order:
* 1. Explicit `X-Workspace-Id` header
* 2. Conversation's workspaceId (if conversationId is provided and conversation exists)
* 3. Default — user's single workspace (if they belong to exactly one)
* Pure selection — does NOT create, default-pick, or auto-provision.
* Provisioning is an identity-layer concern (see ensureUserWorkspace
* wired into each provider's verifyRequest). Defaulting to "the user's
* only workspace" was a footgun: a client that "just worked" one day
* would 400 the next when the user was added to a second workspace.
* Honest contract: the caller names the workspace via X-Workspace-Id
* on every data-path request. Bootstrap is the only place the server
* is allowed to pick a default, and it does that in its own handler
* (not through this resolver).
*
* Returns the resolved workspace ID.
* Throws WorkspaceResolutionError (400 or 403) on failure.
Expand All @@ -117,54 +122,18 @@ export async function resolveWorkspace(
req: Request,
identity: UserIdentity,
workspaceStore: WorkspaceStore,
conversationWorkspaceId?: string,
): Promise<string> {
const headerWsId = req.headers.get("x-workspace-id");

let workspaceId: string | undefined;

// 1. Explicit header
if (headerWsId) {
workspaceId = headerWsId;
}
// 2. Conversation's workspace
else if (conversationWorkspaceId) {
workspaceId = conversationWorkspaceId;
}
// 3. Default — single workspace
else {
const userWorkspaces = await workspaceStore.getWorkspacesForUser(identity.id);
if (userWorkspaces.length === 1) {
workspaceId = userWorkspaces[0]!.id;
} else if (userWorkspaces.length === 0) {
// Auto-provision a workspace when the user has none.
// This handles: first login, manual deletion, or disk cleanup.
const slug = identity.id
.replace(/^user_/, "")
.toLowerCase()
.slice(0, 16);
const name = identity.displayName ? `${identity.displayName}'s Workspace` : "Workspace";
try {
const ws = await workspaceStore.create(name, slug);
await workspaceStore.addMember(ws.id, identity.id, "admin");
workspaceId = ws.id;
} catch {
// Slug collision — try with timestamp suffix
const fallbackSlug = `ws-${Date.now().toString(36)}`;
const ws = await workspaceStore.create(name, fallbackSlug);
await workspaceStore.addMember(ws.id, identity.id, "admin");
workspaceId = ws.id;
}
} else {
throw new WorkspaceResolutionError(
"Multiple workspaces available. Set X-Workspace-Id header to specify which workspace to use.",
400,
);
}
const workspaceId = req.headers.get("x-workspace-id");
if (!workspaceId) {
throw new WorkspaceResolutionError(
"Workspace required. Set the X-Workspace-Id header. " +
"The workspace ID is available from GET /v1/bootstrap or Settings → Profile → MCP Connection.",
400,
);
}

// Validate workspace ID format (prevents path traversal)
if (workspaceId && !WORKSPACE_ID_RE.test(workspaceId)) {
if (!WORKSPACE_ID_RE.test(workspaceId)) {
throw new WorkspaceResolutionError("Invalid workspace ID format.", 400);
}

Expand Down
64 changes: 33 additions & 31 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { RunInProgressError } from "../runtime/errors.ts";
import { type RequestContext, runWithRequestContext } from "../runtime/request-context.ts";
import type { Runtime } from "../runtime/runtime.ts";
import type { ChatRequest } from "../runtime/types.ts";
import { filterPlacementsForWorkspace } from "../runtime/workspace-access.ts";
import type { HealthMonitor } from "../tools/health-monitor.ts";
import { InlineSource } from "../tools/inline-source.ts";
import { validateToolInput } from "../tools/validate-input.ts";
Expand Down Expand Up @@ -634,25 +633,32 @@ export async function handleBootstrap(
ws.members.some((m) => m.userId === identity.id),
);

// 2. Resolve active workspace
const preferred = req.headers.get("X-Preferred-Workspace");
let activeWorkspace: string | undefined;
if (preferred && userWorkspaces.some((ws) => ws.id === preferred)) {
activeWorkspace = preferred;
} else if (userWorkspaces.length === 1) {
activeWorkspace = userWorkspaces[0]!.id;
} else if (userWorkspaces.length > 0) {
activeWorkspace = userWorkspaces[0]!.id;
// Invariant (Phase 1): authenticated users have at least one workspace.
// Provisioning runs at the identity boundary (provider.provisionUser →
// ensureUserWorkspace). If we hit zero here, something upstream is broken
// and we want to know loudly, not silently leak every workspace's apps.
if (userWorkspaces.length === 0) {
return apiError(
500,
"workspace_invariant_violation",
"Authenticated user has no workspace. Provisioning should have run at login.",
);
}

// 3. Shell placements filtered by active workspace
let placements = runtime.getPlacementRegistry().all();
if (activeWorkspace) {
const workspace = allWorkspaces.find((ws) => ws.id === activeWorkspace);
if (workspace) {
placements = filterPlacementsForWorkspace(placements, workspace);
}
}
// 2. Resolve active workspace — permissive: honor X-Workspace-Id when it
// matches a membership, otherwise pick the first. Bootstrap is the one
// place the server defaults, because it's the only place a client can
// legitimately not yet know a wsId. On data endpoints the same header is
// authoritative (unknown wsId → 400); bootstrap is the discovery surface
// so the contract is weaker here by design.
const requested = req.headers.get("X-Workspace-Id");
const activeWorkspace: string =
requested && userWorkspaces.some((ws) => ws.id === requested)
? requested
: userWorkspaces[0]!.id;

// 3. Shell placements for the active workspace (ambient + scoped, merged).
const placements = runtime.getPlacementRegistry().forWorkspace(activeWorkspace);

// 4. Config
const models = runtime.getModelSlots();
Expand All @@ -676,7 +682,7 @@ export async function handleBootstrap(
memberCount: ws.members.length,
bundleCount: ws.bundles.length,
})),
activeWorkspace: activeWorkspace ?? null,
activeWorkspace,
shell: {
placements,
chatEndpoint: "/v1/chat/stream",
Expand All @@ -694,19 +700,15 @@ export async function handleBootstrap(
});
}

/** Handle GET /v1/shell — placement registry for web client bootstrap. */
export async function handleShell(runtime: Runtime, workspaceId?: string): Promise<Response> {
let placements = runtime.getPlacementRegistry().all();

if (workspaceId) {
const workspace = await runtime.getWorkspaceStore().get(workspaceId);
if (workspace) {
placements = filterPlacementsForWorkspace(placements, workspace);
}
}

/**
* Handle GET /v1/shell — placement registry for web client bootstrap.
*
* workspaceId comes from requireWorkspace middleware; by the time this
* handler runs, it's resolved and membership-checked.
*/
export async function handleShell(runtime: Runtime, workspaceId: string): Promise<Response> {
return json({
placements,
placements: runtime.getPlacementRegistry().forWorkspace(workspaceId),
chatEndpoint: "/v1/chat/stream",
eventsEndpoint: "/v1/events",
});
Expand Down
2 changes: 1 addition & 1 deletion src/api/middleware/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createMiddleware } from "hono/factory";
const STATIC_CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-ID, Mcp-Protocol-Version, X-Workspace-Id, X-Preferred-Workspace",
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-ID, Mcp-Protocol-Version, X-Workspace-Id",
"Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version",
};

Expand Down
6 changes: 5 additions & 1 deletion src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export function startServer(options: ServerOptions): ServerHandle {
let effectiveProvider: IdentityProvider | null = optProvider ?? runtime.getIdentityProvider();
if (!effectiveProvider) {
const workDir = runtime.getWorkDir();
effectiveProvider = new DevIdentityProvider(workDir, runtime.getUserStore());
effectiveProvider = new DevIdentityProvider(
workDir,
runtime.getUserStore(),
runtime.getWorkspaceStore(),
);
}
const authMode = resolveAuthMode(effectiveProvider);
const authConfigured = authMode.type !== "dev";
Expand Down
6 changes: 4 additions & 2 deletions src/identity/provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { WorkspaceStore } from "../workspace/workspace-store.ts";
import type { InstanceConfig } from "./instance.ts";
import { OidcIdentityProvider } from "./providers/oidc.ts";
import { WorkosIdentityProvider } from "./providers/workos.ts";
Expand Down Expand Up @@ -107,16 +108,17 @@ export interface IdentityProvider {
export function createIdentityProvider(
config: InstanceConfig | null,
userStore: UserStore,
workspaceStore: WorkspaceStore,
): IdentityProvider | null {
if (config === null) return null;

const adapter = config.auth.adapter;

switch (adapter) {
case "oidc":
return new OidcIdentityProvider(config.auth, userStore);
return new OidcIdentityProvider(config.auth, userStore, workspaceStore);
case "workos":
return new WorkosIdentityProvider(config.auth, userStore);
return new WorkosIdentityProvider(config.auth, userStore, workspaceStore);
default:
throw new Error(`Unknown identity provider: "${adapter as string}"`);
}
Expand Down
25 changes: 21 additions & 4 deletions src/identity/providers/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { ensureUserWorkspace } from "../../workspace/provisioning.ts";
import type { WorkspaceStore } from "../../workspace/workspace-store.ts";
import type {
CreateUserInput,
CreateUserResult,
Expand All @@ -24,8 +26,7 @@ export const DEV_IDENTITY: UserIdentity = {

/**
* Identity provider for dev mode — always returns a default user identity.
* Creates the default user profile on first access if missing.
* Workspace provisioning is handled by resolveWorkspace() in auth-middleware.
* Creates the default user profile and workspace on first access if missing.
*/
export class DevIdentityProvider implements IdentityProvider {
readonly capabilities: ProviderCapabilities = {
Expand All @@ -36,17 +37,27 @@ export class DevIdentityProvider implements IdentityProvider {

private initialized = false;
private usersDir: string;
private workspaceStore: WorkspaceStore;

constructor(
workDir: string,
private userStore: UserStore,
workspaceStore: WorkspaceStore,
) {
this.usersDir = join(workDir, "users");
this.workspaceStore = workspaceStore;
console.warn("Running in dev mode — no authentication configured");
}

async verifyRequest(_req: Request): Promise<UserIdentity | null> {
await this.ensureDefaults();
await this.ensureUserProfile();
// Run on every request (idempotent) so the "authenticated user has
// ≥1 workspace" invariant self-heals if the dev workspace is deleted
// out from under the process.
await ensureUserWorkspace(this.workspaceStore, {
id: DEV_IDENTITY.id,
displayName: DEV_IDENTITY.displayName,
});
return DEV_IDENTITY;
}

Expand All @@ -69,7 +80,13 @@ export class DevIdentityProvider implements IdentityProvider {

// ── Private ───────────────────────────────────────────────────

private async ensureDefaults(): Promise<void> {
/**
* Seed the dev user profile on first call. Profile creation doesn't need
* to repeat per request — user identity for dev mode is fixed — so the
* `initialized` gate stays here. Workspace provisioning is handled by
* verifyRequest directly so it self-heals.
*/
private async ensureUserProfile(): Promise<void> {
if (this.initialized) return;

const existingUser = await this.userStore.get(DEV_IDENTITY.id);
Expand Down
18 changes: 17 additions & 1 deletion src/identity/providers/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ensureUserWorkspace } from "../../workspace/provisioning.ts";
import type { WorkspaceStore } from "../../workspace/workspace-store.ts";
import type { OidcAuth } from "../instance.ts";
import type {
CreateUserInput,
Expand Down Expand Up @@ -140,6 +142,7 @@ export class OidcIdentityProvider implements IdentityProvider {
private allowedDomains: string[];
private jwksUri: string | undefined;
private userStore: UserStore;
private workspaceStore: WorkspaceStore;

private jwksCache: CachedJwks | null = null;
private discoveryCache: OidcDiscovery | null = null;
Expand All @@ -150,12 +153,13 @@ export class OidcIdentityProvider implements IdentityProvider {
/** Overridable clock for testing. */
now: () => number = () => Date.now();

constructor(config: OidcAuth, userStore: UserStore) {
constructor(config: OidcAuth, userStore: UserStore, workspaceStore: WorkspaceStore) {
this.issuer = config.issuer.replace(/\/+$/, "");
this.clientId = config.clientId;
this.allowedDomains = config.allowedDomains.map((d) => d.toLowerCase());
this.jwksUri = config.jwksUri;
this.userStore = userStore;
this.workspaceStore = workspaceStore;
}

async verifyRequest(req: Request): Promise<UserIdentity | null> {
Expand Down Expand Up @@ -195,6 +199,18 @@ export class OidcIdentityProvider implements IdentityProvider {
});
}

// Enforce the invariant "authenticated user has ≥1 workspace" on every
// successful auth, not only first login. Idempotent: happy path is one
// filesystem read and no writes. Running on every request makes the
// invariant self-healing for any state where the user exists but their
// workspace doesn't — admin deletion, partial failure, migrations from
// a prior build, cross-provider drift. A first-login-only gate leaves
// those users stuck at 500 forever with no client-side recovery path.
await ensureUserWorkspace(this.workspaceStore, {
id: user.id,
displayName: user.displayName,
});

return toIdentity(user);
}

Expand Down
Loading