Unified · Split
| @@ -89,6 +89,7 @@ describe("protected API key auth", () => { · 88 unmodified lines | ||
| 89 | 89 | ); |
| 90 | 90 | |
| 91 | 91 | expect(identity).toEqual({ |
| 92 | + kind: "member", | |
| 92 | 93 | accountId: "user_123", |
| 93 | 94 | organizationId: "org_123", |
| 94 | 95 | organizationName: "Org org_123", |
| @@ -110,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 109 unmodified lines | ||
| 110 | 110 | const identity = yield* run(request(token), config); |
| 111 | 111 | |
| 112 | 112 | expect(identity).toEqual({ |
| 113 | + kind: "member", | |
| 113 | 114 | accountId: "user_123", |
| 114 | 115 | organizationId: "org_123", |
| 115 | 116 | organizationName: "Org org_123", |
| @@ -145,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 144 unmodified lines | ||
| 145 | 145 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); |
| 146 | 146 | |
| 147 | 147 | return { |
| 148 | + kind: "member", | |
| 148 | 149 | accountId: verified.accountId, |
| 149 | 150 | organizationId: org.id, |
| 150 | 151 | organizationName: org.name, |
| @@ -243,6 +244,7 @@ export const resolveBearerAuth = ( · 92 unmodified lines | ||
| 243 | 244 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); |
| 244 | 245 | |
| 245 | 246 | return { |
| 247 | + kind: "member", | |
| 246 | 248 | accountId: owner.accountId, |
| 247 | 249 | organizationId: org.id, |
| 248 | 250 | organizationName: org.name, |
| @@ -315,6 +317,7 @@ export const resolveSessionPrincipal = (request: Request) => · 66 unmodified lines | ||
| 315 | 317 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 316 | 318 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 317 | 319 | return { |
| 320 | + kind: "member", | |
| 318 | 321 | accountId: session.userId, |
| 319 | 322 | organizationId: org.id, |
| 320 | 323 | organizationName: org.name, |
| @@ -38,6 +38,7 @@ export const principalFromAccessClaims = ( · 37 unmodified lines | ||
| 38 | 38 | const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); |
| 39 | 39 | |
| 40 | 40 | return { |
| 41 | + kind: "member", | |
| 41 | 42 | accountId: sub || email || commonName, |
| 42 | 43 | organizationId: config.organizationId, |
| 43 | 44 | organizationName: config.organizationName, |
| @@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { · 24 unmodified lines | ||
| 68 | 69 | // fixed admin. Only when explicitly enabled (and the instance is otherwise |
| 69 | 70 | // unprotected). Mirrors the local app's single-user model. |
| 70 | 71 | const devPrincipal: Principal = { |
| 72 | + kind: "member", | |
| 71 | 73 | accountId: "dev", |
| 72 | 74 | organizationId: config.organizationId, |
| 73 | 75 | organizationName: config.organizationName, |
| @@ -67,6 +67,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette · 66 unmodified lines | ||
| 67 | 67 | // default to the seeded org rather than rejecting with NoOrganization. |
| 68 | 68 | const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId; |
| 69 | 69 | return { |
| 70 | + kind: "member" as const, | |
| 70 | 71 | accountId: resolved.user.id, |
| 71 | 72 | organizationId: resolvedOrganizationId, |
| 72 | 73 | organizationName, |
| @@ -127,9 +127,8 @@ test("connection reads answer org-owned rows and never a member's personal ones" · 126 unmodified lines | ||
| 127 | 127 | new Request("http://localhost/api/connections", { headers: platformHeaders }), |
| 128 | 128 | ); |
| 129 | 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 }>; | |
| 130 | + const body = (await res.json()) as ReadonlyArray<{ readonly name: string }>; | |
| 131 | + const raw = JSON.stringify(body); | |
| 133 | 132 | expect( |
| 134 | 133 | body.map((connection) => connection.name), |
| 135 | 134 | "the org-owned connection is visible", |
| @@ -141,6 +140,21 @@ test("connection reads answer org-owned rows and never a member's personal ones" · 5 unmodified lines | ||
| 141 | 140 | expect(raw, "no credential material either way").not.toContain("token"); |
| 142 | 141 | }); |
| 143 | 142 | |
| 143 | +test("the OAuth callback is refused despite being a GET", async () => { | |
| 144 | + // The one core GET with side effects: completing it would burn an org-owned | |
| 145 | + // in-flight authorization code in an outbound token exchange before the | |
| 146 | + // storage policy could refuse the final write. The safe-request gate excludes | |
| 147 | + // it by name; a platform credential landing here is a clear 403, not a | |
| 148 | + // half-executed flow. | |
| 149 | + const res = await handler( | |
| 150 | + new Request("http://localhost/api/oauth/callback?code=x&state=y", { | |
| 151 | + headers: platformHeaders, | |
| 152 | + }), | |
| 153 | + ); | |
| 154 | + expect(res.status, "the callback is not a safe read").toBe(403); | |
| 155 | + expect(await res.text()).toContain("read-only"); | |
| 156 | +}); | |
| 157 | + | |
| 144 | 158 | test("every non-GET is refused before a handler runs", async () => { |
| 145 | 159 | const post = await handler( |
| 146 | 160 | new Request("http://localhost/api/connections", { |
| @@ -81,6 +81,7 @@ export const singleAdminIdentityLayer = ( · 80 unmodified lines | ||
| 81 | 81 | IdentityProvider.of({ |
| 82 | 82 | authenticate: () => |
| 83 | 83 | Effect.succeed<Principal>({ |
| 84 | + kind: "member", | |
| 84 | 85 | accountId: options.userId, |
| 85 | 86 | organizationId: options.organizationId, |
| 86 | 87 | organizationName: options.organizationName, |
| @@ -117,6 +118,7 @@ export const headerIdentityLayer: Layer.Layer<IdentityProvider> = Layer.succeed( · 30 unmodified lines | ||
| 117 | 118 | const organizationId = request.headers.get("x-test-org"); |
| 118 | 119 | if (!userId || !organizationId) return Effect.fail(new Unauthorized()); |
| 119 | 120 | return Effect.succeed<Principal>({ |
| 121 | + kind: "member", | |
| 120 | 122 | accountId: userId, |
| 121 | 123 | organizationId, |
| 122 | 124 | organizationName: `Org ${organizationId}`, |
| @@ -31,6 +31,7 @@ import { safeEqual } from "./serve-shared"; · 30 unmodified lines | ||
| 31 | 31 | * cwd-derived (in `app.ts`), independent of these ids. |
| 32 | 32 | */ |
| 33 | 33 | export const LOCAL_PRINCIPAL: Principal = { |
| 34 | + kind: "member", | |
| 34 | 35 | accountId: "local", |
| 35 | 36 | organizationId: "local", |
| 36 | 37 | organizationName: "Local", |
| @@ -35,7 +35,7 @@ · 34 unmodified lines | ||
| 35 | 35 | // --------------------------------------------------------------------------- |
| 36 | 36 | |
| 37 | 37 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 38 | −import { Context, Effect, Layer } from "effect"; | |
| 38 | +import { Context, Data, Effect, Layer } from "effect"; | |
| 39 | 39 | import type * as Cause from "effect/Cause"; |
| 40 | 40 | |
| 41 | 41 | import type { AnyPlugin } from "@executor-js/sdk"; |
| @@ -185,15 +185,16 @@ export const makeExecutionStackMiddleware = < · 143 unmodified lines | ||
| 185 | 185 | const webRequest = yield* HttpServerRequest.toWeb(request); |
| 186 | 186 | const resolved = yield* options.strategy.renderFailure( |
| 187 | 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). | |
| 188 | + // The PLATFORM branch's gate lives here, before any handler: an | |
| 189 | + // org credential is read-only by construction, so anything that | |
| 190 | + // is not a safe read is refused as a typed 403 rather than | |
| 191 | + // reaching a handler that would bind it to a subject or attempt | |
| 192 | + // a write (the storage policy would refuse the write anyway — | |
| 193 | + // this makes the refusal a clear response instead of a 500). | |
| 194 | 194 | Effect.filterOrFail( |
| 195 | − (principal): principal is ResolvedPrincipal => | |
| 196 | − !isPlatformPrincipal(principal) || webRequest.method === "GET", | |
| 195 | + (principal) => | |
| 196 | + !isPlatformPrincipal(principal) || | |
| 197 | + isPlatformSafeRequest(webRequest.method, new URL(webRequest.url).pathname), | |
| 197 | 198 | () => |
| 198 | 199 | new ReadOnlyCredential({ |
| 199 | 200 | code: "read_only_credential", |
| @@ -208,14 +209,14 @@ export const makeExecutionStackMiddleware = < · 8 unmodified lines | ||
| 208 | 209 | if (isPlatformPrincipal(resolved)) { |
| 209 | 210 | // Org credential: the subject-less, write-refusing platform stack. |
| 210 | 211 | // No `touchSubject` runs, so the credential never mints a phantom |
| 211 | − // user row in the very lists the admin plane serves. | |
| 212 | + // user row in the very lists the admin plane serves. Neither | |
| 213 | + // `RequestWebOrigin` nor `RequestOrgSlug` is provided: both feed | |
| 214 | + // browser-handoff URL construction, which only interactive member | |
| 215 | + // flows perform, and `makePlatformExecutor` reads neither. | |
| 212 | 216 | const { executor } = yield* makePlatformExecutionStack<TPlugins>( |
| 213 | 217 | resolved.organizationId, |
| 214 | 218 | ).pipe( |
| 215 | 219 | Effect.provide(options.stackLayer), |
| 216 | − Effect.provideService(RequestWebOrigin, { | |
| 217 | − origin: requestWebOriginFromRequest(webRequest), | |
| 218 | − }), | |
| 219 | 220 | Effect.withSpan("executor.stack.http.resolve_platform"), |
| 220 | 221 | ); |
| 221 | 222 | return yield* httpEffect.pipe( |
| @@ -274,24 +275,48 @@ const isPrincipal = ( · 52 unmodified lines | ||
| 274 | 275 | ): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value); |
| 275 | 276 | |
| 276 | 277 | /** |
| 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. | |
| 278 | + * Whether a request is a SAFE READ the platform branch may serve. The method | |
| 279 | + * check (GET, plus HEAD — the router serves HEAD off the GET route) is | |
| 280 | + * necessary but NOT sufficient: `GET /oauth/callback` is the one core GET with | |
| 281 | + * side effects — completing it reads an org-owned oauth session, performs an | |
| 282 | + * outbound authorization-code exchange with the org's client credentials, and | |
| 283 | + * then attempts the connection write. A read-only credential must be refused | |
| 284 | + * BEFORE that exchange burns the in-flight code, not after the storage policy | |
| 285 | + * rejects the final write, so the callback path is excluded by name. It is a | |
| 286 | + * browser-redirect surface — no legitimate machine-credential caller lands on | |
| 287 | + * it with a Bearer header. | |
| 288 | + */ | |
| 289 | +const isPlatformSafeRequest = (method: string, pathname: string): boolean => | |
| 290 | + (method === "GET" || method === "HEAD") && !pathname.endsWith("/oauth/callback"); | |
| 291 | + | |
| 292 | +/** Reaching an engine member the platform branch cannot honestly serve is a | |
| 293 | + * wiring bug (the safe-request gate keeps those paths unreachable), so it | |
| 294 | + * dies as a tagged defect rather than failing with a typed error a client | |
| 295 | + * could mistake for a product answer. */ | |
| 296 | +class PlatformEngineUnavailable extends Data.TaggedError("PlatformEngineUnavailable")<{ | |
| 297 | + readonly member: string; | |
| 298 | +}> {} | |
| 299 | + | |
| 300 | +/** | |
| 301 | + * The engine the platform branch provides. The one member a safe read can | |
| 302 | + * actually reach — `getPausedExecution`, via `GET /executions/:id` — answers | |
| 303 | + * null (a 404), honestly: the platform view owns no executions. The paused | |
| 304 | + * counters answer the same empty story for any future reader. Everything else | |
| 305 | + * (execute, resume, the MCP tool description) exists to satisfy the service | |
| 306 | + * shape, sits behind the middleware's safe-request gate, and dies if reached. | |
| 283 | 307 | */ |
| 284 | 308 | 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"), | |
| 309 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome | |
| 310 | + execute: () => Effect.die(new PlatformEngineUnavailable({ member: "execute" })), | |
| 311 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome | |
| 312 | + executeWithPause: () => Effect.die(new PlatformEngineUnavailable({ member: "executeWithPause" })), | |
| 313 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome | |
| 314 | + resume: () => Effect.die(new PlatformEngineUnavailable({ member: "resume" })), | |
| 291 | 315 | getPausedExecution: () => Effect.succeed(null), |
| 292 | 316 | pausedExecutionCount: () => Effect.succeed(0), |
| 293 | 317 | hasPausedExecutions: () => Effect.succeed(false), |
| 294 | − getDescription: Effect.succeed(""), | |
| 318 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: only the MCP tool server reads this, and the MCP plane never serves a platform credential | |
| 319 | + getDescription: Effect.die(new PlatformEngineUnavailable({ member: "getDescription" })), | |
| 295 | 320 | }; |
| 296 | 321 | |
| 297 | 322 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); |
| @@ -147,12 +147,13 @@ export const makeExecutionStack = < · 146 unmodified lines | ||
| 147 | 147 | |
| 148 | 148 | // --------------------------------------------------------------------------- |
| 149 | 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. | |
| 150 | +// write-refusing executor (`makePlatformExecutor`) and no REAL engine. An org | |
| 151 | +// key is an observer; the execution engine exists to run code as an acting | |
| 152 | +// member, so no engine is built here. The middleware still provides | |
| 153 | +// `ExecutionEngineService` (handlers' service requirements demand one) — as a | |
| 154 | +// stub whose reachable reads answer "nothing here" and whose execute/resume | |
| 155 | +// members sit behind the middleware's safe-request gate (see | |
| 156 | +// `readOnlyExecutionEngine` in ./execution-stack-middleware.ts). | |
| 156 | 157 | // --------------------------------------------------------------------------- |
| 157 | 158 | |
| 158 | 159 | export const makePlatformExecutionStack = < |
| @@ -29,6 +29,11 @@ import { Context, Effect, Schema } from "effect"; · 28 unmodified lines | ||
| 29 | 29 | * resolver already yielded it) AND `roles` (cloud supplies `[]`). |
| 30 | 30 | */ |
| 31 | 31 | export interface Principal { |
| 32 | + /** Discriminant of {@link ResolvedPrincipal}: an acting member, as opposed | |
| 33 | + * to the org-level `"platform"` credential. Required so every construction | |
| 34 | + * site declares which arm it is, and the union matches on a literal tag | |
| 35 | + * instead of probing for a property's presence. */ | |
| 36 | + readonly kind: "member"; | |
| 32 | 37 | readonly accountId: string; |
| 33 | 38 | readonly organizationId: string; |
| 34 | 39 | readonly organizationName: string; |
| @@ -73,24 +78,26 @@ export interface PlatformPrincipal { · 38 unmodified lines | ||
| 73 | 78 | export type ResolvedPrincipal = Principal | PlatformPrincipal; |
| 74 | 79 | |
| 75 | 80 | export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal => |
| 76 | − "kind" in value && value.kind === "platform"; | |
| 81 | + value.kind === "platform"; | |
| 77 | 82 | |
| 78 | 83 | /** |
| 79 | 84 | * The single `AuthContext` every executor-API handler reads. The roles-bearing |
| 80 | 85 | * tag from self-host is the model; cloud now provides `roles: []` on it, which |
| 81 | 86 | * is forward-compatible (cloud handlers never read roles today). |
| 82 | 87 | * |
| 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. | |
| 88 | + * `accountId` and `email` are `null` for an org-level platform credential — | |
| 89 | + * there is no acting member behind it. Nullable rather than sentinel-valued so | |
| 90 | + * any handler that needs a member has to say so (and refuse), instead of | |
| 91 | + * silently acting as a user that does not exist. (`email: ""` on the member | |
| 92 | + * api-key path is the pre-existing "member with no resolved email" value and | |
| 93 | + * unrelated to this.) | |
| 87 | 94 | */ |
| 88 | 95 | export class AuthContext extends Context.Service< |
| 89 | 96 | AuthContext, |
| 90 | 97 | { |
| 91 | 98 | readonly accountId: string | null; |
| 92 | 99 | readonly organizationId: string; |
| 93 | − readonly email: string; | |
| 100 | + readonly email: string | null; | |
| 94 | 101 | readonly name: string | null; |
| 95 | 102 | readonly avatarUrl: string | null; |
| 96 | 103 | readonly roles: readonly string[]; |
| @@ -111,7 +118,7 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser · 14 unmodified lines | ||
| 111 | 118 | export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({ |
| 112 | 119 | accountId: null, |
| 113 | 120 | organizationId: principal.organizationId, |
| 114 | − email: "", | |
| 121 | + email: null, | |
| 115 | 122 | name: null, |
| 116 | 123 | avatarUrl: null, |
| 117 | 124 | roles: [], |
| @@ -3585,6 +3585,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3584 unmodified lines | ||
| 3585 | 3585 | // Best-effort: a failed rebuild leaves the stale-but-working catalog in |
| 3586 | 3586 | // place and retries on the next read. |
| 3587 | 3587 | const syncStaleConnectionTools = Effect.gen(function* () { |
| 3588 | + // The platform view can never persist a rebuilt catalog (writes are | |
| 3589 | + // denied at the storage boundary), so attempting the sync would only | |
| 3590 | + // fire upstream `resolveTools` calls whose results are thrown away — | |
| 3591 | + // network side effects on a read-only credential. Skip it entirely: | |
| 3592 | + // read-only-ness of the platform read path is a stated invariant here, | |
| 3593 | + // not an accident of the best-effort catch below. | |
| 3594 | + if (config.platformView === true) return; | |
| 3588 | 3595 | const integrations = yield* core.findMany("integration", {}); |
| 3589 | 3596 | if (integrations.length === 0) return; |
| 3590 | 3597 | const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); |