diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index bbdc096e..e74b95f9 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -101,7 +101,11 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null, avatarUrl: session.avatarUrl ?? null, }); - const { executor, engine } = yield* makeExecutionStack(auth.accountId, org.id, org.name); + const { executor, engine } = yield* makeExecutionStack({ + userId: auth.accountId, + organizationId: org.id, + organizationName: org.name, + }); return yield* httpEffect.pipe( Effect.provideService(AuthContext, auth), Effect.provideService(ExecutorService, executor), diff --git a/apps/cloud/src/mcp-session.ts b/apps/cloud/src/mcp-session.ts index 26a45795..338b489e 100644 --- a/apps/cloud/src/mcp-session.ts +++ b/apps/cloud/src/mcp-session.ts @@ -282,11 +282,11 @@ export class McpSessionDO extends DurableObject { ) { const self = this; return Effect.gen(function* () { - const { executor, engine } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - ); + const { executor, engine } = yield* makeExecutionStack({ + userId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + organizationName: sessionMeta.organizationName, + }); // Build the description here so the postgres query it runs // (`executor.sources.list`) lands as a child of // `McpSessionDO.createRuntime`. host-mcp would otherwise call diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index 9985d6d1..e09a371e 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -32,7 +32,6 @@ import { import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { - Scope, collectSchemas, createExecutor, } from "@executor-js/sdk"; @@ -51,6 +50,7 @@ import { } from "../../api/protected-layers"; import { DbService } from "../db"; import { orgScopeId, userOrgScopeId } from "../ids"; +import { buildGlobalScopeStack } from "../scope-stack"; export const TEST_BASE_URL = "http://test.local"; export const TEST_ORG_HEADER = "x-test-org-id"; @@ -82,18 +82,12 @@ const createTestScopedExecutor = ( const schema = collectSchemas(plugins); const adapter = makePostgresAdapter({ db, schema }); const blobs = makePostgresBlobStore({ db }); - const orgScope = new Scope({ - id: orgScopeId(orgId), - name: orgName, - createdAt: new Date(), - }); - const userOrgScope = new Scope({ - id: userOrgScopeId(userId, orgId), - name: `Personal · ${orgName}`, - createdAt: new Date(), - }); return yield* createExecutor({ - scopes: [userOrgScope, orgScope], + scopes: buildGlobalScopeStack({ + userId, + organizationId: orgId, + organizationName: orgName, + }), adapter, blobs, plugins, diff --git a/apps/cloud/src/services/execution-stack.ts b/apps/cloud/src/services/execution-stack.ts index f576ca34..f6bac4e0 100644 --- a/apps/cloud/src/services/execution-stack.ts +++ b/apps/cloud/src/services/execution-stack.ts @@ -1,7 +1,7 @@ // --------------------------------------------------------------------------- -// Shared execution stack — the wiring that turns an organization into a +// Shared execution stack — the wiring that turns a request context into a // runnable executor + engine. Used by the protected HTTP API (per-request) -// and the MCP session DO (per-session) so changes to the stack flow to both. +// and the MCP session DO (per-session) so changes flow to both. // --------------------------------------------------------------------------- import { env } from "cloudflare:workers"; @@ -12,23 +12,29 @@ import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; import { withExecutionUsageTracking } from "../api/execution-usage"; import { AutumnService } from "./autumn"; -import { createScopedExecutor } from "./executor"; +import { + createGlobalExecutor, + createWorkspaceExecutor, +} from "./executor"; +import type { + GlobalContext, + WorkspaceContext, +} from "./scope-stack"; -export const makeExecutionStack = ( - userId: string, - organizationId: string, - organizationName: string, -) => +const buildExecutor = (ctx: GlobalContext | WorkspaceContext) => + "workspaceId" in ctx + ? createWorkspaceExecutor(ctx) + : createGlobalExecutor(ctx); + +export const makeExecutionStack = (ctx: GlobalContext | WorkspaceContext) => Effect.gen(function* () { - const executor = yield* createScopedExecutor( - userId, - organizationId, - organizationName, - ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor")); + const executor = yield* buildExecutor(ctx).pipe( + Effect.withSpan("McpSessionDO.createExecutor"), + ); const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); const autumn = yield* AutumnService; const engine = withExecutionUsageTracking( - organizationId, + ctx.organizationId, createExecutionEngine({ executor, codeExecutor }), (orgId) => Effect.runFork(autumn.trackExecution(orgId)), ); diff --git a/apps/cloud/src/services/executor.ts b/apps/cloud/src/services/executor.ts index a32f8c2d..83857e9c 100644 --- a/apps/cloud/src/services/executor.ts +++ b/apps/cloud/src/services/executor.ts @@ -2,18 +2,18 @@ // Cloud executor — stateless, per-request, new SDK shape // --------------------------------------------------------------------------- // -// Each invocation of `createScopedExecutor` runs inside a request-scoped -// Effect and yields a fresh executor bound to the current DbService's -// per-request postgres.js client. Cloudflare Workers + Hyperdrive demand -// fresh connections per request, so "build once" means "once per request" -// here. +// Each invocation of `createGlobalExecutor` / `createWorkspaceExecutor` runs +// inside a request-scoped Effect and yields a fresh executor bound to the +// current DbService's per-request postgres.js client. Cloudflare Workers + +// Hyperdrive demand fresh connections per request, so "build once" means +// "once per request" here. import { Effect } from "effect"; import { - Scope, collectSchemas, createExecutor, + type Scope, } from "@executor-js/sdk"; import { makePostgresAdapter, @@ -23,7 +23,12 @@ import { import { env } from "cloudflare:workers"; import executorConfig from "../../executor.config"; import { DbService } from "./db"; -import { orgScopeId, userOrgScopeId } from "./ids"; +import { + buildGlobalScopeStack, + buildWorkspaceScopeStack, + type GlobalContext, + type WorkspaceContext, +} from "./scope-stack"; // --------------------------------------------------------------------------- // Plugin list lives in `executor.config.ts` — that file is the single @@ -43,53 +48,35 @@ const orgPlugins = (): CloudPlugins => }); // --------------------------------------------------------------------------- -// Create a fresh executor for a (user, org) pair (stateless, per-request). +// Create a fresh executor for a request context (stateless, per-request). // -// Scope stack is `[userOrgScope, orgScope]` — innermost first. Scope ids are -// deterministic and prefixed (`org_`, `user_org__`) so -// the same WorkOS user in a different org gets a distinct scope row, and -// future workspace scopes can slot in between without colliding with org or -// user-org rows. +// Scope stacks are built innermost-first by `./scope-stack`: +// global -> [userOrgScope, orgScope] +// workspace -> [userWorkspaceScope, workspaceScope, userOrgScope, orgScope] // -// OAuth tokens land at `ctx.scopes[0]` (the user-org scope) by default, so -// a member's access/refresh tokens can't leak to other members via -// `secrets.list`, while source rows and org-wide credentials live on the -// outer scope. +// OAuth tokens land at `ctx.scopes[0]` (the most-personal scope) by default, +// so per-user credentials can't leak across users in the same workspace/org. +// Source rows and shared credentials live on the outer scopes. // --------------------------------------------------------------------------- -export const createScopedExecutor = ( - userId: string, - organizationId: string, - organizationName: string, -) => +const buildExecutor = (scopes: ReadonlyArray) => Effect.gen(function* () { const { db } = yield* DbService; - const plugins = orgPlugins(); const schema = collectSchemas(plugins); const adapter = makePostgresAdapter({ db, schema }); const blobs = makePostgresBlobStore({ db }); - - const orgScope = new Scope({ - id: orgScopeId(organizationId), - name: organizationName, - createdAt: new Date(), - }); - const userOrgScope = new Scope({ - id: userOrgScopeId(userId, organizationId), - name: `Personal · ${organizationName}`, - createdAt: new Date(), - }); - - // The executor surface returns raw `StorageFailure`; translation to - // the opaque `InternalError({ traceId })` happens at the HTTP edge - // via `withCapture` (see `api/protected-layers.ts`). That's - // where `ErrorCaptureLive` (Sentry) gets wired in. return yield* createExecutor({ - scopes: [userOrgScope, orgScope], + scopes, adapter, blobs, plugins, onElicitation: "accept-all", }); }); + +export const createGlobalExecutor = (ctx: GlobalContext) => + buildExecutor(buildGlobalScopeStack(ctx)); + +export const createWorkspaceExecutor = (ctx: WorkspaceContext) => + buildExecutor(buildWorkspaceScopeStack(ctx)); diff --git a/apps/cloud/src/services/scope-stack.test.ts b/apps/cloud/src/services/scope-stack.test.ts new file mode 100644 index 00000000..97e121f1 --- /dev/null +++ b/apps/cloud/src/services/scope-stack.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + activeWriteScopeId, + buildGlobalScopeStack, + buildWorkspaceScopeStack, +} from "./scope-stack"; + +describe("buildGlobalScopeStack", () => { + it("emits [userOrgScope, orgScope] in inner-first order", () => { + const stack = buildGlobalScopeStack({ + userId: "u1", + organizationId: "o1", + organizationName: "Acme", + }); + expect(stack.length).toBe(2); + expect(stack[0]!.id.toString()).toBe("user_org_u1_o1"); + expect(stack[0]!.name).toBe("Me / Acme"); + expect(stack[1]!.id.toString()).toBe("org_o1"); + expect(stack[1]!.name).toBe("Acme Global"); + }); +}); + +describe("buildWorkspaceScopeStack", () => { + it("emits [userWorkspace, workspace, userOrg, org] in inner-first order", () => { + const stack = buildWorkspaceScopeStack({ + userId: "u1", + organizationId: "o1", + organizationName: "Acme", + workspaceId: "w1", + workspaceName: "Billing API", + }); + expect(stack.length).toBe(4); + expect(stack[0]!.id.toString()).toBe("user_workspace_u1_w1"); + expect(stack[0]!.name).toBe("Me / Billing API"); + expect(stack[1]!.id.toString()).toBe("workspace_w1"); + expect(stack[1]!.name).toBe("Billing API"); + expect(stack[2]!.id.toString()).toBe("user_org_u1_o1"); + expect(stack[2]!.name).toBe("Me / Acme"); + expect(stack[3]!.id.toString()).toBe("org_o1"); + expect(stack[3]!.name).toBe("Acme Global"); + }); +}); + +describe("activeWriteScopeId", () => { + it("returns the org scope id in global context", () => { + const id = activeWriteScopeId({ + userId: "u1", + organizationId: "o1", + organizationName: "Acme", + }); + expect(id.toString()).toBe("org_o1"); + }); + + it("returns the workspace scope id in workspace context", () => { + const id = activeWriteScopeId({ + userId: "u1", + organizationId: "o1", + organizationName: "Acme", + workspaceId: "w1", + workspaceName: "Billing API", + }); + expect(id.toString()).toBe("workspace_w1"); + }); +}); diff --git a/apps/cloud/src/services/scope-stack.ts b/apps/cloud/src/services/scope-stack.ts new file mode 100644 index 00000000..1a8a98a2 --- /dev/null +++ b/apps/cloud/src/services/scope-stack.ts @@ -0,0 +1,96 @@ +// --------------------------------------------------------------------------- +// Scope stack builders +// --------------------------------------------------------------------------- +// +// Two flavors mirror the URL contexts the plan calls out: +// +// Global (`/:org`): +// [user_org__, org_] +// +// Workspace (`/:org/:workspace`): +// [user_workspace__, +// workspace_, +// user_org__, +// org_] +// +// Innermost first — the executor walks the stack so user-level wins over +// org-level on read, and writes target whichever scope the caller names. +// `activeWriteScopeId` is the default scope a source-definition write should +// target unless the caller picks something else. + +import { Scope } from "@executor-js/sdk"; + +import { + orgScopeId, + userOrgScopeId, + userWorkspaceScopeId, + workspaceScopeId, +} from "./ids"; + +export type GlobalContext = { + readonly userId: string; + readonly organizationId: string; + readonly organizationName: string; +}; + +export type WorkspaceContext = GlobalContext & { + readonly workspaceId: string; + readonly workspaceName: string; +}; + +const now = () => new Date(); + +const orgScope = (ctx: GlobalContext): Scope => + new Scope({ + id: orgScopeId(ctx.organizationId), + name: `${ctx.organizationName} Global`, + createdAt: now(), + }); + +const userOrgScope = (ctx: GlobalContext): Scope => + new Scope({ + id: userOrgScopeId(ctx.userId, ctx.organizationId), + name: `Me / ${ctx.organizationName}`, + createdAt: now(), + }); + +const workspaceScope = (ctx: WorkspaceContext): Scope => + new Scope({ + id: workspaceScopeId(ctx.workspaceId), + name: ctx.workspaceName, + createdAt: now(), + }); + +const userWorkspaceScope = (ctx: WorkspaceContext): Scope => + new Scope({ + id: userWorkspaceScopeId(ctx.userId, ctx.workspaceId), + name: `Me / ${ctx.workspaceName}`, + createdAt: now(), + }); + +export const buildGlobalScopeStack = ( + ctx: GlobalContext, +): readonly [Scope, Scope] => [userOrgScope(ctx), orgScope(ctx)] as const; + +export const buildWorkspaceScopeStack = ( + ctx: WorkspaceContext, +): readonly [Scope, Scope, Scope, Scope] => [ + userWorkspaceScope(ctx), + workspaceScope(ctx), + userOrgScope(ctx), + orgScope(ctx), +] as const; + +/** + * Default scope for source-definition writes in the active context. `org` for + * global, `workspace` for workspace contexts. Callers MUST still pass an + * explicit target on the write — this is purely a UI default. + */ +export const activeWriteScopeId = ( + ctx: GlobalContext | WorkspaceContext, +) => { + if ("workspaceId" in ctx) { + return workspaceScopeId(ctx.workspaceId); + } + return orgScopeId(ctx.organizationId); +}; diff --git a/apps/cloud/src/services/url-context.node.test.ts b/apps/cloud/src/services/url-context.node.test.ts new file mode 100644 index 00000000..57924100 --- /dev/null +++ b/apps/cloud/src/services/url-context.node.test.ts @@ -0,0 +1,124 @@ +// URL-context resolver tests against the live test database. + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { DbService, combinedSchema } from "./db"; +import { organizations } from "./schema"; +import { makeUserStore } from "./user-store"; +import { makeWorkspaceStore } from "./workspace-store"; +import { + resolveOrgContext, + resolveWorkspaceContext, +} from "./url-context"; + +const url = + process.env.DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:5434/postgres"; + +const program = (body: Effect.Effect) => + Effect.runPromise( + body.pipe(Effect.provide(DbService.Live), Effect.scoped) as Effect.Effect< + A, + E, + never + >, + ); + +const seedOrgWithHandle = async (handle: string, name = handle) => { + const orgId = `org_${crypto.randomUUID()}`; + const sql = postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 30 }); + try { + const db = drizzle(sql, { schema: combinedSchema }); + await makeUserStore(db).upsertOrganization({ id: orgId, name }); + // Force the handle to a known value (upsertOrganization picks one + // from the name; tests want determinism). + await db + .update(organizations) + .set({ handle }) + .where(eq(organizations.id, orgId)); + } finally { + await sql.end({ timeout: 0 }).catch(() => undefined); + } + return { orgId, handle }; +}; + +describe("resolveOrgContext", () => { + it("resolves a known handle to the org row", async () => { + const handle = `acme-${crypto.randomUUID().slice(0, 8)}`; + const { orgId } = await seedOrgWithHandle(handle, "Acme"); + const result = await program(resolveOrgContext(handle)); + expect(result.organization.id).toBe(orgId); + expect(result.organization.handle).toBe(handle); + }); + + it("fails with OrganizationHandleNotFound for unknown handles", async () => { + const handle = `nope-${crypto.randomUUID().slice(0, 8)}`; + const exit = await Effect.runPromiseExit( + resolveOrgContext(handle).pipe( + Effect.provide(DbService.Live), + Effect.scoped, + ) as Effect.Effect, + ); + expect(exit._tag).toBe("Failure"); + const errors = + exit._tag === "Failure" ? JSON.stringify(exit.cause) : ""; + expect(errors).toContain("OrganizationHandleNotFound"); + }); +}); + +describe("resolveWorkspaceContext", () => { + it("resolves a known org+slug pair to org and workspace rows", async () => { + const handle = `acme-${crypto.randomUUID().slice(0, 8)}`; + const { orgId } = await seedOrgWithHandle(handle, "Acme"); + const sql = postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 30 }); + let wsSlug: string; + let wsId: string; + try { + const db = drizzle(sql, { schema: combinedSchema }); + const ws = await makeWorkspaceStore(db).create({ + organizationId: orgId, + name: "Billing API", + }); + wsSlug = ws.slug; + wsId = ws.id; + } finally { + await sql.end({ timeout: 0 }).catch(() => undefined); + } + const result = await program(resolveWorkspaceContext(handle, wsSlug)); + expect(result.organization.id).toBe(orgId); + expect(result.workspace.id).toBe(wsId); + }); + + it("fails when the slug exists in a different org", async () => { + const aHandle = `org-a-${crypto.randomUUID().slice(0, 8)}`; + const bHandle = `org-b-${crypto.randomUUID().slice(0, 8)}`; + const { orgId: orgA } = await seedOrgWithHandle(aHandle); + await seedOrgWithHandle(bHandle); + const sql = postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 30 }); + let wsSlug: string; + try { + const db = drizzle(sql, { schema: combinedSchema }); + const ws = await makeWorkspaceStore(db).create({ + organizationId: orgA, + name: "Shared", + }); + wsSlug = ws.slug; + } finally { + await sql.end({ timeout: 0 }).catch(() => undefined); + } + const exit = await Effect.runPromiseExit( + resolveWorkspaceContext(bHandle, wsSlug).pipe( + Effect.provide(DbService.Live), + Effect.scoped, + ) as Effect.Effect, + ); + expect(exit._tag).toBe("Failure"); + const errors = + exit._tag === "Failure" ? JSON.stringify(exit.cause) : ""; + expect(errors).toContain("WorkspaceSlugNotFound"); + }); +}); diff --git a/apps/cloud/src/services/url-context.ts b/apps/cloud/src/services/url-context.ts new file mode 100644 index 00000000..8ddeecc6 --- /dev/null +++ b/apps/cloud/src/services/url-context.ts @@ -0,0 +1,80 @@ +// --------------------------------------------------------------------------- +// URL context resolution — `:org` / `:org/:workspace` -> identity records +// --------------------------------------------------------------------------- +// +// The plan moves cloud's "active context" off the session cookie and onto the +// URL. These helpers translate URL handles/slugs to organization + workspace +// rows, gated by the WorkOS membership check the protected middleware +// performs separately. +// +// They do NOT build the scope stack (see `./scope-stack`) and do NOT validate +// org membership — that's the middleware's job. They DO confirm that a +// workspace lives in the org named by the handle, so a workspace slug from +// org A can't be addressed under org B's URL. + +import { Effect } from "effect"; + +import { DbService } from "./db"; +import { makeUserStore, type Organization } from "./user-store"; +import { makeWorkspaceStore, type Workspace } from "./workspace-store"; + +export type ResolvedOrgContext = { + readonly organization: Organization; +}; + +export type ResolvedWorkspaceContext = ResolvedOrgContext & { + readonly workspace: Workspace; +}; + +export class OrganizationHandleNotFound extends Error { + readonly _tag = "OrganizationHandleNotFound" as const; + constructor(readonly handle: string) { + super(`organization handle "${handle}" not found`); + } +} + +export class WorkspaceSlugNotFound extends Error { + readonly _tag = "WorkspaceSlugNotFound" as const; + constructor(readonly orgHandle: string, readonly slug: string) { + super(`workspace "${slug}" not found in org "${orgHandle}"`); + } +} + +/** Resolve a `/:org` URL segment to its organization row. */ +export const resolveOrgContext = (orgHandle: string) => + Effect.gen(function* () { + const { db } = yield* DbService; + const organization = yield* Effect.promise(() => + makeUserStore(db).getOrganizationByHandle(orgHandle), + ); + if (!organization) { + return yield* Effect.fail(new OrganizationHandleNotFound(orgHandle)); + } + return { organization } satisfies ResolvedOrgContext; + }); + +/** + * Resolve a `/:org/:workspace` URL segment pair to its organization + + * workspace rows. Fails if either lookup misses, or if the workspace exists + * but belongs to a different organization than the URL says. + */ +export const resolveWorkspaceContext = ( + orgHandle: string, + workspaceSlug: string, +) => + Effect.gen(function* () { + const orgCtx = yield* resolveOrgContext(orgHandle); + const { db } = yield* DbService; + const workspace = yield* Effect.promise(() => + makeWorkspaceStore(db).getBySlug(orgCtx.organization.id, workspaceSlug), + ); + if (!workspace) { + return yield* Effect.fail( + new WorkspaceSlugNotFound(orgHandle, workspaceSlug), + ); + } + return { + ...orgCtx, + workspace, + } satisfies ResolvedWorkspaceContext; + });