Skip to content
41 changes: 33 additions & 8 deletions apps/cloud/src/api/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,25 +64,50 @@ export const makeNonProtectedApiLive = (
// Routes scoped to a specific org (membership management, switching, etc.).
// Auth is enforced by `OrgAuth` middleware declared on `OrgHttpApi`.
//
// OrgHttpApi mounts under `/api/:org/...` so workspace endpoints are
// addressable per-org (`POST /api/:org/workspaces`,
// `GET /api/:org/workspaces/:slug`). `start.ts` strips the leading `/api`
// before forwarding, so the prefix here is `/:org` (not `/api/:org`).
// OrgHttpApi mounts under `/api/:org/...` AND `/api/:org/:workspace/...` so
// the same org-level endpoints (workspaces list/create, members, etc.) stay
// reachable from either context — the URL-context fetch wrapper in the
// react package always prefixes outgoing `/api/...` requests with the
// page's URL handle pair. `start.ts` strips the leading `/api` before
// forwarding, so the prefixes here omit it.
//
// Each mount needs its own `HttpApiBuilder.layer(OrgHttpApi)` instance
// because Effect's Layer system memoizes a shared instance and only the
// first prefix's routes would register otherwise (see
// `apps/cloud/src/api/protected.ts` for the same pattern).
const OrgPrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
Effect.map(HttpRouter.HttpRouter.asEffect(), (router) =>
router.prefixed("/:org"),
),
);

export const makeOrgApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService>,
) =>
const OrgWorkspacePrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
Effect.map(HttpRouter.HttpRouter.asEffect(), (router) =>
router.prefixed("/:org/:workspace"),
),
);

const makeOrgLayer = () =>
HttpApiBuilder.layer(OrgHttpApi).pipe(
Layer.provide(Layer.mergeAll(OrgHandlers, WorkspacesHandlers)),
Layer.provide(requestScopedMiddleware(rsLive).layer),
);

export const makeOrgApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService>,
) => {
const requestScopedLayer = requestScopedMiddleware(rsLive).layer;
const orgMount = makeOrgLayer().pipe(
Layer.provide(requestScopedLayer),
Layer.provideMerge(OrgAuthLive),
Layer.provide(OrgPrefixedRouterLayer),
);
const workspaceMount = makeOrgLayer().pipe(
Layer.provide(requestScopedLayer),
Layer.provideMerge(OrgAuthLive),
Layer.provide(OrgWorkspacePrefixedRouterLayer),
);
return Layer.mergeAll(orgMount, workspaceMount);
};

// Default exports use the production per-request layer. Existing callers
// that import `NonProtectedApiLive`/`OrgApiLive` continue to work; the
Expand Down
27 changes: 23 additions & 4 deletions apps/cloud/src/api/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
// because `makeExecutionStack` imports `cloudflare:workers`, which the test
// harness can't load in the workerd test runtime.

import { HttpApiSwagger } from "effect/unstable/httpapi";
import { HttpApiBuilder, HttpApiSwagger } from "effect/unstable/httpapi";
import {
HttpRouter,
HttpServerRequest,
} from "effect/unstable/http";
import { Effect, Layer } from "effect";

import { observabilityMiddleware } from "@executor-js/api";

import { ErrorCaptureLive } from "../observability";

