Unified · Split
| @@ -124,19 +124,25 @@ describe("org-level API keys", () => { · 123 unmodified lines | ||
| 124 | 124 | }), |
| 125 | 125 | ); |
| 126 | 126 | |
| 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", () => | |
| 128 | 128 | 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), | |
| 133 | 136 | ); |
| 134 | 137 | |
| 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", | |
| 139 | 144 | }); |
| 145 | + expect(principal).not.toHaveProperty("accountId"); | |
| 140 | 146 | }), |
| 141 | 147 | ); |
| 142 | 148 | |
| @@ -38,7 +38,13 @@ import { · 37 unmodified lines | ||
| 38 | 38 | Unauthorized, |
| 39 | 39 | Unavailable, |
| 40 | 40 | } 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"; | |
| 42 | 48 | |
| 43 | 49 | import { ApiKeyService } from "./api-keys"; |
| 44 | 50 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; |
| @@ -104,14 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 59 unmodified lines | ||
| 104 | 110 | code: "no_organization", |
| 105 | 111 | message: "No organization in access token", |
| 106 | 112 | }; |
| 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 | − | |
| 115 | 113 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access |
| 116 | 114 | // token from the CLI device-login); anything else is treated as an API key. |
| 117 | 115 | // Same discriminator the MCP plane uses (`mcp/auth.ts`). |
| @@ -257,23 +255,36 @@ export const resolveBearerAuth = ( · 139 unmodified lines | ||
| 257 | 255 | }); |
| 258 | 256 | |
| 259 | 257 | /** |
| 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.) | |
| 265 | 268 | */ |
| 266 | 269 | export const resolveApiKeyPrincipal = ( |
| 267 | 270 | request: Request, |
| 268 | 271 | jwt: JwtBearerConfig | null = null, |
| 269 | 272 | ): Effect.Effect< |
| 270 | − Principal | null, | |
| 273 | + ResolvedPrincipal | null, | |
| 271 | 274 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 272 | 275 | WorkOSClient | ApiKeyService | UserStoreService |
| 273 | 276 | > => |
| 274 | 277 | Effect.gen(function* () { |
| 275 | 278 | 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 | + } | |
| 277 | 288 | return auth; |
| 278 | 289 | }); |
| 279 | 290 | |
| @@ -330,7 +341,7 @@ export const resolveProtectedPrincipal = ( · 50 unmodified lines | ||
| 330 | 341 | request: Request, |
| 331 | 342 | jwt: JwtBearerConfig | null = null, |
| 332 | 343 | ): Effect.Effect< |
| 333 | − Principal, | |
| 344 | + ResolvedPrincipal, | |
| 334 | 345 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 335 | 346 | WorkOSClient | ApiKeyService | UserStoreService |
| 336 | 347 | > => |
| @@ -411,6 +422,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines | ||
| 411 | 422 | "service_unavailable", |
| 412 | 423 | "Service temporarily unavailable", |
| 413 | 424 | ), |
| 425 | + ReadOnlyCredential: renderIdentityFailure( | |
| 426 | + 403, | |
| 427 | + "read_only_credential", | |
| 428 | + "Organization API keys are read-only", | |
| 429 | + ), | |
| 414 | 430 | }), |
| 415 | 431 | ), |
| 416 | 432 | }; |
| @@ -70,6 +70,7 @@ const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => · 69 unmodified lines | ||
| 70 | 70 | // Mirrors `org/handlers.ts` `requireAdmin`. |
| 71 | 71 | const requireAdmin = Effect.gen(function* () { |
| 72 | 72 | const auth = yield* AuthContext; |
| 73 | + if (auth.accountId === null) return yield* new Forbidden(); | |
| 73 | 74 | const workos = yield* WorkOSClient; |
| 74 | 75 | const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); |
| 75 | 76 | if (!current || current.role?.slug !== "admin") { |
| @@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api"; · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | const requireAdmin = Effect.gen(function* () { |
| 19 | 19 | 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(); | |
| 20 | 25 | const workos = yield* WorkOSClient; |
| 21 | 26 | const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); |
| 22 | 27 | if (!currentMembership || currentMembership.role?.slug !== "admin") { |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins"; |
| 3 | 3 | |
| 4 | −import { IdentityProvider } from "@executor-js/api/server"; | |
| 4 | +import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; | |
| 5 | 5 | import { |
| 6 | 6 | authenticated, |
| 7 | 7 | McpAuthProvider, |
| @@ -233,12 +233,17 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 225 unmodified lines | ||
| 233 | 233 | }).pipe(Effect.orElseSucceed(() => null)); |
| 234 | 234 | |
| 235 | 235 | /** (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. */ | |
| 237 | 240 | const authenticateSession = (request: Request): Effect.Effect<Principal | null> => |
| 238 | 241 | fallback.authenticate(request).pipe( |
| 242 | + Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)), | |
| 239 | 243 | Effect.catchTags({ |
| 240 | 244 | Unauthorized: () => Effect.succeed(null), |
| 241 | 245 | NoOrganization: () => Effect.succeed(null), |
| 246 | + ReadOnlyCredential: () => Effect.succeed(null), | |
| 242 | 247 | }), |
| 243 | 248 | ); |
| 244 | 249 | |
| @@ -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 | +}); | |
| @@ -5,8 +5,10 @@ import { · 4 unmodified lines | ||
| 5 | 5 | composePluginApi, |
| 6 | 6 | ExecutorApp, |
| 7 | 7 | IdentityProvider, |
| 8 | + isPlatformPrincipal, | |
| 8 | 9 | PluginsProvider, |
| 9 | 10 | type Principal, |
| 11 | + type ResolvedPrincipal, | |
| 10 | 12 | textFailureStrategy, |
| 11 | 13 | Unauthorized, |
| 12 | 14 | } from "@executor-js/api/server"; |
| @@ -98,6 +100,19 @@ export const headerIdentityLayer: Layer.Layer<IdentityProvider> = Layer.succeed( · 85 unmodified lines | ||
| 98 | 100 | IdentityProvider, |
| 99 | 101 | IdentityProvider.of({ |
| 100 | 102 | 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 | + } | |
| 101 | 116 | const userId = request.headers.get("x-test-user"); |
| 102 | 117 | const organizationId = request.headers.get("x-test-org"); |
| 103 | 118 | if (!userId || !organizationId) return Effect.fail(new Unauthorized()); |
| @@ -143,12 +158,17 @@ const stubMcpAuth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = Layer · 39 unmodified lines | ||
| 143 | 158 | resourceMetadataUrl: resourceMetadataUrlFor, |
| 144 | 159 | authenticate: (request: Request): Effect.Effect<AuthOutcome> => |
| 145 | 160 | 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. | |
| 146 | 163 | Effect.map((principal) => |
| 147 | − principal ? authenticated(principal) : unauthorized(challengeFor(request)), | |
| 164 | + principal && !isPlatformPrincipal(principal) | |
| 165 | + ? authenticated(principal) | |
| 166 | + : unauthorized(challengeFor(request)), | |
| 148 | 167 | ), |
| 149 | 168 | Effect.catchTags({ |
| 150 | 169 | Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))), |
| 151 | 170 | NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))), |
| 171 | + ReadOnlyCredential: () => Effect.succeed(unauthorized(challengeFor(request))), | |
| 152 | 172 | }), |
| 153 | 173 | ), |
| 154 | 174 | }; |
| @@ -48,6 +48,7 @@ export { RouterConfigLive } from "./server/router-config"; · 47 unmodified lines | ||
| 48 | 48 | export { consoleErrorCapture } from "./server/console-error-capture"; |
| 49 | 49 | export { |
| 50 | 50 | makeExecutionStack, |
| 51 | + makePlatformExecutionStack, | |
| 51 | 52 | CodeExecutorProvider, |
| 52 | 53 | EngineDecorator, |
| 53 | 54 | EngineDecoratorNoop, |
| @@ -95,8 +96,13 @@ export { · 41 unmodified lines | ||
| 95 | 96 | Unauthorized, |
| 96 | 97 | NoOrganization, |
| 97 | 98 | Unavailable, |
| 99 | + ReadOnlyCredential, | |
| 98 | 100 | authContextFromPrincipal, |
| 101 | + authContextFromPlatform, | |
| 102 | + isPlatformPrincipal, | |
| 99 | 103 | type Principal, |
| 104 | + type PlatformPrincipal, | |
| 105 | + type ResolvedPrincipal, | |
| 100 | 106 | type IdentityProviderShape, |
| 101 | 107 | type IdentityFailure, |
| 102 | 108 | } from "./server/identity"; |
| @@ -36,8 +36,10 @@ · 35 unmodified lines | ||
| 36 | 36 | |
| 37 | 37 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 38 | 38 | import { Context, Effect, Layer } from "effect"; |
| 39 | +import type * as Cause from "effect/Cause"; | |
| 39 | 40 | |
| 40 | 41 | import type { AnyPlugin } from "@executor-js/sdk"; |
| 42 | +import type { ExecutionEngine } from "@executor-js/execution"; | |
| 41 | 43 | |
| 42 | 44 | import type { DbProvider } from "./executor-fuma-db"; |
| 43 | 45 | import { |
| @@ -49,13 +51,17 @@ import { · 5 unmodified lines | ||
| 49 | 51 | import { ExecutionEngineService, ExecutorService } from "../services"; |
| 50 | 52 | import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; |
| 51 | 53 | import { |
| 54 | + authContextFromPlatform, | |
| 52 | 55 | authContextFromPrincipal, |
| 53 | 56 | AuthContext, |
| 57 | + isPlatformPrincipal, | |
| 58 | + ReadOnlyCredential, | |
| 54 | 59 | type IdentityFailure, |
| 55 | − type Principal, | |
| 60 | + type ResolvedPrincipal, | |
| 56 | 61 | } from "./identity"; |
| 57 | 62 | import { |
| 58 | 63 | makeExecutionStack, |
| 64 | + makePlatformExecutionStack, | |
| 59 | 65 | type CodeExecutorProvider, |
| 60 | 66 | type EngineDecorator, |
| 61 | 67 | } from "./execution-stack"; |
| @@ -68,9 +74,9 @@ import { · 6 unmodified lines | ||
| 68 | 74 | * residual requirement the strategy adds (always `never` in practice). |
| 69 | 75 | */ |
| 70 | 76 | 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>; | |
| 74 | 80 | } |
| 75 | 81 | |
| 76 | 82 | /** |
| @@ -98,6 +104,15 @@ export const textFailureStrategy: FailureRenderingStrategy<IdentityFailure> = { · 21 unmodified lines | ||
| 98 | 104 | status: 503, |
| 99 | 105 | }), |
| 100 | 106 | ), |
| 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 | + ), | |
| 101 | 116 | }), |
| 102 | 117 | ), |
| 103 | 118 | }; |
| @@ -112,12 +127,14 @@ export interface MakeExecutionStackMiddlewareOptions< · 8 unmodified lines | ||
| 112 | 127 | /** The host's plugin tuple — drives the typed extension Services and binding. */ |
| 113 | 128 | readonly plugins: TPlugins; |
| 114 | 129 | /** |
| 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. | |
| 117 | 134 | */ |
| 118 | − readonly authenticate: (request: Request) => Effect.Effect<Principal, E, RLong>; | |
| 135 | + readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, E, RLong>; | |
| 119 | 136 | /** Render `authenticate` failures (passthrough for cloud, text for self-host). */ |
| 120 | − readonly strategy: FailureRenderingStrategy<E, RStrategy>; | |
| 137 | + readonly strategy: FailureRenderingStrategy<E | ReadOnlyCredential, RStrategy>; | |
| 121 | 138 | /** The host's `makeExecutionStack` seam Layer. */ |
| 122 | 139 | readonly stackLayer: Layer.Layer< |
| 123 | 140 | DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, |
| @@ -166,9 +183,52 @@ export const makeExecutionStackMiddleware = < · 42 unmodified lines | ||
| 166 | 183 | Effect.gen(function* () { |
| 167 | 184 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 168 | 185 | 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 | + ); | |
| 170 | 205 | // The strategy recovered the failure into a Response — return it. |
| 171 | 206 | 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 | + } | |
| 172 | 232 | const auth = AuthContext.of(authContextFromPrincipal(resolved)); |
| 173 | 233 | // The public origin the caller actually hit, so a host with no static |
| 174 | 234 | // web base URL (a Worker) derives one zero-config. An explicit |
| @@ -206,13 +266,33 @@ export const makeExecutionStackMiddleware = < · 31 unmodified lines | ||
| 206 | 266 | ); |
| 207 | 267 | }; |
| 208 | 268 | |
| 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. | |
| 213 | 272 | 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 | +}; | |
| 216 | 296 | |
| 217 | 297 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); |
| 218 | 298 | |
| @@ -35,7 +35,12 @@ import { · 34 unmodified lines | ||
| 35 | 35 | } from "@executor-js/execution"; |
| 36 | 36 | |
| 37 | 37 | 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"; | |
| 39 | 44 | |
| 40 | 45 | // --------------------------------------------------------------------------- |
| 41 | 46 | // CodeExecutorProvider seam — the host's code-execution substrate. Typed to the |
| @@ -139,3 +144,29 @@ export const makeExecutionStack = < · 97 unmodified lines | ||
| 139 | 144 | ).pipe(Effect.withSpan("executor.stack.engine.init")); |
| 140 | 145 | return { executor, engine }; |
| 141 | 146 | }).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 | + ); | |
| @@ -69,7 +69,12 @@ import { · 68 unmodified lines | ||
| 69 | 69 | type FailureRenderingStrategy, |
| 70 | 70 | } from "./execution-stack-middleware"; |
| 71 | 71 | 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"; | |
| 73 | 78 | import { |
| 74 | 79 | makeAccountApiLayer, |
| 75 | 80 | makeProtectedApiLayer, |
| @@ -418,7 +423,7 @@ export const make = < · 342 unmodified lines | ||
| 418 | 423 | // `Unauthorized | NoOrganization | Unavailable`. |
| 419 | 424 | const authenticate = ( |
| 420 | 425 | request: Request, |
| 421 | − ): Effect.Effect<Principal, IdentityFailure, IdentityProvider> => | |
| 426 | + ): Effect.Effect<ResolvedPrincipal, IdentityFailure, IdentityProvider> => | |
| 422 | 427 | Effect.flatMap(IdentityProvider.asEffect(), (provider) => provider.authenticate(request)); |
| 423 | 428 | |
| 424 | 429 | // The per-request layer combined into the middleware: cloud's `requestScoped` |
| @@ -35,7 +35,14 @@ import type { ExecutionEngine } from "@executor-js/execution"; · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | import { ExecutionEngineService, ExecutorService } from "../services"; |
| 37 | 37 | 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"; | |
| 39 | 46 | import type { FailureRenderingStrategy } from "./execution-stack-middleware"; |
| 40 | 47 | |
| 41 | 48 | /** |
| @@ -66,11 +73,13 @@ export interface MakeFixedExecutionMiddlewareOptions< · 24 unmodified lines | ||
| 66 | 73 | /** |
| 67 | 74 | * Resolve the inbound web `Request` to a neutral `Principal`. The credential |
| 68 | 75 | * 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.) | |
| 70 | 79 | */ |
| 71 | − readonly authenticate: (request: Request) => Effect.Effect<Principal, E, RLong>; | |
| 80 | + readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, E, RLong>; | |
| 72 | 81 | /** Render `authenticate` failures (text for local, matching self-host). */ |
| 73 | − readonly strategy: FailureRenderingStrategy<E, RStrategy>; | |
| 82 | + readonly strategy: FailureRenderingStrategy<E | ReadOnlyCredential, RStrategy>; | |
| 74 | 83 | } |
| 75 | 84 | |
| 76 | 85 | /** |
| @@ -108,7 +117,21 @@ export const makeFixedExecutionMiddleware = < · 31 unmodified lines | ||
| 108 | 117 | Effect.gen(function* () { |
| 109 | 118 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 110 | 119 | 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 | + ); | |
| 112 | 135 | // The strategy recovered the failure into a Response — return it. |
| 113 | 136 | if (!isPrincipal(resolved)) return resolved; |
| 114 | 137 | const auth = AuthContext.of(authContextFromPrincipal(resolved)); |
| @@ -45,15 +45,50 @@ export interface Principal { · 44 unmodified lines | ||
| 45 | 45 | readonly roles: readonly string[]; |
| 46 | 46 | } |
| 47 | 47 | |
| 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 | + | |
| 48 | 78 | /** |
| 49 | 79 | * The single `AuthContext` every executor-API handler reads. The roles-bearing |
| 50 | 80 | * tag from self-host is the model; cloud now provides `roles: []` on it, which |
| 51 | 81 | * 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. | |
| 52 | 87 | */ |
| 53 | 88 | export class AuthContext extends Context.Service< |
| 54 | 89 | AuthContext, |
| 55 | 90 | { |
| 56 | − readonly accountId: string; | |
| 91 | + readonly accountId: string | null; | |
| 57 | 92 | readonly organizationId: string; |
| 58 | 93 | readonly email: string; |
| 59 | 94 | readonly name: string | null; |
| @@ -72,6 +107,16 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser · 12 unmodified lines | ||
| 72 | 107 | roles: principal.roles, |
| 73 | 108 | }); |
| 74 | 109 | |
| 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 | + | |
| 75 | 120 | // Optional per-failure render hints. Self-host produces the bare error (these |
| 76 | 121 | // stay `undefined`) and its text strategy renders a generic body. Cloud fills |
| 77 | 122 | // `code` + `message` so its failure strategy can reproduce the exact |
| @@ -110,6 +155,21 @@ export class Unavailable extends Schema.TaggedErrorClass<Unavailable>()( · 32 unmodified lines | ||
| 110 | 155 | { httpApiStatus: 503 }, |
| 111 | 156 | ) {} |
| 112 | 157 | |
| 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 | + | |
| 113 | 173 | /** |
| 114 | 174 | * The swap seam. Resolves an incoming request to a `Principal`. WorkOS (cloud) |
| 115 | 175 | * and Better Auth (self-host) are interchangeable implementations; nothing |
| @@ -126,10 +186,10 @@ export class Unavailable extends Schema.TaggedErrorClass<Unavailable>()( · 10 unmodified lines | ||
| 126 | 186 | * Adapter infra defects (cloud's WorkOS / user-store failures) are `Effect.die`d |
| 127 | 187 | * INSIDE the impl so they surface as 500 defects, never as this error channel. |
| 128 | 188 | */ |
| 129 | −export type IdentityFailure = Unauthorized | NoOrganization | Unavailable; | |
| 189 | +export type IdentityFailure = Unauthorized | NoOrganization | Unavailable | ReadOnlyCredential; | |
| 130 | 190 | |
| 131 | 191 | export interface IdentityProviderShape { |
| 132 | − readonly authenticate: (request: Request) => Effect.Effect<Principal, IdentityFailure>; | |
| 192 | + readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, IdentityFailure>; | |
| 133 | 193 | } |
| 134 | 194 | |
| 135 | 195 | export class IdentityProvider extends Context.Service<IdentityProvider, IdentityProviderShape>()( |