Unified · Split
| @@ -89,6 +89,7 @@ describe("protected API key auth", () => { · 88 unmodified lines | |||
| 89 | ); | 89 | ); |
| 90 | 90 | ||
| 91 | expect(identity).toEqual({ | 91 | expect(identity).toEqual({ |
| 92 | kind: "member", | ||
| 92 | accountId: "user_123", | 93 | accountId: "user_123", |
| 93 | organizationId: "org_123", | 94 | organizationId: "org_123", |
| 94 | organizationName: "Org org_123", | 95 | organizationName: "Org org_123", |
| @@ -110,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 109 unmodified lines | |||
| 110 | const identity = yield* run(request(token), config); | 110 | const identity = yield* run(request(token), config); |
| 111 | 111 | ||
| 112 | expect(identity).toEqual({ | 112 | expect(identity).toEqual({ |
| 113 | kind: "member", | ||
| 113 | accountId: "user_123", | 114 | accountId: "user_123", |
| 114 | organizationId: "org_123", | 115 | organizationId: "org_123", |
| 115 | organizationName: "Org org_123", | 116 | organizationName: "Org org_123", |
| @@ -145,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 144 unmodified lines | |||
| 145 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); | 145 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); |
| 146 | 146 | ||
| 147 | return { | 147 | return { |
| 148 | kind: "member", | ||
| 148 | accountId: verified.accountId, | 149 | accountId: verified.accountId, |
| 149 | organizationId: org.id, | 150 | organizationId: org.id, |
| 150 | organizationName: org.name, | 151 | organizationName: org.name, |
| @@ -243,6 +244,7 @@ export const resolveBearerAuth = ( · 92 unmodified lines | |||
| 243 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); | 244 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); |
| 244 | 245 | ||
| 245 | return { | 246 | return { |
| 247 | kind: "member", | ||
| 246 | accountId: owner.accountId, | 248 | accountId: owner.accountId, |
| 247 | organizationId: org.id, | 249 | organizationId: org.id, |
| 248 | organizationName: org.name, | 250 | organizationName: org.name, |
| @@ -315,6 +317,7 @@ export const resolveSessionPrincipal = (request: Request) => · 66 unmodified lines | |||
| 315 | const org = yield* authorizeOrganizationSelector(session.userId, selector); | 317 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 316 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 318 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 317 | return { | 319 | return { |
| 320 | kind: "member", | ||
| 318 | accountId: session.userId, | 321 | accountId: session.userId, |
| 319 | organizationId: org.id, | 322 | organizationId: org.id, |
| 320 | organizationName: org.name, | 323 | organizationName: org.name, |
| @@ -38,6 +38,7 @@ export const principalFromAccessClaims = ( · 37 unmodified lines | |||
| 38 | const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); | 38 | const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); |
| 39 | 39 | ||
| 40 | return { | 40 | return { |
| 41 | kind: "member", | ||
| 41 | accountId: sub || email || commonName, | 42 | accountId: sub || email || commonName, |
| 42 | organizationId: config.organizationId, | 43 | organizationId: config.organizationId, |
| 43 | organizationName: config.organizationName, | 44 | organizationName: config.organizationName, |
| @@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { · 24 unmodified lines | |||
| 68 | // fixed admin. Only when explicitly enabled (and the instance is otherwise | 69 | // fixed admin. Only when explicitly enabled (and the instance is otherwise |
| 69 | // unprotected). Mirrors the local app's single-user model. | 70 | // unprotected). Mirrors the local app's single-user model. |
| 70 | const devPrincipal: Principal = { | 71 | const devPrincipal: Principal = { |
| 72 | kind: "member", | ||
| 71 | accountId: "dev", | 73 | accountId: "dev", |
| 72 | organizationId: config.organizationId, | 74 | organizationId: config.organizationId, |
| 73 | organizationName: config.organizationName, | 75 | organizationName: config.organizationName, |
| @@ -67,6 +67,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette · 66 unmodified lines | |||
| 67 | // default to the seeded org rather than rejecting with NoOrganization. | 67 | // default to the seeded org rather than rejecting with NoOrganization. |
| 68 | const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId; | 68 | const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId; |
| 69 | return { | 69 | return { |
| 70 | kind: "member" as const, | ||
| 70 | accountId: resolved.user.id, | 71 | accountId: resolved.user.id, |
| 71 | organizationId: resolvedOrganizationId, | 72 | organizationId: resolvedOrganizationId, |
| 72 | organizationName, | 73 | organizationName, |
| @@ -127,9 +127,8 @@ test("connection reads answer org-owned rows and never a member's personal ones" · 126 unmodified lines | |||
| 127 | new Request("http://localhost/api/connections", { headers: platformHeaders }), | 127 | new Request("http://localhost/api/connections", { headers: platformHeaders }), |
| 128 | ); | 128 | ); |
| 129 | expect(res.status).toBe(200); | 129 | expect(res.status).toBe(200); |
| 130 | const raw = await res.text(); | 130 | const body = (await res.json()) as ReadonlyArray<{ readonly name: string }>; |
| 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 | 131 | const raw = JSON.stringify(body); |
| 132 | const body = JSON.parse(raw) as ReadonlyArray<{ readonly name: string }>; | ||
| 133 | expect( | 132 | expect( |
| 134 | body.map((connection) => connection.name), | 133 | body.map((connection) => connection.name), |
| 135 | "the org-owned connection is visible", | 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 | expect(raw, "no credential material either way").not.toContain("token"); | 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 | test("every non-GET is refused before a handler runs", async () => { | 158 | test("every non-GET is refused before a handler runs", async () => { |
| 145 | const post = await handler( | 159 | const post = await handler( |
| 146 | new Request("http://localhost/api/connections", { | 160 | new Request("http://localhost/api/connections", { |
| @@ -81,6 +81,7 @@ export const singleAdminIdentityLayer = ( · 80 unmodified lines | |||
| 81 | IdentityProvider.of({ | 81 | IdentityProvider.of({ |
| 82 | authenticate: () => | 82 | authenticate: () => |
| 83 | Effect.succeed<Principal>({ | 83 | Effect.succeed<Principal>({ |
| 84 | kind: "member", | ||
| 84 | accountId: options.userId, | 85 | accountId: options.userId, |
| 85 | organizationId: options.organizationId, | 86 | organizationId: options.organizationId, |
| 86 | organizationName: options.organizationName, | 87 | organizationName: options.organizationName, |
| @@ -117,6 +118,7 @@ export const headerIdentityLayer: Layer.Layer<IdentityProvider> = Layer.succeed( · 30 unmodified lines | |||
| 117 | const organizationId = request.headers.get("x-test-org"); | 118 | const organizationId = request.headers.get("x-test-org"); |
| 118 | if (!userId || !organizationId) return Effect.fail(new Unauthorized()); | 119 | if (!userId || !organizationId) return Effect.fail(new Unauthorized()); |
| 119 | return Effect.succeed<Principal>({ | 120 | return Effect.succeed<Principal>({ |
| 121 | kind: "member", | ||
| 120 | accountId: userId, | 122 | accountId: userId, |
| 121 | organizationId, | 123 | organizationId, |
| 122 | organizationName: `Org ${organizationId}`, | 124 | organizationName: `Org ${organizationId}`, |
| @@ -31,6 +31,7 @@ import { safeEqual } from "./serve-shared"; · 30 unmodified lines | |||
| 31 | * cwd-derived (in `app.ts`), independent of these ids. | 31 | * cwd-derived (in `app.ts`), independent of these ids. |
| 32 | */ | 32 | */ |
| 33 | export const LOCAL_PRINCIPAL: Principal = { | 33 | export const LOCAL_PRINCIPAL: Principal = { |
| 34 | kind: "member", | ||
| 34 | accountId: "local", | 35 | accountId: "local", |
| 35 | organizationId: "local", | 36 | organizationId: "local", |
| 36 | organizationName: "Local", | 37 | organizationName: "Local", |
| @@ -35,7 +35,7 @@ · 34 unmodified lines | |||
| 35 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 36 | 36 | ||
| 37 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 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 | import type * as Cause from "effect/Cause"; | 39 | import type * as Cause from "effect/Cause"; |
| 40 | 40 | ||
| 41 | import type { AnyPlugin } from "@executor-js/sdk"; | 41 | import type { AnyPlugin } from "@executor-js/sdk"; |
| @@ -185,15 +185,16 @@ export const makeExecutionStackMiddleware = < · 143 unmodified lines | |||
| 185 | const webRequest = yield* HttpServerRequest.toWeb(request); | 185 | const webRequest = yield* HttpServerRequest.toWeb(request); |
| 186 | const resolved = yield* options.strategy.renderFailure( | 186 | const resolved = yield* options.strategy.renderFailure( |
| 187 | options.authenticate(webRequest).pipe( | 187 | options.authenticate(webRequest).pipe( |
| 188 | // The PLATFORM branch's method gate lives here, before any | 188 | // The PLATFORM branch's gate lives here, before any handler: an |
| 189 | // handler: an org credential is read-only by construction, so a | 189 | // org credential is read-only by construction, so anything that |
| 190 | // non-GET request is refused as a typed 403 rather than reaching | 190 | // is not a safe read is refused as a typed 403 rather than |
| 191 | // a handler that would bind it to a subject or attempt a write | 191 | // reaching a handler that would bind it to a subject or attempt |
| 192 | // (the storage policy would refuse the write anyway — this makes | 192 | // a write (the storage policy would refuse the write anyway — |
| 193 | // the refusal a clear response instead of a 500). | 193 | // this makes the refusal a clear response instead of a 500). |
| 194 | Effect.filterOrFail( | 194 | Effect.filterOrFail( |
| 195 | (principal): principal is ResolvedPrincipal => | 195 | (principal) => |
| 196 | !isPlatformPrincipal(principal) || webRequest.method === "GET", | 196 | !isPlatformPrincipal(principal) || |
| 197 | isPlatformSafeRequest(webRequest.method, new URL(webRequest.url).pathname), | ||
| 197 | () => | 198 | () => |
| 198 | new ReadOnlyCredential({ | 199 | new ReadOnlyCredential({ |
| 199 | code: "read_only_credential", | 200 | code: "read_only_credential", |
| @@ -208,14 +209,14 @@ export const makeExecutionStackMiddleware = < · 8 unmodified lines | |||
| 208 | if (isPlatformPrincipal(resolved)) { | 209 | if (isPlatformPrincipal(resolved)) { |
| 209 | // Org credential: the subject-less, write-refusing platform stack. | 210 | // Org credential: the subject-less, write-refusing platform stack. |
| 210 | // No `touchSubject` runs, so the credential never mints a phantom | 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 | const { executor } = yield* makePlatformExecutionStack<TPlugins>( | 216 | const { executor } = yield* makePlatformExecutionStack<TPlugins>( |
| 213 | resolved.organizationId, | 217 | resolved.organizationId, |
| 214 | ).pipe( | 218 | ).pipe( |
| 215 | Effect.provide(options.stackLayer), | 219 | Effect.provide(options.stackLayer), |
| 216 | Effect.provideService(RequestWebOrigin, { | ||
| 217 | origin: requestWebOriginFromRequest(webRequest), | ||
| 218 | }), | ||
| 219 | Effect.withSpan("executor.stack.http.resolve_platform"), | 220 | Effect.withSpan("executor.stack.http.resolve_platform"), |
| 220 | ); | 221 | ); |
| 221 | return yield* httpEffect.pipe( | 222 | return yield* httpEffect.pipe( |
| @@ -274,24 +275,48 @@ const isPrincipal = ( · 52 unmodified lines | |||
| 274 | ): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value); | 275 | ): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value); |
| 275 | 276 | ||
| 276 | /** | 277 | /** |
| 277 | * The engine the platform branch provides: every read answers "nothing here", | 278 | * Whether a request is a SAFE READ the platform branch may serve. The method |
| 278 | * honestly — the platform view owns no executions and pauses none. The | 279 | * check (GET, plus HEAD — the router serves HEAD off the GET route) is |
| 279 | * execute/resume members exist only to satisfy the service shape; they sit | 280 | * necessary but NOT sufficient: `GET /oauth/callback` is the one core GET with |
| 280 | * behind the middleware's GET-only gate, so reaching one is a wiring bug and | 281 | * side effects — completing it reads an org-owned oauth session, performs an |
| 281 | * dies as a defect rather than failing with a typed error a client could | 282 | * outbound authorization-code exchange with the org's client credentials, and |
| 282 | * mistake for a product answer. | 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 | const readOnlyExecutionEngine: ExecutionEngine<Cause.YieldableError> = { | 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 | 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 |
| 286 | execute: () => Effect.die("platform credential: execution engine unavailable"), | 310 | execute: () => Effect.die(new PlatformEngineUnavailable({ member: "execute" })), |
| 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 | 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 |
| 288 | executeWithPause: () => Effect.die("platform credential: execution engine unavailable"), | 312 | executeWithPause: () => Effect.die(new PlatformEngineUnavailable({ member: "executeWithPause" })), |
| 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 | 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 |
| 290 | resume: () => Effect.die("platform credential: execution engine unavailable"), | 314 | resume: () => Effect.die(new PlatformEngineUnavailable({ member: "resume" })), |
| 291 | getPausedExecution: () => Effect.succeed(null), | 315 | getPausedExecution: () => Effect.succeed(null), |
| 292 | pausedExecutionCount: () => Effect.succeed(0), | 316 | pausedExecutionCount: () => Effect.succeed(0), |
| 293 | hasPausedExecutions: () => Effect.succeed(false), | 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 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); | 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 | // makePlatformExecutionStack — the org-credential sibling: a subject-less, | 149 | // makePlatformExecutionStack — the org-credential sibling: a subject-less, |
| 150 | // write-refusing executor (`makePlatformExecutor`) and NO engine. An org key is | 150 | // write-refusing executor (`makePlatformExecutor`) and no REAL engine. An org |
| 151 | // an observer; the execution engine exists to run code as an acting member, so | 151 | // key is an observer; the execution engine exists to run code as an acting |
| 152 | // rather than building one that would fail on use, the middleware refuses | 152 | // member, so no engine is built here. The middleware still provides |
| 153 | // non-GET requests up front and never provides `ExecutionEngineService` on this | 153 | // `ExecutionEngineService` (handlers' service requirements demand one) — as a |
| 154 | // branch. Read-only handlers never read the engine, so the platform branch | 154 | // stub whose reachable reads answer "nothing here" and whose execute/resume |
| 155 | // serves them unchanged. | 155 | // members sit behind the middleware's safe-request gate (see |
| 156 | // `readOnlyExecutionEngine` in ./execution-stack-middleware.ts). | ||
| 156 | // --------------------------------------------------------------------------- | 157 | // --------------------------------------------------------------------------- |
| 157 | 158 | ||
| 158 | export const makePlatformExecutionStack = < | 159 | export const makePlatformExecutionStack = < |
| @@ -29,6 +29,11 @@ import { Context, Effect, Schema } from "effect"; · 28 unmodified lines | |||
| 29 | * resolver already yielded it) AND `roles` (cloud supplies `[]`). | 29 | * resolver already yielded it) AND `roles` (cloud supplies `[]`). |
| 30 | */ | 30 | */ |
| 31 | export interface Principal { | 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 | readonly accountId: string; | 37 | readonly accountId: string; |
| 33 | readonly organizationId: string; | 38 | readonly organizationId: string; |
| 34 | readonly organizationName: string; | 39 | readonly organizationName: string; |
| @@ -73,24 +78,26 @@ export interface PlatformPrincipal { · 38 unmodified lines | |||
| 73 | export type ResolvedPrincipal = Principal | PlatformPrincipal; | 78 | export type ResolvedPrincipal = Principal | PlatformPrincipal; |
| 74 | 79 | ||
| 75 | export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal => | 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 | * The single `AuthContext` every executor-API handler reads. The roles-bearing | 84 | * The single `AuthContext` every executor-API handler reads. The roles-bearing |
| 80 | * tag from self-host is the model; cloud now provides `roles: []` on it, which | 85 | * tag from self-host is the model; cloud now provides `roles: []` on it, which |
| 81 | * is forward-compatible (cloud handlers never read roles today). | 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 | 88 | * `accountId` and `email` are `null` for an org-level platform credential — |
| 84 | * acting member behind it. Nullable rather than a sentinel so any handler that | 89 | * there is no acting member behind it. Nullable rather than sentinel-valued so |
| 85 | * needs a member has to say so (and refuse), instead of silently acting as a | 90 | * any handler that needs a member has to say so (and refuse), instead of |
| 86 | * user that does not exist. | 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 | export class AuthContext extends Context.Service< | 95 | export class AuthContext extends Context.Service< |
| 89 | AuthContext, | 96 | AuthContext, |
| 90 | { | 97 | { |
| 91 | readonly accountId: string | null; | 98 | readonly accountId: string | null; |
| 92 | readonly organizationId: string; | 99 | readonly organizationId: string; |
| 93 | readonly email: string; | 100 | readonly email: string | null; |
| 94 | readonly name: string | null; | 101 | readonly name: string | null; |
| 95 | readonly avatarUrl: string | null; | 102 | readonly avatarUrl: string | null; |
| 96 | readonly roles: readonly string[]; | 103 | readonly roles: readonly string[]; |
| @@ -111,7 +118,7 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser · 14 unmodified lines | |||
| 111 | export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({ | 118 | export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({ |
| 112 | accountId: null, | 119 | accountId: null, |
| 113 | organizationId: principal.organizationId, | 120 | organizationId: principal.organizationId, |
| 114 | email: "", | 121 | email: null, |
| 115 | name: null, | 122 | name: null, |
| 116 | avatarUrl: null, | 123 | avatarUrl: null, |
| 117 | roles: [], | 124 | roles: [], |
| @@ -3585,6 +3585,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3584 unmodified lines | |||
| 3585 | // Best-effort: a failed rebuild leaves the stale-but-working catalog in | 3585 | // Best-effort: a failed rebuild leaves the stale-but-working catalog in |
| 3586 | // place and retries on the next read. | 3586 | // place and retries on the next read. |
| 3587 | const syncStaleConnectionTools = Effect.gen(function* () { | 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 | const integrations = yield* core.findMany("integration", {}); | 3595 | const integrations = yield* core.findMany("integration", {}); |
| 3589 | if (integrations.length === 0) return; | 3596 | if (integrations.length === 0) return; |
| 3590 | const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); | 3597 | const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); |