import {
ExecutionEngineService,
ExecutorService,
Expand All @@ -32,7 +36,7 @@ import { HttpResponseError } from "./error-response";
import { RequestScopedServicesLive } from "./layers";
import {
ProtectedCloudApi,
ProtectedCloudApiLive,
ProtectedCloudApiHandlers,
RouterConfig,
} from "./protected-layers";
import { requestScopedMiddleware } from "./request-scoped";
Expand Down Expand Up @@ -206,17 +210,32 @@ const WorkspacePrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
// the layer rebuilds per HTTP request, satisfying Cloudflare Workers'
// I/O isolation. Exposed as a factory so tests can swap in a counting
// fake — see `apps/cloud/src/api.request-scope.node.test.ts`.
// Build a fresh `HttpApiBuilder.layer(ProtectedCloudApi)` per mount. Each
// layer instance registers its own `router.add(...)` calls; sharing one
// instance across two prefixed routers makes Effect's layer-memoization
// install only the first prefix, leaving `/:org/:workspace/...` unrouted.
const makeProtectedLayer = () =>
HttpApiBuilder.layer(ProtectedCloudApi).pipe(
Layer.provide(
Layer.mergeAll(
ProtectedCloudApiHandlers,
observabilityMiddleware(ProtectedCloudApi),
),
),
Layer.provide(ErrorCaptureLive),
);

export const makeProtectedApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService>,
) => {
const protectedMiddleware = ExecutionStackMiddleware.combine(
requestScopedMiddleware(rsLive),
).layer;
const orgMount = ProtectedCloudApiLive.pipe(
const orgMount = makeProtectedLayer().pipe(
Layer.provide(protectedMiddleware),
Layer.provide(OrgPrefixedRouterLayer),
);
const workspaceMount = ProtectedCloudApiLive.pipe(
const workspaceMount = makeProtectedLayer().pipe(
Layer.provide(protectedMiddleware),
Layer.provide(WorkspacePrefixedRouterLayer),
);
Expand Down
9 changes: 0 additions & 9 deletions apps/cloud/src/routes/$org.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router"
import { useEffect } from "react";
import { AutumnProvider } from "autumn-js/react";
import { ExecutorProvider } from "@executor-js/react/api/provider";
import { setBaseUrl } from "@executor-js/react/api/base-url";
import { Toaster } from "@executor-js/react/components/sonner";
import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
import { plugins as clientPlugins } from "virtual:executor/plugins-client";
Expand Down Expand Up @@ -37,14 +36,6 @@ function OrgLayout() {
if (auth.status !== "authenticated") return null;
if (!matched) return null;

// Point the executor API client at this org's prefixed routes. Done before
// first render of the executor providers so all queries see the right URL.
// The cloud app is single-tenant per page, so a one-shot setter is fine —
// when the URL handle changes, this re-runs at the start of the next render.
if (typeof window !== "undefined") {
setBaseUrl(`${window.location.origin}/api/${matched.handle}`);
}

return (
<OrgRouteProvider
value={{ orgId: matched.id, orgName: matched.name, orgHandle: matched.handle }}
Expand Down
13 changes: 0 additions & 13 deletions apps/cloud/src/routes/$org/$workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { createFileRoute, Outlet, useNavigate, useParams } from "@tanstack/react
import { useEffect, useMemo } from "react";
import { useAtomValue } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { setBaseUrl } from "@executor-js/react/api/base-url";

import { useOrgRoute } from "../../web/org-route";
import { WorkspaceRouteProvider } from "../../web/workspace-route";
Expand Down Expand Up @@ -46,18 +45,6 @@ function WorkspaceLayout() {
// surfaces as a fast remount once the parent updates.
if (orgHandle !== org) return null;

// Re-point the executor API base URL at the workspace-prefixed mount.
// Mirrors the parent `/$org` layout's `setBaseUrl` call but tacks on
// `/${slug}` so executor-side queries (sources/secrets/connections/...)
// hit `/api/${org}/${workspace}/...` and the middleware builds the
// workspace scope stack. On unmount/back-nav the parent layout re-runs
// and resets the URL to the org-only prefix.
if (typeof window !== "undefined") {
setBaseUrl(
`${window.location.origin}/api/${orgHandle}/${workspace.slug}`,
);
}

return (
<WorkspaceRouteProvider
value={{
Expand Down
120 changes: 120 additions & 0 deletions apps/cloud/src/services/policies-stack.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Policies storage-stack invariants — verifies that policy writes accept
// any scope in the workspace stack (`user-workspace → workspace →
// user-org → org`) and that listing from workspace context returns every
// row sorted innermost-first. Pins the SDK precedence rule used by tool
// invocation: the innermost row wins.

import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import {
asWorkspaceUser,
orgScopeId,
testUserOrgScopeId,
testUserWorkspaceScopeId,
testWorkspaceScopeId,
} from "./__test-harness__/api-harness";

const PATTERN = "*"; // every tool

describe("policies storage stack in workspace context", () => {
it.effect(
"policies write at every scope level in the URL-resolved stack",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const userId = `u_${crypto.randomUUID().slice(0, 8)}`;
const wsScope = testWorkspaceScopeId(org, slug);
const orgScope = orgScopeId(org);
const userOrg = testUserOrgScopeId(userId, org);
const userWs = testUserWorkspaceScopeId(userId, org, slug);

// Each level gets a distinct action so the listing can match
// rows back to their scopes deterministically.
yield* asWorkspaceUser(userId, org, slug, (client) =>
Effect.gen(function* () {
yield* client.policies.create({
params: { scopeId: userWs },
payload: { pattern: PATTERN, action: "block" },
});
yield* client.policies.create({
params: { scopeId: wsScope },
payload: { pattern: PATTERN, action: "require_approval" },
});
yield* client.policies.create({
params: { scopeId: userOrg },
payload: { pattern: PATTERN, action: "approve" },
});
yield* client.policies.create({
params: { scopeId: orgScope },
payload: { pattern: PATTERN, action: "require_approval" },
});
}),
);

// Listing from workspace context returns every row, each tagged
// with its owning scope id.
const list = yield* asWorkspaceUser(userId, org, slug, (client) =>
client.policies.list({ params: { scopeId: wsScope } }),
);
const byScope = new Map(list.map((p) => [p.scopeId, p.action]));
expect(byScope.get(userWs)).toBe("block");
expect(byScope.get(wsScope)).toBe("require_approval");
expect(byScope.get(userOrg)).toBe("approve");
expect(byScope.get(orgScope)).toBe("require_approval");

// Sort order: innermost-first. The user-workspace row lands at
// index 0, the org row at index N-1.
const innermost = list[0];
const outermost = list[list.length - 1];
expect(innermost?.scopeId).toBe(userWs);
expect(outermost?.scopeId).toBe(orgScope);
}),
);

it.effect(
"innermost matching policy wins — user-workspace beats workspace beats org",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const userId = `u_${crypto.randomUUID().slice(0, 8)}`;
const wsScope = testWorkspaceScopeId(org, slug);
const orgScope = orgScopeId(org);
const userWs = testUserWorkspaceScopeId(userId, org, slug);

// Three same-pattern rows with different actions across the
// stack. The SDK's `resolveToolPolicy` (in
// `packages/core/sdk/src/policies.ts`) ranks by `scope_id` →
// `position` and picks the innermost match.
yield* asWorkspaceUser(userId, org, slug, (client) =>
Effect.gen(function* () {
yield* client.policies.create({
params: { scopeId: orgScope },
payload: { pattern: PATTERN, action: "block" },
});
yield* client.policies.create({
params: { scopeId: wsScope },
payload: { pattern: PATTERN, action: "require_approval" },
});
yield* client.policies.create({
params: { scopeId: userWs },
payload: { pattern: PATTERN, action: "approve" },
});
}),
);

// Listing returns all 3, but the innermost is at position 0.
// That ordering is what `resolveToolPolicy` consumes when the
// executor looks up the effective policy for a tool id.
const list = yield* asWorkspaceUser(userId, org, slug, (client) =>
client.policies.list({ params: { scopeId: wsScope } }),
);
const policies = list.filter((p) => p.pattern === PATTERN);
expect(policies).toHaveLength(3);
expect(policies[0]?.scopeId).toBe(userWs);
expect(policies[0]?.action).toBe("approve");
}),
);
});
50 changes: 40 additions & 10 deletions apps/cloud/src/web/workspace-route.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import React, { createContext, useContext } from "react";
import React, { createContext, useContext, useMemo } from "react";
import { useParams } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";

import { workspacesAtom } from "./workspaces";

// ---------------------------------------------------------------------------
// WorkspaceRouteContext — provided by the `/$org/$workspace` layout, consumed
// by descendants (shell nav, nav links, etc.) that need to know the URL-active
// workspace. Mirrors `OrgRouteContext` but for the inner workspace segment.
// WorkspaceRouteContext — provided by the `/$org/$workspace` layout. The
// hook below also falls back to deriving the value from URL params +
// `workspacesAtom` so callers rendered ABOVE the workspace layout (e.g. the
// Shell + UserFooter, which live in the parent `/$org` layout) still see the
// active workspace.
// ---------------------------------------------------------------------------

export type WorkspaceRouteValue = {
Expand All @@ -25,16 +32,39 @@ export const WorkspaceRouteProvider = (props: {
);

export const useWorkspaceRoute = (): WorkspaceRouteValue => {
const value = useContext(WorkspaceRouteContext);
const value = useOptionalWorkspaceRoute();
if (!value) {
throw new Error(
"useWorkspaceRoute must be used within a WorkspaceRouteProvider",
"useWorkspaceRoute requires a workspace URL segment or WorkspaceRouteProvider",
);
}
return value;
};

/** Optional variant for shell components rendered both inside and outside the
* workspace layout. Returns `null` when the URL is org-only. */
export const useOptionalWorkspaceRoute = (): WorkspaceRouteValue | null =>
useContext(WorkspaceRouteContext);
/**
* Returns the active workspace if one is encoded in the URL, otherwise null.
* Resolution order:
* 1. WorkspaceRouteContext (set by the `/$org/$workspace` layout)
* 2. URL `workspace` param + workspacesAtom lookup (so callers rendered
* above the layout — the parent shell — still see workspace context).
*/
export const useOptionalWorkspaceRoute = (): WorkspaceRouteValue | null => {
const fromContext = useContext(WorkspaceRouteContext);
const params = useParams({ strict: false }) as {
workspace?: string;
};
const slug = params.workspace ?? null;
const result = useAtomValue(workspacesAtom);
return useMemo<WorkspaceRouteValue | null>(() => {
if (fromContext) return fromContext;
if (!slug) return null;
if (!AsyncResult.isSuccess(result)) return null;
const found = result.value.workspaces.find((w) => w.slug === slug);
if (!found) return null;
return {
workspaceId: found.id,
workspaceSlug: found.slug,
workspaceName: found.name,
};
}, [fromContext, slug, result]);
};
11 changes: 9 additions & 2 deletions packages/core/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ export interface CreatePluginAtomClientOptions {
* when forwarding to the Effect handler) — same convention as the
* core `ExecutorApiClient`. */
readonly baseUrl?: string;
/** Override the HTTP client layer. Hosts that need URL-context-aware
* prefixing (e.g. cloud's `/api/:org/...` mounting) inject a wrapped
* `FetchHttpClient.layer` here so plugin requests pick up the active
* context the same way the core `ExecutorApiClient` does. */
readonly httpClient?: import("effect").Layer.Layer<
import("effect/unstable/http").HttpClient.HttpClient
>;
}

/**
Expand All @@ -211,14 +218,14 @@ export const createPluginAtomClient = <
group: G,
options: CreatePluginAtomClientOptions = {},
) => {
const { baseUrl = "/api" } = options;
const { baseUrl = "/api", httpClient = FetchHttpClient.layer } = options;
const pluginId = group.identifier;
const bundle = HttpApi.make(`plugin-${pluginId}`).add(group);
return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(
`Plugin_${pluginId}Client`,
{
api: bundle,
httpClient: FetchHttpClient.layer,
httpClient,
baseUrl,
},
);
Expand Down
Loading
Loading