History

Serve tenant-level product reads to organization API keys

Rhys Sullivan committed 3 months ago

Commit abbf4411 parent 676af1a13 files · +509 −55Browse files

Unified · Split

13 files
  1. apps/cloud/src/auth/org-api-key-auth.node.test.ts +15 −9
  2. apps/cloud/src/auth/workos-auth-provider.ts +33 −17
  3. apps/cloud/src/org/handlers.test.ts +1 −0
  4. apps/cloud/src/org/handlers.ts +5 −0
  5. apps/host-selfhost/src/mcp/auth.ts +7 −2
  6. apps/host-selfhost/src/platform-credential.test.ts +196 −0
  7. apps/host-selfhost/src/testing/test-app.ts +21 −1
  8. packages/core/api/src/server.ts +6 −0
  9. packages/core/api/src/server/execution-stack-middleware.ts +95 −15
  10. packages/core/api/src/server/execution-stack.ts +32 −1
  11. packages/core/api/src/server/executor-app.ts +7 −2
  12. packages/core/api/src/server/fixed-execution-middleware.ts +28 −5
  13. packages/core/api/src/server/identity.ts +63 −3
apps/cloud/src/auth/org-api-key-auth.node.test.ts+15−9
@@ -124,19 +124,25 @@ describe("org-level API keys", () => { · 123 unmodified lines
124124 }),
125125 );
126126
127− it.effect("are REJECTED on the product surface rather than bound to a subject", () =>
127+ it.effect("resolve on the product surface as a platform principal, never a subject", () =>
128128 Effect.gen(function* () {
129− // THE security property of the api-key half: a product request carrying
130− // an org key must fail, not silently act as some user.
131− const error = yield* Effect.flip(
132− resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(Effect.provide(layers)),
129+ // THE security property of the api-key half, updated for platform reads:
130+ // a product request carrying an org key resolves to the NEUTRAL platform
131+ // shape — which the shared middleware routes to the subject-less,
132+ // GET-only platform executor — and must never carry an accountId a
133+ // handler could bind as an acting member.
134+ const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(
135+ Effect.provide(layers),
133136 );
134137
135− expect(error).toMatchObject({
136− _tag: "Unauthorized",
137− code: "invalid_api_key",
138− message: "Organization API keys cannot be used on this endpoint",
138+ expect(principal).toEqual({
139+ kind: "platform",
140+ organizationId: "org_123",
141+ organizationName: "Org org_123",
142+ organizationSlug: "org-slug-org_123",
143+ keyId: "api_key_org",
139144 });
145+ expect(principal).not.toHaveProperty("accountId");
140146 }),
141147 );
142148
apps/cloud/src/auth/workos-auth-provider.ts+33−17
@@ -38,7 +38,13 @@ import { · 37 unmodified lines
3838 Unauthorized,
3939 Unavailable,
4040 } from "@executor-js/api/server";
41−import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";
41+import type {
42+ FailureRenderingStrategy,
43+ IdentityFailure,
44+ PlatformPrincipal,
45+ Principal,
46+ ResolvedPrincipal,
47+} from "@executor-js/api/server";
4248
4349 import { ApiKeyService } from "./api-keys";
4450 import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
@@ -104,14 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 59 unmodified lines
104110 code: "no_organization",
105111 message: "No organization in access token",
106112 };
107−// An org-level key resolves to the PLATFORM view, which has no acting member.
108−// The product endpoints are bound to one subject, so they reject it outright
109−// rather than inventing a subject for it to act as.
110−const ORG_KEY_ON_PRODUCT_SURFACE = {
111− code: "invalid_api_key",
112− message: "Organization API keys cannot be used on this endpoint",
113−};
114−
115113 // A bearer value with three dot-separated segments is a JWT (a WorkOS access
116114 // token from the CLI device-login); anything else is treated as an API key.
117115 // Same discriminator the MCP plane uses (`mcp/auth.ts`).
@@ -257,23 +255,36 @@ export const resolveBearerAuth = ( · 139 unmodified lines
257255 });
258256
259257 /**
260− * The PRODUCT-view bearer resolver: as {@link resolveBearerAuth}, but an
261− * org-level key is REJECTED rather than downgraded. The product endpoints are
262− * bound to one acting subject, so there is no honest way to serve them an
263− * org key — those belong at the `/admin/*` mount instead. (Kept the historical
264− * name; the re-export and resolver tests reference it.)
258+ * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral
259+ * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared
260+ * middleware routes it to the subject-less, read-only platform executor
261+ * (refusing non-GET up front). Previously the product plane rejected org keys
262+ * outright; serving tenant-level READS to them is deliberate: the catalog,
263+ * tools, policies, and org-owned connection listings are tenant-shared answers
264+ * a machine credential can honestly receive, while everything subject-bound
265+ * stays structurally out of reach (a platform executor binds no subject, so no
266+ * member's personal rows resolve). (Kept the historical name; the re-export and
267+ * resolver tests reference it.)
265268 */
266269 export const resolveApiKeyPrincipal = (
267270 request: Request,
268271 jwt: JwtBearerConfig | null = null,
269272 ): Effect.Effect<
270− Principal | null,
273+ ResolvedPrincipal | null,
271274 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
272275 WorkOSClient | ApiKeyService | UserStoreService
273276 > =>
274277 Effect.gen(function* () {
275278 const auth = yield* resolveBearerAuth(request, jwt);
276− if (isPlatformAuth(auth)) return yield* new Unauthorized(ORG_KEY_ON_PRODUCT_SURFACE);
279+ if (isPlatformAuth(auth)) {
280+ return {
281+ kind: "platform",
282+ organizationId: auth.organizationId,
283+ organizationName: auth.organizationName,
284+ ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }),
285+ keyId: auth.keyId,
286+ } satisfies PlatformPrincipal;
287+ }
277288 return auth;
278289 });
279290
@@ -330,7 +341,7 @@ export const resolveProtectedPrincipal = ( · 50 unmodified lines
330341 request: Request,
331342 jwt: JwtBearerConfig | null = null,
332343 ): Effect.Effect<
333− Principal,
344+ ResolvedPrincipal,
334345 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
335346 WorkOSClient | ApiKeyService | UserStoreService
336347 > =>
@@ -411,6 +422,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines
411422 "service_unavailable",
412423 "Service temporarily unavailable",
413424 ),
425+ ReadOnlyCredential: renderIdentityFailure(
426+ 403,
427+ "read_only_credential",
428+ "Organization API keys are read-only",
429+ ),
414430 }),
415431 ),
416432 };
apps/cloud/src/org/handlers.test.ts+1−0
@@ -70,6 +70,7 @@ const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => · 69 unmodified lines
7070 // Mirrors `org/handlers.ts` `requireAdmin`.
7171 const requireAdmin = Effect.gen(function* () {
7272 const auth = yield* AuthContext;
73+ if (auth.accountId === null) return yield* new Forbidden();
7374 const workos = yield* WorkOSClient;
7475 const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
7576 if (!current || current.role?.slug !== "admin") {
apps/cloud/src/org/handlers.ts+5−0
@@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api"; · 16 unmodified lines
1717
1818 const requireAdmin = Effect.gen(function* () {
1919 const auth = yield* AuthContext;
20+ // This plane is mounted behind the session-only `orgAuthMiddleware`, so the
21+ // caller is always a member — but `AuthContext.accountId` is nullable for the
22+ // platform credential, and membership of "no member" is not a question worth
23+ // asking WorkOS. Refuse rather than assert.
24+ if (auth.accountId === null) return yield* new Forbidden();
2025 const workos = yield* WorkOSClient;
2126 const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
2227 if (!currentMembership || currentMembership.role?.slug !== "admin") {
apps/host-selfhost/src/mcp/auth.ts+7−2
@@ -1,7 +1,7 @@
11 import { Effect, Layer } from "effect";
22 import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";
33
4−import { IdentityProvider } from "@executor-js/api/server";
4+import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server";
55 import {
66 authenticated,
77 McpAuthProvider,
@@ -233,12 +233,17 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 225 unmodified lines
233233 }).pipe(Effect.orElseSucceed(() => null));
234234
235235 /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's
236− * api `Principal` shape is byte-identical to host-mcp's `Principal`. */
236+ * api `Principal` shape is byte-identical to host-mcp's `Principal`. The
237+ * neutral seam can also resolve a platform credential, which self-host's
238+ * identity never produces — narrowed away rather than asserted, so an MCP
239+ * session can never bind to a subject-less credential if that changes. */
237240 const authenticateSession = (request: Request): Effect.Effect<Principal | null> =>
238241 fallback.authenticate(request).pipe(
242+ Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)),
239243 Effect.catchTags({
240244 Unauthorized: () => Effect.succeed(null),
241245 NoOrganization: () => Effect.succeed(null),
246+ ReadOnlyCredential: () => Effect.succeed(null),
242247 }),
243248 );
244249
apps/host-selfhost/src/platform-credential.test.ts+196−0
@@ -0,0 +1,196 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
6+
7+process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-platform-"));
8+
9+// ---------------------------------------------------------------------------
10+// The shared middleware's PLATFORM branch, end-to-end over the real router: an
11+// org-level credential (the shape cloud's org-scoped API key resolves to) gets
12+// tenant-level READS on the ordinary product paths, and nothing else.
13+//
14+// Served through the self-host test app because it runs the SAME
15+// `makeExecutionStackMiddleware` cloud's protected plane uses — the branch
16+// under test is host-neutral, and this is the harness that can drive it
17+// without WorkOS. The `x-test-platform-org` header resolves the platform
18+// principal (see testing/test-app.ts).
19+//
20+// Pinned here:
21+// 1. GET /api/integrations answers the tenant catalog — the read that used
22+// to require a second, member credential (the customer-reported gap);
23+// 2. GET /api/connections answers org-owned rows WITHOUT any member's
24+// personal connections (bound reach, no subject);
25+// 3. any non-GET is refused 403 before a handler runs;
26+// 4. the credential mints NO subject row — an org key must never appear as a
27+// user of the workspace it observes.
28+// ---------------------------------------------------------------------------
29+
30+let handler!: (request: Request) => Promise<Response>;
31+let dispose: () => Promise<void> = async () => {};
32+
33+beforeAll(async () => {
34+ const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app");
35+ const app = await makeSelfHostTestApp({
36+ identity: headerIdentityLayer,
37+ });
38+ handler = app.handler;
39+ dispose = app.dispose;
40+});
41+afterAll(() => dispose());
42+
43+const ORG = "platform-org";
44+const MEMBER = "member-user";
45+
46+const TINY_SPEC = JSON.stringify({
47+ openapi: "3.0.0",
48+ info: { title: "Tiny", version: "1.0.0" },
49+ servers: [{ url: "https://httpbin.org" }],
50+ paths: {
51+ "/get": {
52+ get: {
53+ operationId: "httpGet",
54+ summary: "GET",
55+ responses: { "200": { description: "ok" } },
56+ },
57+ },
58+ },
59+});
60+
61+const memberHeaders: Record<string, string> = {
62+ "x-test-user": MEMBER,
63+ "x-test-org": ORG,
64+ "content-type": "application/json",
65+};
66+
67+const platformHeaders: Record<string, string> = {
68+ "x-test-platform-org": ORG,
69+ "content-type": "application/json",
70+};
71+
72+/** Seed as the MEMBER: a catalog row, an org-owned connection, and a personal
73+ * one — so the platform reads below have both a hit and a must-miss. */
74+beforeAll(async () => {
75+ const add = await handler(
76+ new Request("http://localhost/api/openapi/specs", {
77+ method: "POST",
78+ headers: memberHeaders,
79+ body: JSON.stringify({ spec: { kind: "blob", value: TINY_SPEC }, slug: "cat", baseUrl: "" }),
80+ }),
81+ );
82+ expect(add.status).toBe(200);
83+ const orgConn = await handler(
84+ new Request("http://localhost/api/connections", {
85+ method: "POST",
86+ headers: memberHeaders,
87+ body: JSON.stringify({
88+ owner: "org",
89+ name: "shared",
90+ integration: "cat",
91+ template: "bearer",
92+ value: "org-shared-token",
93+ }),
94+ }),
95+ );
96+ expect(orgConn.status).toBe(200);
97+ const userConn = await handler(
98+ new Request("http://localhost/api/connections", {
99+ method: "POST",
100+ headers: memberHeaders,
101+ body: JSON.stringify({
102+ owner: "user",
103+ name: "personal",
104+ integration: "cat",
105+ template: "bearer",
106+ value: "member-personal-token",
107+ }),
108+ }),
109+ );
110+ expect(userConn.status).toBe(200);
111+});
112+
113+test("a platform credential reads the tenant catalog on the ordinary product path", async () => {
114+ const res = await handler(
115+ new Request("http://localhost/api/integrations", { headers: platformHeaders }),
116+ );
117+ expect(res.status).toBe(200);
118+ const body = (await res.json()) as ReadonlyArray<{ readonly slug: string }>;
119+ expect(
120+ body.map((integration) => integration.slug),
121+ "the catalog the member configured is readable with the org credential alone",
122+ ).toContain("cat");
123+});
124+
125+test("connection reads answer org-owned rows and never a member's personal ones", async () => {
126+ const res = await handler(
127+ new Request("http://localhost/api/connections", { headers: platformHeaders }),
128+ );
129+ expect(res.status).toBe(200);
130+ const raw = await res.text();
131+ // oxlint-disable-next-line executor/no-json-parse -- boundary: test asserts on the raw wire body (for the token sweep) and its parsed form together
132+ const body = JSON.parse(raw) as ReadonlyArray<{ readonly name: string }>;
133+ expect(
134+ body.map((connection) => connection.name),
135+ "the org-owned connection is visible",
136+ ).toContain("shared");
137+ expect(
138+ body.map((connection) => connection.name),
139+ "a member's personal connection is not — the platform view binds no subject",
140+ ).not.toContain("personal");
141+ expect(raw, "no credential material either way").not.toContain("token");
142+});
143+
144+test("every non-GET is refused before a handler runs", async () => {
145+ const post = await handler(
146+ new Request("http://localhost/api/connections", {
147+ method: "POST",
148+ headers: platformHeaders,
149+ body: JSON.stringify({
150+ owner: "org",
151+ name: "sneaky",
152+ integration: "cat",
153+ template: "bearer",
154+ value: "nope",
155+ }),
156+ }),
157+ );
158+ expect(post.status, "a write with a read-only credential is a clear 403").toBe(403);
159+ expect(await post.text()).toContain("read-only");
160+
161+ const del = await handler(
162+ new Request("http://localhost/api/integrations/cat", {
163+ method: "DELETE",
164+ headers: platformHeaders,
165+ }),
166+ );
167+ expect(del.status).toBe(403);
168+
169+ // The refusal happened before any handler: the connection it tried to write
170+ // does not exist.
171+ const after = await handler(
172+ new Request("http://localhost/api/connections", { headers: platformHeaders }),
173+ );
174+ const names = ((await after.json()) as ReadonlyArray<{ readonly name: string }>).map(
175+ (connection) => connection.name,
176+ );
177+ expect(names).not.toContain("sneaky");
178+});
179+
180+test("the credential never mints a subject row in the workspace it observes", async () => {
181+ // Several platform reads have run by now. If any of them touched the subject
182+ // table, the org would report a phantom user; only the seeding member may
183+ // appear.
184+ const { withQueryContext } = await import("@executor-js/fumadb/query");
185+ const { createSelfHostDb } = await import("./db/self-host-db");
186+ const { loadConfig } = await import("./config");
187+ const db = await createSelfHostDb({ path: loadConfig().dbPath });
188+ const rows = await withQueryContext(db.db, { tenant: ORG, subject: null }).findMany("subject", {
189+ orderBy: ["external_id", "asc"],
190+ });
191+ await db.close();
192+ expect(
193+ rows.map((row) => String(row.external_id)),
194+ "only the member has a footprint",
195+ ).toEqual([MEMBER]);
196+});
apps/host-selfhost/src/testing/test-app.ts+21−1
@@ -5,8 +5,10 @@ import { · 4 unmodified lines
55 composePluginApi,
66 ExecutorApp,
77 IdentityProvider,
8+ isPlatformPrincipal,
89 PluginsProvider,
910 type Principal,
11+ type ResolvedPrincipal,
1012 textFailureStrategy,
1113 Unauthorized,
1214 } from "@executor-js/api/server";
@@ -98,6 +100,19 @@ export const headerIdentityLayer: Layer.Layer<IdentityProvider> = Layer.succeed( · 85 unmodified lines
98100 IdentityProvider,
99101 IdentityProvider.of({
100102 authenticate: (request) => {
103+ // `x-test-platform-org` resolves the org-level PLATFORM credential — the
104+ // neutral shape cloud's org-scoped API key produces — so the shared
105+ // middleware's platform branch is testable against this harness even
106+ // though self-host's real identity never mints one.
107+ const platformOrg = request.headers.get("x-test-platform-org");
108+ if (platformOrg) {
109+ return Effect.succeed<ResolvedPrincipal>({
110+ kind: "platform",
111+ organizationId: platformOrg,
112+ organizationName: `Org ${platformOrg}`,
113+ keyId: "test_platform_key",
114+ });
115+ }
101116 const userId = request.headers.get("x-test-user");
102117 const organizationId = request.headers.get("x-test-org");
103118 if (!userId || !organizationId) return Effect.fail(new Unauthorized());
@@ -143,12 +158,17 @@ const stubMcpAuth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = Layer · 39 unmodified lines
143158 resourceMetadataUrl: resourceMetadataUrlFor,
144159 authenticate: (request: Request): Effect.Effect<AuthOutcome> =>
145160 fallback.authenticate(request).pipe(
161+ // The test identity never resolves a platform credential; narrow it
162+ // away (as the production selfHostMcpAuth does) rather than asserting.
146163 Effect.map((principal) =>
147− principal ? authenticated(principal) : unauthorized(challengeFor(request)),
164+ principal && !isPlatformPrincipal(principal)
165+ ? authenticated(principal)
166+ : unauthorized(challengeFor(request)),
148167 ),
149168 Effect.catchTags({
150169 Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))),
151170 NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))),
171+ ReadOnlyCredential: () => Effect.succeed(unauthorized(challengeFor(request))),
152172 }),
153173 ),
154174 };
packages/core/api/src/server.ts+6−0
@@ -48,6 +48,7 @@ export { RouterConfigLive } from "./server/router-config"; · 47 unmodified lines
4848 export { consoleErrorCapture } from "./server/console-error-capture";
4949 export {
5050 makeExecutionStack,
51+ makePlatformExecutionStack,
5152 CodeExecutorProvider,
5253 EngineDecorator,
5354 EngineDecoratorNoop,
@@ -95,8 +96,13 @@ export { · 41 unmodified lines
9596 Unauthorized,
9697 NoOrganization,
9798 Unavailable,
99+ ReadOnlyCredential,
98100 authContextFromPrincipal,
101+ authContextFromPlatform,
102+ isPlatformPrincipal,
99103 type Principal,
104+ type PlatformPrincipal,
105+ type ResolvedPrincipal,
100106 type IdentityProviderShape,
101107 type IdentityFailure,
102108 } from "./server/identity";
packages/core/api/src/server/execution-stack-middleware.ts+95−15
@@ -36,8 +36,10 @@ · 35 unmodified lines
3636
3737 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3838 import { Context, Effect, Layer } from "effect";
39+import type * as Cause from "effect/Cause";
3940
4041 import type { AnyPlugin } from "@executor-js/sdk";
42+import type { ExecutionEngine } from "@executor-js/execution";
4143
4244 import type { DbProvider } from "./executor-fuma-db";
4345 import {
@@ -49,13 +51,17 @@ import { · 5 unmodified lines
4951 import { ExecutionEngineService, ExecutorService } from "../services";
5052 import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";
5153 import {
54+ authContextFromPlatform,
5255 authContextFromPrincipal,
5356 AuthContext,
57+ isPlatformPrincipal,
58+ ReadOnlyCredential,
5459 type IdentityFailure,
55− type Principal,
60+ type ResolvedPrincipal,
5661 } from "./identity";
5762 import {
5863 makeExecutionStack,
64+ makePlatformExecutionStack,
5965 type CodeExecutorProvider,
6066 type EngineDecorator,
6167 } from "./execution-stack";
@@ -68,9 +74,9 @@ import { · 6 unmodified lines
6874 * residual requirement the strategy adds (always `never` in practice).
6975 */
7076 export interface FailureRenderingStrategy<E, RR = never> {
71− readonly renderFailure: <R>(
72− effect: Effect.Effect<Principal, E, R>,
73− ) => Effect.Effect<Principal | HttpServerResponse.HttpServerResponse, E, R | RR>;
77+ readonly renderFailure: <A extends ResolvedPrincipal, R>(
78+ effect: Effect.Effect<A, E, R>,
79+ ) => Effect.Effect<A | HttpServerResponse.HttpServerResponse, E, R | RR>;
7480 }
7581
7682 /**
@@ -98,6 +104,15 @@ export const textFailureStrategy: FailureRenderingStrategy<IdentityFailure> = { · 21 unmodified lines
98104 status: 503,
99105 }),
100106 ),
107+ // Self-host/local identity never resolves a platform credential, but the
108+ // method gate raises this tag on the SHARED channel, so total coverage
109+ // requires rendering it (and keeps the strategy honest if that changes).
110+ ReadOnlyCredential: () =>
111+ Effect.succeed(
112+ HttpServerResponse.text("Organization API keys are read-only", {
113+ status: 403,
114+ }),
115+ ),
101116 }),
102117 ),
103118 };
@@ -112,12 +127,14 @@ export interface MakeExecutionStackMiddlewareOptions< · 8 unmodified lines
112127 /** The host's plugin tuple — drives the typed extension Services and binding. */
113128 readonly plugins: TPlugins;
114129 /**
115− * Resolve the inbound web `Request` to a neutral `Principal`. Adapter-specific
116− * credential precedence stays inside this function.
130+ * Resolve the inbound web `Request` to a neutral `Principal` — or, for an
131+ * org-level credential (cloud's org-scoped API key), a `PlatformPrincipal`
132+ * that the middleware routes to the read-only platform branch.
133+ * Adapter-specific credential precedence stays inside this function.
117134 */
118− readonly authenticate: (request: Request) => Effect.Effect<Principal, E, RLong>;
135+ readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, E, RLong>;
119136 /** Render `authenticate` failures (passthrough for cloud, text for self-host). */
120− readonly strategy: FailureRenderingStrategy<E, RStrategy>;
137+ readonly strategy: FailureRenderingStrategy<E | ReadOnlyCredential, RStrategy>;
121138 /** The host's `makeExecutionStack` seam Layer. */
122139 readonly stackLayer: Layer.Layer<
123140 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator,
@@ -166,9 +183,52 @@ export const makeExecutionStackMiddleware = < · 42 unmodified lines
166183 Effect.gen(function* () {
167184 const request = yield* HttpServerRequest.HttpServerRequest;
168185 const webRequest = yield* HttpServerRequest.toWeb(request);
169− const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest));
186+ const resolved = yield* options.strategy.renderFailure(
187+ options.authenticate(webRequest).pipe(
188+ // The PLATFORM branch's method gate lives here, before any
189+ // handler: an org credential is read-only by construction, so a
190+ // non-GET request is refused as a typed 403 rather than reaching
191+ // a handler that would bind it to a subject or attempt a write
192+ // (the storage policy would refuse the write anyway — this makes
193+ // the refusal a clear response instead of a 500).
194+ Effect.filterOrFail(
195+ (principal): principal is ResolvedPrincipal =>
196+ !isPlatformPrincipal(principal) || webRequest.method === "GET",
197+ () =>
198+ new ReadOnlyCredential({
199+ code: "read_only_credential",
200+ message: "Organization API keys are read-only",
201+ }),
202+ ),
203+ ),
204+ );
170205 // The strategy recovered the failure into a Response — return it.
171206 if (!isPrincipal(resolved)) return resolved;
207+
208+ if (isPlatformPrincipal(resolved)) {
209+ // Org credential: the subject-less, write-refusing platform stack.
210+ // No `touchSubject` runs, so the credential never mints a phantom
211+ // user row in the very lists the admin plane serves.
212+ const { executor } = yield* makePlatformExecutionStack<TPlugins>(
213+ resolved.organizationId,
214+ ).pipe(
215+ Effect.provide(options.stackLayer),
216+ Effect.provideService(RequestWebOrigin, {
217+ origin: requestWebOriginFromRequest(webRequest),
218+ }),
219+ Effect.withSpan("executor.stack.http.resolve_platform"),
220+ );
221+ return yield* httpEffect.pipe(
222+ Effect.provideService(AuthContext, AuthContext.of(authContextFromPlatform(resolved))),
223+ Effect.provideService(ExecutorService, executor),
224+ // The engine runs code as an acting member; the platform view has
225+ // none, and owns no executions. GET readers get the honest empty
226+ // answers; the execute/resume paths sit behind the method gate
227+ // above and are unreachable.
228+ Effect.provideService(ExecutionEngineService, readOnlyExecutionEngine),
229+ provideExecutorExtensions(executor),
230+ );
231+ }
172232 const auth = AuthContext.of(authContextFromPrincipal(resolved));
173233 // The public origin the caller actually hit, so a host with no static
174234 // web base URL (a Worker) derives one zero-config. An explicit
@@ -206,13 +266,33 @@ export const makeExecutionStackMiddleware = < · 31 unmodified lines
206266 );
207267 };
208268
209−// `renderFailure` yields either the resolved `Principal` (proceed) or an
210−// already-built `HttpServerResponse` (the strategy recovered the failure). A
211−// `Principal` is a plain object with `accountId`; a response is tagged. Discern
212−// by the marker the response framework brands its values with.
269+// `renderFailure` yields either the resolved principal (proceed) or an
270+// already-built `HttpServerResponse` (the strategy recovered the failure).
271+// Discern by the marker the response framework brands its values with.
213272 const isPrincipal = (
214− value: Principal | HttpServerResponse.HttpServerResponse,
215−): value is Principal => !HttpServerResponse.isHttpServerResponse(value);
273+ value: ResolvedPrincipal | HttpServerResponse.HttpServerResponse,
274+): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value);
275+
276+/**
277+ * The engine the platform branch provides: every read answers "nothing here",
278+ * honestly — the platform view owns no executions and pauses none. The
279+ * execute/resume members exist only to satisfy the service shape; they sit
280+ * behind the middleware's GET-only gate, so reaching one is a wiring bug and
281+ * dies as a defect rather than failing with a typed error a client could
282+ * mistake for a product answer.
283+ */
284+const readOnlyExecutionEngine: ExecutionEngine<Cause.YieldableError> = {
285+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome
286+ execute: () => Effect.die("platform credential: execution engine unavailable"),
287+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome
288+ executeWithPause: () => Effect.die("platform credential: execution engine unavailable"),
289+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome
290+ resume: () => Effect.die("platform credential: execution engine unavailable"),
291+ getPausedExecution: () => Effect.succeed(null),
292+ pausedExecutionCount: () => Effect.succeed(0),
293+ hasPausedExecutions: () => Effect.succeed(false),
294+ getDescription: Effect.succeed(""),
295+};
216296
217297 const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
218298
packages/core/api/src/server/execution-stack.ts+32−1
@@ -35,7 +35,12 @@ import { · 34 unmodified lines
3535 } from "@executor-js/execution";
3636
3737 import { DbProvider } from "./executor-fuma-db";
38−import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-executor";
38+import {
39+ HostConfig,
40+ PluginsProvider,
41+ makePlatformExecutor,
42+ makeScopedExecutor,
43+} from "./scoped-executor";
3944
4045 // ---------------------------------------------------------------------------
4146 // CodeExecutorProvider seam — the host's code-execution substrate. Typed to the
@@ -139,3 +144,29 @@ export const makeExecutionStack = < · 97 unmodified lines
139144 ).pipe(Effect.withSpan("executor.stack.engine.init"));
140145 return { executor, engine };
141146 }).pipe(Effect.withSpan("executor.stack.build"));
147+
148+// ---------------------------------------------------------------------------
149+// makePlatformExecutionStack — the org-credential sibling: a subject-less,
150+// write-refusing executor (`makePlatformExecutor`) and NO engine. An org key is
151+// an observer; the execution engine exists to run code as an acting member, so
152+// rather than building one that would fail on use, the middleware refuses
153+// non-GET requests up front and never provides `ExecutionEngineService` on this
154+// branch. Read-only handlers never read the engine, so the platform branch
155+// serves them unchanged.
156+// ---------------------------------------------------------------------------
157+
158+export const makePlatformExecutionStack = <
159+ const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[],
160+>(
161+ organizationId: string,
162+): Effect.Effect<
163+ { readonly executor: Executor<TPlugins> },
164+ StorageFailure,
165+ DbProvider | PluginsProvider | HostConfig
166+> =>
167+ makePlatformExecutor(organizationId).pipe(
168+ // The platform executor is built against the erased plugin set; re-narrow
169+ // via the same phantom cast `makeScopedExecutor` performs for the scoped one.
170+ Effect.map((executor) => ({ executor: executor as Executor<TPlugins> })),
171+ Effect.withSpan("executor.stack.platform.build"),
172+ );
packages/core/api/src/server/executor-app.ts+7−2
@@ -69,7 +69,12 @@ import { · 68 unmodified lines
6969 type FailureRenderingStrategy,
7070 } from "./execution-stack-middleware";
7171 import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware";
72−import { IdentityProvider, type IdentityFailure, type Principal } from "./identity";
72+import {
73+ IdentityProvider,
74+ type IdentityFailure,
75+ type Principal,
76+ type ResolvedPrincipal,
77+} from "./identity";
7378 import {
7479 makeAccountApiLayer,
7580 makeProtectedApiLayer,
@@ -418,7 +423,7 @@ export const make = < · 342 unmodified lines
418423 // `Unauthorized | NoOrganization | Unavailable`.
419424 const authenticate = (
420425 request: Request,
421− ): Effect.Effect<Principal, IdentityFailure, IdentityProvider> =>
426+ ): Effect.Effect<ResolvedPrincipal, IdentityFailure, IdentityProvider> =>
422427 Effect.flatMap(IdentityProvider.asEffect(), (provider) => provider.authenticate(request));
423428
424429 // The per-request layer combined into the middleware: cloud's `requestScoped`
packages/core/api/src/server/fixed-execution-middleware.ts+28−5
@@ -35,7 +35,14 @@ import type { ExecutionEngine } from "@executor-js/execution"; · 34 unmodified lines
3535
3636 import { ExecutionEngineService, ExecutorService } from "../services";
3737 import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";
38−import { authContextFromPrincipal, AuthContext, type Principal } from "./identity";
38+import {
39+ authContextFromPrincipal,
40+ AuthContext,
41+ isPlatformPrincipal,
42+ ReadOnlyCredential,
43+ type Principal,
44+ type ResolvedPrincipal,
45+} from "./identity";
3946 import type { FailureRenderingStrategy } from "./execution-stack-middleware";
4047
4148 /**
@@ -66,11 +73,13 @@ export interface MakeFixedExecutionMiddlewareOptions< · 24 unmodified lines
6673 /**
6774 * Resolve the inbound web `Request` to a neutral `Principal`. The credential
6875 * shape stays inside this function; local's single-user provider always
69− * resolves the one local Principal.
76+ * resolves the one local Principal. (The seam's type admits a platform
77+ * credential, but a fixed-executor host has no platform view — one resolving
78+ * here is refused below.)
7079 */
71− readonly authenticate: (request: Request) => Effect.Effect<Principal, E, RLong>;
80+ readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, E, RLong>;
7281 /** Render `authenticate` failures (text for local, matching self-host). */
73− readonly strategy: FailureRenderingStrategy<E, RStrategy>;
82+ readonly strategy: FailureRenderingStrategy<E | ReadOnlyCredential, RStrategy>;
7483 }
7584
7685 /**
@@ -108,7 +117,21 @@ export const makeFixedExecutionMiddleware = < · 31 unmodified lines
108117 Effect.gen(function* () {
109118 const request = yield* HttpServerRequest.HttpServerRequest;
110119 const webRequest = yield* HttpServerRequest.toWeb(request);
111− const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest));
120+ const resolved = yield* options.strategy.renderFailure(
121+ options.authenticate(webRequest).pipe(
122+ // The fixed executor binds ONE subject at boot; there is no
123+ // platform view to serve an org credential, so it is refused
124+ // rather than silently acting as the boot subject.
125+ Effect.filterOrFail(
126+ (principal): principal is Principal => !isPlatformPrincipal(principal),
127+ () =>
128+ new ReadOnlyCredential({
129+ code: "read_only_credential",
130+ message: "Organization API keys are not accepted on this host",
131+ }),
132+ ),
133+ ),
134+ );
112135 // The strategy recovered the failure into a Response — return it.
113136 if (!isPrincipal(resolved)) return resolved;
114137 const auth = AuthContext.of(authContextFromPrincipal(resolved));
packages/core/api/src/server/identity.ts+63−3
@@ -45,15 +45,50 @@ export interface Principal { · 44 unmodified lines
4545 readonly roles: readonly string[];
4646 }
4747
48+/**
49+ * An ORG-level credential (cloud's org-scoped API key), resolved. Deliberately
50+ * NOT a `Principal`: a `Principal` names an acting member, and this credential
51+ * has none — mirroring the resolution layer's own split (`ApiKeyOwner` keeps
52+ * `accountId: string | null` for the same reason). Keeping it a separate shape
53+ * means no code path can accidentally bind an org credential to a subject; the
54+ * middleware routes it to the subject-less platform executor instead, and
55+ * refuses every non-read method before a handler ever runs.
56+ *
57+ * Self-host and local never produce this: their credentials always name a
58+ * member. It exists on the NEUTRAL seam so the shared middleware owns the
59+ * platform branch once, instead of each host reinventing it.
60+ */
61+export interface PlatformPrincipal {
62+ readonly kind: "platform";
63+ readonly organizationId: string;
64+ readonly organizationName: string;
65+ /** See {@link Principal.organizationSlug}. */
66+ readonly organizationSlug?: string;
67+ /** The credential's own id, for audit trails — never an acting member. */
68+ readonly keyId: string;
69+}
70+
71+/** What `authenticate` resolves: an acting member, or the org-level platform
72+ * credential. */
73+export type ResolvedPrincipal = Principal | PlatformPrincipal;
74+
75+export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal =>
76+ "kind" in value && value.kind === "platform";
77+
4878 /**
4979 * The single `AuthContext` every executor-API handler reads. The roles-bearing
5080 * tag from self-host is the model; cloud now provides `roles: []` on it, which
5181 * is forward-compatible (cloud handlers never read roles today).
82+ *
83+ * `accountId` is `null` for an org-level platform credential — there is no
84+ * acting member behind it. Nullable rather than a sentinel so any handler that
85+ * needs a member has to say so (and refuse), instead of silently acting as a
86+ * user that does not exist.
5287 */
5388 export class AuthContext extends Context.Service<
5489 AuthContext,
5590 {
56− readonly accountId: string;
91+ readonly accountId: string | null;
5792 readonly organizationId: string;
5893 readonly email: string;
5994 readonly name: string | null;
@@ -72,6 +107,16 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser · 12 unmodified lines
72107 roles: principal.roles,
73108 });
74109
110+/** The platform credential's `AuthContext`: org identity, no member fields. */
111+export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({
112+ accountId: null,
113+ organizationId: principal.organizationId,
114+ email: "",
115+ name: null,
116+ avatarUrl: null,
117+ roles: [],
118+});
119+
75120 // Optional per-failure render hints. Self-host produces the bare error (these
76121 // stay `undefined`) and its text strategy renders a generic body. Cloud fills
77122 // `code` + `message` so its failure strategy can reproduce the exact
@@ -110,6 +155,21 @@ export class Unavailable extends Schema.TaggedErrorClass<Unavailable>()( · 32 unmodified lines
110155 { httpApiStatus: 503 },
111156 ) {}
112157
158+/**
159+ * A valid PLATFORM credential (org-scoped API key) attempted something only an
160+ * acting member can do: a write on the product plane, or any request on a host
161+ * that serves no platform view (local's fixed executor). Renders 403.
162+ *
163+ * Its own tag rather than a reuse of `NoOrganization`: the caller DID present a
164+ * valid credential of a known org — telling them "no organization" would send
165+ * them debugging the wrong thing. The message names the actual constraint.
166+ */
167+export class ReadOnlyCredential extends Schema.TaggedErrorClass<ReadOnlyCredential>()(
168+ "ReadOnlyCredential",
169+ renderHints,
170+ { httpApiStatus: 403 },
171+) {}
172+
113173 /**
114174 * The swap seam. Resolves an incoming request to a `Principal`. WorkOS (cloud)
115175 * and Better Auth (self-host) are interchangeable implementations; nothing
@@ -126,10 +186,10 @@ export class Unavailable extends Schema.TaggedErrorClass<Unavailable>()( · 10 unmodified lines
126186 * Adapter infra defects (cloud's WorkOS / user-store failures) are `Effect.die`d
127187 * INSIDE the impl so they surface as 500 defects, never as this error channel.
128188 */
129−export type IdentityFailure = Unauthorized | NoOrganization | Unavailable;
189+export type IdentityFailure = Unauthorized | NoOrganization | Unavailable | ReadOnlyCredential;
130190
131191 export interface IdentityProviderShape {
132− readonly authenticate: (request: Request) => Effect.Effect<Principal, IdentityFailure>;
192+ readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, IdentityFailure>;
133193 }
134194
135195 export class IdentityProvider extends Context.Service<IdentityProvider, IdentityProviderShape>()(