Unified · Split
| @@ -5,6 +5,7 @@ · 4 unmodified lines | |||
| 5 | "fixed": [ | 5 | "fixed": [ |
| 6 | [ | 6 | [ |
| 7 | "@executor-js/sdk", | 7 | "@executor-js/sdk", |
| 8 | "@executor-js/product-access", | ||
| 8 | "@executor-js/codemode-core", | 9 | "@executor-js/codemode-core", |
| 9 | "@executor-js/runtime-quickjs", | 10 | "@executor-js/runtime-quickjs", |
| 10 | "@executor-js/execution", | 11 | "@executor-js/execution", |
| @@ -0,0 +1,21 @@ | |||
| 1 | --- | ||
| 2 | "executor": minor | ||
| 3 | --- | ||
| 4 | |||
| 5 | Extract personal/organization access rules from the core SDK into the new | ||
| 6 | `@executor-js/product-access` package. BREAKING for SDK embedders: | ||
| 7 | `createExecutor` (Effect and Promise APIs alike) now REQUIRES | ||
| 8 | `access: ExecutorAccess` — the product's decisions for row visibility and | ||
| 9 | write partitions (`owners`), user-intent settings authorization | ||
| 10 | (`settingsWrite`), view capabilities (`adminReads` / `storageWrites`), and | ||
| 11 | effective tool-policy evaluation (`toolPolicy`). The former `orgWrites` and | ||
| 12 | `platformView` options are removed; select a posture from | ||
| 13 | `@executor-js/product-access` (`singleUserAccess()`, | ||
| 14 | `workspaceServiceAccess()`, `memberAccessForRole(...)`, | ||
| 15 | `requestBoundMemberAccess()`, `platformObserverAccess()`) or supply your own. | ||
| 16 | Core keeps enforcing tenant isolation, the storage owner policy clamps, and | ||
| 17 | approval mechanics; it no longer decides any product rule. Test helpers | ||
| 18 | changed too: `makeTestConfig` and the workspace harness require `access` | ||
| 19 | (postures in `@executor-js/product-access/testing`). No stored schema or data | ||
| 20 | migration — persisted rows, tenants, owners, subjects and addresses are | ||
| 21 | unchanged. | ||
| @@ -149,6 +149,7 @@ jobs: · 148 unmodified lines | |||
| 149 | './apps/cli/dist/executor' | 149 | './apps/cli/dist/executor' |
| 150 | './packages/core/storage-core' | 150 | './packages/core/storage-core' |
| 151 | './packages/core/sdk' | 151 | './packages/core/sdk' |
| 152 | './packages/core/product-access' | ||
| 152 | './packages/core/config' | 153 | './packages/core/config' |
| 153 | './packages/core/execution' | 154 | './packages/core/execution' |
| 154 | './packages/core/cli' | 155 | './packages/core/cli' |
| @@ -139,10 +139,18 @@ Embed Executor with the TypeScript SDK (a Promise API; an Effect-native API is · 138 unmodified lines | |||
| 139 | also available): | 139 | also available): |
| 140 | 140 | ||
| 141 | ```ts | 141 | ```ts |
| 142 | import { createExecutor } from "@executor-js/sdk/promise"; | 142 | import { workspaceServiceAccess } from "@executor-js/product-access"; |
| 143 | import { openApiPlugin } from "@executor-js/plugin-openapi/promise"; | 143 | import { createExecutor } from "@executor-js/sdk"; |
| 144 | 144 | import { openApiPlugin } from "@executor-js/plugin-openapi"; | |
| 145 | const executor = await createExecutor({ plugins: [openApiPlugin()] }); | 145 | |
| 146 | const executor = await createExecutor({ | ||
| 147 | plugins: [openApiPlugin()], | ||
| 148 | onElicitation: "accept-all", | ||
| 149 | // Product access decisions are REQUIRED and stated explicitly — the SDK | ||
| 150 | // ships no default posture. `@executor-js/product-access` has the standard | ||
| 151 | // ones (this is the subject-less single-workspace posture). | ||
| 152 | access: workspaceServiceAccess(), | ||
| 153 | }); | ||
| 146 | 154 | ||
| 147 | // add an integration, create a connection, then list and call tools | 155 | // add an integration, create a connection, then list and call tools |
| 148 | const tools = await executor.tools.list({ integration: "inventory" }); | 156 | const tools = await executor.tools.list({ integration: "inventory" }); |
| @@ -55,6 +55,7 @@ · 54 unmodified lines | |||
| 55 | "@executor-js/plugin-openapi": "workspace:*", | 55 | "@executor-js/plugin-openapi": "workspace:*", |
| 56 | "@executor-js/plugin-toolkits": "workspace:*", | 56 | "@executor-js/plugin-toolkits": "workspace:*", |
| 57 | "@executor-js/plugin-workos-vault": "workspace:*", | 57 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 58 | "@executor-js/product-access": "workspace:*", | ||
| 58 | "@executor-js/react": "workspace:*", | 59 | "@executor-js/react": "workspace:*", |
| 59 | "@executor-js/runtime-dynamic-worker": "workspace:*", | 60 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 60 | "@executor-js/runtime-quickjs": "workspace:*", | 61 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -17,7 +17,7 @@ · 16 unmodified lines | |||
| 17 | // acting member, and this plane deliberately serves the whole tenant. | 17 | // acting member, and this plane deliberately serves the whole tenant. |
| 18 | // | 18 | // |
| 19 | // The executor is built by `makePlatformExecutor` — `{ tenant, subject: | 19 | // The executor is built by `makePlatformExecutor` — `{ tenant, subject: |
| 20 | // undefined, platformView: true }` — so the reads are tenant-wide and read-only | 20 | // undefined, access: platformObserverAccess() }` — so the reads are tenant-wide and read-only |
| 21 | // by storage policy, and no `subject` row is minted for the caller. | 21 | // by storage policy, and no `subject` row is minted for the caller. |
| 22 | // | 22 | // |
| 23 | // Cross-tenant isolation is structural, not a check in this file: the tenant is | 23 | // Cross-tenant isolation is structural, not a check in this file: the tenant is |
| @@ -174,7 +174,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 173 unmodified lines | |||
| 174 | * `subject: null` + the read-only tenant reach rather than inventing a subject. | 174 | * `subject: null` + the read-only tenant reach rather than inventing a subject. |
| 175 | * | 175 | * |
| 176 | * The `/admin/*` mount turns this into an executor with `{ tenant: | 176 | * The `/admin/*` mount turns this into an executor with `{ tenant: |
| 177 | * organizationId, subject: undefined, platformView: true }`. | 177 | * organizationId, subject: undefined, access: platformObserverAccess() }`. |
| 178 | */ | 178 | */ |
| 179 | export interface PlatformAuth { | 179 | export interface PlatformAuth { |
| 180 | readonly kind: "platform"; | 180 | readonly kind: "platform"; |
| @@ -39,6 +39,7 @@ import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/test · 38 unmodified lines | |||
| 39 | import executorConfig from "../executor.config"; | 39 | import executorConfig from "../executor.config"; |
| 40 | import { DbService } from "./db/db"; | 40 | import { DbService } from "./db/db"; |
| 41 | import { createDrizzleFumaDb } from "./db/fuma"; | 41 | import { createDrizzleFumaDb } from "./db/fuma"; |
| 42 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 42 | 43 | ||
| 43 | // --------------------------------------------------------------------------- | 44 | // --------------------------------------------------------------------------- |
| 44 | // Test-only plugin: exposes one in-memory tool that elicits once. Lets the | 45 | // Test-only plugin: exposes one in-memory tool that elicits once. Lets the |
| @@ -124,6 +125,7 @@ const buildScopedExecutor = ( · 79 unmodified lines | |||
| 124 | plugins, | 125 | plugins, |
| 125 | httpClientLayer: FetchHttpClient.layer, | 126 | httpClientLayer: FetchHttpClient.layer, |
| 126 | onElicitation: "accept-all", | 127 | onElicitation: "accept-all", |
| 128 | access: testAccess.member(), | ||
| 127 | }); | 129 | }); |
| 128 | }); | 130 | }); |
| 129 | 131 | ||
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | } from "@executor-js/host-mcp/tool-server"; | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 29 | import { requestBoundMemberAccess } from "@executor-js/product-access"; | ||
| 29 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | 30 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| 30 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; | 31 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 31 | import { | 32 | import { |
| @@ -349,7 +350,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 317 unmodified lines | |||
| 349 | sessionMeta.organizationName, | 350 | sessionMeta.organizationName, |
| 350 | { | 351 | { |
| 351 | mcpResource: sessionMeta.resource, | 352 | mcpResource: sessionMeta.resource, |
| 352 | orgWrites: "request", | 353 | access: requestBoundMemberAccess(), |
| 353 | }, | 354 | }, |
| 354 | ).pipe( | 355 | ).pipe( |
| 355 | // The metered stack tracks each execution to Autumn. It requires | 356 | // The metered stack tracks each execution to Autumn. It requires |
| @@ -32,6 +32,7 @@ import { createExecutor } from "@executor-js/sdk"; · 31 unmodified lines | |||
| 32 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 32 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 33 | 33 | ||
| 34 | import { UrlRedactingSpanProcessor } from "./redact-span-urls"; | 34 | import { UrlRedactingSpanProcessor } from "./redact-span-urls"; |
| 35 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 35 | 36 | ||
| 36 | // Synthetic placeholders — never a real authorization code or CSRF state. | 37 | // Synthetic placeholders — never a real authorization code or CSRF state. |
| 37 | const CODE = "synthetic-authorization-code-9f2c"; | 38 | const CODE = "synthetic-authorization-code-9f2c"; |
| @@ -55,7 +56,7 @@ describe("oauth callback telemetry", () => { · 17 unmodified lines | |||
| 55 | it.effect("exports no span attribute containing the authorization code or state", () => | 56 | it.effect("exports no span attribute containing the authorization code or state", () => |
| 56 | Effect.gen(function* () { | 57 | Effect.gen(function* () { |
| 57 | const { exporter, provider, tracerLayer } = makeTracing(); | 58 | const { exporter, provider, tracerLayer } = makeTracing(); |
| 58 | const executor = yield* createExecutor(makeTestConfig({})); | 59 | const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() })); |
| 59 | 60 | ||
| 60 | const web = yield* Effect.acquireRelease( | 61 | const web = yield* Effect.acquireRelease( |
| 61 | Effect.sync(() => | 62 | Effect.sync(() => |
| @@ -30,6 +30,7 @@ · 29 unmodified lines | |||
| 30 | "@executor-js/plugin-openapi": "workspace:*", | 30 | "@executor-js/plugin-openapi": "workspace:*", |
| 31 | "@executor-js/plugin-provider-service-split": "workspace:*", | 31 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 32 | "@executor-js/plugin-toolkits": "workspace:*", | 32 | "@executor-js/plugin-toolkits": "workspace:*", |
| 33 | "@executor-js/product-access": "workspace:*", | ||
| 33 | "@executor-js/react": "workspace:*", | 34 | "@executor-js/react": "workspace:*", |
| 34 | "@executor-js/runtime-dynamic-worker": "workspace:*", | 35 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 35 | "@executor-js/runtime-quickjs": "workspace:*", | 36 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -9,6 +9,7 @@ import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; · 8 unmodified lines | |||
| 9 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | 9 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| 10 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; | 10 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 11 | import type { ExecutorDbHandle } from "@executor-js/api/server"; | 11 | import type { ExecutorDbHandle } from "@executor-js/api/server"; |
| 12 | import { requestBoundMemberAccess } from "@executor-js/product-access"; | ||
| 12 | import { | 13 | import { |
| 13 | McpAgentSessionDOBase, | 14 | McpAgentSessionDOBase, |
| 14 | type BuiltMcpServer, | 15 | type BuiltMcpServer, |
| @@ -151,7 +152,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 136 unmodified lines | |||
| 151 | sessionMeta.userId, | 152 | sessionMeta.userId, |
| 152 | sessionMeta.organizationId, | 153 | sessionMeta.organizationId, |
| 153 | sessionMeta.organizationName, | 154 | sessionMeta.organizationName, |
| 154 | { mcpResource: sessionMeta.resource, orgWrites: "request" }, | 155 | { mcpResource: sessionMeta.resource, access: requestBoundMemberAccess() }, |
| 155 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | 156 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); |
| 156 | // Browser elicitation mode (the base owns the approval store + the HTTP | 157 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 157 | // approval RPCs): a gated execution pauses and returns an approvalUrl into | 158 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| @@ -35,6 +35,7 @@ · 34 unmodified lines | |||
| 35 | "@executor-js/plugin-openapi": "workspace:*", | 35 | "@executor-js/plugin-openapi": "workspace:*", |
| 36 | "@executor-js/plugin-provider-service-split": "workspace:*", | 36 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 37 | "@executor-js/plugin-toolkits": "workspace:*", | 37 | "@executor-js/plugin-toolkits": "workspace:*", |
| 38 | "@executor-js/product-access": "workspace:*", | ||
| 38 | "@executor-js/react": "workspace:*", | 39 | "@executor-js/react": "workspace:*", |
| 39 | "@executor-js/runtime-quickjs": "workspace:*", | 40 | "@executor-js/runtime-quickjs": "workspace:*", |
| 40 | "@executor-js/sdk": "workspace:*", | 41 | "@executor-js/sdk": "workspace:*", |
| @@ -12,6 +12,7 @@ import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; · 11 unmodified lines | |||
| 12 | import { mintInviteCode } from "./testing/mint-invite"; | 12 | import { mintInviteCode } from "./testing/mint-invite"; |
| 13 | import { SelfHostScopedExecutorSeams } from "./execution"; | 13 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 14 | import type { SelfHostPlugins } from "./plugins"; | 14 | import type { SelfHostPlugins } from "./plugins"; |
| 15 | import { memberAccess } from "@executor-js/product-access"; | ||
| 15 | 16 | ||
| 16 | // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, | 17 | // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, |
| 17 | // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, | 18 | // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, |
| @@ -22,9 +23,9 @@ const createScopedExecutor = ( · 4 unmodified lines | |||
| 22 | organizationId: string, | 23 | organizationId: string, |
| 23 | organizationName: string, | 24 | organizationName: string, |
| 24 | ) => | 25 | ) => |
| 25 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe( | 26 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, { |
| 26 | Effect.provide(SelfHostScopedExecutorSeams), | 27 | access: memberAccess("allowed"), |
| 27 | ); | 28 | }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); |
| 28 | 29 | ||
| 29 | // End-to-end: an org-owned connection's tools are reachable from a user's MCP | 30 | // End-to-end: an org-owned connection's tools are reachable from a user's MCP |
| 30 | // `execute` sandbox. | 31 | // `execute` sandbox. |
| @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 10 unmodified lines | |||
| 11 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; | 11 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 12 | import { SelfHostScopedExecutorSeams } from "./execution"; | 12 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 13 | import type { SelfHostPlugins } from "./plugins"; | 13 | import type { SelfHostPlugins } from "./plugins"; |
| 14 | import { memberAccess } from "@executor-js/product-access"; | ||
| 14 | 15 | ||
| 15 | // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, | 16 | // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, |
| 16 | // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, | 17 | // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, |
| @@ -21,9 +22,9 @@ const createScopedExecutor = ( · 4 unmodified lines | |||
| 21 | organizationId: string, | 22 | organizationId: string, |
| 22 | organizationName: string, | 23 | organizationName: string, |
| 23 | ) => | 24 | ) => |
| 24 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe( | 25 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, { |
| 25 | Effect.provide(SelfHostScopedExecutorSeams), | 26 | access: memberAccess("allowed"), |
| 26 | ); | 27 | }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); |
| 27 | 28 | ||
| 28 | const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); | 29 | const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); |
| 29 | process.env.EXECUTOR_DATA_DIR = dataDir; | 30 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -12,6 +12,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 11 unmodified lines | |||
| 12 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; | 12 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 13 | import { SelfHostScopedExecutorSeams } from "./execution"; | 13 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 14 | import type { SelfHostPlugins } from "./plugins"; | 14 | import type { SelfHostPlugins } from "./plugins"; |
| 15 | import { memberAccess } from "@executor-js/product-access"; | ||
| 15 | 16 | ||
| 16 | // In v2 a connection IS the credential: its inline `value` is written through the | 17 | // In v2 a connection IS the credential: its inline `value` is written through the |
| 17 | // default writable provider — here the encrypted-secrets provider, which stores | 18 | // default writable provider — here the encrypted-secrets provider, which stores |
| @@ -28,9 +29,9 @@ const createScopedExecutor = ( · 10 unmodified lines | |||
| 28 | organizationId: string, | 29 | organizationId: string, |
| 29 | organizationName: string, | 30 | organizationName: string, |
| 30 | ) => | 31 | ) => |
| 31 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe( | 32 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, { |
| 32 | Effect.provide(SelfHostScopedExecutorSeams), | 33 | access: memberAccess("allowed"), |
| 33 | ); | 34 | }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); |
| 34 | 35 | ||
| 35 | let dbLayer!: Layer.Layer<SelfHostDb>; | 36 | let dbLayer!: Layer.Layer<SelfHostDb>; |
| 36 | let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; | 37 | let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; |
| @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 10 unmodified lines | |||
| 11 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; | 11 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 12 | import { SelfHostScopedExecutorSeams } from "./execution"; | 12 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 13 | import type { SelfHostPlugins } from "./plugins"; | 13 | import type { SelfHostPlugins } from "./plugins"; |
| 14 | import { memberAccess } from "@executor-js/product-access"; | ||
| 14 | 15 | ||
| 15 | // The `subject` table is populated at the request seam: `makeScopedExecutor` is | 16 | // The `subject` table is populated at the request seam: `makeScopedExecutor` is |
| 16 | // what every HTTP request and MCP session on every host passes through, so | 17 | // what every HTTP request and MCP session on every host passes through, so |
| @@ -19,9 +20,9 @@ import type { SelfHostPlugins } from "./plugins"; · 2 unmodified lines | |||
| 19 | // the point is that the production wiring reaches the writer at all. | 20 | // the point is that the production wiring reaches the writer at all. |
| 20 | 21 | ||
| 21 | const createScopedExecutor = (accountId: string, organizationId: string) => | 22 | const createScopedExecutor = (accountId: string, organizationId: string) => |
| 22 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe( | 23 | makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default", { |
| 23 | Effect.provide(SelfHostScopedExecutorSeams), | 24 | access: memberAccess("allowed"), |
| 24 | ); | 25 | }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); |
| 25 | 26 | ||
| 26 | const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); | 27 | const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); |
| 27 | process.env.EXECUTOR_DATA_DIR = dataDir; | 28 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -41,6 +41,7 @@ · 40 unmodified lines | |||
| 41 | "@executor-js/plugin-openapi": "workspace:*", | 41 | "@executor-js/plugin-openapi": "workspace:*", |
| 42 | "@executor-js/plugin-provider-service-split": "workspace:*", | 42 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 43 | "@executor-js/plugin-toolkits": "workspace:*", | 43 | "@executor-js/plugin-toolkits": "workspace:*", |
| 44 | "@executor-js/product-access": "workspace:*", | ||
| 44 | "@executor-js/react": "workspace:*", | 45 | "@executor-js/react": "workspace:*", |
| 45 | "@executor-js/runtime-quickjs": "workspace:*", | 46 | "@executor-js/runtime-quickjs": "workspace:*", |
| 46 | "@executor-js/sdk": "workspace:*", | 47 | "@executor-js/sdk": "workspace:*", |
| @@ -61,6 +61,7 @@ import { · 60 unmodified lines | |||
| 61 | createExecutor, | 61 | createExecutor, |
| 62 | } from "@executor-js/sdk"; | 62 | } from "@executor-js/sdk"; |
| 63 | import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 63 | import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 64 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 64 | 65 | ||
| 65 | import { ErrorCaptureLive } from "./observability"; | 66 | import { ErrorCaptureLive } from "./observability"; |
| 66 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; | 67 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; |
| @@ -109,6 +110,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 42 unmodified lines | |||
| 109 | db: sqlite.db, | 110 | db: sqlite.db, |
| 110 | plugins, | 111 | plugins, |
| 111 | onElicitation: "accept-all", | 112 | onElicitation: "accept-all", |
| 113 | access: testAccess.member(), | ||
| 112 | }), | 114 | }), |
| 113 | ); | 115 | ); |
| 114 | 116 | ||
| @@ -17,13 +17,21 @@ import { Effect } from "effect"; · 16 unmodified lines | |||
| 17 | import { withQueryContext } from "@executor-js/fumadb/query"; | 17 | import { withQueryContext } from "@executor-js/fumadb/query"; |
| 18 | 18 | ||
| 19 | import { collectTables } from "@executor-js/api/server"; | 19 | import { collectTables } from "@executor-js/api/server"; |
| 20 | import { runSqliteDataMigrations } from "@executor-js/sdk"; | 20 | import { runSqliteDataMigrations, type ExecutorOwnerPolicyContext } from "@executor-js/sdk"; |
| 21 | 21 | ||
| 22 | import { localDataMigrations } from "./data-migrations"; | 22 | import { localDataMigrations } from "./data-migrations"; |
| 23 | import { createSqliteFumaDb } from "./sqlite-fumadb"; | 23 | import { createSqliteFumaDb } from "./sqlite-fumadb"; |
| 24 | 24 | ||
| 25 | const TENANT = "executor-workspace-1771"; | 25 | const TENANT = "executor-workspace-1771"; |
| 26 | const SUBJECT = "local"; | 26 | const SUBJECT = "local"; |
| 27 | // The fixture's inspection context: the local single-user binding's full | ||
| 28 | // identity view, with its owner partitions stated explicitly — the owner | ||
| 29 | // policy has no full-view default, and the seeded row is a `user` row. | ||
| 30 | const FIXTURE_CONTEXT: ExecutorOwnerPolicyContext = { | ||
| 31 | tenant: TENANT, | ||
| 32 | subject: SUBJECT, | ||
| 33 | owners: ["user", "org"], | ||
| 34 | }; | ||
| 27 | // Epoch millis, the shape an OAuth token expiry takes. | 35 | // Epoch millis, the shape an OAuth token expiry takes. |
| 28 | const LEGACY_EXPIRES_AT = 1787321623456; | 36 | const LEGACY_EXPIRES_AT = 1787321623456; |
| 29 | 37 | ||
| @@ -71,7 +79,7 @@ describe("local boot over a legacy bigint database", () => { · 41 unmodified lines | |||
| 71 | await seedLegacyConnection(dbPath); | 79 | await seedLegacyConnection(dbPath); |
| 72 | 80 | ||
| 73 | const sqlite = await openDb(dbPath); | 81 | const sqlite = await openDb(dbPath); |
| 74 | const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT }); | 82 | const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT); |
| 75 | // The reported symptom: not a wrong value, a throw — so the gateway lost | 83 | // The reported symptom: not a wrong value, a throw — so the gateway lost |
| 76 | // every saved integration at once. | 84 | // every saved integration at once. |
| 77 | await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); | 85 | await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); |
| @@ -88,7 +96,7 @@ describe("local boot over a legacy bigint database", () => { · 10 unmodified lines | |||
| 88 | ); | 96 | ); |
| 89 | expect(applied).toContain("2026-08-28-bigint-storage-class"); | 97 | expect(applied).toContain("2026-08-28-bigint-storage-class"); |
| 90 | 98 | ||
| 91 | const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT }); | 99 | const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT); |
| 92 | const rows = await scoped.findMany("connection", {}); | 100 | const rows = await scoped.findMany("connection", {}); |
| 93 | expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([ | 101 | expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([ |
| 94 | ["default", LEGACY_EXPIRES_AT], | 102 | ["default", LEGACY_EXPIRES_AT], |
| @@ -112,7 +120,7 @@ describe("local boot over a legacy bigint database", () => { · 17 unmodified lines | |||
| 112 | ); | 120 | ); |
| 113 | expect(applied).not.toContain("2026-08-28-bigint-storage-class"); | 121 | expect(applied).not.toContain("2026-08-28-bigint-storage-class"); |
| 114 | 122 | ||
| 115 | const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT }); | 123 | const scoped = withQueryContext(second.db, FIXTURE_CONTEXT); |
| 116 | const rows = await scoped.findMany("connection", {}); | 124 | const rows = await scoped.findMany("connection", {}); |
| 117 | expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); | 125 | expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); |
| 118 | await second.close(); | 126 | await second.close(); |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | |||
| 36 | runSqliteDataMigrations, | 36 | runSqliteDataMigrations, |
| 37 | } from "@executor-js/sdk"; | 37 | } from "@executor-js/sdk"; |
| 38 | import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 38 | import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 39 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 39 | import { openApiPlugin } from "@executor-js/plugin-openapi"; | 40 | import { openApiPlugin } from "@executor-js/plugin-openapi"; |
| 40 | import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing"; | 41 | import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing"; |
| 41 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 42 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| @@ -123,6 +124,7 @@ const bootRealStack = async (dbPath: string) => { · 81 unmodified lines | |||
| 123 | memoryCredentialsPlugin(), | 124 | memoryCredentialsPlugin(), |
| 124 | ] as const, | 125 | ] as const, |
| 125 | onElicitation: "accept-all", | 126 | onElicitation: "accept-all", |
| 127 | access: testAccess.member(), | ||
| 126 | }), | 128 | }), |
| 127 | ); | 129 | ); |
| 128 | return { | 130 | return { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | type Executor, | 13 | type Executor, |
| 14 | } from "@executor-js/sdk"; | 14 | } from "@executor-js/sdk"; |
| 15 | import { collectTables } from "@executor-js/api/server"; | 15 | import { collectTables } from "@executor-js/api/server"; |
| 16 | import { singleUserAccess } from "@executor-js/product-access"; | ||
| 16 | import { loadPluginsFromJsonc } from "@executor-js/config"; | 17 | import { loadPluginsFromJsonc } from "@executor-js/config"; |
| 17 | import type { McpPluginExtension } from "@executor-js/plugin-mcp"; | 18 | import type { McpPluginExtension } from "@executor-js/plugin-mcp"; |
| 18 | 19 | ||
| @@ -220,6 +221,9 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 201 unmodified lines | |||
| 220 | { plugin_key: event.pluginKey }, | 221 | { plugin_key: event.pluginKey }, |
| 221 | ), | 222 | ), |
| 222 | onElicitation: "accept-all", | 223 | onElicitation: "accept-all", |
| 224 | // Single-user product: one human owns the deployment and may | ||
| 225 | // configure everything (see @executor-js/product-access). | ||
| 226 | access: singleUserAccess(), | ||
| 223 | oauthEndpointUrlPolicy: { allowHttp: true }, | 227 | oauthEndpointUrlPolicy: { allowHttp: true }, |
| 224 | // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` | 228 | // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` |
| 225 | // route on the same origin as the web UI. Derived from `webBaseUrl` | 229 | // route on the same origin as the web UI. Derived from `webBaseUrl` |
| @@ -34,6 +34,7 @@ import { · 33 unmodified lines | |||
| 34 | 34 | ||
| 35 | import { createMcpRequestHandler } from "./mcp"; | 35 | import { createMcpRequestHandler } from "./mcp"; |
| 36 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; | 36 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; |
| 37 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 37 | 38 | ||
| 38 | const TEST_BASE_URL = "http://local.test"; | 39 | const TEST_BASE_URL = "http://local.test"; |
| 39 | 40 | ||
| @@ -90,6 +91,7 @@ const makeExecutor = async (tmpDir: string): Promise<Executor> => { · 50 unmodified lines | |||
| 90 | db: sqlite.db, | 91 | db: sqlite.db, |
| 91 | plugins, | 92 | plugins, |
| 92 | onElicitation: "accept-all", | 93 | onElicitation: "accept-all", |
| 94 | access: testAccess.member(), | ||
| 93 | }), | 95 | }), |
| 94 | ); | 96 | ); |
| 95 | 97 | ||
| @@ -49,6 +49,7 @@ import { · 48 unmodified lines | |||
| 49 | createExecutor, | 49 | createExecutor, |
| 50 | } from "@executor-js/sdk"; | 50 | } from "@executor-js/sdk"; |
| 51 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | 51 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; |
| 52 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 52 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 53 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 53 | import { mcpPlugin } from "@executor-js/plugin-mcp"; | 54 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| 54 | import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; | 55 | import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; |
| @@ -98,6 +99,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 43 unmodified lines | |||
| 98 | db: sqlite.db, | 99 | db: sqlite.db, |
| 99 | plugins, | 100 | plugins, |
| 100 | onElicitation: "accept-all", | 101 | onElicitation: "accept-all", |
| 102 | access: testAccess.member(), | ||
| 101 | oauthEndpointUrlPolicy: { allowHttp: true }, | 103 | oauthEndpointUrlPolicy: { allowHttp: true }, |
| 102 | // EXPLICIT OAuth callback — required now that the localhost default is | 104 | // EXPLICIT OAuth callback — required now that the localhost default is |
| 103 | // gone; the local daemon serves `/api/oauth/callback` on the web origin. | 105 | // gone; the local daemon serves `/api/oauth/callback` on the web origin. |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | "@effect/tsgo": "^0.5.2", | 11 | "@effect/tsgo": "^0.5.2", |
| 12 | "@effect/vitest": "catalog:", | 12 | "@effect/vitest": "catalog:", |
| 13 | "@executor-js/plugin-provider-service-split": "workspace:*", | 13 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 14 | "@executor-js/product-access": "workspace:*", | ||
| 14 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", | 15 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 15 | "@vitest/expect": "catalog:", | 16 | "@vitest/expect": "catalog:", |
| 16 | "@vitest/mocker": "catalog:", | 17 | "@vitest/mocker": "catalog:", |
| @@ -76,6 +77,7 @@ · 59 unmodified lines | |||
| 76 | "@executor-js/plugin-openapi": "workspace:*", | 77 | "@executor-js/plugin-openapi": "workspace:*", |
| 77 | "@executor-js/plugin-toolkits": "workspace:*", | 78 | "@executor-js/plugin-toolkits": "workspace:*", |
| 78 | "@executor-js/plugin-workos-vault": "workspace:*", | 79 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 80 | "@executor-js/product-access": "workspace:*", | ||
| 79 | "@executor-js/react": "workspace:*", | 81 | "@executor-js/react": "workspace:*", |
| 80 | "@executor-js/runtime-dynamic-worker": "workspace:*", | 82 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 81 | "@executor-js/runtime-quickjs": "workspace:*", | 83 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -186,6 +188,7 @@ · 104 unmodified lines | |||
| 186 | "@executor-js/plugin-openapi": "workspace:*", | 188 | "@executor-js/plugin-openapi": "workspace:*", |
| 187 | "@executor-js/plugin-provider-service-split": "workspace:*", | 189 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 188 | "@executor-js/plugin-toolkits": "workspace:*", | 190 | "@executor-js/plugin-toolkits": "workspace:*", |
| 191 | "@executor-js/product-access": "workspace:*", | ||
| 189 | "@executor-js/react": "workspace:*", | 192 | "@executor-js/react": "workspace:*", |
| 190 | "@executor-js/runtime-dynamic-worker": "workspace:*", | 193 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 191 | "@executor-js/runtime-quickjs": "workspace:*", | 194 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -239,6 +242,7 @@ · 47 unmodified lines | |||
| 239 | "@executor-js/plugin-openapi": "workspace:*", | 242 | "@executor-js/plugin-openapi": "workspace:*", |
| 240 | "@executor-js/plugin-provider-service-split": "workspace:*", | 243 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 241 | "@executor-js/plugin-toolkits": "workspace:*", | 244 | "@executor-js/plugin-toolkits": "workspace:*", |
| 245 | "@executor-js/product-access": "workspace:*", | ||
| 242 | "@executor-js/react": "workspace:*", | 246 | "@executor-js/react": "workspace:*", |
| 243 | "@executor-js/runtime-quickjs": "workspace:*", | 247 | "@executor-js/runtime-quickjs": "workspace:*", |
| 244 | "@executor-js/sdk": "workspace:*", | 248 | "@executor-js/sdk": "workspace:*", |
| @@ -296,6 +300,7 @@ · 51 unmodified lines | |||
| 296 | "@executor-js/plugin-openapi": "workspace:*", | 300 | "@executor-js/plugin-openapi": "workspace:*", |
| 297 | "@executor-js/plugin-provider-service-split": "workspace:*", | 301 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 298 | "@executor-js/plugin-toolkits": "workspace:*", | 302 | "@executor-js/plugin-toolkits": "workspace:*", |
| 303 | "@executor-js/product-access": "workspace:*", | ||
| 299 | "@executor-js/react": "workspace:*", | 304 | "@executor-js/react": "workspace:*", |
| 300 | "@executor-js/runtime-quickjs": "workspace:*", | 305 | "@executor-js/runtime-quickjs": "workspace:*", |
| 301 | "@executor-js/sdk": "workspace:*", | 306 | "@executor-js/sdk": "workspace:*", |
| @@ -397,6 +402,7 @@ · 95 unmodified lines | |||
| 397 | "@executor-js/plugin-onepassword": "workspace:*", | 402 | "@executor-js/plugin-onepassword": "workspace:*", |
| 398 | "@executor-js/plugin-openapi": "workspace:*", | 403 | "@executor-js/plugin-openapi": "workspace:*", |
| 399 | "@executor-js/plugin-workos-vault": "workspace:*", | 404 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 405 | "@executor-js/product-access": "workspace:*", | ||
| 400 | "@executor-js/sdk": "workspace:*", | 406 | "@executor-js/sdk": "workspace:*", |
| 401 | "effect": "catalog:", | 407 | "effect": "catalog:", |
| 402 | }, | 408 | }, |
| @@ -410,6 +416,7 @@ · 7 unmodified lines | |||
| 410 | "version": "0.0.54", | 416 | "version": "0.0.54", |
| 411 | "dependencies": { | 417 | "dependencies": { |
| 412 | "@executor-js/plugin-openapi": "workspace:*", | 418 | "@executor-js/plugin-openapi": "workspace:*", |
| 419 | "@executor-js/product-access": "workspace:*", | ||
| 413 | "@executor-js/sdk": "workspace:*", | 420 | "@executor-js/sdk": "workspace:*", |
| 414 | "effect": "catalog:", | 421 | "effect": "catalog:", |
| 415 | }, | 422 | }, |
| @@ -425,6 +432,7 @@ · 9 unmodified lines | |||
| 425 | "@executor-js/plugin-graphql": "workspace:*", | 432 | "@executor-js/plugin-graphql": "workspace:*", |
| 426 | "@executor-js/plugin-mcp": "workspace:*", | 433 | "@executor-js/plugin-mcp": "workspace:*", |
| 427 | "@executor-js/plugin-openapi": "workspace:*", | 434 | "@executor-js/plugin-openapi": "workspace:*", |
| 435 | "@executor-js/product-access": "workspace:*", | ||
| 428 | "@executor-js/sdk": "workspace:*", | 436 | "@executor-js/sdk": "workspace:*", |
| 429 | }, | 437 | }, |
| 430 | "devDependencies": { | 438 | "devDependencies": { |
| @@ -484,6 +492,7 @@ · 53 unmodified lines | |||
| 484 | "dependencies": { | 492 | "dependencies": { |
| 485 | "@executor-js/execution": "workspace:*", | 493 | "@executor-js/execution": "workspace:*", |
| 486 | "@executor-js/host-mcp": "workspace:*", | 494 | "@executor-js/host-mcp": "workspace:*", |
| 495 | "@executor-js/product-access": "workspace:*", | ||
| 487 | "@executor-js/sdk": "workspace:*", | 496 | "@executor-js/sdk": "workspace:*", |
| 488 | "effect": "catalog:", | 497 | "effect": "catalog:", |
| 489 | }, | 498 | }, |
| @@ -546,6 +555,7 @@ · 56 unmodified lines | |||
| 546 | }, | 555 | }, |
| 547 | "devDependencies": { | 556 | "devDependencies": { |
| 548 | "@effect/vitest": "catalog:", | 557 | "@effect/vitest": "catalog:", |
| 558 | "@executor-js/product-access": "workspace:*", | ||
| 549 | "@executor-js/runtime-quickjs": "workspace:*", | 559 | "@executor-js/runtime-quickjs": "workspace:*", |
| 550 | "@types/node": "catalog:", | 560 | "@types/node": "catalog:", |
| 551 | "bun-types": "catalog:", | 561 | "bun-types": "catalog:", |
| @@ -603,6 +613,25 @@ · 51 unmodified lines | |||
| 603 | "vitest": "catalog:", | 613 | "vitest": "catalog:", |
| 604 | }, | 614 | }, |
| 605 | }, | 615 | }, |
| 616 | "packages/core/product-access": { | ||
| 617 | "name": "@executor-js/product-access", | ||
| 618 | "version": "1.6.8", | ||
| 619 | "dependencies": { | ||
| 620 | "@executor-js/sdk": "workspace:*", | ||
| 621 | }, | ||
| 622 | "devDependencies": { | ||
| 623 | "@effect/vitest": "catalog:", | ||
| 624 | "@types/node": "catalog:", | ||
| 625 | "bun-types": "catalog:", | ||
| 626 | "effect": "catalog:", | ||
| 627 | "tsup": "catalog:", | ||
| 628 | "typescript": "catalog:", | ||
| 629 | "vitest": "catalog:", | ||
| 630 | }, | ||
| 631 | "peerDependencies": { | ||
| 632 | "effect": "catalog:", | ||
| 633 | }, | ||
| 634 | }, | ||
| 606 | "packages/core/sdk": { | 635 | "packages/core/sdk": { |
| 607 | "name": "@executor-js/sdk", | 636 | "name": "@executor-js/sdk", |
| 608 | "version": "1.6.8", | 637 | "version": "1.6.8", |
| @@ -704,6 +733,7 @@ · 95 unmodified lines | |||
| 704 | "dependencies": { | 733 | "dependencies": { |
| 705 | "@cfworker/json-schema": "^4.1.1", | 734 | "@cfworker/json-schema": "^4.1.1", |
| 706 | "@executor-js/execution": "workspace:*", | 735 | "@executor-js/execution": "workspace:*", |
| 736 | "@executor-js/product-access": "workspace:*", | ||
| 707 | "@executor-js/sdk": "workspace:*", | 737 | "@executor-js/sdk": "workspace:*", |
| 708 | "@modelcontextprotocol/ext-apps": "^1.7.4", | 738 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 709 | "@modelcontextprotocol/sdk": "^1.29.0", | 739 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -734,6 +764,7 @@ · 24 unmodified lines | |||
| 734 | "@effect/vitest": "catalog:", | 764 | "@effect/vitest": "catalog:", |
| 735 | "@executor-js/execution": "workspace:*", | 765 | "@executor-js/execution": "workspace:*", |
| 736 | "@executor-js/host-mcp": "workspace:*", | 766 | "@executor-js/host-mcp": "workspace:*", |
| 767 | "@executor-js/product-access": "workspace:*", | ||
| 737 | "@executor-js/sdk": "workspace:*", | 768 | "@executor-js/sdk": "workspace:*", |
| 738 | "@tailwindcss/browser": "^4.2.2", | 769 | "@tailwindcss/browser": "^4.2.2", |
| 739 | "@tailwindcss/vite": "catalog:", | 770 | "@tailwindcss/vite": "catalog:", |
| @@ -820,6 +851,7 @@ · 80 unmodified lines | |||
| 820 | "@executor-js/execution": "workspace:*", | 851 | "@executor-js/execution": "workspace:*", |
| 821 | "@executor-js/fumadb": "workspace:*", | 852 | "@executor-js/fumadb": "workspace:*", |
| 822 | "@executor-js/plugin-openapi": "workspace:*", | 853 | "@executor-js/plugin-openapi": "workspace:*", |
| 854 | "@executor-js/product-access": "workspace:*", | ||
| 823 | "@executor-js/sdk": "workspace:*", | 855 | "@executor-js/sdk": "workspace:*", |
| 824 | "@types/node": "catalog:", | 856 | "@types/node": "catalog:", |
| 825 | "drizzle-orm": "catalog:", | 857 | "drizzle-orm": "catalog:", |
| @@ -895,6 +927,7 @@ · 69 unmodified lines | |||
| 895 | "react": "catalog:", | 927 | "react": "catalog:", |
| 896 | }, | 928 | }, |
| 897 | "devDependencies": { | 929 | "devDependencies": { |
| 930 | "@executor-js/product-access": "workspace:*", | ||
| 898 | "@types/react": "catalog:", | 931 | "@types/react": "catalog:", |
| 899 | "tsup": "catalog:", | 932 | "tsup": "catalog:", |
| 900 | "typescript": "catalog:", | 933 | "typescript": "catalog:", |
| @@ -945,6 +978,7 @@ · 44 unmodified lines | |||
| 945 | "@executor-js/sdk": "workspace:*", | 978 | "@executor-js/sdk": "workspace:*", |
| 946 | }, | 979 | }, |
| 947 | "devDependencies": { | 980 | "devDependencies": { |
| 981 | "@executor-js/product-access": "workspace:*", | ||
| 948 | "@types/node": "catalog:", | 982 | "@types/node": "catalog:", |
| 949 | "bun-types": "catalog:", | 983 | "bun-types": "catalog:", |
| 950 | "effect": "catalog:", | 984 | "effect": "catalog:", |
| @@ -969,6 +1003,7 @@ · 18 unmodified lines | |||
| 969 | "@effect/atom-react": "catalog:", | 1003 | "@effect/atom-react": "catalog:", |
| 970 | "@effect/vitest": "catalog:", | 1004 | "@effect/vitest": "catalog:", |
| 971 | "@executor-js/api": "workspace:*", | 1005 | "@executor-js/api": "workspace:*", |
| 1006 | "@executor-js/product-access": "workspace:*", | ||
| 972 | "@executor-js/react": "workspace:*", | 1007 | "@executor-js/react": "workspace:*", |
| 973 | "@types/node": "catalog:", | 1008 | "@types/node": "catalog:", |
| 974 | "@types/react": "catalog:", | 1009 | "@types/react": "catalog:", |
| @@ -1003,6 +1038,7 @@ · 28 unmodified lines | |||
| 1003 | }, | 1038 | }, |
| 1004 | "devDependencies": { | 1039 | "devDependencies": { |
| 1005 | "@effect/vitest": "catalog:", | 1040 | "@effect/vitest": "catalog:", |
| 1041 | "@executor-js/product-access": "workspace:*", | ||
| 1006 | "@types/node": "catalog:", | 1042 | "@types/node": "catalog:", |
| 1007 | "bun-types": "catalog:", | 1043 | "bun-types": "catalog:", |
| 1008 | "effect": "catalog:", | 1044 | "effect": "catalog:", |
| @@ -1031,6 +1067,7 @@ · 22 unmodified lines | |||
| 1031 | "@effect/atom-react": "catalog:", | 1067 | "@effect/atom-react": "catalog:", |
| 1032 | "@effect/vitest": "catalog:", | 1068 | "@effect/vitest": "catalog:", |
| 1033 | "@executor-js/api": "workspace:*", | 1069 | "@executor-js/api": "workspace:*", |
| 1070 | "@executor-js/product-access": "workspace:*", | ||
| 1034 | "@executor-js/react": "workspace:*", | 1071 | "@executor-js/react": "workspace:*", |
| 1035 | "@modelcontextprotocol/server": "2.0.0", | 1072 | "@modelcontextprotocol/server": "2.0.0", |
| 1036 | "@types/node": "catalog:", | 1073 | "@types/node": "catalog:", |
| @@ -1068,6 +1105,7 @@ · 31 unmodified lines | |||
| 1068 | "devDependencies": { | 1105 | "devDependencies": { |
| 1069 | "@effect/vitest": "catalog:", | 1106 | "@effect/vitest": "catalog:", |
| 1070 | "@executor-js/api": "workspace:*", | 1107 | "@executor-js/api": "workspace:*", |
| 1108 | "@executor-js/product-access": "workspace:*", | ||
| 1071 | "@types/node": "catalog:", | 1109 | "@types/node": "catalog:", |
| 1072 | "@types/react": "catalog:", | 1110 | "@types/react": "catalog:", |
| 1073 | "bun-types": "catalog:", | 1111 | "bun-types": "catalog:", |
| @@ -1105,6 +1143,7 @@ · 31 unmodified lines | |||
| 1105 | "@effect/atom-react": "catalog:", | 1143 | "@effect/atom-react": "catalog:", |
| 1106 | "@effect/vitest": "catalog:", | 1144 | "@effect/vitest": "catalog:", |
| 1107 | "@executor-js/api": "workspace:*", | 1145 | "@executor-js/api": "workspace:*", |
| 1146 | "@executor-js/product-access": "workspace:*", | ||
| 1108 | "@executor-js/react": "workspace:*", | 1147 | "@executor-js/react": "workspace:*", |
| 1109 | "@types/js-yaml": "4.0.9", | 1148 | "@types/js-yaml": "4.0.9", |
| 1110 | "@types/node": "catalog:", | 1149 | "@types/node": "catalog:", |
| @@ -1158,6 +1197,7 @@ · 47 unmodified lines | |||
| 1158 | "@effect/atom-react": "catalog:", | 1197 | "@effect/atom-react": "catalog:", |
| 1159 | "@effect/vitest": "catalog:", | 1198 | "@effect/vitest": "catalog:", |
| 1160 | "@executor-js/api": "workspace:*", | 1199 | "@executor-js/api": "workspace:*", |
| 1200 | "@executor-js/product-access": "workspace:*", | ||
| 1161 | "@executor-js/react": "workspace:*", | 1201 | "@executor-js/react": "workspace:*", |
| 1162 | "@tanstack/react-router": "catalog:", | 1202 | "@tanstack/react-router": "catalog:", |
| 1163 | "@types/node": "catalog:", | 1203 | "@types/node": "catalog:", |
| @@ -1224,6 +1264,7 @@ · 60 unmodified lines | |||
| 1224 | "@base-ui/react": "^1.3.0", | 1264 | "@base-ui/react": "^1.3.0", |
| 1225 | "@effect/atom-react": "catalog:", | 1265 | "@effect/atom-react": "catalog:", |
| 1226 | "@executor-js/api": "workspace:*", | 1266 | "@executor-js/api": "workspace:*", |
| 1267 | "@executor-js/product-access": "workspace:*", | ||
| 1227 | "@executor-js/sdk": "workspace:*", | 1268 | "@executor-js/sdk": "workspace:*", |
| 1228 | "@hookform/resolvers": "^5.2.2", | 1269 | "@hookform/resolvers": "^5.2.2", |
| 1229 | "@lobehub/icons": "^5.4.0", | 1270 | "@lobehub/icons": "^5.4.0", |
| @@ -1869,6 +1910,8 @@ · 639 unmodified lines | |||
| 1869 | 1910 | ||
| 1870 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], | 1911 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], |
| 1871 | 1912 | ||
| 1913 | "@executor-js/product-access": ["@executor-js/product-access@workspace:packages/core/product-access"], | ||
| 1914 | |||
| 1872 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], | 1915 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], |
| 1873 | 1916 | ||
| 1874 | "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"], | 1917 | "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"], |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | "@executor-js/plugin-onepassword": "workspace:*", | 16 | "@executor-js/plugin-onepassword": "workspace:*", |
| 17 | "@executor-js/plugin-openapi": "workspace:*", | 17 | "@executor-js/plugin-openapi": "workspace:*", |
| 18 | "@executor-js/plugin-workos-vault": "workspace:*", | 18 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 19 | "@executor-js/product-access": "workspace:*", | ||
| 19 | "@executor-js/sdk": "workspace:*", | 20 | "@executor-js/sdk": "workspace:*", |
| 20 | "effect": "catalog:" | 21 | "effect": "catalog:" |
| 21 | }, | 22 | }, |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | |||
| 31 | ToolAddress, | 31 | ToolAddress, |
| 32 | type CredentialProvider, | 32 | type CredentialProvider, |
| 33 | } from "@executor-js/sdk"; | 33 | } from "@executor-js/sdk"; |
| 34 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 34 | 35 | ||
| 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 36 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 36 | import { | 37 | import { |
| @@ -192,6 +193,8 @@ const program = Effect.gen(function* () { · 155 unmodified lines | |||
| 192 | 193 | ||
| 193 | const executor = yield* createExecutor({ | 194 | const executor = yield* createExecutor({ |
| 194 | tenant: Tenant.make("example-tenant"), | 195 | tenant: Tenant.make("example-tenant"), |
| 196 | // Subject-less single-workspace example: org partition, writes allowed. | ||
| 197 | access: workspaceServiceAccess(), | ||
| 195 | plugins, | 198 | plugins, |
| 196 | providers: [memoryProvider], | 199 | providers: [memoryProvider], |
| 197 | onElicitation: "accept-all" as const, | 200 | onElicitation: "accept-all" as const, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | |||
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@executor-js/plugin-openapi": "workspace:*", | 12 | "@executor-js/plugin-openapi": "workspace:*", |
| 13 | "@executor-js/product-access": "workspace:*", | ||
| 13 | "@executor-js/sdk": "workspace:*", | 14 | "@executor-js/sdk": "workspace:*", |
| 14 | "effect": "catalog:" | 15 | "effect": "catalog:" |
| 15 | }, | 16 | }, |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | type CredentialProvider, | 8 | type CredentialProvider, |
| 9 | } from "@executor-js/sdk/promise"; | 9 | } from "@executor-js/sdk/promise"; |
| 10 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; | 10 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; |
| 11 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 11 | 12 | ||
| 12 | const inventoryApi = { | 13 | const inventoryApi = { |
| 13 | openapi: "3.0.0", | 14 | openapi: "3.0.0", |
| @@ -92,6 +93,9 @@ const memoryProvider: CredentialProvider = { · 78 unmodified lines | |||
| 92 | }; | 93 | }; |
| 93 | 94 | ||
| 94 | const executor = await createExecutor({ | 95 | const executor = await createExecutor({ |
| 96 | // Subject-less single-workspace embedder: the product posture is stated | ||
| 97 | // explicitly — the SDK ships no default. | ||
| 98 | access: workspaceServiceAccess(), | ||
| 95 | plugins: [openApiPlugin()], | 99 | plugins: [openApiPlugin()], |
| 96 | providers: [memoryProvider], | 100 | providers: [memoryProvider], |
| 97 | onElicitation: "accept-all", | 101 | onElicitation: "accept-all", |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | |||
| 12 | "@executor-js/plugin-graphql": "workspace:*", | 12 | "@executor-js/plugin-graphql": "workspace:*", |
| 13 | "@executor-js/plugin-mcp": "workspace:*", | 13 | "@executor-js/plugin-mcp": "workspace:*", |
| 14 | "@executor-js/plugin-openapi": "workspace:*", | 14 | "@executor-js/plugin-openapi": "workspace:*", |
| 15 | "@executor-js/product-access": "workspace:*", | ||
| 15 | "@executor-js/sdk": "workspace:*" | 16 | "@executor-js/sdk": "workspace:*" |
| 16 | }, | 17 | }, |
| 17 | "devDependencies": { | 18 | "devDependencies": { |
| @@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines | |||
| 20 | import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; | 20 | import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; |
| 21 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; | 21 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; |
| 22 | import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; | 22 | import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; |
| 23 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 23 | 24 | ||
| 24 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 25 | // 1. Create the executor with all plugins | 26 | // 1. Create the executor with all plugins |
| @@ -44,6 +45,9 @@ const memoryProvider: CredentialProvider = { · 18 unmodified lines | |||
| 44 | }; | 45 | }; |
| 45 | 46 | ||
| 46 | const executor = await createExecutor({ | 47 | const executor = await createExecutor({ |
| 48 | // Subject-less single-workspace embedder: the product posture is stated | ||
| 49 | // explicitly — the SDK ships no default. | ||
| 50 | access: workspaceServiceAccess(), | ||
| 47 | plugins, | 51 | plugins, |
| 48 | providers: [memoryProvider], | 52 | providers: [memoryProvider], |
| 49 | onElicitation: "accept-all", | 53 | onElicitation: "accept-all", |
| @@ -39,7 +39,7 @@ · 38 unmodified lines | |||
| 39 | "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}", | 39 | "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}", |
| 40 | "test:e2e": "bun run --cwd e2e test", | 40 | "test:e2e": "bun run --cwd e2e test", |
| 41 | "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", | 41 | "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", |
| 42 | "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", | 42 | "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/product-access' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", |
| 43 | "typecheck": "turbo run typecheck", | 43 | "typecheck": "turbo run typecheck", |
| 44 | "typecheck:slow": "turbo run typecheck:slow", | 44 | "typecheck:slow": "turbo run typecheck:slow", |
| 45 | "ci": "bun run lint && bun run typecheck && bun run test", | 45 | "ci": "bun run lint && bun run typecheck && bun run test", |
| @@ -78,6 +78,7 @@ · 32 unmodified lines | |||
| 78 | "@effect/tsgo": "^0.5.2", | 78 | "@effect/tsgo": "^0.5.2", |
| 79 | "@effect/vitest": "catalog:", | 79 | "@effect/vitest": "catalog:", |
| 80 | "@executor-js/plugin-provider-service-split": "workspace:*", | 80 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 81 | "@executor-js/product-access": "workspace:*", | ||
| 81 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", | 82 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 82 | "@vitest/expect": "catalog:", | 83 | "@vitest/expect": "catalog:", |
| 83 | "@vitest/mocker": "catalog:", | 84 | "@vitest/mocker": "catalog:", |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | "dependencies": { | 16 | "dependencies": { |
| 17 | "@executor-js/execution": "workspace:*", | 17 | "@executor-js/execution": "workspace:*", |
| 18 | "@executor-js/host-mcp": "workspace:*", | 18 | "@executor-js/host-mcp": "workspace:*", |
| 19 | "@executor-js/product-access": "workspace:*", | ||
| 19 | "@executor-js/sdk": "workspace:*", | 20 | "@executor-js/sdk": "workspace:*", |
| 20 | "effect": "catalog:" | 21 | "effect": "catalog:" |
| 21 | }, | 22 | }, |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | type ExecutorAdmin, | 13 | type ExecutorAdmin, |
| 14 | } from "@executor-js/sdk"; | 14 | } from "@executor-js/sdk"; |
| 15 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; | 15 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; |
| 16 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 16 | import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal"; | 17 | import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal"; |
| 17 | 18 | ||
| 18 | import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api"; | 19 | import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api"; |
| @@ -204,7 +205,7 @@ const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effec · 185 unmodified lines | |||
| 204 | tenant: Tenant.make(tenant), | 205 | tenant: Tenant.make(tenant), |
| 205 | db: db.db, | 206 | db: db.db, |
| 206 | onElicitation: "accept-all", | 207 | onElicitation: "accept-all", |
| 207 | platformView: true, | 208 | access: testAccess.platform({ subject: false }), |
| 208 | }).pipe(Effect.orDie); | 209 | }).pipe(Effect.orDie); |
| 209 | 210 | ||
| 210 | /** A product-view executor: bound to one subject, no platform view at all. */ | 211 | /** A product-view executor: bound to one subject, no platform view at all. */ |
| @@ -214,6 +215,7 @@ const productExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect · 3 unmodified lines | |||
| 214 | subject: Subject.make(USER_A1), | 215 | subject: Subject.make(USER_A1), |
| 215 | db: db.db, | 216 | db: db.db, |
| 216 | onElicitation: "accept-all", | 217 | onElicitation: "accept-all", |
| 218 | access: testAccess.member(), | ||
| 217 | }).pipe(Effect.orDie); | 219 | }).pipe(Effect.orDie); |
| 218 | 220 | ||
| 219 | /** | 221 | /** |
| @@ -639,7 +641,7 @@ describe("admin users API", () => { · 419 unmodified lines | |||
| 639 | withDb((db) => | 641 | withDb((db) => |
| 640 | Effect.gen(function* () { | 642 | Effect.gen(function* () { |
| 641 | yield* seed(db); | 643 | yield* seed(db); |
| 642 | // A host that forgot `platformView: true` must not silently report an | 644 | // A host that forgot the platform access posture must not silently report an |
| 643 | // empty tenant — that would read as "this owner has no users". | 645 | // empty tenant — that would read as "this owner has no users". |
| 644 | const web = yield* webHandlerFor( | 646 | const web = yield* webHandlerFor( |
| 645 | stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize), | 647 | stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize), |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | |||
| 4 | // The product plane (`/api/*`) answers "what can I, this member, reach". This | 4 | // The product plane (`/api/*`) answers "what can I, this member, reach". This |
| 5 | // one answers the OWNER's question: "who are my users, and what have they | 5 | // one answers the OWNER's question: "who are my users, and what have they |
| 6 | // connected". It reads the SDK's platform view (`executor.admin`, opt-in via | 6 | // connected". It reads the SDK's platform view (`executor.admin`, opt-in via |
| 7 | // `platformView: true`), which is read-only by construction — the owner policy | 7 | // `platformObserverAccess()`), which is read-only by construction — the owner policy |
| 8 | // rejects writes at `reach: "tenant"` — so every endpoint here is a GET. | 8 | // rejects writes at `reach: "tenant"` — so every endpoint here is a GET. |
| 9 | // | 9 | // |
| 10 | // VOCABULARY: this is the translation seam. Internal code says subject / tenant | 10 | // VOCABULARY: this is the translation seam. Internal code says subject / tenant |
| @@ -35,7 +35,7 @@ import type { AdminUsersListOptions } from "./service"; · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | /** | 36 | /** |
| 37 | * Narrow an executor to its platform view. `admin` is present only when the | 37 | * Narrow an executor to its platform view. `admin` is present only when the |
| 38 | * executor was built with `platformView: true`; a host that reaches these reads | 38 | * executor was built with the platform access posture; a host that reaches these reads |
| 39 | * with a product-view executor is a wiring bug, so it fails loudly as a 500 | 39 | * with a product-view executor is a wiring bug, so it fails loudly as a 500 |
| 40 | * rather than silently returning an empty tenant (which would read as "this | 40 | * rather than silently returning an empty tenant (which would read as "this |
| 41 | * owner has no users"). | 41 | * owner has no users"). |
| @@ -16,6 +16,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 15 unmodified lines | |||
| 16 | import { ExecutorApi } from "../api"; | 16 | import { ExecutorApi } from "../api"; |
| 17 | import { observabilityMiddleware } from "../observability"; | 17 | import { observabilityMiddleware } from "../observability"; |
| 18 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; | 18 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; |
| 19 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 19 | 20 | ||
| 20 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| 21 | // The catalog response surfaces each plugin's DECLARED auth methods (projected | 22 | // The catalog response surfaces each plugin's DECLARED auth methods (projected |
| @@ -109,7 +110,9 @@ interface IntegrationResponseBody { · 87 unmodified lines | |||
| 109 | describe("catalog surfaces declared auth methods", () => { | 110 | describe("catalog surfaces declared auth methods", () => { |
| 110 | it.effect("an OAuth integration with zero connections advertises an oauth method", () => | 111 | it.effect("an OAuth integration with zero connections advertises an oauth method", () => |
| 111 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 112 | const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); | 113 | const executor = yield* createExecutor( |
| 114 | makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), | ||
| 115 | ); | ||
| 113 | const slug = IntegrationSlug.make("oauth-server"); | 116 | const slug = IntegrationSlug.make("oauth-server"); |
| 114 | yield* executor.declaring.seed(slug, [OAUTH_METHOD]); | 117 | yield* executor.declaring.seed(slug, [OAUTH_METHOD]); |
| 115 | 118 | ||
| @@ -133,7 +136,9 @@ describe("catalog surfaces declared auth methods", () => { · 17 unmodified lines | |||
| 133 | 136 | ||
| 134 | it.effect("an apikey integration advertises an apikey method", () => | 137 | it.effect("an apikey integration advertises an apikey method", () => |
| 135 | Effect.gen(function* () { | 138 | Effect.gen(function* () { |
| 136 | const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); | 139 | const executor = yield* createExecutor( |
| 140 | makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), | ||
| 141 | ); | ||
| 137 | const slug = IntegrationSlug.make("apikey-server"); | 142 | const slug = IntegrationSlug.make("apikey-server"); |
| 138 | yield* executor.declaring.seed(slug, [APIKEY_METHOD]); | 143 | yield* executor.declaring.seed(slug, [APIKEY_METHOD]); |
| 139 | 144 | ||
| @@ -156,7 +161,9 @@ describe("catalog surfaces declared auth methods", () => { · 16 unmodified lines | |||
| 156 | 161 | ||
| 157 | it.effect("list surfaces authMethods and a plugin with no projector yields []", () => | 162 | it.effect("list surfaces authMethods and a plugin with no projector yields []", () => |
| 158 | Effect.gen(function* () { | 163 | Effect.gen(function* () { |
| 159 | const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); | 164 | const executor = yield* createExecutor( |
| 165 | makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), | ||
| 166 | ); | ||
| 160 | yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]); | 167 | yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]); |
| 161 | yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []); | 168 | yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []); |
| 162 | 169 | ||
| @@ -180,7 +187,9 @@ describe("catalog surfaces declared auth methods", () => { · 17 unmodified lines | |||
| 180 | 187 | ||
| 181 | it.effect("surfaces plugin-derived display URLs without exposing config", () => | 188 | it.effect("surfaces plugin-derived display URLs without exposing config", () => |
| 182 | Effect.gen(function* () { | 189 | Effect.gen(function* () { |
| 183 | const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); | 190 | const executor = yield* createExecutor( |
| 191 | makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), | ||
| 192 | ); | ||
| 184 | const slug = IntegrationSlug.make("autumn"); | 193 | const slug = IntegrationSlug.make("autumn"); |
| 185 | yield* executor.declaring.seed(slug, [], "https://api.useautumn.com"); | 194 | yield* executor.declaring.seed(slug, [], "https://api.useautumn.com"); |
| 186 | 195 | ||
| @@ -17,6 +17,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 16 unmodified lines | |||
| 17 | import { ExecutorApi } from "./api"; | 17 | import { ExecutorApi } from "./api"; |
| 18 | import { observabilityMiddleware } from "./observability"; | 18 | import { observabilityMiddleware } from "./observability"; |
| 19 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; | 19 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; |
| 20 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 20 | 21 | ||
| 21 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| 22 | // v2 owner-scoped API behaviour. | 23 | // v2 owner-scoped API behaviour. |
| @@ -88,7 +89,7 @@ const vercelPlugin = definePlugin(() => ({ · 65 unmodified lines | |||
| 88 | describe("core API owner-scoped writes (v2)", () => { | 89 | describe("core API owner-scoped writes (v2)", () => { |
| 89 | it.effect("policy create + update target an explicit owner", () => | 90 | it.effect("policy create + update target an explicit owner", () => |
| 90 | Effect.gen(function* () { | 91 | Effect.gen(function* () { |
| 91 | const executor = yield* createExecutor(makeTestConfig({})); | 92 | const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() })); |
| 92 | const web = yield* webHandlerFor(executor); | 93 | const web = yield* webHandlerFor(executor); |
| 93 | const context = handlerContextFor(executor); | 94 | const context = handlerContextFor(executor); |
| 94 | 95 | ||
| @@ -135,6 +136,7 @@ describe("core API owner-scoped writes (v2)", () => { · 40 unmodified lines | |||
| 135 | it.effect("connection remove deletes the named owner row, not the other owner", () => | 136 | it.effect("connection remove deletes the named owner row, not the other owner", () => |
| 136 | Effect.gen(function* () { | 137 | Effect.gen(function* () { |
| 137 | const config = makeTestConfig({ | 138 | const config = makeTestConfig({ |
| 139 | access: testAccess.member(), | ||
| 138 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, | 140 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, |
| 139 | }); | 141 | }); |
| 140 | const executor = yield* createExecutor(config); | 142 | const executor = yield* createExecutor(config); |
| @@ -179,6 +181,7 @@ describe("core API owner-scoped writes (v2)", () => { · 38 unmodified lines | |||
| 179 | it.effect("connection create accepts pasted values payloads", () => | 181 | it.effect("connection create accepts pasted values payloads", () => |
| 180 | Effect.gen(function* () { | 182 | Effect.gen(function* () { |
| 181 | const config = makeTestConfig({ | 183 | const config = makeTestConfig({ |
| 184 | access: testAccess.member(), | ||
| 182 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, | 185 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, |
| 183 | }); | 186 | }); |
| 184 | const executor = yield* createExecutor(config); | 187 | const executor = yield* createExecutor(config); |
| @@ -220,6 +223,7 @@ describe("core API owner-scoped writes (v2)", () => { · 35 unmodified lines | |||
| 220 | it.effect("connection list returns both owners' rows under one integration", () => | 223 | it.effect("connection list returns both owners' rows under one integration", () => |
| 221 | Effect.gen(function* () { | 224 | Effect.gen(function* () { |
| 222 | const config = makeTestConfig({ | 225 | const config = makeTestConfig({ |
| 226 | access: testAccess.member(), | ||
| 223 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, | 227 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, |
| 224 | }); | 228 | }); |
| 225 | const executor = yield* createExecutor(config); | 229 | const executor = yield* createExecutor(config); |
| @@ -38,6 +38,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab · 37 unmodified lines | |||
| 38 | import { Context, Data, 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 { memberAccessForRole } from "@executor-js/product-access"; | ||
| 41 | import type { AnyPlugin } from "@executor-js/sdk"; | 42 | import type { AnyPlugin } from "@executor-js/sdk"; |
| 42 | import type { ExecutionEngine } from "@executor-js/execution"; | 43 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 43 | 44 | ||
| @@ -247,10 +248,9 @@ export const makeExecutionStackMiddleware = < · 203 unmodified lines | |||
| 247 | resolved.organizationId, | 248 | resolved.organizationId, |
| 248 | resolved.organizationName, | 249 | resolved.organizationName, |
| 249 | { | 250 | { |
| 250 | orgWrites: | 251 | // The product's role rule, decided from the freshly resolved |
| 251 | resolved.orgRoleModel === "none" || resolved.orgRole === "admin" | 252 | // principal for exactly this request's stack. |
| 252 | ? "allowed" | 253 | access: memberAccessForRole(resolved), |
| 253 | : "denied", | ||
| 254 | }, | 254 | }, |
| 255 | ).pipe( | 255 | ).pipe( |
| 256 | Effect.provide(options.stackLayer, { local: true }), | 256 | Effect.provide(options.stackLayer, { local: true }), |
| @@ -27,7 +27,7 @@ import { Context, Effect, Layer } from "effect"; · 26 unmodified lines | |||
| 27 | import type * as Cause from "effect/Cause"; | 27 | import type * as Cause from "effect/Cause"; |
| 28 | 28 | ||
| 29 | import type { McpResource } from "@executor-js/host-mcp"; | 29 | import type { McpResource } from "@executor-js/host-mcp"; |
| 30 | import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk"; | 30 | import type { AnyPlugin, Executor, ExecutorAccess, StorageFailure } from "@executor-js/sdk"; |
| 31 | import { | 31 | import { |
| 32 | createExecutionEngine, | 32 | createExecutionEngine, |
| 33 | type ExecutionEngine, | 33 | type ExecutionEngine, |
| @@ -112,11 +112,12 @@ export const makeExecutionStack = < · 78 unmodified lines | |||
| 112 | accountId: string, | 112 | accountId: string, |
| 113 | organizationId: string, | 113 | organizationId: string, |
| 114 | organizationName: string, | 114 | organizationName: string, |
| 115 | options?: { | 115 | options: { |
| 116 | readonly mcpResource?: McpResource; | 116 | readonly mcpResource?: McpResource; |
| 117 | /** Workspace-settings permission for this binding (see | 117 | /** The product's access decisions for this binding (see |
| 118 | * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ | 118 | * `ExecutorConfig.access`), selected by the host — role-derived on the |
| 119 | readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"]; | 119 | * HTTP plane, request-bound on MCP session stacks. */ |
| 120 | readonly access: ExecutorAccess; | ||
| 120 | }, | 121 | }, |
| 121 | ): Effect.Effect< | 122 | ): Effect.Effect< |
| 122 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, | 123 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, |
| @@ -129,8 +130,8 @@ export const makeExecutionStack = < · 6 unmodified lines | |||
| 129 | organizationId, | 130 | organizationId, |
| 130 | organizationName, | 131 | organizationName, |
| 131 | { | 132 | { |
| 132 | plugins: { mcpResource: options?.mcpResource }, | 133 | plugins: { mcpResource: options.mcpResource }, |
| 133 | ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), | 134 | access: options.access, |
| 134 | }, | 135 | }, |
| 135 | ).pipe(Effect.withSpan("executor.stack.scoped_executor")); | 136 | ).pipe(Effect.withSpan("executor.stack.scoped_executor")); |
| 136 | const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( | 137 | const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( |
| @@ -147,7 +148,7 @@ export const makeExecutionStack = < · 10 unmodified lines | |||
| 147 | organizationId, | 148 | organizationId, |
| 148 | organizationName, | 149 | organizationName, |
| 149 | }, | 150 | }, |
| 150 | { mcpResource: options?.mcpResource }, | 151 | { mcpResource: options.mcpResource }, |
| 151 | ), | 152 | ), |
| 152 | ); | 153 | ); |
| 153 | return { executor, engine }; | 154 | return { executor, engine }; |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; | 3 | import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; |
| 4 | import { requestBoundMemberAccess } from "@executor-js/product-access"; | ||
| 4 | import { | 5 | import { |
| 5 | McpEngineBuildError, | 6 | McpEngineBuildError, |
| 6 | type McpBuildServer, | 7 | type McpBuildServer, |
| @@ -50,7 +51,10 @@ export const makeMcpBuildServer = · 43 unmodified lines | |||
| 50 | principal.organizationName, | 51 | principal.organizationName, |
| 51 | { | 52 | { |
| 52 | mcpResource: options?.resource, | 53 | mcpResource: options?.resource, |
| 53 | orgWrites: "request", | 54 | // Session-lifetime stack: the workspace-settings decision is read |
| 55 | // from the request-stamped fiber-local at every guarded sink, so a | ||
| 56 | // session never caches a positive authorization. | ||
| 57 | access: requestBoundMemberAccess(), | ||
| 54 | }, | 58 | }, |
| 55 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); | 59 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); |
| 56 | // Read inside the provided boundary: `webBaseUrl` is a host seam, and | 60 | // Read inside the provided boundary: `webBaseUrl` is a host seam, and |
| @@ -34,12 +34,14 @@ · 33 unmodified lines | |||
| 34 | import { Context, Effect, Option } from "effect"; | 34 | import { Context, Effect, Option } from "effect"; |
| 35 | 35 | ||
| 36 | import type { McpResource } from "@executor-js/host-mcp"; | 36 | import type { McpResource } from "@executor-js/host-mcp"; |
| 37 | import { platformObserverAccess } from "@executor-js/product-access"; | ||
| 37 | import { | 38 | import { |
| 38 | createExecutor, | 39 | createExecutor, |
| 39 | Subject, | 40 | Subject, |
| 40 | Tenant, | 41 | Tenant, |
| 41 | type AnyPlugin, | 42 | type AnyPlugin, |
| 42 | type Executor, | 43 | type Executor, |
| 44 | type ExecutorAccess, | ||
| 43 | type ExecutorConfig, | 45 | type ExecutorConfig, |
| 44 | type FirstPartyOAuthClientConfig, | 46 | type FirstPartyOAuthClientConfig, |
| 45 | type StorageFailure, | 47 | type StorageFailure, |
| @@ -258,12 +260,13 @@ export const makeScopedExecutor = < · 212 unmodified lines | |||
| 258 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the | 260 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the |
| 259 | // v2 executor binding, which is `{ tenant, subject }` only. | 261 | // v2 executor binding, which is `{ tenant, subject }` only. |
| 260 | _organizationName: string, | 262 | _organizationName: string, |
| 261 | options?: { | 263 | options: { |
| 262 | readonly plugins?: PluginsProviderContext; | 264 | readonly plugins?: PluginsProviderContext; |
| 263 | /** Workspace-settings permission for this binding (see | 265 | /** The product's access decisions for this binding (see |
| 264 | * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's | 266 | * `ExecutorConfig.access`) — REQUIRED, selected by the host from |
| 265 | * role; omitted -> allowed (hosts with no role model). */ | 267 | * `@executor-js/product-access` (role-derived on the HTTP plane, |
| 266 | readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"]; | 268 | * request-bound on session stacks). */ |
| 269 | readonly access: ExecutorAccess; | ||
| 267 | }, | 270 | }, |
| 268 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => | 271 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => |
| 269 | Effect.gen(function* () { | 272 | Effect.gen(function* () { |
| @@ -336,7 +339,7 @@ export const makeScopedExecutor = < · 66 unmodified lines | |||
| 336 | ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), | 339 | ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), |
| 337 | ...(waitUntil !== undefined ? { waitUntil } : {}), | 340 | ...(waitUntil !== undefined ? { waitUntil } : {}), |
| 338 | onElicitation: "accept-all", | 341 | onElicitation: "accept-all", |
| 339 | ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), | 342 | access: options.access, |
| 340 | redirectUri, | 343 | redirectUri, |
| 341 | oauthCallbackStateOrgSlug: orgSlug, | 344 | oauthCallbackStateOrgSlug: orgSlug, |
| 342 | firstPartyOAuthClients: config.firstPartyOAuthClients, | 345 | firstPartyOAuthClients: config.firstPartyOAuthClients, |
| @@ -370,9 +373,9 @@ export const makeScopedExecutor = < · 27 unmodified lines | |||
| 370 | // An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on | 373 | // An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on |
| 371 | // the whole workspace) has NO acting member, so there is no honest subject to | 374 | // the whole workspace) has NO acting member, so there is no honest subject to |
| 372 | // bind. This builds the executor that shape implies: `{ tenant, subject: | 375 | // bind. This builds the executor that shape implies: `{ tenant, subject: |
| 373 | // undefined, platformView: true }`. | 376 | // undefined, access: platformObserverAccess() }`. |
| 374 | // | 377 | // |
| 375 | // WHAT "READ-ONLY" MEANS HERE, PRECISELY. `platformView: true` puts | 378 | // WHAT "READ-ONLY" MEANS HERE, PRECISELY. The platform access posture puts |
| 376 | // `writes: "denied"` on the executor's base owner-policy context and | 379 | // `writes: "denied"` on the executor's base owner-policy context and |
| 377 | // `reach: "tenant"` on the `admin` handle's. So: | 380 | // `reach: "tenant"` on the `admin` handle's. So: |
| 378 | // - EVERY surface on this executor — `admin`, and the ordinary `connections` | 381 | // - EVERY surface on this executor — `admin`, and the ordinary `connections` |
| @@ -429,6 +432,6 @@ export const makePlatformExecutor = ( · 50 unmodified lines | |||
| 429 | httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions), | 432 | httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions), |
| 430 | fetch: makeHostedFetch(hostedHttpOptions), | 433 | fetch: makeHostedFetch(hostedHttpOptions), |
| 431 | onElicitation: "accept-all", | 434 | onElicitation: "accept-all", |
| 432 | platformView: true, | 435 | access: platformObserverAccess(), |
| 433 | }).pipe(Effect.withSpan("executor.platform.create_executor")); | 436 | }).pipe(Effect.withSpan("executor.platform.create_executor")); |
| 434 | }); | 437 | }); |
| @@ -17,12 +17,14 @@ npm install @executor-js/sdk @executor-js/execution @executor-js/runtime-quickjs · 16 unmodified lines | |||
| 17 | ## Usage | 17 | ## Usage |
| 18 | 18 | ||
| 19 | ```ts | 19 | ```ts |
| 20 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 20 | import { createExecutor } from "@executor-js/sdk"; | 21 | import { createExecutor } from "@executor-js/sdk"; |
| 21 | import { createExecutionEngine } from "@executor-js/execution"; | 22 | import { createExecutionEngine } from "@executor-js/execution"; |
| 22 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 23 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 23 | 24 | ||
| 24 | const executor = await createExecutor({ | 25 | const executor = await createExecutor({ |
| 25 | onElicitation: "accept-all", | 26 | onElicitation: "accept-all", |
| 27 | access: workspaceServiceAccess(), | ||
| 26 | }); | 28 | }); |
| 27 | 29 | ||
| 28 | const engine = createExecutionEngine({ | 30 | const engine = createExecutionEngine({ |
| @@ -48,6 +48,7 @@ · 47 unmodified lines | |||
| 48 | }, | 48 | }, |
| 49 | "devDependencies": { | 49 | "devDependencies": { |
| 50 | "@effect/vitest": "catalog:", | 50 | "@effect/vitest": "catalog:", |
| 51 | "@executor-js/product-access": "workspace:*", | ||
| 51 | "@executor-js/runtime-quickjs": "workspace:*", | 52 | "@executor-js/runtime-quickjs": "workspace:*", |
| 52 | "@types/node": "catalog:", | 53 | "@types/node": "catalog:", |
| 53 | "bun-types": "catalog:", | 54 | "bun-types": "catalog:", |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | |||
| 14 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 14 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 15 | 15 | ||
| 16 | import { buildExecuteDescription, parseIntegrationInventory } from "./description"; | 16 | import { buildExecuteDescription, parseIntegrationInventory } from "./description"; |
| 17 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 17 | 18 | ||
| 18 | const memoryProvider = (): CredentialProvider => { | 19 | const memoryProvider = (): CredentialProvider => { |
| 19 | const store = new Map<string, string>(); | 20 | const store = new Map<string, string>(); |
| @@ -74,7 +75,10 @@ describe("buildExecuteDescription", () => { · 54 unmodified lines | |||
| 74 | it.effect("lists the connected integrations, not the connection prefixes", () => | 75 | it.effect("lists the connected integrations, not the connection prefixes", () => |
| 75 | Effect.gen(function* () { | 76 | Effect.gen(function* () { |
| 76 | const executor = yield* createExecutor( | 77 | const executor = yield* createExecutor( |
| 77 | makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), | 78 | makeTestConfig({ |
| 79 | access: testAccess.member(), | ||
| 80 | plugins: [slackPlugin, githubPlugin] as const, | ||
| 81 | }), | ||
| 78 | ); | 82 | ); |
| 79 | yield* executor["slack-plugin"].seed(); | 83 | yield* executor["slack-plugin"].seed(); |
| 80 | yield* executor["github-plugin"].seed(); | 84 | yield* executor["github-plugin"].seed(); |
| @@ -121,7 +125,10 @@ describe("buildExecuteDescription", () => { · 40 unmodified lines | |||
| 121 | it.effect("lists integration names only, with no descriptions", () => | 125 | it.effect("lists integration names only, with no descriptions", () => |
| 122 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 123 | const executor = yield* createExecutor( | 127 | const executor = yield* createExecutor( |
| 124 | makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), | 128 | makeTestConfig({ |
| 129 | access: testAccess.member(), | ||
| 130 | plugins: [slackPlugin, githubPlugin] as const, | ||
| 131 | }), | ||
| 125 | ); | 132 | ); |
| 126 | yield* executor["slack-plugin"].seed(); | 133 | yield* executor["slack-plugin"].seed(); |
| 127 | yield* executor["github-plugin"].seed(); | 134 | yield* executor["github-plugin"].seed(); |
| @@ -154,7 +161,9 @@ describe("buildExecuteDescription", () => { · 26 unmodified lines | |||
| 154 | 161 | ||
| 155 | it.effect("dedupes many connections of one integration into a single line", () => | 162 | it.effect("dedupes many connections of one integration into a single line", () => |
| 156 | Effect.gen(function* () { | 163 | Effect.gen(function* () { |
| 157 | const executor = yield* createExecutor(makeTestConfig({ plugins: [githubPlugin] as const })); | 164 | const executor = yield* createExecutor( |
| 165 | makeTestConfig({ access: testAccess.member(), plugins: [githubPlugin] as const }), | ||
| 166 | ); | ||
| 158 | yield* executor["github-plugin"].seed(); | 167 | yield* executor["github-plugin"].seed(); |
| 159 | yield* executor.connections.create({ | 168 | yield* executor.connections.create({ |
| 160 | owner: "org", | 169 | owner: "org", |
| @@ -181,7 +190,9 @@ describe("buildExecuteDescription", () => { · 20 unmodified lines | |||
| 181 | 190 | ||
| 182 | it.effect("omits the Available integrations section when no connections exist", () => | 191 | it.effect("omits the Available integrations section when no connections exist", () => |
| 183 | Effect.gen(function* () { | 192 | Effect.gen(function* () { |
| 184 | const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); | 193 | const executor = yield* createExecutor( |
| 194 | makeTestConfig({ access: testAccess.member(), plugins: [] as const }), | ||
| 195 | ); | ||
| 185 | 196 | ||
| 186 | const description = yield* buildExecuteDescription(executor); | 197 | const description = yield* buildExecuteDescription(executor); |
| 187 | 198 | ||
| @@ -195,7 +206,10 @@ describe("parseIntegrationInventory", () => { · 7 unmodified lines | |||
| 195 | it.effect("round-trips the slugs a built description lists", () => | 206 | it.effect("round-trips the slugs a built description lists", () => |
| 196 | Effect.gen(function* () { | 207 | Effect.gen(function* () { |
| 197 | const executor = yield* createExecutor( | 208 | const executor = yield* createExecutor( |
| 198 | makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), | 209 | makeTestConfig({ |
| 210 | access: testAccess.member(), | ||
| 211 | plugins: [slackPlugin, githubPlugin] as const, | ||
| 212 | }), | ||
| 199 | ); | 213 | ); |
| 200 | yield* executor["slack-plugin"].seed(); | 214 | yield* executor["slack-plugin"].seed(); |
| 201 | yield* executor["github-plugin"].seed(); | 215 | yield* executor["github-plugin"].seed(); |
| @@ -14,6 +14,7 @@ import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine"; | 15 | import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine"; |
| 16 | import { FormElicitation } from "@executor-js/sdk/core"; | 16 | import { FormElicitation } from "@executor-js/sdk/core"; |
| 17 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 17 | 18 | ||
| 18 | // Regression for the hang reported as the executor-MCP "180s timeout" against | 19 | // Regression for the hang reported as the executor-MCP "180s timeout" against |
| 19 | // Cowork (Claude web). Cowork goes down the `executeWithPause` branch because | 20 | // Cowork (Claude web). Cowork goes down the `executeWithPause` branch because |
| @@ -42,7 +43,10 @@ const emptyPlugin = definePlugin(() => ({ · 22 unmodified lines | |||
| 42 | staticIntegrations: () => [], | 43 | staticIntegrations: () => [], |
| 43 | })); | 44 | })); |
| 44 | 45 | ||
| 45 | const makeExecutor = () => createExecutor(makeTestConfig({ plugins: [emptyPlugin()] as const })); | 46 | const makeExecutor = () => |
| 47 | createExecutor( | ||
| 48 | makeTestConfig({ access: testAccess.member(), plugins: [emptyPlugin()] as const }), | ||
| 49 | ); | ||
| 46 | 50 | ||
| 47 | describe("executeWithPause failure propagation", () => { | 51 | describe("executeWithPause failure propagation", () => { |
| 48 | it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () => | 52 | it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () => |
| @@ -98,7 +102,7 @@ describe("paused execution authorization", () => { · 49 unmodified lines | |||
| 98 | it.effect("uses the resumer's current org-write access after approval", () => | 102 | it.effect("uses the resumer's current org-write access after approval", () => |
| 99 | Effect.gen(function* () { | 103 | Effect.gen(function* () { |
| 100 | const executor = yield* createExecutor( | 104 | const executor = yield* createExecutor( |
| 101 | makeTestConfig({ coreTools: {}, orgWrites: "request" }), | 105 | makeTestConfig({ coreTools: {}, access: testAccess.requestBound() }), |
| 102 | ); | 106 | ); |
| 103 | const engine = createExecutionEngine({ | 107 | const engine = createExecutionEngine({ |
| 104 | executor, | 108 | executor, |
| @@ -170,7 +174,7 @@ describe("paused execution authorization", () => { · 65 unmodified lines | |||
| 170 | const executor = yield* createExecutor( | 174 | const executor = yield* createExecutor( |
| 171 | makeTestConfig({ | 175 | makeTestConfig({ |
| 172 | coreTools: {}, | 176 | coreTools: {}, |
| 173 | orgWrites: "request", | 177 | access: testAccess.requestBound(), |
| 174 | plugins: [joinPlugin()] as const, | 178 | plugins: [joinPlugin()] as const, |
| 175 | }), | 179 | }), |
| 176 | ); | 180 | ); |
| @@ -5,6 +5,7 @@ import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/ · 4 unmodified lines | |||
| 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 6 | import { ExecutionToolError } from "./errors"; | 6 | import { ExecutionToolError } from "./errors"; |
| 7 | import { makeExecutorToolInvoker } from "./tool-invoker"; | 7 | import { makeExecutorToolInvoker } from "./tool-invoker"; |
| 8 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 8 | 9 | ||
| 9 | const EmptyInputSchema = Schema.toStandardSchemaV1( | 10 | const EmptyInputSchema = Schema.toStandardSchemaV1( |
| 10 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | 11 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), |
| @@ -138,7 +139,9 @@ const leakyPlugin = definePlugin(() => ({ · 127 unmodified lines | |||
| 138 | describe("internal-error leak audit (opaque defects)", () => { | 139 | describe("internal-error leak audit (opaque defects)", () => { |
| 139 | it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () => | 140 | it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () => |
| 140 | Effect.gen(function* () { | 141 | Effect.gen(function* () { |
| 141 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | 142 | const executor = yield* createExecutor( |
| 143 | makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), | ||
| 144 | ); | ||
| 142 | const invoker = makeExecutorToolInvoker(executor, { | 145 | const invoker = makeExecutorToolInvoker(executor, { |
| 143 | invokeOptions: { onElicitation: acceptAll }, | 146 | invokeOptions: { onElicitation: acceptAll }, |
| 144 | }); | 147 | }); |
| @@ -160,7 +163,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 15 unmodified lines | |||
| 160 | 163 | ||
| 161 | it.effect("plain Error with stack: stack and message do NOT escape", () => | 164 | it.effect("plain Error with stack: stack and message do NOT escape", () => |
| 162 | Effect.gen(function* () { | 165 | Effect.gen(function* () { |
| 163 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | 166 | const executor = yield* createExecutor( |
| 167 | makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), | ||
| 168 | ); | ||
| 164 | const invoker = makeExecutorToolInvoker(executor, { | 169 | const invoker = makeExecutorToolInvoker(executor, { |
| 165 | invokeOptions: { onElicitation: acceptAll }, | 170 | invokeOptions: { onElicitation: acceptAll }, |
| 166 | }); | 171 | }); |
| @@ -179,7 +184,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 12 unmodified lines | |||
| 179 | describe("openapi pre-flight invocation errors (expected failures)", () => { | 184 | describe("openapi pre-flight invocation errors (expected failures)", () => { |
| 180 | it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () => | 185 | it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () => |
| 181 | Effect.gen(function* () { | 186 | Effect.gen(function* () { |
| 182 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | 187 | const executor = yield* createExecutor( |
| 188 | makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), | ||
| 189 | ); | ||
| 183 | const invoker = makeExecutorToolInvoker(executor, { | 190 | const invoker = makeExecutorToolInvoker(executor, { |
| 184 | invokeOptions: { onElicitation: acceptAll }, | 191 | invokeOptions: { onElicitation: acceptAll }, |
| 185 | }); | 192 | }); |
| @@ -197,7 +204,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines | |||
| 197 | 204 | ||
| 198 | it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () => | 205 | it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () => |
| 199 | Effect.gen(function* () { | 206 | Effect.gen(function* () { |
| 200 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | 207 | const executor = yield* createExecutor( |
| 208 | makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), | ||
| 209 | ); | ||
| 201 | const invoker = makeExecutorToolInvoker(executor, { | 210 | const invoker = makeExecutorToolInvoker(executor, { |
| 202 | invokeOptions: { onElicitation: acceptAll }, | 211 | invokeOptions: { onElicitation: acceptAll }, |
| 203 | }); | 212 | }); |
| @@ -215,7 +224,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines | |||
| 215 | 224 | ||
| 216 | it.effect("transport failure (statusCode None but cause present) stays opaque", () => | 225 | it.effect("transport failure (statusCode None but cause present) stays opaque", () => |
| 217 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 218 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | 227 | const executor = yield* createExecutor( |
| 228 | makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), | ||
| 229 | ); | ||
| 219 | const invoker = makeExecutorToolInvoker(executor, { | 230 | const invoker = makeExecutorToolInvoker(executor, { |
| 220 | invokeOptions: { onElicitation: acceptAll }, | 231 | invokeOptions: { onElicitation: acceptAll }, |
| 221 | }); | 232 | }); |
| @@ -235,7 +246,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 13 unmodified lines | |||
| 235 | 246 | ||
| 236 | it.effect("post-response failure (statusCode Some) stays opaque", () => | 247 | it.effect("post-response failure (statusCode Some) stays opaque", () => |
| 237 | Effect.gen(function* () { | 248 | Effect.gen(function* () { |
| 238 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | 249 | const executor = yield* createExecutor( |
| 250 | makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), | ||
| 251 | ); | ||
| 239 | const invoker = makeExecutorToolInvoker(executor, { | 252 | const invoker = makeExecutorToolInvoker(executor, { |
| 240 | invokeOptions: { onElicitation: acceptAll }, | 253 | invokeOptions: { onElicitation: acceptAll }, |
| 241 | }); | 254 | }); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | |||
| 38 | searchTools, | 38 | searchTools, |
| 39 | type ToolDiscoveryProvider, | 39 | type ToolDiscoveryProvider, |
| 40 | } from "./tool-invoker"; | 40 | } from "./tool-invoker"; |
| 41 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 41 | 42 | ||
| 42 | // --------------------------------------------------------------------------- | 43 | // --------------------------------------------------------------------------- |
| 43 | // v2 port. The v1 suite modelled namespaces as `staticSources` whose tools | 44 | // v2 port. The v1 suite modelled namespaces as `staticSources` whose tools |
| @@ -477,7 +478,7 @@ const provision = ( · 433 unmodified lines | |||
| 477 | }); | 478 | }); |
| 478 | 479 | ||
| 479 | const makeExecutorWith = <const TPlugins extends readonly AnyPlugin[]>(plugins: TPlugins) => | 480 | const makeExecutorWith = <const TPlugins extends readonly AnyPlugin[]>(plugins: TPlugins) => |
| 480 | createExecutor(makeTestConfig({ plugins })); | 481 | createExecutor(makeTestConfig({ access: testAccess.member(), plugins })); |
| 481 | 482 | ||
| 482 | const makeSearchExecutor = () => | 483 | const makeSearchExecutor = () => |
| 483 | Effect.gen(function* () { | 484 | Effect.gen(function* () { |
| @@ -1250,6 +1251,7 @@ describe("tool discovery", () => { · 766 unmodified lines | |||
| 1250 | }); | 1251 | }); |
| 1251 | const executor = yield* createExecutor( | 1252 | const executor = yield* createExecutor( |
| 1252 | makeTestConfig({ | 1253 | makeTestConfig({ |
| 1254 | access: testAccess.member(), | ||
| 1253 | plugins: [memoryCredentialsPlugin()] as const, | 1255 | plugins: [memoryCredentialsPlugin()] as const, |
| 1254 | coreTools: {}, | 1256 | coreTools: {}, |
| 1255 | }), | 1257 | }), |
| @@ -1303,6 +1305,7 @@ describe("tool discovery", () => { · 47 unmodified lines | |||
| 1303 | Effect.gen(function* () { | 1305 | Effect.gen(function* () { |
| 1304 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1306 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1305 | const config = makeTestConfig({ | 1307 | const config = makeTestConfig({ |
| 1308 | access: testAccess.member(), | ||
| 1306 | plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, | 1309 | plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, |
| 1307 | }); | 1310 | }); |
| 1308 | const executor = yield* createExecutor(config); | 1311 | const executor = yield* createExecutor(config); |
| @@ -1386,6 +1389,7 @@ describe("tool discovery", () => { · 77 unmodified lines | |||
| 1386 | invalidRefreshTokenDescription: "Refresh token expired", | 1389 | invalidRefreshTokenDescription: "Refresh token expired", |
| 1387 | }); | 1390 | }); |
| 1388 | const config = makeTestConfig({ | 1391 | const config = makeTestConfig({ |
| 1392 | access: testAccess.member(), | ||
| 1389 | plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, | 1393 | plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, |
| 1390 | }); | 1394 | }); |
| 1391 | const executor = yield* createExecutor(config); | 1395 | const executor = yield* createExecutor(config); |
| @@ -0,0 +1 @@ | |||
| 1 | # @executor-js/product-access | ||
| @@ -0,0 +1,79 @@ | |||
| 1 | # @executor-js/product-access | ||
| 2 | |||
| 3 | The product's personal/organization access rules for | ||
| 4 | [`@executor-js/sdk`](../sdk). The SDK executes tools and enforces tenant | ||
| 5 | isolation; it deliberately owns no product rule. Every `createExecutor` call | ||
| 6 | requires an `ExecutorAccess` — the product's decisions for: | ||
| 7 | |||
| 8 | - **`owners`** — which owner partitions (`"user"`, `"org"`) the binding sees | ||
| 9 | and may write, in precedence order. Really filters storage CRUD, tool | ||
| 10 | list/schema/invoke, plugin storage and plugin blobs. | ||
| 11 | - **`settingsWrite(target)`** — whether a user-intent settings mutation | ||
| 12 | (connections, policies, OAuth clients, the integration catalog) is allowed, | ||
| 13 | asked live at every sink. | ||
| 14 | - **`capabilities`** — `adminReads` (the tenant-wide read-only admin surface) | ||
| 15 | and `storageWrites` (`"denied"` = whole-executor read-only). | ||
| 16 | - **`toolPolicy(sources)`** — how stored policy rows and toolkit capability | ||
| 17 | rules resolve into an effective allow/approve/block decision. | ||
| 18 | |||
| 19 | This package ships the standard rules. Core never falls back to them — a | ||
| 20 | composition root states its posture explicitly. | ||
| 21 | |||
| 22 | ## Effect SDK | ||
| 23 | |||
| 24 | ```ts | ||
| 25 | import { Effect } from "effect"; | ||
| 26 | import { createExecutor } from "@executor-js/sdk/core"; | ||
| 27 | import { singleUserAccess } from "@executor-js/product-access"; | ||
| 28 | |||
| 29 | const program = Effect.gen(function* () { | ||
| 30 | const executor = yield* createExecutor({ | ||
| 31 | tenant, | ||
| 32 | subject, | ||
| 33 | onElicitation: "accept-all", | ||
| 34 | access: singleUserAccess(), | ||
| 35 | }); | ||
| 36 | // Execution stays on the executor namespaces: executor.tools, executor.execute, … | ||
| 37 | }); | ||
| 38 | ``` | ||
| 39 | |||
| 40 | ## Promise SDK | ||
| 41 | |||
| 42 | ```ts | ||
| 43 | import { createExecutor } from "@executor-js/sdk"; // published root = Promise API | ||
| 44 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 45 | |||
| 46 | const executor = await createExecutor({ | ||
| 47 | onElicitation: "accept-all", | ||
| 48 | // Subject-less single-workspace embedder posture. | ||
| 49 | access: workspaceServiceAccess(), | ||
| 50 | }); | ||
| 51 | ``` | ||
| 52 | |||
| 53 | ## Postures | ||
| 54 | |||
| 55 | | Constructor | Binding | Used by | | ||
| 56 | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------- | | ||
| 57 | | `singleUserAccess()` | bound subject, everything allowed | local daemon, CLI, desktop | | ||
| 58 | | `memberAccessForRole({ orgRoleModel, orgRole })` | bound member; admins configure, members use | HTTP API plane | | ||
| 59 | | `requestBoundMemberAccess()` | bound member; decision re-read per request via `CurrentOrgWriteAccess`, re-stamped on approval resume | MCP session stacks | | ||
| 60 | | `workspaceServiceAccess()` | subject-less org service; boot convergence | seeding/system executors, embedders | | ||
| 61 | | `platformObserverAccess()` | subject-less, storage read-only, tenant-wide `admin` reads | `/admin/*` org-credential plane | | ||
| 62 | |||
| 63 | `orgWriteAccessForRole` is the single home of the admin/member rule. | ||
| 64 | `./policy` exports the resolution semantics (owner ranking, most-restrictive | ||
| 65 | merge, plugin-default fallback, toolkit allowlist) — browser-safe, built on | ||
| 66 | `@executor-js/sdk/shared`. `./testing` exports `testAccess` postures for | ||
| 67 | tests (`makeTestConfig({ access: testAccess.member() })`). | ||
| 68 | |||
| 69 | ## Breaking changes (from the pre-split SDK) | ||
| 70 | |||
| 71 | - `ExecutorConfig.orgWrites` and `platformView` are gone; `access` is | ||
| 72 | required on both the Effect and Promise `createExecutor`. | ||
| 73 | - `makeTestConfig` / `makeTestWorkspaceHarness` / `makeTestExecutor` / | ||
| 74 | `makeTestWorkspaceLayer` require `access`. | ||
| 75 | - Policy resolution functions moved here from `@executor-js/sdk` | ||
| 76 | (`resolveEffectivePolicy`, `effectivePolicyFromSorted`, …); pattern | ||
| 77 | matching (`matchPattern`, `isValidPattern`) stays in the SDK. | ||
| 78 | - No stored schema or data migration: rows, tenants, owners, subjects and | ||
| 79 | tool addresses are unchanged. | ||
| @@ -0,0 +1,67 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor-js/product-access", | ||
| 3 | "version": "1.6.8", | ||
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/product-access", | ||
| 5 | "bugs": { | ||
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | ||
| 7 | }, | ||
| 8 | "license": "MIT", | ||
| 9 | "repository": { | ||
| 10 | "type": "git", | ||
| 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | ||
| 12 | "directory": "packages/core/product-access" | ||
| 13 | }, | ||
| 14 | "files": [ | ||
| 15 | "dist" | ||
| 16 | ], | ||
| 17 | "type": "module", | ||
| 18 | "exports": { | ||
| 19 | ".": "./src/index.ts", | ||
| 20 | "./policy": "./src/policy.ts", | ||
| 21 | "./testing": "./src/testing.ts" | ||
| 22 | }, | ||
| 23 | "publishConfig": { | ||
| 24 | "access": "public", | ||
| 25 | "exports": { | ||
| 26 | ".": { | ||
| 27 | "import": { | ||
| 28 | "types": "./dist/index.d.ts", | ||
| 29 | "default": "./dist/index.js" | ||
| 30 | } | ||
| 31 | }, | ||
| 32 | "./policy": { | ||
| 33 | "import": { | ||
| 34 | "types": "./dist/policy.d.ts", | ||
| 35 | "default": "./dist/policy.js" | ||
| 36 | } | ||
| 37 | }, | ||
| 38 | "./testing": { | ||
| 39 | "import": { | ||
| 40 | "types": "./dist/testing.d.ts", | ||
| 41 | "default": "./dist/testing.js" | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | }, | ||
| 46 | "scripts": { | ||
| 47 | "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", | ||
| 48 | "typecheck": "tsgo --noEmit", | ||
| 49 | "test": "vitest run", | ||
| 50 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" | ||
| 51 | }, | ||
| 52 | "dependencies": { | ||
| 53 | "@executor-js/sdk": "workspace:*" | ||
| 54 | }, | ||
| 55 | "devDependencies": { | ||
| 56 | "@effect/vitest": "catalog:", | ||
| 57 | "@types/node": "catalog:", | ||
| 58 | "bun-types": "catalog:", | ||
| 59 | "effect": "catalog:", | ||
| 60 | "tsup": "catalog:", | ||
| 61 | "typescript": "catalog:", | ||
| 62 | "vitest": "catalog:" | ||
| 63 | }, | ||
| 64 | "peerDependencies": { | ||
| 65 | "effect": "catalog:" | ||
| 66 | } | ||
| 67 | } | ||
| @@ -0,0 +1,185 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Ref } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | CurrentOrgWriteAccess, | ||
| 6 | makeOrgWriteAccessState, | ||
| 7 | type ToolPolicyRow, | ||
| 8 | } from "@executor-js/sdk/core"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | memberAccess, | ||
| 12 | memberAccessForRole, | ||
| 13 | orgWriteAccessForRole, | ||
| 14 | platformObserverAccess, | ||
| 15 | requestBoundMemberAccess, | ||
| 16 | singleUserAccess, | ||
| 17 | workspaceServiceAccess, | ||
| 18 | } from "./index"; | ||
| 19 | import { resolveProviderPolicyFromRules, standardToolPolicy } from "./policy"; | ||
| 20 | |||
| 21 | describe("orgWriteAccessForRole", () => { | ||
| 22 | it("trusts the single user of a deployment without a role model", () => { | ||
| 23 | expect(orgWriteAccessForRole({ orgRoleModel: "none" })).toBe("allowed"); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("lets admins configure the workspace under a role model", () => { | ||
| 27 | expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "admin" })).toBe( | ||
| 28 | "allowed", | ||
| 29 | ); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("denies plain members", () => { | ||
| 33 | expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "member" })).toBe( | ||
| 34 | "denied", | ||
| 35 | ); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("fails closed when a role model is present but the role is missing", () => { | ||
| 39 | expect(orgWriteAccessForRole({ orgRoleModel: "organization" })).toBe("denied"); | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | describe("settings-write rules", () => { | ||
| 44 | it.effect("the role decision gates workspace targets; personal stays open", () => | ||
| 45 | Effect.gen(function* () { | ||
| 46 | const member = memberAccessForRole({ orgRoleModel: "organization", orgRole: "member" }); | ||
| 47 | const admin = memberAccessForRole({ orgRoleModel: "organization", orgRole: "admin" }); | ||
| 48 | expect(yield* member.settingsWrite({ kind: "workspace" })).toBe("denied"); | ||
| 49 | expect(yield* member.settingsWrite({ kind: "owner", owner: "org" })).toBe("denied"); | ||
| 50 | expect(yield* member.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed"); | ||
| 51 | expect(yield* admin.settingsWrite({ kind: "workspace" })).toBe("allowed"); | ||
| 52 | expect(admin.owners).toEqual(["user", "org"]); | ||
| 53 | expect(admin.capabilities).toEqual({ adminReads: false, storageWrites: "allowed" }); | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | |||
| 57 | it.effect("requestBoundMemberAccess fails closed without a request binding", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const access = requestBoundMemberAccess(); | ||
| 60 | expect(yield* access.settingsWrite({ kind: "workspace" })).toBe("denied"); | ||
| 61 | // Personal targets never consult the request decision. | ||
| 62 | expect(yield* access.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed"); | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | |||
| 66 | it.effect("requestBoundMemberAccess reads the live request binding at every ask", () => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const access = requestBoundMemberAccess(); | ||
| 69 | const state = makeOrgWriteAccessState("allowed"); | ||
| 70 | const ask = access | ||
| 71 | .settingsWrite({ kind: "owner", owner: "org" }) | ||
| 72 | .pipe(Effect.provideService(CurrentOrgWriteAccess, state)); | ||
| 73 | expect(yield* ask).toBe("allowed"); | ||
| 74 | // The engine re-stamps this Ref from the resuming principal; the SAME | ||
| 75 | // access value must observe the new decision, not a snapshot. | ||
| 76 | yield* Ref.set(state.current, "denied"); | ||
| 77 | expect(yield* ask).toBe("denied"); | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | it.effect("single-user and workspace-service postures allow workspace writes", () => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const single = singleUserAccess(); | ||
| 84 | const service = workspaceServiceAccess(); | ||
| 85 | expect(yield* single.settingsWrite({ kind: "workspace" })).toBe("allowed"); | ||
| 86 | expect(single.owners).toEqual(["user", "org"]); | ||
| 87 | expect(yield* service.settingsWrite({ kind: "workspace" })).toBe("allowed"); | ||
| 88 | expect(service.owners).toEqual(["org"]); | ||
| 89 | expect(yield* memberAccess("denied").settingsWrite({ kind: "workspace" })).toBe("denied"); | ||
| 90 | }), | ||
| 91 | ); | ||
| 92 | |||
| 93 | it("the platform observer is a subject-less read-only view with admin reads", () => { | ||
| 94 | const access = platformObserverAccess(); | ||
| 95 | expect(access.owners).toEqual(["org"]); | ||
| 96 | expect(access.capabilities).toEqual({ adminReads: true, storageWrites: "denied" }); | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | |||
| 100 | describe("standardToolPolicy", () => { | ||
| 101 | const row = ( | ||
| 102 | id: string, | ||
| 103 | owner: "org" | "user", | ||
| 104 | pattern: string, | ||
| 105 | action: "approve" | "require_approval" | "block", | ||
| 106 | ): ToolPolicyRow => | ||
| 107 | ({ | ||
| 108 | id, | ||
| 109 | owner, | ||
| 110 | subject: owner === "org" ? "" : "u", | ||
| 111 | pattern, | ||
| 112 | action, | ||
| 113 | position: "a0", | ||
| 114 | created_at: new Date(0), | ||
| 115 | updated_at: new Date(0), | ||
| 116 | }) as ToolPolicyRow; | ||
| 117 | |||
| 118 | const evaluate = ( | ||
| 119 | rows: readonly ReturnType<typeof row>[], | ||
| 120 | toolId: string, | ||
| 121 | defaultRequiresApproval?: boolean, | ||
| 122 | ) => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const evaluator = yield* standardToolPolicy(["user", "org"])({ | ||
| 125 | policyRows: Effect.succeed(rows), | ||
| 126 | provider: null, | ||
| 127 | }); | ||
| 128 | return yield* evaluator.resolve({ toolId, defaultRequiresApproval }); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it.effect("an org guardrail beats a user preference", () => | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const effective = yield* evaluate( | ||
| 134 | [ | ||
| 135 | row("outer", "org", "vercel.*", "block"), | ||
| 136 | row("inner", "user", "vercel.dns.create", "approve"), | ||
| 137 | ], | ||
| 138 | "vercel.dns.create", | ||
| 139 | ); | ||
| 140 | expect(effective).toMatchObject({ action: "block", policyId: "outer" }); | ||
| 141 | }), | ||
| 142 | ); | ||
| 143 | |||
| 144 | it.effect("no authored match falls back to the plugin default", () => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | expect(yield* evaluate([], "vercel.dns.create", true)).toMatchObject({ | ||
| 147 | action: "require_approval", | ||
| 148 | source: "plugin-default", | ||
| 149 | }); | ||
| 150 | expect(yield* evaluate([], "vercel.dns.create")).toMatchObject({ | ||
| 151 | action: "approve", | ||
| 152 | source: "plugin-default", | ||
| 153 | }); | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | |||
| 157 | it.effect("a list-only provider is an allowlist: unmatched tools block", () => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | const evaluator = yield* standardToolPolicy(["user", "org"])({ | ||
| 160 | policyRows: Effect.succeed([]), | ||
| 161 | provider: { | ||
| 162 | list: () => | ||
| 163 | Effect.succeed([ | ||
| 164 | { id: "r1", pattern: "github.*", action: "approve" as const, position: "a0" }, | ||
| 165 | ]), | ||
| 166 | }, | ||
| 167 | }); | ||
| 168 | expect(yield* evaluator.resolve({ toolId: "github.org.acme.repos.list" })).toMatchObject({ | ||
| 169 | action: "approve", | ||
| 170 | }); | ||
| 171 | expect(yield* evaluator.resolve({ toolId: "vercel.org.acme.deploy" })).toMatchObject({ | ||
| 172 | action: "block", | ||
| 173 | pattern: "*", | ||
| 174 | }); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it("resolveProviderPolicyFromRules picks the first match by position", () => { | ||
| 179 | const rules = [ | ||
| 180 | { id: "b", pattern: "github.*", action: "block" as const, position: "a1" }, | ||
| 181 | { id: "a", pattern: "github.*", action: "approve" as const, position: "a0" }, | ||
| 182 | ]; | ||
| 183 | expect(resolveProviderPolicyFromRules("github.x", rules)).toMatchObject({ policyId: "a" }); | ||
| 184 | }); | ||
| 185 | }); | ||
| @@ -0,0 +1,159 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // @executor-js/product-access — the PRODUCT's personal/organization access | ||
| 3 | // rules, stated once and selected explicitly by every composition root. | ||
| 4 | // | ||
| 5 | // The SDK owns none of these. `createExecutor` requires an `ExecutorAccess` | ||
| 6 | // and enforces its decisions at core's sinks — storage row visibility and | ||
| 7 | // write partitions (`owners`), the user-intent settings gate | ||
| 8 | // (`settingsWrite`), view capabilities (`capabilities`), and effective | ||
| 9 | // tool-policy evaluation (`toolPolicy`) — while the storage owner policy | ||
| 10 | // keeps clamping everything to the binding's tenant/subject partition. | ||
| 11 | // This package is where "what may this principal touch" is decided: | ||
| 12 | // - the role rule (admins configure the workspace, members use it), | ||
| 13 | // - the personal-stays-open settings rule (a denied member still manages | ||
| 14 | // Personal-scope resources), | ||
| 15 | // - the request-bound session posture (MCP sessions re-decide per request | ||
| 16 | // and re-stamp on approval resume), | ||
| 17 | // - the single-user posture (local daemon / CLI / examples), | ||
| 18 | // - the subject-less workspace-service and platform-observer postures, | ||
| 19 | // - how authored policy rows and toolkit capability rules resolve | ||
| 20 | // (`./policy`). | ||
| 21 | // Non-HTTP hosts consume it without pulling `@executor-js/api`. | ||
| 22 | // | ||
| 23 | // Imports come from the PUBLISHED sdk entries: `@executor-js/sdk/core` (the | ||
| 24 | // Effect SDK; the sdk ROOT export is the Promise façade) and the | ||
| 25 | // browser-safe `@executor-js/sdk/shared` inside `./policy`. | ||
| 26 | // --------------------------------------------------------------------------- | ||
| 27 | |||
| 28 | import { Effect } from "effect"; | ||
| 29 | |||
| 30 | import { | ||
| 31 | currentOrgWriteAccess, | ||
| 32 | type AccessDecision, | ||
| 33 | type ExecutorAccess, | ||
| 34 | type OrgWriteAccess, | ||
| 35 | type Owner, | ||
| 36 | type SettingsWriteTarget, | ||
| 37 | } from "@executor-js/sdk/core"; | ||
| 38 | |||
| 39 | import { standardToolPolicy } from "./policy"; | ||
| 40 | |||
| 41 | export { | ||
| 42 | effectivePolicyFromSorted, | ||
| 43 | resolveEffectivePolicy, | ||
| 44 | resolveProviderPolicyFromRules, | ||
| 45 | resolveToolPolicy, | ||
| 46 | standardToolPolicy, | ||
| 47 | type ToolPolicyRuleLike, | ||
| 48 | } from "./policy"; | ||
| 49 | |||
| 50 | /** The role shape the org-write rule reads — structural, so both the API's | ||
| 51 | * `Principal` and host-mcp's schema'd copy satisfy it without translation. */ | ||
| 52 | export interface OrgRoleInput { | ||
| 53 | /** `"organization"` when the deployment has an org role model, `"none"` | ||
| 54 | * when it does not (self-host single-org, local). */ | ||
| 55 | readonly orgRoleModel: "organization" | "none"; | ||
| 56 | /** The acting member's role under an `"organization"` model. Missing at a | ||
| 57 | * legacy boundary fails closed (treated as a plain member). */ | ||
| 58 | readonly orgRole?: "admin" | "member" | undefined; | ||
| 59 | } | ||
| 60 | |||
| 61 | /** | ||
| 62 | * THE product rule for workspace-settings writes: a deployment without a | ||
| 63 | * role model trusts its user; under a role model only admins configure the | ||
| 64 | * workspace, and an absent role fails closed. Previously duplicated by the | ||
| 65 | * HTTP execution-stack middleware and the MCP serving seams — this is now | ||
| 66 | * the single home. | ||
| 67 | */ | ||
| 68 | export const orgWriteAccessForRole = (input: OrgRoleInput): OrgWriteAccess => | ||
| 69 | input.orgRoleModel === "none" || input.orgRole === "admin" ? "allowed" : "denied"; | ||
| 70 | |||
| 71 | /** A bound member's partitions, personal shadowing shared. */ | ||
| 72 | const MEMBER_OWNERS: readonly Owner[] = ["user", "org"]; | ||
| 73 | /** A subject-less binding's single partition. */ | ||
| 74 | const ORG_OWNERS: readonly Owner[] = ["org"]; | ||
| 75 | |||
| 76 | /** | ||
| 77 | * The settings rule every current product shares: Personal-scope targets | ||
| 78 | * are always the member's own to configure; workspace-level targets (org | ||
| 79 | * rows and tenant-shared surfaces) follow the workspace decision, evaluated | ||
| 80 | * LIVE at each sink so a request-bound decision is re-read after pauses. | ||
| 81 | */ | ||
| 82 | const personalOpenSettingsWrite = | ||
| 83 | (workspace: Effect.Effect<AccessDecision>) => | ||
| 84 | (target: SettingsWriteTarget): Effect.Effect<AccessDecision> => | ||
| 85 | target.kind === "owner" && target.owner === "user" | ||
| 86 | ? Effect.succeed("allowed" as const) | ||
| 87 | : workspace; | ||
| 88 | |||
| 89 | const memberCapabilities = { adminReads: false, storageWrites: "allowed" } as const; | ||
| 90 | |||
| 91 | const memberPosture = (workspace: Effect.Effect<AccessDecision>): ExecutorAccess => ({ | ||
| 92 | owners: MEMBER_OWNERS, | ||
| 93 | settingsWrite: personalOpenSettingsWrite(workspace), | ||
| 94 | capabilities: memberCapabilities, | ||
| 95 | toolPolicy: standardToolPolicy(MEMBER_OWNERS), | ||
| 96 | }); | ||
| 97 | |||
| 98 | /** | ||
| 99 | * A bound member whose workspace-settings decision was made from the freshly | ||
| 100 | * authenticated request and holds for its lifetime — the HTTP API plane, | ||
| 101 | * where every request re-authenticates and builds a new stack. | ||
| 102 | */ | ||
| 103 | export const memberAccess = (workspaceWrites: OrgWriteAccess): ExecutorAccess => | ||
| 104 | memberPosture(Effect.succeed(workspaceWrites)); | ||
| 105 | |||
| 106 | /** {@link memberAccess} with the decision derived by {@link orgWriteAccessForRole}. */ | ||
| 107 | export const memberAccessForRole = (input: OrgRoleInput): ExecutorAccess => | ||
| 108 | memberAccess(orgWriteAccessForRole(input)); | ||
| 109 | |||
| 110 | /** | ||
| 111 | * A bound member on a session-lifetime stack (MCP sessions): the executor | ||
| 112 | * outlives any one request, so the workspace-settings decision is read from | ||
| 113 | * the fiber-local `CurrentOrgWriteAccess` at EVERY guarded sink. The session | ||
| 114 | * host stamps that reference from each freshly authenticated request, the | ||
| 115 | * engine re-stamps it from the resuming principal on approval resume, and a | ||
| 116 | * missing binding fails closed (denied) — a positive authorization is never | ||
| 117 | * cached for the session lifetime. | ||
| 118 | */ | ||
| 119 | export const requestBoundMemberAccess = (): ExecutorAccess => memberPosture(currentOrgWriteAccess); | ||
| 120 | |||
| 121 | /** | ||
| 122 | * The single-user products (local daemon, CLI, desktop, examples): one human | ||
| 123 | * owns the whole deployment, there is no role model, and the bound subject | ||
| 124 | * may configure everything. | ||
| 125 | */ | ||
| 126 | export const singleUserAccess = (): ExecutorAccess => memberPosture(Effect.succeed("allowed")); | ||
| 127 | |||
| 128 | /** | ||
| 129 | * A subject-less workspace-service binding — boot/system executors that | ||
| 130 | * converge the tenant's shared state (catalog registration, seeding) with no | ||
| 131 | * acting member. Sees and writes only the org partition; workspace writes | ||
| 132 | * are the whole point. | ||
| 133 | */ | ||
| 134 | export const workspaceServiceAccess = (): ExecutorAccess => ({ | ||
| 135 | owners: ORG_OWNERS, | ||
| 136 | settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")), | ||
| 137 | capabilities: memberCapabilities, | ||
| 138 | toolPolicy: standardToolPolicy(ORG_OWNERS), | ||
| 139 | }); | ||
| 140 | |||
| 141 | /** | ||
| 142 | * The platform observer an org-level credential gets (the `/admin/*` plane): | ||
| 143 | * subject-less, the WHOLE executor read-only at the storage boundary | ||
| 144 | * (`storageWrites: "denied"`), and only the `admin` surface reading | ||
| 145 | * tenant-wide (`adminReads: true`). | ||
| 146 | * | ||
| 147 | * `settingsWrite` stays `"allowed"` ON PURPOSE: the platform posture's | ||
| 148 | * enforcement point is the storage owner policy, which refuses every | ||
| 149 | * mutation regardless of the surface gate. Deciding `"denied"` here would | ||
| 150 | * merely swap the failure the (already unreachable-over-HTTP) mutation | ||
| 151 | * surfaces raise from the storage denial to `OrgWriteDeniedError`; the | ||
| 152 | * observer's read-only-ness never depends on it. | ||
| 153 | */ | ||
| 154 | export const platformObserverAccess = (): ExecutorAccess => ({ | ||
| 155 | owners: ORG_OWNERS, | ||
| 156 | settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")), | ||
| 157 | capabilities: { adminReads: true, storageWrites: "denied" }, | ||
| 158 | toolPolicy: standardToolPolicy(ORG_OWNERS), | ||
| 159 | }); | ||
| @@ -0,0 +1,263 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The PRODUCT's tool-policy resolution rules — how authored rows and toolkit | ||
| 3 | // capability rules combine into one `EffectivePolicy`: | ||
| 4 | // | ||
| 5 | // - owner-ranked, per-owner first-match by local position; | ||
| 6 | // - the MOST RESTRICTIVE matched action across owners wins, so a user | ||
| 7 | // preference cannot weaken an org guardrail (org = outer, user = inner); | ||
| 8 | // - no authored match falls back to the tool's own `requiresApproval` | ||
| 9 | // annotation (the plugin default); | ||
| 10 | // - a list-only toolkit provider is a capability ALLOWLIST: no matching | ||
| 11 | // rule means the tool is outside the capability boundary and blocks. | ||
| 12 | // | ||
| 13 | // Core (`@executor-js/sdk`) never applies these rules itself: it hands the | ||
| 14 | // rule material to `ExecutorAccess.toolPolicy` and enforces whatever | ||
| 15 | // `EffectivePolicy` comes back. This module deliberately imports only the | ||
| 16 | // browser-safe `@executor-js/sdk/shared` utilities (pattern matching, row | ||
| 17 | // ordering) at runtime, so UI surfaces can preview the same resolution | ||
| 18 | // without pulling the server SDK. | ||
| 19 | // --------------------------------------------------------------------------- | ||
| 20 | |||
| 21 | import { Effect, Match } from "effect"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | comparePolicyRow, | ||
| 25 | matchPattern, | ||
| 26 | type EffectivePolicy, | ||
| 27 | type PolicyMatch, | ||
| 28 | type ToolPolicy, | ||
| 29 | } from "@executor-js/sdk/shared"; | ||
| 30 | import type { | ||
| 31 | StorageFailure, | ||
| 32 | ToolPolicyEvaluationInput, | ||
| 33 | ToolPolicyEvaluator, | ||
| 34 | ToolPolicyProvider, | ||
| 35 | ToolPolicyProviderRule, | ||
| 36 | ToolPolicySources, | ||
| 37 | } from "@executor-js/sdk/core"; | ||
| 38 | |||
| 39 | /** The stored-row shape resolution reads — structural, so both core's | ||
| 40 | * persisted `ToolPolicyRow` and UI projections satisfy it. */ | ||
| 41 | export interface ToolPolicyRuleLike { | ||
| 42 | readonly id: string; | ||
| 43 | readonly owner: string; | ||
| 44 | readonly pattern: string; | ||
| 45 | /** Persisted rows carry a plain string; resolution narrows it. */ | ||
| 46 | readonly action: string; | ||
| 47 | readonly position: string; | ||
| 48 | } | ||
| 49 | |||
| 50 | const actionRestrictionRank = (action: ToolPolicy["action"]): number => | ||
| 51 | Match.value(action).pipe( | ||
| 52 | Match.when("block", () => 3), | ||
| 53 | Match.when("require_approval", () => 2), | ||
| 54 | Match.when("approve", () => 1), | ||
| 55 | Match.exhaustive, | ||
| 56 | ); | ||
| 57 | |||
| 58 | const moreRestrictive = <T extends { readonly action: ToolPolicy["action"] }>( | ||
| 59 | current: T | undefined, | ||
| 60 | candidate: T, | ||
| 61 | ): T => { | ||
| 62 | if (!current) return candidate; | ||
| 63 | return actionRestrictionRank(candidate.action) > actionRestrictionRank(current.action) | ||
| 64 | ? candidate | ||
| 65 | : current; | ||
| 66 | }; | ||
| 67 | |||
| 68 | const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => | ||
| 69 | defaultRequiresApproval | ||
| 70 | ? { action: "require_approval", source: "plugin-default" } | ||
| 71 | : { action: "approve", source: "plugin-default" }; | ||
| 72 | |||
| 73 | const liftUser = (match: PolicyMatch): EffectivePolicy => ({ | ||
| 74 | action: match.action, | ||
| 75 | source: "user", | ||
| 76 | pattern: match.pattern, | ||
| 77 | policyId: match.policyId, | ||
| 78 | }); | ||
| 79 | |||
| 80 | /** | ||
| 81 | * Resolve the matching authored rule for one tool id: each owner contributes | ||
| 82 | * its first matching rule by local position, then the most restrictive | ||
| 83 | * matched action across owners wins. `ownerRank` orders owners for the | ||
| 84 | * per-owner scan (the product's precedence); it never weakens the merge. | ||
| 85 | * Returns undefined when no rule matches. | ||
| 86 | */ | ||
| 87 | export const resolveToolPolicy = ( | ||
| 88 | toolId: string, | ||
| 89 | policies: readonly ToolPolicyRuleLike[], | ||
| 90 | ownerRank: (row: { readonly owner: string }) => number, | ||
| 91 | ): PolicyMatch | undefined => { | ||
| 92 | if (policies.length === 0) return undefined; | ||
| 93 | const sorted = [...policies].sort((a, b) => { | ||
| 94 | const sa = ownerRank(a); | ||
| 95 | const sb = ownerRank(b); | ||
| 96 | if (sa !== sb) return sa - sb; | ||
| 97 | return comparePolicyRow(a, b); | ||
| 98 | }); | ||
| 99 | const firstMatchByOwner = new Map<string, PolicyMatch>(); | ||
| 100 | for (const row of sorted) { | ||
| 101 | if (firstMatchByOwner.has(row.owner)) continue; | ||
| 102 | if (matchPattern(row.pattern, toolId)) { | ||
| 103 | firstMatchByOwner.set(row.owner, { | ||
| 104 | action: row.action as ToolPolicy["action"], | ||
| 105 | pattern: row.pattern, | ||
| 106 | policyId: row.id, | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | let selected: PolicyMatch | undefined; | ||
| 111 | for (const match of firstMatchByOwner.values()) { | ||
| 112 | selected = moreRestrictive(selected, match); | ||
| 113 | } | ||
| 114 | return selected; | ||
| 115 | }; | ||
| 116 | |||
| 117 | /** | ||
| 118 | * {@link resolveToolPolicy} lifted to an {@link EffectivePolicy}: an authored | ||
| 119 | * match wins, else the plugin default (`requiresApproval` → | ||
| 120 | * `require_approval`, otherwise `approve`). | ||
| 121 | */ | ||
| 122 | export const resolveEffectivePolicy = ( | ||
| 123 | toolId: string, | ||
| 124 | policies: readonly ToolPolicyRuleLike[], | ||
| 125 | ownerRank: (row: { readonly owner: string }) => number, | ||
| 126 | defaultRequiresApproval?: boolean, | ||
| 127 | ): EffectivePolicy => { | ||
| 128 | const match = resolveToolPolicy(toolId, policies, ownerRank); | ||
| 129 | return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); | ||
| 130 | }; | ||
| 131 | |||
| 132 | /** | ||
| 133 | * UI-preview variant over an ALREADY-SORTED rule list (a settings page's | ||
| 134 | * loaded policy list): same per-owner first-match + most-restrictive merge, | ||
| 135 | * treating rows without an owner as one flat list. | ||
| 136 | */ | ||
| 137 | export const effectivePolicyFromSorted = ( | ||
| 138 | toolId: string, | ||
| 139 | sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> & | ||
| 140 | Partial<Pick<ToolPolicy, "owner">>)[], | ||
| 141 | defaultRequiresApproval?: boolean, | ||
| 142 | ): EffectivePolicy => { | ||
| 143 | const firstMatchByOwner = new Map<string, EffectivePolicy>(); | ||
| 144 | for (const p of sortedPolicies) { | ||
| 145 | const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__"; | ||
| 146 | if (firstMatchByOwner.has(ownerKey)) continue; | ||
| 147 | if (matchPattern(p.pattern, toolId)) { | ||
| 148 | firstMatchByOwner.set(ownerKey, { | ||
| 149 | action: p.action, | ||
| 150 | source: "user", | ||
| 151 | pattern: p.pattern, | ||
| 152 | policyId: p.id, | ||
| 153 | }); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | let selected: EffectivePolicy | undefined; | ||
| 157 | for (const match of firstMatchByOwner.values()) { | ||
| 158 | selected = moreRestrictive(selected, match); | ||
| 159 | } | ||
| 160 | return selected ?? liftPlugin(defaultRequiresApproval); | ||
| 161 | }; | ||
| 162 | |||
| 163 | const compareProviderPolicyRule = ( | ||
| 164 | a: ToolPolicyProviderRule, | ||
| 165 | b: ToolPolicyProviderRule, | ||
| 166 | ): number => { | ||
| 167 | if (a.position < b.position) return -1; | ||
| 168 | if (a.position > b.position) return 1; | ||
| 169 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| 170 | }; | ||
| 171 | |||
| 172 | /** | ||
| 173 | * Resolution for a list-only toolkit provider: first matching rule by | ||
| 174 | * position wins; NO matching rule blocks — the provider is a capability | ||
| 175 | * allowlist, and a tool it does not name is outside the boundary. | ||
| 176 | */ | ||
| 177 | export const resolveProviderPolicyFromRules = ( | ||
| 178 | toolId: string, | ||
| 179 | rules: readonly ToolPolicyProviderRule[], | ||
| 180 | ): EffectivePolicy => { | ||
| 181 | for (const rule of [...rules].sort(compareProviderPolicyRule)) { | ||
| 182 | if (!matchPattern(rule.pattern, toolId)) continue; | ||
| 183 | return { | ||
| 184 | action: rule.action, | ||
| 185 | source: "user", | ||
| 186 | pattern: rule.pattern, | ||
| 187 | policyId: rule.id, | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | return { | ||
| 191 | action: "block", | ||
| 192 | source: "user", | ||
| 193 | pattern: "*", | ||
| 194 | }; | ||
| 195 | }; | ||
| 196 | |||
| 197 | /** | ||
| 198 | * The standard product implementation of `ExecutorAccess.toolPolicy`. | ||
| 199 | * | ||
| 200 | * With a plugin-registered provider (a toolkit session), the provider's own | ||
| 201 | * batched `prepare` / per-tool `resolve` / plain `list` allowlist governs. | ||
| 202 | * Otherwise the stored owner-scoped rows resolve under the binding's owner | ||
| 203 | * precedence with the most-restrictive merge and plugin-default fallback. | ||
| 204 | */ | ||
| 205 | export const standardToolPolicy = | ||
| 206 | (owners: readonly string[]) => | ||
| 207 | (sources: ToolPolicySources): Effect.Effect<ToolPolicyEvaluator, StorageFailure> => { | ||
| 208 | const provider: ToolPolicyProvider | null = sources.provider; | ||
| 209 | if (provider) { | ||
| 210 | if (provider.prepare) { | ||
| 211 | // Batched per-operation resolver: fetch all policy + connection | ||
| 212 | // state once, resolve every tool in the operation from the snapshot. | ||
| 213 | return provider.prepare().pipe( | ||
| 214 | Effect.map( | ||
| 215 | (resolve): ToolPolicyEvaluator => ({ | ||
| 216 | resolve: (input: ToolPolicyEvaluationInput) => | ||
| 217 | Effect.succeed( | ||
| 218 | resolve({ | ||
| 219 | toolId: input.toolId, | ||
| 220 | ...(input.defaultRequiresApproval === undefined | ||
| 221 | ? {} | ||
| 222 | : { defaultRequiresApproval: input.defaultRequiresApproval }), | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | const perTool = provider.resolve; | ||
| 230 | if (perTool) { | ||
| 231 | return Effect.succeed({ | ||
| 232 | resolve: (input: ToolPolicyEvaluationInput) => | ||
| 233 | perTool({ | ||
| 234 | toolId: input.toolId, | ||
| 235 | ...(input.defaultRequiresApproval === undefined | ||
| 236 | ? {} | ||
| 237 | : { defaultRequiresApproval: input.defaultRequiresApproval }), | ||
| 238 | }), | ||
| 239 | }); | ||
| 240 | } | ||
| 241 | return provider.list().pipe( | ||
| 242 | Effect.map( | ||
| 243 | (rules): ToolPolicyEvaluator => ({ | ||
| 244 | resolve: (input) => Effect.succeed(resolveProviderPolicyFromRules(input.toolId, rules)), | ||
| 245 | }), | ||
| 246 | ), | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | const ownerRank = (row: { readonly owner: string }): number => { | ||
| 250 | const rank = owners.indexOf(row.owner); | ||
| 251 | return rank === -1 ? owners.length : rank; | ||
| 252 | }; | ||
| 253 | return sources.policyRows.pipe( | ||
| 254 | Effect.map( | ||
| 255 | (rows): ToolPolicyEvaluator => ({ | ||
| 256 | resolve: (input) => | ||
| 257 | Effect.succeed( | ||
| 258 | resolveEffectivePolicy(input.toolId, rows, ownerRank, input.defaultRequiresApproval), | ||
| 259 | ), | ||
| 260 | }), | ||
| 261 | ), | ||
| 262 | ); | ||
| 263 | }; | ||
| @@ -0,0 +1,54 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Explicit product-access postures for tests. These are the PRODUCTION rules | ||
| 3 | // from ./index, packaged so a test states its posture at the call site — | ||
| 4 | // `makeTestConfig` (from `@executor-js/sdk/testing`) has no default posture. | ||
| 5 | // | ||
| 6 | // `@executor-js/sdk`'s OWN tests import this entry too — resolved through | ||
| 7 | // the workspace ROOT devDependency on `@executor-js/product-access`, not | ||
| 8 | // through the sdk package itself, which declares no dependency on this | ||
| 9 | // package. That keeps the package graph acyclic (turbo rejects even | ||
| 10 | // dev-dependency cycles) while every test states a real product posture. | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | import { Effect } from "effect"; | ||
| 14 | |||
| 15 | import type { ExecutorAccess, OrgWriteAccess } from "@executor-js/sdk/core"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | memberAccess, | ||
| 19 | platformObserverAccess, | ||
| 20 | requestBoundMemberAccess, | ||
| 21 | workspaceServiceAccess, | ||
| 22 | } from "./index"; | ||
| 23 | |||
| 24 | /** Explicit product postures for test bindings. */ | ||
| 25 | export const testAccess = { | ||
| 26 | /** A bound member with a static workspace-settings decision. */ | ||
| 27 | member: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => | ||
| 28 | memberAccess(workspaceWrites), | ||
| 29 | /** A bound member on a session-lifetime stack: the decision is read from | ||
| 30 | * the fiber-local `CurrentOrgWriteAccess` at every guarded sink. */ | ||
| 31 | requestBound: (): ExecutorAccess => requestBoundMemberAccess(), | ||
| 32 | /** A subject-less workspace-service binding (org partition only), with an | ||
| 33 | * optional denied workspace-settings decision for boot-convergence | ||
| 34 | * scenarios. */ | ||
| 35 | org: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => { | ||
| 36 | const base = workspaceServiceAccess(); | ||
| 37 | if (workspaceWrites === "allowed") return base; | ||
| 38 | return { | ||
| 39 | ...base, | ||
| 40 | settingsWrite: (target) => | ||
| 41 | Effect.succeed(target.kind === "owner" && target.owner === "user" ? "allowed" : "denied"), | ||
| 42 | }; | ||
| 43 | }, | ||
| 44 | /** The platform observer posture; `subject: false` is the production | ||
| 45 | * subject-less shape, the default keeps a bound subject's partitions for | ||
| 46 | * tests that combine a member binding with the platform capabilities. */ | ||
| 47 | platform: (options?: { readonly subject?: boolean }): ExecutorAccess => | ||
| 48 | options?.subject === false | ||
| 49 | ? platformObserverAccess() | ||
| 50 | : { | ||
| 51 | ...platformObserverAccess(), | ||
| 52 | owners: ["user", "org"], | ||
| 53 | }, | ||
| 54 | } as const; | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "outDir": "dist", | ||
| 9 | "rootDir": "src", | ||
| 10 | "declaration": true, | ||
| 11 | "declarationMap": true, | ||
| 12 | "sourceMap": true, | ||
| 13 | "lib": ["ES2022"], | ||
| 14 | "types": ["bun-types", "node"], | ||
| 15 | "noUnusedLocals": true, | ||
| 16 | "noImplicitOverride": true, | ||
| 17 | "plugins": [ | ||
| 18 | { | ||
| 19 | "name": "@effect/language-service", | ||
| 20 | "ignoreEffectSuggestionsInTscExitCode": true, | ||
| 21 | "ignoreEffectWarningsInTscExitCode": true, | ||
| 22 | "diagnosticSeverity": {} | ||
| 23 | } | ||
| 24 | ] | ||
| 25 | }, | ||
| 26 | "include": ["src/**/*.ts"] | ||
| 27 | } | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | import { defineConfig } from "tsup"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | entry: { | ||
| 5 | index: "src/index.ts", | ||
| 6 | policy: "src/policy.ts", | ||
| 7 | testing: "src/testing.ts", | ||
| 8 | }, | ||
| 9 | format: ["esm"], | ||
| 10 | dts: false, | ||
| 11 | sourcemap: true, | ||
| 12 | clean: true, | ||
| 13 | external: [/^@executor-js\//, /^effect/, /^@effect\//], | ||
| 14 | }); | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | include: ["src/**/*.test.ts"], | ||
| 6 | }, | ||
| 7 | }); | ||
| @@ -7,14 +7,17 @@ The default surface is `Promise`-based — plugins are built on [Effect](https:/ · 6 unmodified lines | |||
| 7 | ## Install | 7 | ## Install |
| 8 | 8 | ||
| 9 | ```sh | 9 | ```sh |
| 10 | bun add @executor-js/sdk | 10 | bun add @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59 |
| 11 | # or | 11 | # or |
| 12 | npm install @executor-js/sdk | 12 | npm install @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59 |
| 13 | ``` | 13 | ``` |
| 14 | 14 | ||
| 15 | `effect` is a required peer dependency (some package managers don't install peers automatically). `@executor-js/product-access` supplies the standard `access` postures `createExecutor` requires — every snippet below uses it. | ||
| 16 | |||
| 15 | ## Quick start | 17 | ## Quick start |
| 16 | 18 | ||
| 17 | ```ts | 19 | ```ts |
| 20 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 18 | import { createExecutor } from "@executor-js/sdk"; | 21 | import { createExecutor } from "@executor-js/sdk"; |
| 19 | 22 | ||
| 20 | const executor = await createExecutor({ | 23 | const executor = await createExecutor({ |
| @@ -22,6 +25,9 @@ const executor = await createExecutor({ · 1 unmodified line | |||
| 22 | // `"accept-all"` auto-approves every prompt — fine for tests/automation. | 25 | // `"accept-all"` auto-approves every prompt — fine for tests/automation. |
| 23 | // For an interactive host, pass a handler `(ctx) => Promise<ElicitationResponse>`. | 26 | // For an interactive host, pass a handler `(ctx) => Promise<ElicitationResponse>`. |
| 24 | onElicitation: "accept-all", | 27 | onElicitation: "accept-all", |
| 28 | // Required: the product's access decisions for this binding. The SDK ships | ||
| 29 | // no default posture; `@executor-js/product-access` has the standard ones. | ||
| 30 | access: workspaceServiceAccess(), | ||
| 25 | }); | 31 | }); |
| 26 | 32 | ||
| 27 | const tools = await executor.tools.list(); | 33 | const tools = await executor.tools.list(); |
| @@ -35,9 +41,13 @@ await executor.close(); · 7 unmodified lines | |||
| 35 | To invoke a tool once one is registered: | 41 | To invoke a tool once one is registered: |
| 36 | 42 | ||
| 37 | ```ts | 43 | ```ts |
| 44 | import { workspaceServiceAccess } from "@executor-js/product-access"; | ||
| 38 | import { createExecutor } from "@executor-js/sdk"; | 45 | import { createExecutor } from "@executor-js/sdk"; |
| 39 | 46 | ||
| 40 | const executor = await createExecutor({ onElicitation: "accept-all" }); | 47 | const executor = await createExecutor({ |
| 48 | onElicitation: "accept-all", | ||
| 49 | access: workspaceServiceAccess(), | ||
| 50 | }); | ||
| 41 | 51 | ||
| 42 | const tools = await executor.tools.list(); | 52 | const tools = await executor.tools.list(); |
| 43 | const target = tools[0]; | 53 | const target = tools[0]; |
| @@ -110,6 +120,7 @@ export const memorySecretsPlugin = definePlugin((options?: MemorySecretsConfig) · 66 unmodified lines | |||
| 110 | const executor = await createExecutor({ | 120 | const executor = await createExecutor({ |
| 111 | plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const, | 121 | plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const, |
| 112 | onElicitation: "accept-all", | 122 | onElicitation: "accept-all", |
| 123 | access: workspaceServiceAccess(), | ||
| 113 | }); | 124 | }); |
| 114 | 125 | ||
| 115 | console.log(executor.memorySecrets.label); // "in-memory secrets" | 126 | console.log(executor.memorySecrets.label); // "in-memory secrets" |
| @@ -0,0 +1,370 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // ExecutorAccess — the product/core boundary itself. | ||
| 3 | // | ||
| 4 | // `createExecutor` requires an access value, validates it against the | ||
| 5 | // binding, and ENFORCES its decisions without owning any of them. These | ||
| 6 | // tests pin the contract with the REAL product implementations | ||
| 7 | // (@executor-js/product-access, resolved via the workspace root | ||
| 8 | // devDependency): | ||
| 9 | // - a malformed access fails the boot loudly; | ||
| 10 | // - the settings decision is consulted live, never snapshotted; | ||
| 11 | // - `owners` really filters storage CRUD and the tool surfaces (org-only | ||
| 12 | // and personal-only views included — core forces no partition); | ||
| 13 | // - the `toolPolicy` hook is the ONLY policy authority core consults; | ||
| 14 | // - the tenant clamp holds even under an over-permissive access. | ||
| 15 | // --------------------------------------------------------------------------- | ||
| 16 | |||
| 17 | import { describe, expect, it } from "@effect/vitest"; | ||
| 18 | import { Effect, Ref } from "effect"; | ||
| 19 | |||
| 20 | import { standardToolPolicy } from "@executor-js/product-access/policy"; | ||
| 21 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 22 | |||
| 23 | import { executorAccessViolation, type ExecutorAccess } from "./access"; | ||
| 24 | import { createExecutor } from "./executor"; | ||
| 25 | import { | ||
| 26 | AuthTemplateSlug, | ||
| 27 | ConnectionName, | ||
| 28 | IntegrationSlug, | ||
| 29 | ProviderItemId, | ||
| 30 | ProviderKey, | ||
| 31 | Tenant, | ||
| 32 | ToolAddress, | ||
| 33 | ToolName, | ||
| 34 | } from "./ids"; | ||
| 35 | import { definePlugin } from "./plugin"; | ||
| 36 | import type { CredentialProvider } from "./provider"; | ||
| 37 | import { makeTestConfig } from "./testing"; | ||
| 38 | |||
| 39 | const memberShaped: ExecutorAccess = testAccess.member(); | ||
| 40 | |||
| 41 | /** A subject-bound binding whose product view is PERSONAL-ONLY — no | ||
| 42 | * production posture ships this shape yet, which is exactly the point: the | ||
| 43 | * contract must carry it without core forcing the org partition back in. */ | ||
| 44 | const personalOnlyAccess = (): ExecutorAccess => ({ | ||
| 45 | ...testAccess.member(), | ||
| 46 | owners: ["user"], | ||
| 47 | toolPolicy: standardToolPolicy(["user"]), | ||
| 48 | }); | ||
| 49 | |||
| 50 | /** An org-only view for a subject-BOUND member (narrower than identity). */ | ||
| 51 | const orgOnlyMemberAccess = (): ExecutorAccess => ({ | ||
| 52 | ...testAccess.member(), | ||
| 53 | owners: ["org"], | ||
| 54 | toolPolicy: standardToolPolicy(["org"]), | ||
| 55 | }); | ||
| 56 | |||
| 57 | const memoryProvider = (): CredentialProvider => { | ||
| 58 | const store = new Map<string, string>(); | ||
| 59 | return { | ||
| 60 | key: ProviderKey.make("memory"), | ||
| 61 | writable: true, | ||
| 62 | get: (id) => Effect.sync(() => store.get(String(id)) ?? null), | ||
| 63 | set: (id, value) => Effect.sync(() => void store.set(String(id), value)), | ||
| 64 | delete: (id) => Effect.sync(() => void store.delete(String(id))), | ||
| 65 | has: (id) => Effect.sync(() => store.has(String(id))), | ||
| 66 | list: () => | ||
| 67 | Effect.sync(() => | ||
| 68 | Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key })), | ||
| 69 | ), | ||
| 70 | }; | ||
| 71 | }; | ||
| 72 | |||
| 73 | const INTEG = IntegrationSlug.make("vercel"); | ||
| 74 | const TEMPLATE = AuthTemplateSlug.make("apiKey"); | ||
| 75 | |||
| 76 | const demoPlugin = definePlugin(() => ({ | ||
| 77 | id: "demo" as const, | ||
| 78 | credentialProviders: [memoryProvider()], | ||
| 79 | storage: () => ({}), | ||
| 80 | resolveTools: () => | ||
| 81 | Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), | ||
| 82 | invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), | ||
| 83 | extension: (ctx) => ({ | ||
| 84 | seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), | ||
| 85 | }), | ||
| 86 | }))(); | ||
| 87 | |||
| 88 | /** Seed one org and one user connection (with their tools) plus one org and | ||
| 89 | * one user policy row, through a full-view member executor. */ | ||
| 90 | const seedWorkspace = Effect.fn("seedWorkspace")(function* ( | ||
| 91 | config: ReturnType<typeof makeTestConfig<readonly [typeof demoPlugin]>>, | ||
| 92 | ) { | ||
| 93 | const admin = yield* createExecutor(config); | ||
| 94 | yield* Effect.addFinalizer(() => admin.close().pipe(Effect.ignore)); | ||
| 95 | yield* admin.demo.seed(); | ||
| 96 | yield* admin.connections.create({ | ||
| 97 | owner: "org", | ||
| 98 | name: ConnectionName.make("shared"), | ||
| 99 | integration: INTEG, | ||
| 100 | template: TEMPLATE, | ||
| 101 | value: "org-token", | ||
| 102 | }); | ||
| 103 | yield* admin.connections.create({ | ||
| 104 | owner: "user", | ||
| 105 | name: ConnectionName.make("mine"), | ||
| 106 | integration: INTEG, | ||
| 107 | template: TEMPLATE, | ||
| 108 | value: "user-token", | ||
| 109 | }); | ||
| 110 | yield* admin.policies.create({ owner: "org", pattern: "github.*", action: "block" }); | ||
| 111 | yield* admin.policies.create({ owner: "user", pattern: "linear.*", action: "approve" }); | ||
| 112 | return admin; | ||
| 113 | }); | ||
| 114 | |||
| 115 | describe("executorAccessViolation", () => { | ||
| 116 | it("accepts the production postures against their bindings", () => { | ||
| 117 | expect(executorAccessViolation(memberShaped, "subject-1")).toBeNull(); | ||
| 118 | expect(executorAccessViolation(testAccess.org(), null)).toBeNull(); | ||
| 119 | expect(executorAccessViolation(testAccess.platform({ subject: false }), null)).toBeNull(); | ||
| 120 | // Personal-only is a VALID product shape: core mandates no partition. | ||
| 121 | expect(executorAccessViolation(personalOnlyAccess(), "subject-1")).toBeNull(); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("rejects an empty owners list", () => { | ||
| 125 | expect(executorAccessViolation({ ...memberShaped, owners: [] }, "subject-1")).toContain( | ||
| 126 | "must not be empty", | ||
| 127 | ); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("rejects a duplicated owner", () => { | ||
| 131 | expect(executorAccessViolation({ ...memberShaped, owners: ["user", "user"] }, "s")).toContain( | ||
| 132 | "repeat", | ||
| 133 | ); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("rejects a user partition on a subject-less binding", () => { | ||
| 137 | expect(executorAccessViolation(memberShaped, null)).toContain("no subject"); | ||
| 138 | }); | ||
| 139 | }); | ||
| 140 | |||
| 141 | describe("createExecutor access validation", () => { | ||
| 142 | it.effect("fails the boot when the access does not fit the binding", () => | ||
| 143 | Effect.gen(function* () { | ||
| 144 | const config = makeTestConfig({ access: testAccess.org(), subject: null }); | ||
| 145 | const error = yield* Effect.flip( | ||
| 146 | // member-shaped access (user partition) on a subject-less binding. | ||
| 147 | createExecutor({ ...config, access: testAccess.member() }), | ||
| 148 | ); | ||
| 149 | expect(error).toMatchObject({ | ||
| 150 | _tag: "StorageError", | ||
| 151 | message: expect.stringContaining("no subject"), | ||
| 152 | }); | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | describe("settingsWrite is consulted live", () => { | ||
| 158 | it.effect("a flipped decision applies to the next guarded call, not a snapshot", () => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const decision = yield* Ref.make<"allowed" | "denied">("allowed"); | ||
| 161 | const config = makeTestConfig({ | ||
| 162 | access: { | ||
| 163 | ...testAccess.member(), | ||
| 164 | settingsWrite: (target) => | ||
| 165 | target.kind === "owner" && target.owner === "user" | ||
| 166 | ? Effect.succeed("allowed" as const) | ||
| 167 | : Ref.get(decision), | ||
| 168 | }, | ||
| 169 | }); | ||
| 170 | const executor = yield* createExecutor(config); | ||
| 171 | yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | ||
| 172 | |||
| 173 | const created = yield* executor.policies.create({ | ||
| 174 | owner: "org", | ||
| 175 | pattern: "vercel.*", | ||
| 176 | action: "block", | ||
| 177 | }); | ||
| 178 | expect(created.pattern).toBe("vercel.*"); | ||
| 179 | |||
| 180 | yield* Ref.set(decision, "denied"); | ||
| 181 | const error = yield* Effect.flip( | ||
| 182 | executor.policies.create({ owner: "org", pattern: "github.*", action: "block" }), | ||
| 183 | ); | ||
| 184 | expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); | ||
| 185 | |||
| 186 | // Personal-scope writes stay open under the denied decision. | ||
| 187 | const personal = yield* executor.policies.create({ | ||
| 188 | owner: "user", | ||
| 189 | pattern: "linear.*", | ||
| 190 | action: "approve", | ||
| 191 | }); | ||
| 192 | expect(personal.owner).toBe("user"); | ||
| 193 | }).pipe(Effect.scoped), | ||
| 194 | ); | ||
| 195 | }); | ||
| 196 | |||
| 197 | describe("owners really filters storage and tool surfaces", () => { | ||
| 198 | it.effect("an org-only member view sees no user rows anywhere", () => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | const config = makeTestConfig({ | ||
| 201 | access: testAccess.member(), | ||
| 202 | plugins: [demoPlugin] as const, | ||
| 203 | }); | ||
| 204 | yield* seedWorkspace(config); | ||
| 205 | |||
| 206 | const orgOnly = yield* createExecutor({ ...config, access: orgOnlyMemberAccess() }); | ||
| 207 | yield* Effect.addFinalizer(() => orgOnly.close().pipe(Effect.ignore)); | ||
| 208 | |||
| 209 | const policies = yield* orgOnly.policies.list(); | ||
| 210 | expect(policies.map((p) => p.owner)).toEqual(["org"]); | ||
| 211 | |||
| 212 | const connections = yield* orgOnly.connections.list(); | ||
| 213 | expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["org:shared"]); | ||
| 214 | |||
| 215 | const tools = yield* orgOnly.tools.list(); | ||
| 216 | const dynamic = tools.filter((t) => !t.static); | ||
| 217 | expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["org:shared"]); | ||
| 218 | |||
| 219 | // The excluded partition is not writable either: the settings gate | ||
| 220 | // (personal stays open) passes, and the STORAGE clamp refuses. | ||
| 221 | const error = yield* Effect.flip( | ||
| 222 | orgOnly.policies.create({ owner: "user", pattern: "x.*", action: "approve" }), | ||
| 223 | ); | ||
| 224 | expect(error).toMatchObject({ | ||
| 225 | _tag: "StorageError", | ||
| 226 | message: expect.stringContaining("does not include"), | ||
| 227 | }); | ||
| 228 | }).pipe(Effect.scoped), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.effect("a personal-only view sees no org rows anywhere", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const config = makeTestConfig({ | ||
| 234 | access: testAccess.member(), | ||
| 235 | plugins: [demoPlugin] as const, | ||
| 236 | }); | ||
| 237 | yield* seedWorkspace(config); | ||
| 238 | |||
| 239 | const personal = yield* createExecutor({ ...config, access: personalOnlyAccess() }); | ||
| 240 | yield* Effect.addFinalizer(() => personal.close().pipe(Effect.ignore)); | ||
| 241 | |||
| 242 | const policies = yield* personal.policies.list(); | ||
| 243 | expect(policies.map((p) => p.owner)).toEqual(["user"]); | ||
| 244 | |||
| 245 | const connections = yield* personal.connections.list(); | ||
| 246 | expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["user:mine"]); | ||
| 247 | |||
| 248 | const tools = yield* personal.tools.list(); | ||
| 249 | const dynamic = tools.filter((t) => !t.static); | ||
| 250 | expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["user:mine"]); | ||
| 251 | |||
| 252 | // Invoking a tool over the hidden org connection fails as not-found — | ||
| 253 | // the row is invisible, not merely blocked. | ||
| 254 | const invokeError = yield* Effect.flip( | ||
| 255 | personal.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), | ||
| 256 | ); | ||
| 257 | expect(invokeError).toMatchObject({ _tag: "ToolNotFoundError" }); | ||
| 258 | |||
| 259 | // Shared-catalog (org-partition) writes refuse loudly at storage. | ||
| 260 | const writeError = yield* Effect.flip( | ||
| 261 | personal.policies.create({ owner: "org", pattern: "x.*", action: "block" }), | ||
| 262 | ); | ||
| 263 | expect(writeError).toMatchObject({ | ||
| 264 | _tag: "StorageError", | ||
| 265 | message: expect.stringContaining("does not include"), | ||
| 266 | }); | ||
| 267 | }).pipe(Effect.scoped), | ||
| 268 | ); | ||
| 269 | }); | ||
| 270 | |||
| 271 | describe("the product toolPolicy hook is the only policy authority", () => { | ||
| 272 | it.effect("a hook that blocks everything gates execute and list, whatever rows say", () => | ||
| 273 | Effect.gen(function* () { | ||
| 274 | const config = makeTestConfig({ | ||
| 275 | access: testAccess.member(), | ||
| 276 | plugins: [demoPlugin] as const, | ||
| 277 | }); | ||
| 278 | yield* seedWorkspace(config); | ||
| 279 | |||
| 280 | const blockAll: ExecutorAccess = { | ||
| 281 | ...testAccess.member(), | ||
| 282 | toolPolicy: () => | ||
| 283 | Effect.succeed({ | ||
| 284 | resolve: () => | ||
| 285 | Effect.succeed({ action: "block" as const, source: "user" as const, pattern: "*" }), | ||
| 286 | }), | ||
| 287 | }; | ||
| 288 | const executor = yield* createExecutor({ ...config, access: blockAll }); | ||
| 289 | yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | ||
| 290 | |||
| 291 | const tools = yield* executor.tools.list(); | ||
| 292 | expect(tools.filter((t) => !t.static)).toHaveLength(0); | ||
| 293 | const error = yield* Effect.flip( | ||
| 294 | executor.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), | ||
| 295 | ); | ||
| 296 | expect(error).toMatchObject({ _tag: "ToolBlockedError" }); | ||
| 297 | }).pipe(Effect.scoped), | ||
| 298 | ); | ||
| 299 | |||
| 300 | it.effect("a hook that approves everything overrides a stored org block", () => | ||
| 301 | Effect.gen(function* () { | ||
| 302 | const config = makeTestConfig({ | ||
| 303 | access: testAccess.member(), | ||
| 304 | plugins: [demoPlugin] as const, | ||
| 305 | }); | ||
| 306 | const admin = yield* seedWorkspace(config); | ||
| 307 | // A stored rule that would block the tool under the standard product | ||
| 308 | // resolution — proving core itself never consults the rows. | ||
| 309 | yield* admin.policies.create({ owner: "org", pattern: "*", action: "block" }); | ||
| 310 | |||
| 311 | const approveAll: ExecutorAccess = { | ||
| 312 | ...testAccess.member(), | ||
| 313 | toolPolicy: () => | ||
| 314 | Effect.succeed({ | ||
| 315 | resolve: () => | ||
| 316 | Effect.succeed({ | ||
| 317 | action: "approve" as const, | ||
| 318 | source: "plugin-default" as const, | ||
| 319 | }), | ||
| 320 | }), | ||
| 321 | }; | ||
| 322 | const executor = yield* createExecutor({ ...config, access: approveAll }); | ||
| 323 | yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | ||
| 324 | |||
| 325 | const result = yield* executor.execute( | ||
| 326 | ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), | ||
| 327 | {}, | ||
| 328 | ); | ||
| 329 | expect(result).toMatchObject({ ran: "deploy" }); | ||
| 330 | }).pipe(Effect.scoped), | ||
| 331 | ); | ||
| 332 | }); | ||
| 333 | |||
| 334 | describe("tenant clamp is not product-configurable", () => { | ||
| 335 | it.effect("an over-permissive access never reads another tenant's rows", () => | ||
| 336 | Effect.gen(function* () { | ||
| 337 | const config = makeTestConfig({ | ||
| 338 | access: testAccess.member(), | ||
| 339 | plugins: [demoPlugin] as const, | ||
| 340 | }); | ||
| 341 | yield* seedWorkspace(config); | ||
| 342 | |||
| 343 | // Same database, different tenant, maximally permissive product view. | ||
| 344 | const intruder = yield* createExecutor({ | ||
| 345 | ...config, | ||
| 346 | tenant: Tenant.make("other-tenant"), | ||
| 347 | access: { | ||
| 348 | ...testAccess.member(), | ||
| 349 | toolPolicy: () => | ||
| 350 | Effect.succeed({ | ||
| 351 | resolve: () => | ||
| 352 | Effect.succeed({ | ||
| 353 | action: "approve" as const, | ||
| 354 | source: "plugin-default" as const, | ||
| 355 | }), | ||
| 356 | }), | ||
| 357 | }, | ||
| 358 | }); | ||
| 359 | yield* Effect.addFinalizer(() => intruder.close().pipe(Effect.ignore)); | ||
| 360 | |||
| 361 | expect(yield* intruder.policies.list()).toHaveLength(0); | ||
| 362 | expect(yield* intruder.connections.list()).toHaveLength(0); | ||
| 363 | expect((yield* intruder.tools.list()).filter((t) => !t.static)).toHaveLength(0); | ||
| 364 | const error = yield* Effect.flip( | ||
| 365 | intruder.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), | ||
| 366 | ); | ||
| 367 | expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); | ||
| 368 | }).pipe(Effect.scoped), | ||
| 369 | ); | ||
| 370 | }); | ||
| @@ -0,0 +1,215 @@ | |||
| 1 | import { Context, Effect, Ref } from "effect"; | ||
| 2 | |||
| 3 | import type { ToolPolicyRow } from "./core-schema"; | ||
| 4 | import type { StorageFailure } from "./fuma-runtime"; | ||
| 5 | import type { Owner } from "./ids"; | ||
| 6 | import type { ToolPolicyProvider } from "./plugin"; | ||
| 7 | import type { EffectivePolicy } from "./policies"; | ||
| 8 | |||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | // Product access — the defined interface through which the PRODUCT hands the | ||
| 11 | // executor its personal/organization access decisions. | ||
| 12 | // | ||
| 13 | // Core deliberately owns no product rule here: it does not know what a role | ||
| 14 | // is, which member is an admin, or why a deployment has no role model at all. | ||
| 15 | // Core defines the decision vocabulary (this module), consults the supplied | ||
| 16 | // decisions at its enforcement sinks, and keeps enforcing the security | ||
| 17 | // invariants that are NOT product-configurable: tenant isolation and the | ||
| 18 | // storage owner policy (owner-policy.ts), which clamp whatever the product | ||
| 19 | // supplies. `ExecutorConfig.access` is required — there is no default posture | ||
| 20 | // in core, so every composition root states its product's rules explicitly. | ||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | |||
| 23 | /** Workspace-settings authorization bound to the currently executing request. */ | ||
| 24 | export type OrgWriteAccess = "allowed" | "denied"; | ||
| 25 | |||
| 26 | /** A single product authorization answer core enforces verbatim. */ | ||
| 27 | export type AccessDecision = "allowed" | "denied"; | ||
| 28 | |||
| 29 | /** | ||
| 30 | * The target of a user-intent settings mutation, as core describes it to the | ||
| 31 | * product's {@link ExecutorAccess.settingsWrite} rule: | ||
| 32 | * | ||
| 33 | * - `{ kind: "owner", owner }` — an owner-scoped settings row (a connection, | ||
| 34 | * tool policy, OAuth client, …) filed under that partition. | ||
| 35 | * - `{ kind: "workspace" }` — a tenant-shared surface (the integration | ||
| 36 | * catalog and other whole-workspace settings). | ||
| 37 | */ | ||
| 38 | export type SettingsWriteTarget = | ||
| 39 | | { readonly kind: "owner"; readonly owner: Owner } | ||
| 40 | | { readonly kind: "workspace" }; | ||
| 41 | |||
| 42 | /** | ||
| 43 | * Structural view capabilities the product grants a binding. Core maps each | ||
| 44 | * to a non-overridable mechanism: | ||
| 45 | * | ||
| 46 | * - `adminReads` — expose `executor.admin`, the ONLY surface whose reads are | ||
| 47 | * tenant-wide. The widened context is read-only by construction | ||
| 48 | * (owner-policy reach mechanics), regardless of what the product says. | ||
| 49 | * - `storageWrites` — `"denied"` makes the WHOLE executor read-only at the | ||
| 50 | * storage boundary (every create/update/delete on every guarded table is | ||
| 51 | * refused, and catalog re-sync is skipped). The platform observer posture | ||
| 52 | * combines both. | ||
| 53 | */ | ||
| 54 | export interface ExecutorAccessCapabilities { | ||
| 55 | readonly adminReads: boolean; | ||
| 56 | readonly storageWrites: AccessDecision; | ||
| 57 | } | ||
| 58 | |||
| 59 | /** One effective-policy question, as core asks the product's evaluator. */ | ||
| 60 | export interface ToolPolicyEvaluationInput { | ||
| 61 | /** Normalized policy id: a static tool's address, or the 4-segment | ||
| 62 | * `<integration>.<owner>.<connection>.<tool>` form. */ | ||
| 63 | readonly toolId: string; | ||
| 64 | /** The tool's own `requiresApproval` annotation — the fallback material a | ||
| 65 | * product resolution may lift when no authored rule matches. */ | ||
| 66 | readonly defaultRequiresApproval?: boolean | undefined; | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * A per-operation effective-policy evaluator returned by | ||
| 71 | * {@link ExecutorAccess.toolPolicy}. Built once per surface operation (one | ||
| 72 | * tools list / schema read / invocation / post-approval recheck) so a product | ||
| 73 | * implementation can batch its underlying reads; `resolve` is then asked once | ||
| 74 | * per tool. Core ENFORCES whatever comes back — `block` refuses the call, | ||
| 75 | * `require_approval` gates it behind an elicitation — but never decides it. | ||
| 76 | */ | ||
| 77 | export interface ToolPolicyEvaluator { | ||
| 78 | readonly resolve: ( | ||
| 79 | input: ToolPolicyEvaluationInput, | ||
| 80 | ) => Effect.Effect<EffectivePolicy, StorageFailure>; | ||
| 81 | } | ||
| 82 | |||
| 83 | /** | ||
| 84 | * The rule material core hands the product's {@link ExecutorAccess.toolPolicy} | ||
| 85 | * hook for one operation. Both members are the product's to consult or | ||
| 86 | * ignore; core pre-selects nothing. | ||
| 87 | */ | ||
| 88 | export interface ToolPolicySources { | ||
| 89 | /** | ||
| 90 | * The stored, owner-scoped `tool_policy` rows visible to this binding — | ||
| 91 | * a LAZY effect, fetched only if the product's resolution reads it. | ||
| 92 | */ | ||
| 93 | readonly policyRows: Effect.Effect<readonly ToolPolicyRow[], StorageFailure>; | ||
| 94 | /** | ||
| 95 | * The plugin-registered policy provider, when a plugin opted this executor | ||
| 96 | * instance into a session rule source (a toolkit's capability set), else | ||
| 97 | * null. Toolkit sessions also hide catalog entries that grant no tools; | ||
| 98 | * that presentation follows the provider's presence (part of the | ||
| 99 | * `ToolPolicyProvider` plugin contract), while HOW its rules resolve — | ||
| 100 | * including the empty-match default — is decided here, by the product. | ||
| 101 | */ | ||
| 102 | readonly provider: ToolPolicyProvider | null; | ||
| 103 | } | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Product-owned access decisions for one executor binding. Supplied by the | ||
| 107 | * host's composition root (see `@executor-js/product-access` for the product | ||
| 108 | * rule implementations); enforced — never decided — by core. | ||
| 109 | * | ||
| 110 | * Shape invariants, validated at `createExecutor` (violations fail the boot): | ||
| 111 | * - `owners` is a non-empty, duplicate-free subset of `["user", "org"]`; | ||
| 112 | * - `"user"` may appear only when the executor binds a subject. | ||
| 113 | * | ||
| 114 | * Core's non-overridable clamps apply to every decision: the tenant clause | ||
| 115 | * is never relaxed, user rows only ever resolve to the bound subject, | ||
| 116 | * tenant-reach contexts are read-only (or delete-only, for the internal | ||
| 117 | * removal cascade), and a mid-run tool is never interrupted by a policy | ||
| 118 | * change — `block` means "don't start new runs". | ||
| 119 | * | ||
| 120 | * PAUSED EXECUTIONS: a pause/resume boundary re-evaluates the | ||
| 121 | * request-bound `settingsWrite` decision as the RESUMING principal (via | ||
| 122 | * {@link CurrentOrgWriteAccess}), and the post-approval recheck re-resolves | ||
| 123 | * the tool policy and re-reads rows — but storage reads and credential | ||
| 124 | * resolution still run under the STARTER's binding, whose executor instance | ||
| 125 | * owns the paused fiber. | ||
| 126 | */ | ||
| 127 | export interface ExecutorAccess { | ||
| 128 | /** | ||
| 129 | * ROW VISIBILITY AND WRITE AUTHORIZATION: the owner partitions this | ||
| 130 | * binding sees and may write, in precedence order (the first entry shadows | ||
| 131 | * later ones on plugin-storage reads, ranks first in policy listings, and | ||
| 132 | * is the partition static tools present under). Carried onto every storage | ||
| 133 | * context: reads, updates and deletes are FILTERED to these partitions and | ||
| 134 | * creates outside them are refused — an org-only or personal-only product | ||
| 135 | * view really is one, across storage CRUD and the tool surfaces built on | ||
| 136 | * it. Core contributes no partition of its own. | ||
| 137 | */ | ||
| 138 | readonly owners: readonly Owner[]; | ||
| 139 | /** | ||
| 140 | * USER-INTENT SETTINGS AUTHORIZATION, including the owner-target rule. | ||
| 141 | * Consulted live at every guarded sink (never cached by core, so a | ||
| 142 | * request-bound implementation is re-read after pauses and on resume); | ||
| 143 | * `"denied"` surfaces as `OrgWriteDeniedError`. Operational writes a | ||
| 144 | * denied member's usage implies (token refresh, catalog re-sync) do not | ||
| 145 | * pass through this gate — they are bounded by `owners` and the storage | ||
| 146 | * clamps instead. | ||
| 147 | */ | ||
| 148 | readonly settingsWrite: (target: SettingsWriteTarget) => Effect.Effect<AccessDecision>; | ||
| 149 | /** View capabilities — see {@link ExecutorAccessCapabilities}. */ | ||
| 150 | readonly capabilities: ExecutorAccessCapabilities; | ||
| 151 | /** | ||
| 152 | * EFFECTIVE TOOL-POLICY EVALUATION. Called once per surface operation with | ||
| 153 | * this binding's rule material; the returned evaluator answers every | ||
| 154 | * per-tool question in that operation. Owner ranking, cross-owner merging, | ||
| 155 | * plugin-default fallback and the capability-allowlist default all live in | ||
| 156 | * the product implementation. | ||
| 157 | */ | ||
| 158 | readonly toolPolicy: ( | ||
| 159 | sources: ToolPolicySources, | ||
| 160 | ) => Effect.Effect<ToolPolicyEvaluator, StorageFailure>; | ||
| 161 | } | ||
| 162 | |||
| 163 | /** | ||
| 164 | * Validate an {@link ExecutorAccess} against the executor's `{ tenant, | ||
| 165 | * subject }` binding. Returns null when well-formed; a violation is a | ||
| 166 | * programmer error at a composition root and fails fast via the returned | ||
| 167 | * message so `createExecutor` can surface it as a startup failure. | ||
| 168 | */ | ||
| 169 | export const executorAccessViolation = ( | ||
| 170 | access: ExecutorAccess, | ||
| 171 | subject: string | null, | ||
| 172 | ): string | null => { | ||
| 173 | const owners = access.owners; | ||
| 174 | if (owners.length === 0) return "ExecutorAccess.owners must not be empty."; | ||
| 175 | if (new Set(owners).size !== owners.length) { | ||
| 176 | return "ExecutorAccess.owners must not repeat an owner."; | ||
| 177 | } | ||
| 178 | if (owners.includes("user") && subject == null) { | ||
| 179 | return 'ExecutorAccess.owners includes "user" but the executor has no subject.'; | ||
| 180 | } | ||
| 181 | return null; | ||
| 182 | }; | ||
| 183 | |||
| 184 | /** | ||
| 185 | * Fiber-local workspace-settings authorization for request-bound executors. | ||
| 186 | * | ||
| 187 | * This is the decision CARRIER, not a decision rule: a session host stamps it | ||
| 188 | * from each freshly authenticated request, the execution engine snapshots the | ||
| 189 | * state onto a paused execution and re-stamps `current` from the resuming (or | ||
| 190 | * joining) principal's fiber, and a request-bound `ExecutorAccess` | ||
| 191 | * implementation reads it at every guarded sink. The denied default makes a | ||
| 192 | * missing request binding fail closed. Executors whose product supplies a | ||
| 193 | * static decision never consult this reference. | ||
| 194 | */ | ||
| 195 | export interface OrgWriteAccessState { | ||
| 196 | /** Mutable value inherited by a detached execution and refreshed on resume. */ | ||
| 197 | readonly current: Ref.Ref<OrgWriteAccess>; | ||
| 198 | } | ||
| 199 | |||
| 200 | /** Create an isolated request/execution authorization state. */ | ||
| 201 | export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ | ||
| 202 | current: Ref.makeUnsafe(access), | ||
| 203 | }); | ||
| 204 | |||
| 205 | /** Request-local workspace-write authorization inherited by child fibers. */ | ||
| 206 | export const CurrentOrgWriteAccess = Context.Reference<OrgWriteAccessState>( | ||
| 207 | "@executor-js/sdk/CurrentOrgWriteAccess", | ||
| 208 | { defaultValue: () => makeOrgWriteAccessState("denied") }, | ||
| 209 | ); | ||
| 210 | |||
| 211 | /** Read the effective authorization at a workspace-write sink. */ | ||
| 212 | export const currentOrgWriteAccess: Effect.Effect<OrgWriteAccess> = Effect.gen(function* () { | ||
| 213 | const state = yield* CurrentOrgWriteAccess; | ||
| 214 | return yield* Ref.get(state.current); | ||
| 215 | }); | ||
| @@ -0,0 +1,138 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Ref, Schema } from "effect"; | ||
| 3 | |||
| 4 | import { ElicitationResponse } from "./elicitation"; | ||
| 5 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; | ||
| 6 | import { definePlugin, tool } from "./plugin"; | ||
| 7 | import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; | ||
| 8 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 9 | |||
| 10 | const integration = IntegrationSlug.make("approvalcheck"); | ||
| 11 | const connection = ConnectionName.make("main"); | ||
| 12 | const staticAddress = ToolAddress.make("approvalcheck.control.run"); | ||
| 13 | const dynamicAddress = ToolAddress.make("tools.approvalcheck.org.main.run"); | ||
| 14 | |||
| 15 | const setup = Effect.gen(function* () { | ||
| 16 | const invocations = yield* Ref.make(0); | ||
| 17 | const recordInvocation = Ref.update(invocations, (count) => count + 1).pipe( | ||
| 18 | Effect.as("executed"), | ||
| 19 | ); | ||
| 20 | const plugin = definePlugin(() => ({ | ||
| 21 | id: "approval-check" as const, | ||
| 22 | storage: () => ({}), | ||
| 23 | staticIntegrations: () => [ | ||
| 24 | { | ||
| 25 | id: "approvalcheck.control", | ||
| 26 | kind: "control" as const, | ||
| 27 | name: "Approval check", | ||
| 28 | tools: [ | ||
| 29 | tool({ | ||
| 30 | name: "run", | ||
| 31 | description: "Record an invocation after approval.", | ||
| 32 | annotations: { requiresApproval: true }, | ||
| 33 | inputSchema: Schema.toStandardSchemaV1( | ||
| 34 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | ||
| 35 | ), | ||
| 36 | execute: () => recordInvocation, | ||
| 37 | }), | ||
| 38 | ], | ||
| 39 | }, | ||
| 40 | ], | ||
| 41 | resolveTools: () => | ||
| 42 | Effect.succeed({ | ||
| 43 | tools: [ | ||
| 44 | { | ||
| 45 | name: ToolName.make("run"), | ||
| 46 | description: "Record an invocation after approval.", | ||
| 47 | annotations: { requiresApproval: true }, | ||
| 48 | }, | ||
| 49 | ], | ||
| 50 | }), | ||
| 51 | invokeTool: () => recordInvocation, | ||
| 52 | extension: (ctx) => ({ | ||
| 53 | register: () => | ||
| 54 | ctx.core.integrations.register({ | ||
| 55 | slug: integration, | ||
| 56 | description: "Approval check", | ||
| 57 | config: {}, | ||
| 58 | }), | ||
| 59 | }), | ||
| 60 | })); | ||
| 61 | const executor = yield* makeTestExecutor({ | ||
| 62 | access: testAccess.member(), | ||
| 63 | plugins: [plugin(), memoryCredentialsPlugin()] as const, | ||
| 64 | }); | ||
| 65 | yield* executor["approval-check"].register(); | ||
| 66 | yield* executor.connections.create({ | ||
| 67 | owner: "org", | ||
| 68 | integration, | ||
| 69 | name: connection, | ||
| 70 | template: AuthTemplateSlug.make("none"), | ||
| 71 | value: "synthetic-approval-test-key", | ||
| 72 | }); | ||
| 73 | return { executor, invocations }; | ||
| 74 | }); | ||
| 75 | |||
| 76 | describe("access changes while approval is pending", () => { | ||
| 77 | for (const address of [staticAddress, dynamicAddress]) { | ||
| 78 | it.effect(`blocks ${address} if policy changes before acceptance`, () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const { executor, invocations } = yield* setup; | ||
| 81 | const error = yield* Effect.flip( | ||
| 82 | executor.execute( | ||
| 83 | address, | ||
| 84 | {}, | ||
| 85 | { | ||
| 86 | onElicitation: () => | ||
| 87 | executor.policies | ||
| 88 | .create({ owner: "org", pattern: "approvalcheck.*", action: "block" }) | ||
| 89 | .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))), | ||
| 90 | }, | ||
| 91 | ), | ||
| 92 | ); | ||
| 93 | expect(error).toMatchObject({ _tag: "ToolBlockedError" }); | ||
| 94 | expect(yield* Ref.get(invocations)).toBe(0); | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | it.effect("does not invoke a connection disconnected before acceptance", () => | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const { executor, invocations } = yield* setup; | ||
| 102 | const error = yield* Effect.flip( | ||
| 103 | executor.execute( | ||
| 104 | dynamicAddress, | ||
| 105 | {}, | ||
| 106 | { | ||
| 107 | onElicitation: () => | ||
| 108 | executor.connections | ||
| 109 | .remove({ owner: "org", integration, name: connection }) | ||
| 110 | .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))), | ||
| 111 | }, | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); | ||
| 115 | expect(yield* Ref.get(invocations)).toBe(0); | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | |||
| 119 | it.effect("accepts once when access stays valid", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const { executor, invocations } = yield* setup; | ||
| 122 | const approvals = yield* Ref.make(0); | ||
| 123 | const result = yield* executor.execute( | ||
| 124 | dynamicAddress, | ||
| 125 | {}, | ||
| 126 | { | ||
| 127 | onElicitation: () => | ||
| 128 | Ref.update(approvals, (count) => count + 1).pipe( | ||
| 129 | Effect.as(ElicitationResponse.make({ action: "accept" })), | ||
| 130 | ), | ||
| 131 | }, | ||
| 132 | ); | ||
| 133 | expect(result).toBe("executed"); | ||
| 134 | expect(yield* Ref.get(invocations)).toBe(1); | ||
| 135 | expect(yield* Ref.get(approvals)).toBe(1); | ||
| 136 | }), | ||
| 137 | ); | ||
| 138 | }); | ||
| @@ -0,0 +1,90 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Ref } from "effect"; | ||
| 3 | |||
| 4 | import { ElicitationResponse } from "./elicitation"; | ||
| 5 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; | ||
| 6 | import { definePlugin } from "./plugin"; | ||
| 7 | import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; | ||
| 8 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Regression: the plugin RUNTIME bound before an approval pause must never | ||
| 12 | // invoke a REPLACEMENT tool row. If the integration is torn down and | ||
| 13 | // re-registered under a different plugin while the call waits on a human, | ||
| 14 | // the tool the user approved no longer exists in that form — the resumed | ||
| 15 | // call fails as not-found instead of handing the new row (and the new | ||
| 16 | // connection's credential) to the old plugin's handler. | ||
| 17 | // --------------------------------------------------------------------------- | ||
| 18 | |||
| 19 | const integration = IntegrationSlug.make("swapcheck"); | ||
| 20 | const connection = ConnectionName.make("main"); | ||
| 21 | const address = ToolAddress.make("tools.swapcheck.org.main.run"); | ||
| 22 | |||
| 23 | describe("integration replaced under a different plugin during approval", () => { | ||
| 24 | it.effect("fails as not-found; neither plugin's handler runs", () => | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const oldInvocations = yield* Ref.make(0); | ||
| 27 | const newInvocations = yield* Ref.make(0); | ||
| 28 | |||
| 29 | const makeCatalogPlugin = (id: "swap-old" | "swap-new", invocations: Ref.Ref<number>) => | ||
| 30 | definePlugin(() => ({ | ||
| 31 | id, | ||
| 32 | storage: () => ({}), | ||
| 33 | resolveTools: () => | ||
| 34 | Effect.succeed({ | ||
| 35 | tools: [ | ||
| 36 | { | ||
| 37 | name: ToolName.make("run"), | ||
| 38 | description: "Run after approval.", | ||
| 39 | annotations: { requiresApproval: true }, | ||
| 40 | }, | ||
| 41 | ], | ||
| 42 | }), | ||
| 43 | invokeTool: () => Ref.update(invocations, (count) => count + 1).pipe(Effect.as("ran")), | ||
| 44 | extension: (ctx) => ({ | ||
| 45 | register: () => | ||
| 46 | ctx.core.integrations.register({ | ||
| 47 | slug: integration, | ||
| 48 | description: `registered by ${id}`, | ||
| 49 | config: {}, | ||
| 50 | }), | ||
| 51 | }), | ||
| 52 | }))(); | ||
| 53 | |||
| 54 | const executor = yield* makeTestExecutor({ | ||
| 55 | access: testAccess.member(), | ||
| 56 | plugins: [ | ||
| 57 | makeCatalogPlugin("swap-old", oldInvocations), | ||
| 58 | makeCatalogPlugin("swap-new", newInvocations), | ||
| 59 | memoryCredentialsPlugin(), | ||
| 60 | ] as const, | ||
| 61 | }); | ||
| 62 | yield* executor["swap-old"].register(); | ||
| 63 | const createConnection = executor.connections.create({ | ||
| 64 | owner: "org", | ||
| 65 | integration, | ||
| 66 | name: connection, | ||
| 67 | template: AuthTemplateSlug.make("none"), | ||
| 68 | value: "synthetic-swap-test-key", | ||
| 69 | }); | ||
| 70 | yield* createConnection; | ||
| 71 | |||
| 72 | const swapPlugins = Effect.gen(function* () { | ||
| 73 | // Tear the integration down (cascading its connection and tool rows) | ||
| 74 | // and re-register the SAME slug under the other plugin, with a fresh | ||
| 75 | // connection producing a same-named tool row owned by `swap-new`. | ||
| 76 | yield* executor.integrations.remove(integration); | ||
| 77 | yield* executor["swap-new"].register(); | ||
| 78 | yield* createConnection; | ||
| 79 | return ElicitationResponse.make({ action: "accept" }); | ||
| 80 | }).pipe(Effect.orDie); | ||
| 81 | |||
| 82 | const error = yield* Effect.flip( | ||
| 83 | executor.execute(address, {}, { onElicitation: () => swapPlugins }), | ||
| 84 | ); | ||
| 85 | expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); | ||
| 86 | expect(yield* Ref.get(oldInvocations)).toBe(0); | ||
| 87 | expect(yield* Ref.get(newInvocations)).toBe(0); | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | }); | ||
| @@ -119,11 +119,19 @@ describe("artifact table", () => { · 118 unmodified lines | |||
| 119 | title: "Theirs", | 119 | title: "Theirs", |
| 120 | }); | 120 | }); |
| 121 | 121 | ||
| 122 | const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); | 122 | const scoped = withQueryContext(db.db, { |
| 123 | tenant: TENANT, | ||
| 124 | subject: SUBJECT, | ||
| 125 | owners: ["user", "org"], | ||
| 126 | }); | ||
| 123 | const visible = await scoped.findMany("artifact", {}); | 127 | const visible = await scoped.findMany("artifact", {}); |
| 124 | expect(visible.map((row) => row.id)).toEqual(["art_mine"]); | 128 | expect(visible.map((row) => row.id)).toEqual(["art_mine"]); |
| 125 | 129 | ||
| 126 | const otherScoped = withQueryContext(db.db, { tenant: TENANT, subject: OTHER_SUBJECT }); | 130 | const otherScoped = withQueryContext(db.db, { |
| 131 | tenant: TENANT, | ||
| 132 | subject: OTHER_SUBJECT, | ||
| 133 | owners: ["user", "org"], | ||
| 134 | }); | ||
| 127 | const otherVisible = await otherScoped.findMany("artifact", {}); | 135 | const otherVisible = await otherScoped.findMany("artifact", {}); |
| 128 | expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]); | 136 | expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]); |
| 129 | }), | 137 | }), |
| @@ -4,6 +4,7 @@ import { Effect, Predicate, Result } from "effect"; · 3 unmodified lines | |||
| 4 | import { makeTestExecutor } from "./testing"; | 4 | import { makeTestExecutor } from "./testing"; |
| 5 | import type { ArtifactBinding } from "./artifact"; | 5 | import type { ArtifactBinding } from "./artifact"; |
| 6 | import { ConnectionName, IntegrationSlug } from "./ids"; | 6 | import { ConnectionName, IntegrationSlug } from "./ids"; |
| 7 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 7 | 8 | ||
| 8 | const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({ | 9 | const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({ |
| 9 | integration: IntegrationSlug.make(integration), | 10 | integration: IntegrationSlug.make(integration), |
| @@ -20,14 +21,14 @@ const CODE = "export default function App() { return <div>hi</div>; }"; · 10 unmodified lines | |||
| 20 | describe("executor.artifacts", () => { | 21 | describe("executor.artifacts", () => { |
| 21 | it.effect("list is empty when nothing is saved", () => | 22 | it.effect("list is empty when nothing is saved", () => |
| 22 | Effect.gen(function* () { | 23 | Effect.gen(function* () { |
| 23 | const executor = yield* makeTestExecutor(); | 24 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 24 | expect(yield* executor.artifacts.list()).toEqual([]); | 25 | expect(yield* executor.artifacts.list()).toEqual([]); |
| 25 | }), | 26 | }), |
| 26 | ); | 27 | ); |
| 27 | 28 | ||
| 28 | it.effect("save mints an id and stores the source", () => | 29 | it.effect("save mints an id and stores the source", () => |
| 29 | Effect.gen(function* () { | 30 | Effect.gen(function* () { |
| 30 | const executor = yield* makeTestExecutor(); | 31 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 31 | const saved = yield* executor.artifacts.save({ | 32 | const saved = yield* executor.artifacts.save({ |
| 32 | title: "Active users", | 33 | title: "Active users", |
| 33 | description: "Daily active users over time", | 34 | description: "Daily active users over time", |
| @@ -46,7 +47,7 @@ describe("executor.artifacts", () => { · 12 unmodified lines | |||
| 46 | 47 | ||
| 47 | it.effect("round-trips connection bindings through the json column", () => | 48 | it.effect("round-trips connection bindings through the json column", () => |
| 48 | Effect.gen(function* () { | 49 | Effect.gen(function* () { |
| 49 | const executor = yield* makeTestExecutor(); | 50 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 50 | const bindings = { | 51 | const bindings = { |
| 51 | vercel: binding("vercel", "user", "personalVercel"), | 52 | vercel: binding("vercel", "user", "personalVercel"), |
| 52 | prod: binding("linear", "org", "linearProd"), | 53 | prod: binding("linear", "org", "linearProd"), |
| @@ -59,7 +60,7 @@ describe("executor.artifacts", () => { · 6 unmodified lines | |||
| 59 | 60 | ||
| 60 | it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () => | 61 | it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () => |
| 61 | Effect.gen(function* () { | 62 | Effect.gen(function* () { |
| 62 | const executor = yield* makeTestExecutor(); | 63 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 63 | const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} }); | 64 | const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} }); |
| 64 | // `{}` and `null` are different facts: `{}` is an artifact that calls no | 65 | // `{}` and `null` are different facts: `{}` is an artifact that calls no |
| 65 | // integration, `null` is one written before bindings existed and whose | 66 | // integration, `null` is one written before bindings existed and whose |
| @@ -71,7 +72,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | |||
| 71 | 72 | ||
| 72 | it.effect("leaves bindings null when a save omits them", () => | 73 | it.effect("leaves bindings null when a save omits them", () => |
| 73 | Effect.gen(function* () { | 74 | Effect.gen(function* () { |
| 74 | const executor = yield* makeTestExecutor(); | 75 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 75 | const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE }); | 76 | const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE }); |
| 76 | expect(saved.bindings).toBe(null); | 77 | expect(saved.bindings).toBe(null); |
| 77 | }), | 78 | }), |
| @@ -79,7 +80,7 @@ describe("executor.artifacts", () => { · 1 unmodified line | |||
| 79 | 80 | ||
| 80 | it.effect("overwrites bindings along with the source", () => | 81 | it.effect("overwrites bindings along with the source", () => |
| 81 | Effect.gen(function* () { | 82 | Effect.gen(function* () { |
| 82 | const executor = yield* makeTestExecutor(); | 83 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 83 | const saved = yield* executor.artifacts.save({ | 84 | const saved = yield* executor.artifacts.save({ |
| 84 | title: "Domains", | 85 | title: "Domains", |
| 85 | code: CODE, | 86 | code: CODE, |
| @@ -100,7 +101,7 @@ describe("executor.artifacts", () => { · 14 unmodified lines | |||
| 100 | 101 | ||
| 101 | it.effect("save defaults a missing description to null", () => | 102 | it.effect("save defaults a missing description to null", () => |
| 102 | Effect.gen(function* () { | 103 | Effect.gen(function* () { |
| 103 | const executor = yield* makeTestExecutor(); | 104 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 104 | const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE }); | 105 | const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE }); |
| 105 | expect(saved.description).toBe(null); | 106 | expect(saved.description).toBe(null); |
| 106 | }), | 107 | }), |
| @@ -108,7 +109,7 @@ describe("executor.artifacts", () => { · 1 unmodified line | |||
| 108 | 109 | ||
| 109 | it.effect("list omits the source", () => | 110 | it.effect("list omits the source", () => |
| 110 | Effect.gen(function* () { | 111 | Effect.gen(function* () { |
| 111 | const executor = yield* makeTestExecutor(); | 112 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 112 | const saved = yield* executor.artifacts.save({ | 113 | const saved = yield* executor.artifacts.save({ |
| 113 | title: "Active users", | 114 | title: "Active users", |
| 114 | description: "described", | 115 | description: "described", |
| @@ -141,7 +142,7 @@ describe("executor.artifacts", () => { · 26 unmodified lines | |||
| 141 | // storage layer really has. | 142 | // storage layer really has. |
| 142 | it.live("list returns the most recently updated artifact first", () => | 143 | it.live("list returns the most recently updated artifact first", () => |
| 143 | Effect.gen(function* () { | 144 | Effect.gen(function* () { |
| 144 | const executor = yield* makeTestExecutor(); | 145 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 145 | const first = yield* executor.artifacts.save({ title: "First", code: CODE }); | 146 | const first = yield* executor.artifacts.save({ title: "First", code: CODE }); |
| 146 | yield* Effect.sleep("1100 millis"); | 147 | yield* Effect.sleep("1100 millis"); |
| 147 | const second = yield* executor.artifacts.save({ title: "Second", code: CODE }); | 148 | const second = yield* executor.artifacts.save({ title: "Second", code: CODE }); |
| @@ -159,7 +160,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | |||
| 159 | 160 | ||
| 160 | it.effect("save with an id overwrites in place", () => | 161 | it.effect("save with an id overwrites in place", () => |
| 161 | Effect.gen(function* () { | 162 | Effect.gen(function* () { |
| 162 | const executor = yield* makeTestExecutor(); | 163 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 163 | const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE }); | 164 | const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE }); |
| 164 | const updated = yield* executor.artifacts.save({ | 165 | const updated = yield* executor.artifacts.save({ |
| 165 | id: saved.id, | 166 | id: saved.id, |
| @@ -182,7 +183,7 @@ describe("executor.artifacts", () => { · 16 unmodified lines | |||
| 182 | 183 | ||
| 183 | it.effect("save fails for an id that names no visible artifact", () => | 184 | it.effect("save fails for an id that names no visible artifact", () => |
| 184 | Effect.gen(function* () { | 185 | Effect.gen(function* () { |
| 185 | const executor = yield* makeTestExecutor(); | 186 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 186 | const result = yield* Effect.result( | 187 | const result = yield* Effect.result( |
| 187 | executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }), | 188 | executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }), |
| 188 | ); | 189 | ); |
| @@ -194,7 +195,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | |||
| 194 | 195 | ||
| 195 | it.effect("get fails for an unknown id", () => | 196 | it.effect("get fails for an unknown id", () => |
| 196 | Effect.gen(function* () { | 197 | Effect.gen(function* () { |
| 197 | const executor = yield* makeTestExecutor(); | 198 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 198 | const result = yield* Effect.result(executor.artifacts.get("art_missing")); | 199 | const result = yield* Effect.result(executor.artifacts.get("art_missing")); |
| 199 | expect(Result.isFailure(result)).toBe(true); | 200 | expect(Result.isFailure(result)).toBe(true); |
| 200 | if (!Result.isFailure(result)) return; | 201 | if (!Result.isFailure(result)) return; |
| @@ -204,7 +205,7 @@ describe("executor.artifacts", () => { · 3 unmodified lines | |||
| 204 | 205 | ||
| 205 | it.effect("rename changes only the title", () => | 206 | it.effect("rename changes only the title", () => |
| 206 | Effect.gen(function* () { | 207 | Effect.gen(function* () { |
| 207 | const executor = yield* makeTestExecutor(); | 208 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 208 | const saved = yield* executor.artifacts.save({ | 209 | const saved = yield* executor.artifacts.save({ |
| 209 | title: "Old name", | 210 | title: "Old name", |
| 210 | description: "kept", | 211 | description: "kept", |
| @@ -219,7 +220,7 @@ describe("executor.artifacts", () => { · 8 unmodified lines | |||
| 219 | 220 | ||
| 220 | it.effect("rename fails for an unknown id", () => | 221 | it.effect("rename fails for an unknown id", () => |
| 221 | Effect.gen(function* () { | 222 | Effect.gen(function* () { |
| 222 | const executor = yield* makeTestExecutor(); | 223 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 223 | const result = yield* Effect.result( | 224 | const result = yield* Effect.result( |
| 224 | executor.artifacts.rename({ id: "art_missing", title: "Nope" }), | 225 | executor.artifacts.rename({ id: "art_missing", title: "Nope" }), |
| 225 | ); | 226 | ); |
| @@ -231,7 +232,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | |||
| 231 | 232 | ||
| 232 | it.effect("remove hard-deletes the artifact", () => | 233 | it.effect("remove hard-deletes the artifact", () => |
| 233 | Effect.gen(function* () { | 234 | Effect.gen(function* () { |
| 234 | const executor = yield* makeTestExecutor(); | 235 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 235 | const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE }); | 236 | const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE }); |
| 236 | yield* executor.artifacts.remove({ id: saved.id }); | 237 | yield* executor.artifacts.remove({ id: saved.id }); |
| 237 | 238 | ||
| @@ -245,7 +246,7 @@ describe("executor.artifacts", () => { · 7 unmodified lines | |||
| 245 | 246 | ||
| 246 | it.effect("remove is idempotent", () => | 247 | it.effect("remove is idempotent", () => |
| 247 | Effect.gen(function* () { | 248 | Effect.gen(function* () { |
| 248 | const executor = yield* makeTestExecutor(); | 249 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 249 | yield* executor.artifacts.remove({ id: "art_missing" }); | 250 | yield* executor.artifacts.remove({ id: "art_missing" }); |
| 250 | }), | 251 | }), |
| 251 | ); | 252 | ); |
| @@ -254,7 +255,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines | |||
| 254 | Effect.gen(function* () { | 255 | Effect.gen(function* () { |
| 255 | // Pure-org executors (host request paths with no member bound) have no | 256 | // Pure-org executors (host request paths with no member bound) have no |
| 256 | // user partition to file a personal artifact into. | 257 | // user partition to file a personal artifact into. |
| 257 | const executor = yield* makeTestExecutor({ subject: null }); | 258 | const executor = yield* makeTestExecutor({ access: testAccess.org(), subject: null }); |
| 258 | const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE })); | 259 | const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE })); |
| 259 | expect(Result.isFailure(result)).toBe(true); | 260 | expect(Result.isFailure(result)).toBe(true); |
| 260 | }), | 261 | }), |
| @@ -266,7 +267,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | |||
| 266 | 267 | ||
| 267 | it.effect("round-trips layout markup, and reads it back as a layout preview", () => | 268 | it.effect("round-trips layout markup, and reads it back as a layout preview", () => |
| 268 | Effect.gen(function* () { | 269 | Effect.gen(function* () { |
| 269 | const executor = yield* makeTestExecutor(); | 270 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 270 | const saved = yield* executor.artifacts.save({ | 271 | const saved = yield* executor.artifacts.save({ |
| 271 | title: "Revenue", | 272 | title: "Revenue", |
| 272 | code: CODE, | 273 | code: CODE, |
| @@ -284,7 +285,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | |||
| 284 | 285 | ||
| 285 | it.effect("leaves the preview null when a save omits one", () => | 286 | it.effect("leaves the preview null when a save omits one", () => |
| 286 | Effect.gen(function* () { | 287 | Effect.gen(function* () { |
| 287 | const executor = yield* makeTestExecutor(); | 288 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 288 | const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE }); | 289 | const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE }); |
| 289 | expect(saved.preview).toBe(null); | 290 | expect(saved.preview).toBe(null); |
| 290 | }), | 291 | }), |
| @@ -295,7 +296,7 @@ describe("executor.artifacts", () => { · 4 unmodified lines | |||
| 295 | // The preview interprets `code`, so a save that replaces the source | 296 | // The preview interprets `code`, so a save that replaces the source |
| 296 | // must replace the picture — including back to null. Carrying it | 297 | // must replace the picture — including back to null. Carrying it |
| 297 | // forward would advertise a version of the artifact that is gone. | 298 | // forward would advertise a version of the artifact that is gone. |
| 298 | const executor = yield* makeTestExecutor(); | 299 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 299 | const saved = yield* executor.artifacts.save({ | 300 | const saved = yield* executor.artifacts.save({ |
| 300 | title: "Revenue", | 301 | title: "Revenue", |
| 301 | code: CODE, | 302 | code: CODE, |
| @@ -313,7 +314,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | |||
| 313 | 314 | ||
| 314 | it.effect("setPreview upgrades the preview to the settled render", () => | 315 | it.effect("setPreview upgrades the preview to the settled render", () => |
| 315 | Effect.gen(function* () { | 316 | Effect.gen(function* () { |
| 316 | const executor = yield* makeTestExecutor(); | 317 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 317 | const saved = yield* executor.artifacts.save({ | 318 | const saved = yield* executor.artifacts.save({ |
| 318 | title: "Revenue", | 319 | title: "Revenue", |
| 319 | code: CODE, | 320 | code: CODE, |
| @@ -331,7 +332,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | |||
| 331 | Effect.gen(function* () { | 332 | Effect.gen(function* () { |
| 332 | // `updated_at` is the gallery's sort key. Opening an artifact must not | 333 | // `updated_at` is the gallery's sort key. Opening an artifact must not |
| 333 | // move it to the front — being looked at is not an edit. | 334 | // move it to the front — being looked at is not an edit. |
| 334 | const executor = yield* makeTestExecutor(); | 335 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 335 | const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE }); | 336 | const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE }); |
| 336 | yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED }); | 337 | yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED }); |
| 337 | expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt); | 338 | expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt); |
| @@ -340,7 +341,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines | |||
| 340 | 341 | ||
| 341 | it.effect("setPreview fails for an artifact that is not visible", () => | 342 | it.effect("setPreview fails for an artifact that is not visible", () => |
| 342 | Effect.gen(function* () { | 343 | Effect.gen(function* () { |
| 343 | const executor = yield* makeTestExecutor(); | 344 | const executor = yield* makeTestExecutor({ access: testAccess.member() }); |
| 344 | const result = yield* Effect.result( | 345 | const result = yield* Effect.result( |
| 345 | executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }), | 346 | executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }), |
| 346 | ); | 347 | ); |
| @@ -3,23 +3,35 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { StorageError } from "./fuma-runtime"; | 4 | import { StorageError } from "./fuma-runtime"; |
| 5 | 5 | ||
| 6 | import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob"; | 6 | import { |
| 7 | makeInMemoryBlobStore, | ||
| 8 | pluginBlobStore, | ||
| 9 | type BlobAccess, | ||
| 10 | type OwnerPartitions, | ||
| 11 | } from "./blob"; | ||
| 7 | 12 | ||
| 8 | // v2: owner partitions instead of a scope stack. Reads fall through | 13 | // v2: owner partitions instead of a scope stack. Reads fall through the |
| 9 | // [user, org] (user = innermost); writes/deletes name an explicit owner. | 14 | // product view's owners in their supplied order (first listed wins); writes |
| 15 | // and deletes name an explicit owner and are refused outside the view. | ||
| 10 | const partitions = (org: string, user: string | null): OwnerPartitions => ({ | 16 | const partitions = (org: string, user: string | null): OwnerPartitions => ({ |
| 11 | org, | 17 | org, |
| 12 | user, | 18 | user, |
| 13 | }); | 19 | }); |
| 14 | 20 | ||
| 21 | // The full member view — the pre-BlobAccess behavior: user shadows org on | ||
| 22 | // read, both partitions writable. | ||
| 23 | const fullView: BlobAccess = { owners: ["user", "org"], storageWrites: "allowed" }; | ||
| 24 | const personalOnly: BlobAccess = { owners: ["user"], storageWrites: "allowed" }; | ||
| 25 | const orgOnly: BlobAccess = { owners: ["org"], storageWrites: "allowed" }; | ||
| 26 | |||
| 15 | describe("pluginBlobStore", () => { | 27 | describe("pluginBlobStore", () => { |
| 16 | it.effect("get returns user (innermost) value when both owners have one", () => | 28 | it.effect("get returns user (first-ranked) value when both owners have one", () => |
| 17 | Effect.gen(function* () { | 29 | Effect.gen(function* () { |
| 18 | const store = makeInMemoryBlobStore(); | 30 | const store = makeInMemoryBlobStore(); |
| 19 | yield* store.put("u/my-plugin", "k", "user-value"); | 31 | yield* store.put("u/my-plugin", "k", "user-value"); |
| 20 | yield* store.put("o/my-plugin", "k", "org-value"); | 32 | yield* store.put("o/my-plugin", "k", "org-value"); |
| 21 | 33 | ||
| 22 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); | 34 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); |
| 23 | const value = yield* plugin.get("k"); | 35 | const value = yield* plugin.get("k"); |
| 24 | expect(value).toBe("user-value"); | 36 | expect(value).toBe("user-value"); |
| 25 | }), | 37 | }), |
| @@ -30,7 +42,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines | |||
| 30 | const store = makeInMemoryBlobStore(); | 42 | const store = makeInMemoryBlobStore(); |
| 31 | yield* store.put("o/my-plugin", "k", "org-value"); | 43 | yield* store.put("o/my-plugin", "k", "org-value"); |
| 32 | 44 | ||
| 33 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); | 45 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); |
| 34 | const value = yield* plugin.get("k"); | 46 | const value = yield* plugin.get("k"); |
| 35 | expect(value).toBe("org-value"); | 47 | expect(value).toBe("org-value"); |
| 36 | }), | 48 | }), |
| @@ -39,7 +51,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | |||
| 39 | it.effect("get returns null when no owner has the key", () => | 51 | it.effect("get returns null when no owner has the key", () => |
| 40 | Effect.gen(function* () { | 52 | Effect.gen(function* () { |
| 41 | const store = makeInMemoryBlobStore(); | 53 | const store = makeInMemoryBlobStore(); |
| 42 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); | 54 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); |
| 43 | const value = yield* plugin.get("k"); | 55 | const value = yield* plugin.get("k"); |
| 44 | expect(value).toBeNull(); | 56 | expect(value).toBeNull(); |
| 45 | }), | 57 | }), |
| @@ -50,7 +62,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines | |||
| 50 | const store = makeInMemoryBlobStore(); | 62 | const store = makeInMemoryBlobStore(); |
| 51 | yield* store.put("o/my-plugin", "k", "v"); | 63 | yield* store.put("o/my-plugin", "k", "v"); |
| 52 | 64 | ||
| 53 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); | 65 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); |
| 54 | const found = yield* plugin.has("k"); | 66 | const found = yield* plugin.has("k"); |
| 55 | expect(found).toBe(true); | 67 | expect(found).toBe(true); |
| 56 | }), | 68 | }), |
| @@ -59,7 +71,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | |||
| 59 | it.effect("has returns false when no owner has the key", () => | 71 | it.effect("has returns false when no owner has the key", () => |
| 60 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| 61 | const store = makeInMemoryBlobStore(); | 73 | const store = makeInMemoryBlobStore(); |
| 62 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); | 74 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); |
| 63 | const found = yield* plugin.has("k"); | 75 | const found = yield* plugin.has("k"); |
| 64 | expect(found).toBe(false); | 76 | expect(found).toBe(false); |
| 65 | }), | 77 | }), |
| @@ -71,8 +83,8 @@ describe("pluginBlobStore", () => { · 5 unmodified lines | |||
| 71 | yield* store.put("u/plugin-a", "k", "a-value"); | 83 | yield* store.put("u/plugin-a", "k", "a-value"); |
| 72 | yield* store.put("u/plugin-b", "k", "b-value"); | 84 | yield* store.put("u/plugin-b", "k", "b-value"); |
| 73 | 85 | ||
| 74 | const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a"); | 86 | const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a", fullView); |
| 75 | const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b"); | 87 | const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b", fullView); |
| 76 | expect(yield* pluginA.get("k")).toBe("a-value"); | 88 | expect(yield* pluginA.get("k")).toBe("a-value"); |
| 77 | expect(yield* pluginB.get("k")).toBe("b-value"); | 89 | expect(yield* pluginB.get("k")).toBe("b-value"); |
| 78 | }), | 90 | }), |
| @@ -81,10 +93,14 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | |||
| 81 | it.effect("put rejects owner:user when the executor has no subject", () => | 93 | it.effect("put rejects owner:user when the executor has no subject", () => |
| 82 | Effect.gen(function* () { | 94 | Effect.gen(function* () { |
| 83 | const store = makeInMemoryBlobStore(); | 95 | const store = makeInMemoryBlobStore(); |
| 84 | // No user partition → owner:"user" writes fail. | 96 | // No user partition → owner:"user" writes fail even when the product |
| 85 | const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); | 97 | // view lists "user"; the identity clamp is not the product's to relax. |
| 98 | const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); | ||
| 86 | const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); | 99 | const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); |
| 87 | expect(err).toBeInstanceOf(StorageError); | 100 | expect(err).toBeInstanceOf(StorageError); |
| 101 | expect(err).toMatchObject({ | ||
| 102 | message: 'Blob write targets owner "user" but the executor has no subject.', | ||
| 103 | }); | ||
| 88 | // Write must not have reached the store. | 104 | // Write must not have reached the store. |
| 89 | expect(yield* store.get("o/my-plugin", "k")).toBeNull(); | 105 | expect(yield* store.get("o/my-plugin", "k")).toBeNull(); |
| 90 | }), | 106 | }), |
| @@ -93,9 +109,142 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | |||
| 93 | it.effect("delete rejects owner:user when the executor has no subject", () => | 109 | it.effect("delete rejects owner:user when the executor has no subject", () => |
| 94 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 95 | const store = makeInMemoryBlobStore(); | 111 | const store = makeInMemoryBlobStore(); |
| 96 | const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); | 112 | const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); |
| 97 | const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip); | 113 | const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip); |
| 98 | expect(err).toBeInstanceOf(StorageError); | 114 | expect(err).toBeInstanceOf(StorageError); |
| 115 | expect(err).toMatchObject({ | ||
| 116 | message: 'Blob write targets owner "user" but the executor has no subject.', | ||
| 117 | }); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("pluginBlobStore access clamp", () => { | ||
| 123 | it.effect("personal-only view cannot read an org blob", () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const store = makeInMemoryBlobStore(); | ||
| 126 | // e.g. an org-shared provider config holding a service-account token. | ||
| 127 | yield* store.put("o/my-plugin", "k", "org-secret"); | ||
| 128 | |||
| 129 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | ||
| 130 | expect(yield* plugin.get("k")).toBeNull(); | ||
| 131 | expect(yield* plugin.has("k")).toBe(false); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect("personal-only view still reads its own user blob", () => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | const store = makeInMemoryBlobStore(); | ||
| 138 | yield* store.put("u/my-plugin", "k", "user-value"); | ||
| 139 | yield* store.put("o/my-plugin", "k", "org-value"); | ||
| 140 | |||
| 141 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | ||
| 142 | expect(yield* plugin.get("k")).toBe("user-value"); | ||
| 143 | }), | ||
| 144 | ); | ||
| 145 | |||
| 146 | it.effect("org-only view with a bound subject cannot read a user blob", () => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | const store = makeInMemoryBlobStore(); | ||
| 149 | yield* store.put("u/my-plugin", "k", "user-value"); | ||
| 150 | |||
| 151 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly); | ||
| 152 | expect(yield* plugin.get("k")).toBeNull(); | ||
| 153 | expect(yield* plugin.has("k")).toBe(false); | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | |||
| 157 | it.effect("read precedence follows the supplied owner order", () => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | const store = makeInMemoryBlobStore(); | ||
| 160 | yield* store.put("u/my-plugin", "k", "user-value"); | ||
| 161 | yield* store.put("o/my-plugin", "k", "org-value"); | ||
| 162 | |||
| 163 | const orgFirst = pluginBlobStore(store, partitions("o", "u"), "my-plugin", { | ||
| 164 | owners: ["org", "user"], | ||
| 165 | storageWrites: "allowed", | ||
| 166 | }); | ||
| 167 | expect(yield* orgFirst.get("k")).toBe("org-value"); | ||
| 168 | }), | ||
| 169 | ); | ||
| 170 | |||
| 171 | it.effect("put outside the view is rejected and does not reach the store", () => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const store = makeInMemoryBlobStore(); | ||
| 174 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | ||
| 175 | const err = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip); | ||
| 176 | expect(err).toBeInstanceOf(StorageError); | ||
| 177 | expect(err).toMatchObject({ | ||
| 178 | message: | ||
| 179 | 'Blob write targets the "org" partition, which this product view does not include.', | ||
| 180 | }); | ||
| 181 | expect(yield* store.get("o/my-plugin", "k")).toBeNull(); | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | |||
| 185 | it.effect("delete outside the view is rejected and leaves the row in place", () => | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const store = makeInMemoryBlobStore(); | ||
| 188 | yield* store.put("o/my-plugin", "k", "org-value"); | ||
| 189 | |||
| 190 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | ||
| 191 | const err = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip); | ||
| 192 | expect(err).toBeInstanceOf(StorageError); | ||
| 193 | expect(err).toMatchObject({ | ||
| 194 | message: | ||
| 195 | 'Blob write targets the "org" partition, which this product view does not include.', | ||
| 196 | }); | ||
| 197 | expect(yield* store.get("o/my-plugin", "k")).toBe("org-value"); | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect("org-only view cannot write the user partition", () => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const store = makeInMemoryBlobStore(); | ||
| 204 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly); | ||
| 205 | const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); | ||
| 206 | expect(err).toBeInstanceOf(StorageError); | ||
| 207 | expect(err).toMatchObject({ | ||
| 208 | message: | ||
| 209 | 'Blob write targets the "user" partition, which this product view does not include.', | ||
| 210 | }); | ||
| 211 | expect(yield* store.get("u/my-plugin", "k")).toBeNull(); | ||
| 212 | }), | ||
| 213 | ); | ||
| 214 | |||
| 215 | it.effect("read-only view refuses every mutation but keeps reading", () => | ||
| 216 | Effect.gen(function* () { | ||
| 217 | const store = makeInMemoryBlobStore(); | ||
| 218 | yield* store.put("o/my-plugin", "k", "org-value"); | ||
| 219 | |||
| 220 | const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", { | ||
| 221 | owners: ["user", "org"], | ||
| 222 | storageWrites: "denied", | ||
| 223 | }); | ||
| 224 | const putErr = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip); | ||
| 225 | expect(putErr).toBeInstanceOf(StorageError); | ||
| 226 | expect(putErr.message).toBe( | ||
| 227 | 'Blob write on plugin "my-plugin" is not allowed: this executor\'s storage is read-only.', | ||
| 228 | ); | ||
| 229 | const userPutErr = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); | ||
| 230 | expect(userPutErr).toBeInstanceOf(StorageError); | ||
| 231 | const deleteErr = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip); | ||
| 232 | expect(deleteErr).toBeInstanceOf(StorageError); | ||
| 233 | // Nothing was mutated; reads keep working. | ||
| 234 | expect(yield* store.get("o/my-plugin", "k")).toBe("org-value"); | ||
| 235 | expect(yield* plugin.get("k")).toBe("org-value"); | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | |||
| 239 | it.effect('a view listing "user" on a subject-less identity reads org rows only', () => | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const store = makeInMemoryBlobStore(); | ||
| 242 | yield* store.put("o/my-plugin", "k", "org-value"); | ||
| 243 | |||
| 244 | // `createExecutor` validation rejects this shape, but the seam must not | ||
| 245 | // assume it: the null partition contributes no namespace, not a failure. | ||
| 246 | const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); | ||
| 247 | expect(yield* plugin.get("k")).toBe("org-value"); | ||
| 99 | }), | 248 | }), |
| 100 | ); | 249 | ); |
| 101 | }); | 250 | }); |
| @@ -4,18 +4,21 @@ · 3 unmodified lines | |||
| 4 | // metadata and plugin-owned config rows. | 4 | // metadata and plugin-owned config rows. |
| 5 | // | 5 | // |
| 6 | // Plugins see a `PluginBlobStore` that's already namespaced to the | 6 | // Plugins see a `PluginBlobStore` that's already namespaced to the |
| 7 | // plugin id and bound to the executor's scope stack. Reads fall through | 7 | // plugin id and clamped to the product's `BlobAccess` view. Reads fall |
| 8 | // the stack in order (innermost first, first hit wins); writes and | 8 | // through the view's owners in order (first listed wins); writes and |
| 9 | // deletes require an explicit scope id naming where the operation | 9 | // deletes require an explicit owner naming where the operation should |
| 10 | // should land. That mirrors the secrets API — shadowing by key on | 10 | // land, and are refused outside the view. That mirrors the secrets API — |
| 11 | // read, explicit target on write. | 11 | // shadowing by key on read, explicit target on write — and it is where |
| 12 | // the executor's access decisions apply to blobs at all: the blob table | ||
| 13 | // sits outside the storage owner policy (isolation is the namespace | ||
| 14 | // string), so nothing downstream re-checks them. | ||
| 12 | // | 15 | // |
| 13 | // Error channel is `StorageError` — blobs only do read/write/delete, so | 16 | // Error channel is `StorageError` — blobs only do read/write/delete, so |
| 14 | // they never produce `UniqueViolationError`. The HTTP edge translates | 17 | // they never produce `UniqueViolationError`. The HTTP edge translates |
| 15 | // `StorageError` to the opaque public `InternalError({ traceId })`. | 18 | // `StorageError` to the opaque public `InternalError({ traceId })`. |
| 16 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 17 | 20 | ||
| 18 | import { Effect } from "effect"; | 21 | import { Effect, Predicate } from "effect"; |
| 19 | 22 | ||
| 20 | import { StorageError, type IFumaClient } from "./fuma-runtime"; | 23 | import { StorageError, type IFumaClient } from "./fuma-runtime"; |
| 21 | import type { Owner } from "./ids"; | 24 | import type { Owner } from "./ids"; |
| @@ -41,53 +44,101 @@ export interface BlobStore { · 19 unmodified lines | |||
| 41 | } | 44 | } |
| 42 | 45 | ||
| 43 | export interface PluginBlobStore { | 46 | export interface PluginBlobStore { |
| 44 | /** Read precedence: this subject's own (`user`) value first, then the | 47 | /** Read precedence: the product view's owner order (`BlobAccess.owners`, |
| 45 | * org-shared value. Returns the first non-null. */ | 48 | * first entry wins). Returns the first non-null. */ |
| 46 | readonly get: (key: string) => Effect.Effect<string | null, StorageError>; | 49 | readonly get: (key: string) => Effect.Effect<string | null, StorageError>; |
| 47 | /** Write `value` under `key` for the named owner (`"org"` shared, `"user"` | 50 | /** Write `value` under `key` for the named owner (`"org"` shared, `"user"` |
| 48 | * private). `"user"` requires the executor to be bound to a subject. */ | 51 | * private). The owner must be in the product view, and `"user"` requires |
| 52 | * the executor to be bound to a subject. */ | ||
| 49 | readonly put: ( | 53 | readonly put: ( |
| 50 | key: string, | 54 | key: string, |
| 51 | value: string, | 55 | value: string, |
| 52 | options: { readonly owner: Owner }, | 56 | options: { readonly owner: Owner }, |
| 53 | ) => Effect.Effect<void, StorageError>; | 57 | ) => Effect.Effect<void, StorageError>; |
| 54 | /** Delete `key` for the named owner. */ | 58 | /** Delete `key` for the named owner. Bounded like `put`. */ |
| 55 | readonly delete: ( | 59 | readonly delete: ( |
| 56 | key: string, | 60 | key: string, |
| 57 | options: { readonly owner: Owner }, | 61 | options: { readonly owner: Owner }, |
| 58 | ) => Effect.Effect<void, StorageError>; | 62 | ) => Effect.Effect<void, StorageError>; |
| 59 | /** True if either the user or org partition has a value for `key`. */ | 63 | /** True if any partition in the product view has a value for `key`. */ |
| 60 | readonly has: (key: string) => Effect.Effect<boolean, StorageError>; | 64 | readonly has: (key: string) => Effect.Effect<boolean, StorageError>; |
| 61 | } | 65 | } |
| 62 | 66 | ||
| 63 | /** The owner partition strings an executor binding resolves to: the org | 67 | /** The owner partition strings an executor IDENTITY resolves to: the org |
| 64 | * partition (always present) and this subject's user partition (null for a | 68 | * partition (always present) and this subject's user partition (null for a |
| 65 | * pure-org executor). Reads walk `[user, org]`; writes target one. */ | 69 | * pure-org executor). Identity-shaped on purpose — identity-bound consumers |
| 70 | * (the pending-approval store) keep using it directly; which partitions a | ||
| 71 | * plugin blob store actually reads or writes is the separate, product-owned | ||
| 72 | * `BlobAccess` decision. */ | ||
| 66 | export interface OwnerPartitions { | 73 | export interface OwnerPartitions { |
| 67 | readonly org: string; | 74 | readonly org: string; |
| 68 | readonly user: string | null; | 75 | readonly user: string | null; |
| 69 | } | 76 | } |
| 70 | 77 | ||
| 78 | /** | ||
| 79 | * The slice of the product's access decisions the blob seam enforces: the | ||
| 80 | * blob table is exempt from the storage owner policy (isolation lives in the | ||
| 81 | * row namespace), so `ExecutorAccess.owners` and the read-only capability | ||
| 82 | * must be applied HERE, at namespace construction, or not at all. | ||
| 83 | * | ||
| 84 | * Structural and required — there is no default view: the caller states the | ||
| 85 | * product's decision explicitly, and core contributes only the clamps | ||
| 86 | * (tenant/subject are already baked into `OwnerPartitions`). | ||
| 87 | */ | ||
| 88 | export interface BlobAccess { | ||
| 89 | /** The owner partitions reads see and writes may target, in read-precedence | ||
| 90 | * order — the first listed owner shadows later ones on `get`. */ | ||
| 91 | readonly owners: readonly Owner[]; | ||
| 92 | /** `"denied"` refuses every `put`/`delete` through this store (the | ||
| 93 | * read-only posture, `ExecutorAccessCapabilities.storageWrites`). */ | ||
| 94 | readonly storageWrites: "allowed" | "denied"; | ||
| 95 | } | ||
| 96 | |||
| 71 | const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`; | 97 | const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`; |
| 72 | 98 | ||
| 73 | /** | 99 | /** |
| 74 | * Bind a `BlobStore` to an owner partitioning + plugin id. Reads fall through | 100 | * Bind a `BlobStore` to an owner partitioning + plugin id, clamped to the |
| 75 | * `[user, org]` (user first); writes target an explicit owner. Used by the | 101 | * product's `BlobAccess`. Reads fall through the access owners in their |
| 76 | * executor to build the `blobs` field handed to each plugin's `storage` factory. | 102 | * supplied order (first hit wins); writes target an explicit owner and are |
| 103 | * refused outside the access view. Used by the executor to build the `blobs` | ||
| 104 | * field handed to each plugin's `storage` factory. | ||
| 77 | */ | 105 | */ |
| 78 | export const pluginBlobStore = ( | 106 | export const pluginBlobStore = ( |
| 79 | store: BlobStore, | 107 | store: BlobStore, |
| 80 | partitions: OwnerPartitions, | 108 | partitions: OwnerPartitions, |
| 81 | pluginId: string, | 109 | pluginId: string, |
| 110 | access: BlobAccess, | ||
| 82 | ): PluginBlobStore => { | 111 | ): PluginBlobStore => { |
| 83 | const readNamespaces = (): readonly string[] => | 112 | const partitionOf = (owner: Owner): string | null => |
| 84 | (partitions.user == null ? [partitions.org] : [partitions.user, partitions.org]).map((p) => | 113 | owner === "org" ? partitions.org : partitions.user; |
| 85 | nsFor(p, pluginId), | 114 | |
| 86 | ); | 115 | // The product view's partitions, in ITS precedence order. An owner the |
| 116 | // identity cannot carry (`"user"` with no subject) maps to no namespace | ||
| 117 | // rather than failing a read — the partition simply has no rows to see. | ||
| 118 | const readNamespaces: readonly string[] = access.owners | ||
| 119 | .map(partitionOf) | ||
| 120 | .filter(Predicate.isNotNull) | ||
| 121 | .map((partition) => nsFor(partition, pluginId)); | ||
| 87 | 122 | ||
| 88 | const partitionFor = (owner: Owner): Effect.Effect<string, StorageError> => { | 123 | const partitionFor = (owner: Owner): Effect.Effect<string, StorageError> => { |
| 89 | if (owner === "org") return Effect.succeed(partitions.org); | 124 | if (access.storageWrites === "denied") { |
| 90 | if (partitions.user == null) { | 125 | return Effect.fail( |
| 126 | new StorageError({ | ||
| 127 | message: `Blob write on plugin "${pluginId}" is not allowed: this executor's storage is read-only.`, | ||
| 128 | cause: undefined, | ||
| 129 | }), | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | if (!access.owners.includes(owner)) { | ||
| 133 | return Effect.fail( | ||
| 134 | new StorageError({ | ||
| 135 | message: `Blob write targets the "${owner}" partition, which this product view does not include.`, | ||
| 136 | cause: undefined, | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | } | ||
| 140 | const partition = partitionOf(owner); | ||
| 141 | if (partition == null) { | ||
| 91 | return Effect.fail( | 142 | return Effect.fail( |
| 92 | new StorageError({ | 143 | new StorageError({ |
| 93 | message: 'Blob write targets owner "user" but the executor has no subject.', | 144 | message: 'Blob write targets owner "user" but the executor has no subject.', |
| @@ -95,16 +146,15 @@ export const pluginBlobStore = ( · 1 unmodified line | |||
| 95 | }), | 146 | }), |
| 96 | ); | 147 | ); |
| 97 | } | 148 | } |
| 98 | return Effect.succeed(partitions.user); | 149 | return Effect.succeed(partition); |
| 99 | }; | 150 | }; |
| 100 | 151 | ||
| 101 | return { | 152 | return { |
| 102 | get: (key) => | 153 | get: (key) => |
| 103 | Effect.gen(function* () { | 154 | Effect.gen(function* () { |
| 104 | const namespaces = readNamespaces(); | 155 | const hits = yield* store.getMany(readNamespaces, key); |
| 105 | const hits = yield* store.getMany(namespaces, key); | ||
| 106 | if (hits.size === 0) return null; | 156 | if (hits.size === 0) return null; |
| 107 | for (const ns of namespaces) { | 157 | for (const ns of readNamespaces) { |
| 108 | const v = hits.get(ns); | 158 | const v = hits.get(ns); |
| 109 | if (v !== undefined) return v; | 159 | if (v !== undefined) return v; |
| 110 | } | 160 | } |
| @@ -118,7 +168,7 @@ export const pluginBlobStore = ( · 7 unmodified lines | |||
| 118 | Effect.flatMap(partitionFor(options.owner), (partition) => | 168 | Effect.flatMap(partitionFor(options.owner), (partition) => |
| 119 | store.delete(nsFor(partition, pluginId), key), | 169 | store.delete(nsFor(partition, pluginId), key), |
| 120 | ), | 170 | ), |
| 121 | has: (key) => store.getMany(readNamespaces(), key).pipe(Effect.map((hits) => hits.size > 0)), | 171 | has: (key) => store.getMany(readNamespaces, key).pipe(Effect.map((hits) => hits.size > 0)), |
| 122 | }; | 172 | }; |
| 123 | }; | 173 | }; |
| 124 | 174 | ||
| @@ -33,6 +33,7 @@ import { definePlugin, type ResolveToolsResult } from "./plugin"; · 32 unmodified lines | |||
| 33 | import type { CredentialProvider } from "./provider"; | 33 | import type { CredentialProvider } from "./provider"; |
| 34 | import { makeTestConfig, makeTestExecutor } from "./testing"; | 34 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 35 | import { ToolResult } from "./tool-result"; | 35 | import { ToolResult } from "./tool-result"; |
| 36 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 36 | 37 | ||
| 37 | // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, | 38 | // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, |
| 38 | // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2 | 39 | // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2 |
| @@ -163,7 +164,7 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines | |||
| 163 | }))(); | 164 | }))(); |
| 164 | 165 | ||
| 165 | const setup = () => | 166 | const setup = () => |
| 166 | makeTestExecutor({ plugins: [demoPlugin] as const }).pipe( | 167 | makeTestExecutor({ access: testAccess.member(), plugins: [demoPlugin] as const }).pipe( |
| 167 | Effect.tap((executor) => executor.demo.seed()), | 168 | Effect.tap((executor) => executor.demo.seed()), |
| 168 | ); | 169 | ); |
| 169 | 170 | ||
| @@ -338,7 +339,10 @@ describe("connections.create", () => { · 168 unmodified lines | |||
| 338 | }), | 339 | }), |
| 339 | }), | 340 | }), |
| 340 | }))(); | 341 | }))(); |
| 341 | const config = makeTestConfig({ plugins: [gatedPlugin] as const }); | 342 | const config = makeTestConfig({ |
| 343 | access: testAccess.member(), | ||
| 344 | plugins: [gatedPlugin] as const, | ||
| 345 | }); | ||
| 342 | const executor = yield* createExecutor(config); | 346 | const executor = yield* createExecutor(config); |
| 343 | yield* executor.gated.seed(); | 347 | yield* executor.gated.seed(); |
| 344 | 348 | ||
| @@ -435,7 +439,7 @@ describe("connections.create", () => { · 90 unmodified lines | |||
| 435 | }), | 439 | }), |
| 436 | }), | 440 | }), |
| 437 | }))(); | 441 | }))(); |
| 438 | const config = makeTestConfig({ plugins: [plugin] as const }); | 442 | const config = makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const }); |
| 439 | const first = yield* createExecutor(config); | 443 | const first = yield* createExecutor(config); |
| 440 | yield* first.recoverable.seed(); | 444 | yield* first.recoverable.seed(); |
| 441 | 445 | ||
| @@ -545,7 +549,10 @@ describe("connections.create", () => { · 103 unmodified lines | |||
| 545 | return wrap(db); | 549 | return wrap(db); |
| 546 | }; | 550 | }; |
| 547 | 551 | ||
| 548 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 552 | const config = makeTestConfig({ |
| 553 | access: testAccess.member(), | ||
| 554 | plugins: [demoPlugin] as const, | ||
| 555 | }); | ||
| 549 | const executor = yield* createExecutor({ | 556 | const executor = yield* createExecutor({ |
| 550 | ...config, | 557 | ...config, |
| 551 | db: blindfoldConnectionReads(config.db), | 558 | db: blindfoldConnectionReads(config.db), |
| @@ -1079,6 +1086,7 @@ describe("connections.create credential-write compensation", () => { · 527 unmodified lines | |||
| 1079 | : Effect.sync(() => void store.set(String(id), value)), | 1086 | : Effect.sync(() => void store.set(String(id), value)), |
| 1080 | }); | 1087 | }); |
| 1081 | const executor = yield* makeTestExecutor({ | 1088 | const executor = yield* makeTestExecutor({ |
| 1089 | access: testAccess.member(), | ||
| 1082 | plugins: [durabilityPlugin(provider)] as const, | 1090 | plugins: [durabilityPlugin(provider)] as const, |
| 1083 | }); | 1091 | }); |
| 1084 | yield* executor.durable.seed(); | 1092 | yield* executor.durable.seed(); |
| @@ -1116,7 +1124,10 @@ describe("connections.create credential-write compensation", () => { · 31 unmodified lines | |||
| 1116 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) | 1124 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) |
| 1117 | : Effect.sync(() => void store.set(String(id), value)), | 1125 | : Effect.sync(() => void store.set(String(id), value)), |
| 1118 | }); | 1126 | }); |
| 1119 | const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const }); | 1127 | const executor = yield* makeTestExecutor({ |
| 1128 | access: testAccess.member(), | ||
| 1129 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1130 | }); | ||
| 1120 | yield* executor.durable.seed(); | 1131 | yield* executor.durable.seed(); |
| 1121 | 1132 | ||
| 1122 | const result = yield* Effect.result( | 1133 | const result = yield* Effect.result( |
| @@ -1151,7 +1162,10 @@ describe("connections.create credential-write compensation", () => { · 28 unmodified lines | |||
| 1151 | : Effect.sync(() => void store.set(String(id), value)), | 1162 | : Effect.sync(() => void store.set(String(id), value)), |
| 1152 | delete: undefined, | 1163 | delete: undefined, |
| 1153 | }); | 1164 | }); |
| 1154 | const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const }); | 1165 | const executor = yield* makeTestExecutor({ |
| 1166 | access: testAccess.member(), | ||
| 1167 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1168 | }); | ||
| 1155 | yield* executor.durable.seed(); | 1169 | yield* executor.durable.seed(); |
| 1156 | 1170 | ||
| 1157 | const warnings: string[] = []; | 1171 | const warnings: string[] = []; |
| @@ -1202,7 +1216,10 @@ describe("connections.create credential-write compensation", () => { · 44 unmodified lines | |||
| 1202 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) | 1216 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) |
| 1203 | : Effect.sync(() => void store.set(String(id), value)), | 1217 | : Effect.sync(() => void store.set(String(id), value)), |
| 1204 | }); | 1218 | }); |
| 1205 | const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); | 1219 | const config = makeTestConfig({ |
| 1220 | access: testAccess.member(), | ||
| 1221 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1222 | }); | ||
| 1206 | const executor = yield* createExecutor({ | 1223 | const executor = yield* createExecutor({ |
| 1207 | ...config, | 1224 | ...config, |
| 1208 | db: failableCompensationRowDelete(config.db, () => failRowDelete), | 1225 | db: failableCompensationRowDelete(config.db, () => failRowDelete), |
| @@ -1276,6 +1293,7 @@ describe("connections.create credential-write compensation", () => { · 67 unmodified lines | |||
| 1276 | }, | 1293 | }, |
| 1277 | }); | 1294 | }); |
| 1278 | const executor = yield* makeTestExecutor({ | 1295 | const executor = yield* makeTestExecutor({ |
| 1296 | access: testAccess.member(), | ||
| 1279 | plugins: [durabilityPlugin(provider)] as const, | 1297 | plugins: [durabilityPlugin(provider)] as const, |
| 1280 | }); | 1298 | }); |
| 1281 | yield* executor.durable.seed(); | 1299 | yield* executor.durable.seed(); |
| @@ -1355,7 +1373,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines | |||
| 1355 | }, | 1373 | }, |
| 1356 | }); | 1374 | }); |
| 1357 | const raceState = { armed: false }; | 1375 | const raceState = { armed: false }; |
| 1358 | const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); | 1376 | const config = makeTestConfig({ |
| 1377 | access: testAccess.member(), | ||
| 1378 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1379 | }); | ||
| 1359 | const executor = yield* createExecutor({ | 1380 | const executor = yield* createExecutor({ |
| 1360 | ...config, | 1381 | ...config, |
| 1361 | db: staleCompensationRead(config.db, raceState), | 1382 | db: staleCompensationRead(config.db, raceState), |
| @@ -1435,7 +1456,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines | |||
| 1435 | : Effect.sync(() => void store.set(String(id), value)), | 1456 | : Effect.sync(() => void store.set(String(id), value)), |
| 1436 | }); | 1457 | }); |
| 1437 | const readState = { armed: false }; | 1458 | const readState = { armed: false }; |
| 1438 | const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); | 1459 | const config = makeTestConfig({ |
| 1460 | access: testAccess.member(), | ||
| 1461 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1462 | }); | ||
| 1439 | const executor = yield* createExecutor({ | 1463 | const executor = yield* createExecutor({ |
| 1440 | ...config, | 1464 | ...config, |
| 1441 | db: failableConfirmationRead(config.db, readState), | 1465 | db: failableConfirmationRead(config.db, readState), |
| @@ -1510,7 +1534,10 @@ describe("connections.create credential-write compensation", () => { · 68 unmodified lines | |||
| 1510 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) | 1534 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) |
| 1511 | : Effect.sync(() => void store.set(String(id), value)), | 1535 | : Effect.sync(() => void store.set(String(id), value)), |
| 1512 | }); | 1536 | }); |
| 1513 | const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); | 1537 | const config = makeTestConfig({ |
| 1538 | access: testAccess.member(), | ||
| 1539 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1540 | }); | ||
| 1514 | const executor = yield* createExecutor({ | 1541 | const executor = yield* createExecutor({ |
| 1515 | ...config, | 1542 | ...config, |
| 1516 | db: failableConnectionDeletes(config.db, () => failRowDelete), | 1543 | db: failableConnectionDeletes(config.db, () => failRowDelete), |
| @@ -1586,7 +1613,10 @@ describe("connections.create credential-write compensation", () => { · 69 unmodified lines | |||
| 1586 | ? Effect.die("provider crashed") | 1613 | ? Effect.die("provider crashed") |
| 1587 | : Effect.sync(() => void store.set(String(id), value)), | 1614 | : Effect.sync(() => void store.set(String(id), value)), |
| 1588 | }); | 1615 | }); |
| 1589 | const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); | 1616 | const config = makeTestConfig({ |
| 1617 | access: testAccess.member(), | ||
| 1618 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1619 | }); | ||
| 1590 | const executor = yield* createExecutor({ | 1620 | const executor = yield* createExecutor({ |
| 1591 | ...config, | 1621 | ...config, |
| 1592 | db: failableCompensationRowDelete(config.db, () => failRowDelete), | 1622 | db: failableCompensationRowDelete(config.db, () => failRowDelete), |
| @@ -1646,7 +1676,10 @@ describe("connections.create credential-write compensation", () => { · 53 unmodified lines | |||
| 1646 | ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never)) | 1676 | ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never)) |
| 1647 | : Effect.sync(() => void store.set(String(id), value)), | 1677 | : Effect.sync(() => void store.set(String(id), value)), |
| 1648 | }); | 1678 | }); |
| 1649 | const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); | 1679 | const config = makeTestConfig({ |
| 1680 | access: testAccess.member(), | ||
| 1681 | plugins: [durabilityPlugin(provider)] as const, | ||
| 1682 | }); | ||
| 1650 | const executor = yield* createExecutor({ | 1683 | const executor = yield* createExecutor({ |
| 1651 | ...config, | 1684 | ...config, |
| 1652 | db: failableCompensationRowDelete(config.db, () => failRowDelete), | 1685 | db: failableCompensationRowDelete(config.db, () => failRowDelete), |
| @@ -1696,7 +1729,11 @@ describe("connections.create credential-write compensation", () => { · 43 unmodified lines | |||
| 1696 | describe("connections.list / get", () => { | 1729 | describe("connections.list / get", () => { |
| 1697 | it.effect("only includes full health diagnostics in verbose core tool output", () => | 1730 | it.effect("only includes full health diagnostics in verbose core tool output", () => |
| 1698 | Effect.gen(function* () { | 1731 | Effect.gen(function* () { |
| 1699 | const config = makeTestConfig({ plugins: [demoPlugin] as const, coreTools: {} }); | 1732 | const config = makeTestConfig({ |
| 1733 | access: testAccess.member(), | ||
| 1734 | plugins: [demoPlugin] as const, | ||
| 1735 | coreTools: {}, | ||
| 1736 | }); | ||
| 1700 | const executor = yield* createExecutor(config); | 1737 | const executor = yield* createExecutor(config); |
| 1701 | yield* executor.demo.seed(); | 1738 | yield* executor.demo.seed(); |
| 1702 | yield* executor.connections.create({ | 1739 | yield* executor.connections.create({ |
| @@ -1876,7 +1913,10 @@ describe("tool catalog sync safety", () => { · 173 unmodified lines | |||
| 1876 | }), | 1913 | }), |
| 1877 | }), | 1914 | }), |
| 1878 | }))(); | 1915 | }))(); |
| 1879 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 1916 | const config = makeTestConfig({ |
| 1917 | access: testAccess.member(), | ||
| 1918 | plugins: [guardedPlugin] as const, | ||
| 1919 | }); | ||
| 1880 | const executor = yield* createExecutor(config); | 1920 | const executor = yield* createExecutor(config); |
| 1881 | yield* executor.guarded.seed(); | 1921 | yield* executor.guarded.seed(); |
| 1882 | yield* executor.connections.create({ | 1922 | yield* executor.connections.create({ |
| @@ -1942,7 +1982,10 @@ describe("tool catalog sync safety", () => { · 59 unmodified lines | |||
| 1942 | }), | 1982 | }), |
| 1943 | }), | 1983 | }), |
| 1944 | }))(); | 1984 | }))(); |
| 1945 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 1985 | const config = makeTestConfig({ |
| 1986 | access: testAccess.member(), | ||
| 1987 | plugins: [guardedPlugin] as const, | ||
| 1988 | }); | ||
| 1946 | const executor = yield* createExecutor(config); | 1989 | const executor = yield* createExecutor(config); |
| 1947 | yield* executor.guarded.seed(); | 1990 | yield* executor.guarded.seed(); |
| 1948 | yield* executor.connections.create({ | 1991 | yield* executor.connections.create({ |
| @@ -2005,7 +2048,10 @@ describe("tool catalog sync safety", () => { · 56 unmodified lines | |||
| 2005 | }), | 2048 | }), |
| 2006 | }), | 2049 | }), |
| 2007 | }))(); | 2050 | }))(); |
| 2008 | const config = makeTestConfig({ plugins: [storedStatePlugin] as const }); | 2051 | const config = makeTestConfig({ |
| 2052 | access: testAccess.member(), | ||
| 2053 | plugins: [storedStatePlugin] as const, | ||
| 2054 | }); | ||
| 2009 | const executor = yield* createExecutor(config); | 2055 | const executor = yield* createExecutor(config); |
| 2010 | yield* executor["stored-state"].seed(); | 2056 | yield* executor["stored-state"].seed(); |
| 2011 | yield* executor.connections.create({ | 2057 | yield* executor.connections.create({ |
| @@ -2064,7 +2110,7 @@ describe("tool catalog sync safety", () => { · 52 unmodified lines | |||
| 2064 | }), | 2110 | }), |
| 2065 | }))(); | 2111 | }))(); |
| 2066 | const executor = yield* createExecutor( | 2112 | const executor = yield* createExecutor( |
| 2067 | makeTestConfig({ plugins: [guardedPlugin] as const }), | 2113 | makeTestConfig({ access: testAccess.member(), plugins: [guardedPlugin] as const }), |
| 2068 | ); | 2114 | ); |
| 2069 | yield* executor.guarded.seed(); | 2115 | yield* executor.guarded.seed(); |
| 2070 | yield* executor.connections.create({ | 2116 | yield* executor.connections.create({ |
| @@ -2122,7 +2168,10 @@ describe("tool catalog sync safety", () => { · 51 unmodified lines | |||
| 2122 | }), | 2168 | }), |
| 2123 | }), | 2169 | }), |
| 2124 | }))(); | 2170 | }))(); |
| 2125 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 2171 | const config = makeTestConfig({ |
| 2172 | access: testAccess.member(), | ||
| 2173 | plugins: [guardedPlugin] as const, | ||
| 2174 | }); | ||
| 2126 | const executor = yield* createExecutor(config); | 2175 | const executor = yield* createExecutor(config); |
| 2127 | yield* executor.guarded.seed(); | 2176 | yield* executor.guarded.seed(); |
| 2128 | yield* executor.connections.create({ | 2177 | yield* executor.connections.create({ |
| @@ -2201,7 +2250,10 @@ describe("tool catalog sync safety", () => { · 72 unmodified lines | |||
| 2201 | }), | 2250 | }), |
| 2202 | }), | 2251 | }), |
| 2203 | }))(); | 2252 | }))(); |
| 2204 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 2253 | const config = makeTestConfig({ |
| 2254 | access: testAccess.member(), | ||
| 2255 | plugins: [guardedPlugin] as const, | ||
| 2256 | }); | ||
| 2205 | const executor = yield* createExecutor(config); | 2257 | const executor = yield* createExecutor(config); |
| 2206 | yield* executor.guarded.seed(); | 2258 | yield* executor.guarded.seed(); |
| 2207 | yield* executor.connections.create({ | 2259 | yield* executor.connections.create({ |
| @@ -2282,7 +2334,10 @@ describe("tool catalog sync safety", () => { · 74 unmodified lines | |||
| 2282 | }), | 2334 | }), |
| 2283 | }), | 2335 | }), |
| 2284 | }))(); | 2336 | }))(); |
| 2285 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 2337 | const config = makeTestConfig({ |
| 2338 | access: testAccess.member(), | ||
| 2339 | plugins: [guardedPlugin] as const, | ||
| 2340 | }); | ||
| 2286 | const executor = yield* createExecutor(config); | 2341 | const executor = yield* createExecutor(config); |
| 2287 | yield* executor.guarded.seed(); | 2342 | yield* executor.guarded.seed(); |
| 2288 | yield* executor.connections.create({ | 2343 | yield* executor.connections.create({ |
| @@ -2368,7 +2423,10 @@ describe("tool catalog sync safety", () => { · 79 unmodified lines | |||
| 2368 | }), | 2423 | }), |
| 2369 | }))(); | 2424 | }))(); |
| 2370 | 2425 | ||
| 2371 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 2426 | const config = makeTestConfig({ |
| 2427 | access: testAccess.member(), | ||
| 2428 | plugins: [guardedPlugin] as const, | ||
| 2429 | }); | ||
| 2372 | const executor = yield* createExecutor({ | 2430 | const executor = yield* createExecutor({ |
| 2373 | ...config, | 2431 | ...config, |
| 2374 | db: instrumentTransactions(config.db, { | 2432 | db: instrumentTransactions(config.db, { |
| @@ -2457,7 +2515,10 @@ describe("tool catalog sync safety", () => { · 82 unmodified lines | |||
| 2457 | }), | 2515 | }), |
| 2458 | }))(); | 2516 | }))(); |
| 2459 | 2517 | ||
| 2460 | const config = makeTestConfig({ plugins: [guardedPlugin] as const }); | 2518 | const config = makeTestConfig({ |
| 2519 | access: testAccess.member(), | ||
| 2520 | plugins: [guardedPlugin] as const, | ||
| 2521 | }); | ||
| 2461 | const executor = yield* createExecutor(config); | 2522 | const executor = yield* createExecutor(config); |
| 2462 | yield* executor.guarded.seed(); | 2523 | yield* executor.guarded.seed(); |
| 2463 | for (const name of ["broken", "healthy"]) { | 2524 | for (const name of ["broken", "healthy"]) { |
| @@ -2676,6 +2737,7 @@ const makeHealthHarness = (options?: { · 212 unmodified lines | |||
| 2676 | 2737 | ||
| 2677 | return Effect.gen(function* () { | 2738 | return Effect.gen(function* () { |
| 2678 | const config = makeTestConfig({ | 2739 | const config = makeTestConfig({ |
| 2740 | access: testAccess.member(), | ||
| 2679 | plugins: [plugin] as const, | 2741 | plugins: [plugin] as const, |
| 2680 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 2742 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 2681 | }); | 2743 | }); |
| @@ -3421,6 +3483,7 @@ describe("health probe gate key integrity", () => { · 739 unmodified lines | |||
| 3421 | // Both executors share ONE root db handle — and therefore one gate map; | 3483 | // Both executors share ONE root db handle — and therefore one gate map; |
| 3422 | // only the key separates their probes. | 3484 | // only the key separates their probes. |
| 3423 | const configA = makeTestConfig({ | 3485 | const configA = makeTestConfig({ |
| 3486 | access: testAccess.member(), | ||
| 3424 | plugins: [probingPlugin()] as const, | 3487 | plugins: [probingPlugin()] as const, |
| 3425 | tenant: "a", | 3488 | tenant: "a", |
| 3426 | subject: "user:b", | 3489 | subject: "user:b", |
| @@ -49,7 +49,23 @@ const unscopedExecutorTable = <const TColumns extends UserColumns>( · 48 unmodified lines | |||
| 49 | id: keyColumn("id"), | 49 | id: keyColumn("id"), |
| 50 | }); | 50 | }); |
| 51 | out.unique(`${name}_id_uidx`, ["id"]); | 51 | out.unique(`${name}_id_uidx`, ["id"]); |
| 52 | return out.policy({ name: executorUnscopedPolicyName }); | 52 | // No visibility condition (isolation lives in the namespace string), but |
| 53 | // the read-only / delete-only postures still hold for the FumaDB-backed | ||
| 54 | // blob store. External blob backends never see table policies, so the | ||
| 55 | // executor ALSO wraps its base BlobStore when storage writes are denied — | ||
| 56 | // this is defense in depth for the table-backed path, not the sole guard. | ||
| 57 | return out.policy<ExecutorOwnerPolicyContext>({ | ||
| 58 | name: executorUnscopedPolicyName, | ||
| 59 | onCreate: ({ context }) => assertReachReadOnly(name, "write", context), | ||
| 60 | onUpdate: ({ context }) => { | ||
| 61 | assertReachReadOnly(name, "write", context); | ||
| 62 | return true; | ||
| 63 | }, | ||
| 64 | onDelete: ({ context }) => { | ||
| 65 | assertReachReadOnly(name, "delete", context); | ||
| 66 | return true; | ||
| 67 | }, | ||
| 68 | }); | ||
| 53 | }; | 69 | }; |
| 54 | 70 | ||
| 55 | /** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */ | 71 | /** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */ |
| @@ -149,10 +149,9 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass<I · 148 unmodified lines | |||
| 149 | } | 149 | } |
| 150 | } | 150 | } |
| 151 | 151 | ||
| 152 | /** A connection create or workspace-level mutation (an `owner: "org"` row, or | 152 | /** A user-intent settings mutation was denied by the product's |
| 153 | * the tenant-shared integration catalog) was attempted on an executor bound | 153 | * `ExecutorAccess.settingsWrite` decision for its target. |
| 154 | * with `orgWrites: "denied"` — a member who may USE workspace resources but | 154 | * Reads, tool execution, and operational writes (token |
| 155 | * not configure them. Reads, tool execution, and operational writes (token | ||
| 156 | * refresh, catalog re-sync) are unaffected; only the user-intent settings | 155 | * refresh, catalog re-sync) are unaffected; only the user-intent settings |
| 157 | * surfaces raise this. */ | 156 | * surfaces raise this. */ |
| 158 | export class OrgWriteDeniedError | 157 | export class OrgWriteDeniedError |
| @@ -20,6 +20,7 @@ import type { CredentialProvider } from "./provider"; · 19 unmodified lines | |||
| 20 | import { IntegrationDetectionResult } from "./types"; | 20 | import { IntegrationDetectionResult } from "./types"; |
| 21 | import { makeTestConfig, makeTestExecutor, memoryCredentialsPlugin } from "./testing"; | 21 | import { makeTestConfig, makeTestExecutor, memoryCredentialsPlugin } from "./testing"; |
| 22 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 22 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 23 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 23 | 24 | ||
| 24 | // removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id | 25 | // removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id |
| 25 | // resolution, secrets/sources/scope-stack. The integration coverage below is | 26 | // resolution, secrets/sources/scope-stack. The integration coverage below is |
| @@ -215,6 +216,7 @@ describe("createExecutor", () => { · 189 unmodified lines | |||
| 215 | it.effect("rolls back plugin and core writes from ctx.transaction failures", () => | 216 | it.effect("rolls back plugin and core writes from ctx.transaction failures", () => |
| 216 | Effect.gen(function* () { | 217 | Effect.gen(function* () { |
| 217 | const executor = yield* makeTestExecutor({ | 218 | const executor = yield* makeTestExecutor({ |
| 219 | access: testAccess.member(), | ||
| 218 | plugins: [demoPlugin] as const, | 220 | plugins: [demoPlugin] as const, |
| 219 | }); | 221 | }); |
| 220 | const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites()); | 222 | const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites()); |
| @@ -237,6 +239,7 @@ describe("createExecutor", () => { · 16 unmodified lines | |||
| 237 | close: () => Effect.sync(() => void (closed = true)), | 239 | close: () => Effect.sync(() => void (closed = true)), |
| 238 | }))(); | 240 | }))(); |
| 239 | const executor = yield* makeTestExecutor({ | 241 | const executor = yield* makeTestExecutor({ |
| 242 | access: testAccess.member(), | ||
| 240 | plugins: [closingPlugin] as const, | 243 | plugins: [closingPlugin] as const, |
| 241 | }); | 244 | }); |
| 242 | yield* executor.close(); | 245 | yield* executor.close(); |
| @@ -248,6 +251,7 @@ describe("createExecutor", () => { · 5 unmodified lines | |||
| 248 | Effect.gen(function* () { | 251 | Effect.gen(function* () { |
| 249 | const events: Array<{ kind: string; pluginKey: string; slug: string }> = []; | 252 | const events: Array<{ kind: string; pluginKey: string; slug: string }> = []; |
| 250 | const executor = yield* makeTestExecutor({ | 253 | const executor = yield* makeTestExecutor({ |
| 254 | access: testAccess.member(), | ||
| 251 | plugins: [demoPlugin] as const, | 255 | plugins: [demoPlugin] as const, |
| 252 | onIntegrationChange: (event) => | 256 | onIntegrationChange: (event) => |
| 253 | Effect.sync(() => { | 257 | Effect.sync(() => { |
| @@ -281,6 +285,7 @@ describe("createExecutor", () => { · 27 unmodified lines | |||
| 281 | it.effect("a failing onIntegrationChange observer never fails the operation", () => | 285 | it.effect("a failing onIntegrationChange observer never fails the operation", () => |
| 282 | Effect.gen(function* () { | 286 | Effect.gen(function* () { |
| 283 | const executor = yield* makeTestExecutor({ | 287 | const executor = yield* makeTestExecutor({ |
| 288 | access: testAccess.member(), | ||
| 284 | plugins: [demoPlugin] as const, | 289 | plugins: [demoPlugin] as const, |
| 285 | onIntegrationChange: () => Effect.die("observer exploded"), | 290 | onIntegrationChange: () => Effect.die("observer exploded"), |
| 286 | }); | 291 | }); |
| @@ -294,6 +299,7 @@ describe("createExecutor", () => { · 7 unmodified lines | |||
| 294 | Effect.gen(function* () { | 299 | Effect.gen(function* () { |
| 295 | const events: string[] = []; | 300 | const events: string[] = []; |
| 296 | const executor = yield* makeTestExecutor({ | 301 | const executor = yield* makeTestExecutor({ |
| 302 | access: testAccess.member(), | ||
| 297 | plugins: [demoPlugin] as const, | 303 | plugins: [demoPlugin] as const, |
| 298 | onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))), | 304 | onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))), |
| 299 | }); | 305 | }); |
| @@ -306,6 +312,7 @@ describe("createExecutor", () => { · 6 unmodified lines | |||
| 306 | it.effect("projects core tools as the built-in Executor integration", () => | 312 | it.effect("projects core tools as the built-in Executor integration", () => |
| 307 | Effect.gen(function* () { | 313 | Effect.gen(function* () { |
| 308 | const executor = yield* makeTestExecutor({ | 314 | const executor = yield* makeTestExecutor({ |
| 315 | access: testAccess.member(), | ||
| 309 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 316 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 310 | }); | 317 | }); |
| 311 | const integrations = yield* executor.integrations.list(); | 318 | const integrations = yield* executor.integrations.list(); |
| @@ -351,6 +358,7 @@ describe("createExecutor", () => { · 39 unmodified lines | |||
| 351 | it.effect("can omit provider tools from the built-in Executor integration", () => | 358 | it.effect("can omit provider tools from the built-in Executor integration", () => |
| 352 | Effect.gen(function* () { | 359 | Effect.gen(function* () { |
| 353 | const executor = yield* makeTestExecutor({ | 360 | const executor = yield* makeTestExecutor({ |
| 361 | access: testAccess.member(), | ||
| 354 | coreTools: { | 362 | coreTools: { |
| 355 | webBaseUrl: "http://localhost:3000", | 363 | webBaseUrl: "http://localhost:3000", |
| 356 | includeProviders: false, | 364 | includeProviders: false, |
| @@ -372,6 +380,7 @@ describe("createExecutor", () => { · 15 unmodified lines | |||
| 372 | it.effect("creates provider-backed connections through the built-in Executor tools", () => | 380 | it.effect("creates provider-backed connections through the built-in Executor tools", () => |
| 373 | Effect.gen(function* () { | 381 | Effect.gen(function* () { |
| 374 | const executor = yield* makeTestExecutor({ | 382 | const executor = yield* makeTestExecutor({ |
| 383 | access: testAccess.member(), | ||
| 375 | plugins: [demoPlugin] as const, | 384 | plugins: [demoPlugin] as const, |
| 376 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 385 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 377 | }); | 386 | }); |
| @@ -414,6 +423,7 @@ describe("createExecutor", () => { · 36 unmodified lines | |||
| 414 | it.effect("creates a provider-backed legacy slug-none connection through the core tool", () => | 423 | it.effect("creates a provider-backed legacy slug-none connection through the core tool", () => |
| 415 | Effect.gen(function* () { | 424 | Effect.gen(function* () { |
| 416 | const executor = yield* makeTestExecutor({ | 425 | const executor = yield* makeTestExecutor({ |
| 426 | access: testAccess.member(), | ||
| 417 | plugins: [demoPlugin] as const, | 427 | plugins: [demoPlugin] as const, |
| 418 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 428 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 419 | }); | 429 | }); |
| @@ -466,6 +476,7 @@ describe("createExecutor", () => { · 46 unmodified lines | |||
| 466 | it.effect("removes catalog integrations through the built-in Executor tools", () => | 476 | it.effect("removes catalog integrations through the built-in Executor tools", () => |
| 467 | Effect.gen(function* () { | 477 | Effect.gen(function* () { |
| 468 | const executor = yield* makeTestExecutor({ | 478 | const executor = yield* makeTestExecutor({ |
| 479 | access: testAccess.member(), | ||
| 469 | plugins: [demoPlugin] as const, | 480 | plugins: [demoPlugin] as const, |
| 470 | coreTools: {}, | 481 | coreTools: {}, |
| 471 | }); | 482 | }); |
| @@ -526,6 +537,7 @@ describe("createExecutor", () => { · 54 unmodified lines | |||
| 526 | it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () => | 537 | it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () => |
| 527 | Effect.gen(function* () { | 538 | Effect.gen(function* () { |
| 528 | const executor = yield* makeTestExecutor({ | 539 | const executor = yield* makeTestExecutor({ |
| 540 | access: testAccess.member(), | ||
| 529 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, | 541 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, |
| 530 | coreTools: {}, | 542 | coreTools: {}, |
| 531 | }); | 543 | }); |
| @@ -604,6 +616,7 @@ describe("createExecutor", () => { · 72 unmodified lines | |||
| 604 | it.effect("preserves actionable health from an incomplete tool catalog", () => | 616 | it.effect("preserves actionable health from an incomplete tool catalog", () => |
| 605 | Effect.gen(function* () { | 617 | Effect.gen(function* () { |
| 606 | const executor = yield* makeTestExecutor({ | 618 | const executor = yield* makeTestExecutor({ |
| 619 | access: testAccess.member(), | ||
| 607 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, | 620 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, |
| 608 | coreTools: {}, | 621 | coreTools: {}, |
| 609 | }); | 622 | }); |
| @@ -639,6 +652,7 @@ describe("createExecutor", () => { · 29 unmodified lines | |||
| 639 | it.effect("hands pasted credential entry to the web UI", () => | 652 | it.effect("hands pasted credential entry to the web UI", () => |
| 640 | Effect.gen(function* () { | 653 | Effect.gen(function* () { |
| 641 | const executor = yield* makeTestExecutor({ | 654 | const executor = yield* makeTestExecutor({ |
| 655 | access: testAccess.member(), | ||
| 642 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 656 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 643 | }); | 657 | }); |
| 644 | 658 | ||
| @@ -672,6 +686,7 @@ describe("createExecutor", () => { · 27 unmodified lines | |||
| 672 | Effect.gen(function* () { | 686 | Effect.gen(function* () { |
| 673 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 687 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 674 | const executor = yield* makeTestExecutor({ | 688 | const executor = yield* makeTestExecutor({ |
| 689 | access: testAccess.member(), | ||
| 675 | plugins: [demoPlugin] as const, | 690 | plugins: [demoPlugin] as const, |
| 676 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 691 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 677 | redirectUri: null, | 692 | redirectUri: null, |
| @@ -739,7 +754,7 @@ describe("createExecutor", () => { · 61 unmodified lines | |||
| 739 | detector("high-detector", "high"), | 754 | detector("high-detector", "high"), |
| 740 | detector("medium-detector", "medium"), | 755 | detector("medium-detector", "medium"), |
| 741 | ] as const; | 756 | ] as const; |
| 742 | const executor = yield* makeTestExecutor({ plugins }); | 757 | const executor = yield* makeTestExecutor({ access: testAccess.member(), plugins }); |
| 743 | const results = yield* executor.integrations.detect("https://example.com/thing"); | 758 | const results = yield* executor.integrations.detect("https://example.com/thing"); |
| 744 | // Every detector recognizes the URL; the list contains all three. | 759 | // Every detector recognizes the URL; the list contains all three. |
| 745 | expect(results.map((r) => r.kind).sort()).toEqual([ | 760 | expect(results.map((r) => r.kind).sort()).toEqual([ |
| @@ -753,6 +768,7 @@ describe("createExecutor", () => { · 7 unmodified lines | |||
| 753 | it.effect("tools.schema returns roots with shared reachable definitions", () => | 768 | it.effect("tools.schema returns roots with shared reachable definitions", () => |
| 754 | Effect.gen(function* () { | 769 | Effect.gen(function* () { |
| 755 | const executor = yield* makeTestExecutor({ | 770 | const executor = yield* makeTestExecutor({ |
| 771 | access: testAccess.member(), | ||
| 756 | plugins: [demoPlugin] as const, | 772 | plugins: [demoPlugin] as const, |
| 757 | }); | 773 | }); |
| 758 | yield* executor.demo.seed(); | 774 | yield* executor.demo.seed(); |
| @@ -778,6 +794,7 @@ describe("createExecutor", () => { · 19 unmodified lines | |||
| 778 | it.effect("execute dispatches a connection-produced tool to the owning plugin", () => | 794 | it.effect("execute dispatches a connection-produced tool to the owning plugin", () => |
| 779 | Effect.gen(function* () { | 795 | Effect.gen(function* () { |
| 780 | const executor = yield* makeTestExecutor({ | 796 | const executor = yield* makeTestExecutor({ |
| 797 | access: testAccess.member(), | ||
| 781 | plugins: [demoPlugin] as const, | 798 | plugins: [demoPlugin] as const, |
| 782 | }); | 799 | }); |
| 783 | yield* executor.demo.seed(); | 800 | yield* executor.demo.seed(); |
| @@ -800,6 +817,7 @@ describe("createExecutor", () => { · 16 unmodified lines | |||
| 800 | it.effect("execute on a missing address fails with ToolNotFoundError", () => | 817 | it.effect("execute on a missing address fails with ToolNotFoundError", () => |
| 801 | Effect.gen(function* () { | 818 | Effect.gen(function* () { |
| 802 | const executor = yield* makeTestExecutor({ | 819 | const executor = yield* makeTestExecutor({ |
| 820 | access: testAccess.member(), | ||
| 803 | plugins: [demoPlugin] as const, | 821 | plugins: [demoPlugin] as const, |
| 804 | }); | 822 | }); |
| 805 | yield* executor.demo.seed(); | 823 | yield* executor.demo.seed(); |
| @@ -843,6 +861,7 @@ describe("createExecutor", () => { · 37 unmodified lines | |||
| 843 | describe("muscle memory (observed output shapes)", () => { | 861 | describe("muscle memory (observed output shapes)", () => { |
| 844 | const provisioned = Effect.fn(function* () { | 862 | const provisioned = Effect.fn(function* () { |
| 845 | const executor = yield* makeTestExecutor({ | 863 | const executor = yield* makeTestExecutor({ |
| 864 | access: testAccess.member(), | ||
| 846 | plugins: [demoPlugin] as const, | 865 | plugins: [demoPlugin] as const, |
| 847 | coreTools: { webBaseUrl: "http://localhost:3000" }, | 866 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 848 | }); | 867 | }); |
| @@ -1049,7 +1068,7 @@ const seedRunConnection = < · 200 unmodified lines | |||
| 1049 | const recordToolRowLaunch = (maxOps?: number) => | 1068 | const recordToolRowLaunch = (maxOps?: number) => |
| 1050 | Effect.gen(function* () { | 1069 | Effect.gen(function* () { |
| 1051 | const recorder = makeReadOrderRecorder(); | 1070 | const recorder = makeReadOrderRecorder(); |
| 1052 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 1071 | const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const }); |
| 1053 | const executor = yield* createExecutor({ | 1072 | const executor = yield* createExecutor({ |
| 1054 | ...config, | 1073 | ...config, |
| 1055 | db: withRecordedReads(config.db, recorder.record), | 1074 | db: withRecordedReads(config.db, recorder.record), |
| @@ -1073,6 +1092,7 @@ const recordCredentialLaunch = (maxOps?: number) => · 17 unmodified lines | |||
| 1073 | const calls = { count: 0 }; | 1092 | const calls = { count: 0 }; |
| 1074 | const provider = countingProvider(calls, (run) => recorder.record("credential.get", run)); | 1093 | const provider = countingProvider(calls, (run) => recorder.record("credential.get", run)); |
| 1075 | const config = makeTestConfig({ | 1094 | const config = makeTestConfig({ |
| 1095 | access: testAccess.member(), | ||
| 1076 | plugins: [invokeConcurrencyPlugin(provider)] as const, | 1096 | plugins: [invokeConcurrencyPlugin(provider)] as const, |
| 1077 | }); | 1097 | }); |
| 1078 | const executor = yield* createExecutor({ | 1098 | const executor = yield* createExecutor({ |
| @@ -1199,6 +1219,7 @@ describe("execute read concurrency", () => { · 120 unmodified lines | |||
| 1199 | Effect.gen(function* () { | 1219 | Effect.gen(function* () { |
| 1200 | const calls = { count: 0 }; | 1220 | const calls = { count: 0 }; |
| 1201 | const executor = yield* makeTestExecutor({ | 1221 | const executor = yield* makeTestExecutor({ |
| 1222 | access: testAccess.member(), | ||
| 1202 | plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const, | 1223 | plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const, |
| 1203 | }); | 1224 | }); |
| 1204 | yield* seedRunConnection(executor); | 1225 | yield* seedRunConnection(executor); |
| @@ -1227,7 +1248,10 @@ describe("execute read concurrency", () => { · 22 unmodified lines | |||
| 1227 | 1248 | ||
| 1228 | it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () => | 1249 | it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () => |
| 1229 | Effect.gen(function* () { | 1250 | Effect.gen(function* () { |
| 1230 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 1251 | const config = makeTestConfig({ |
| 1252 | access: testAccess.member(), | ||
| 1253 | plugins: [demoPlugin] as const, | ||
| 1254 | }); | ||
| 1231 | const executor = yield* createExecutor(config); | 1255 | const executor = yield* createExecutor(config); |
| 1232 | yield* seedRunConnection(executor); | 1256 | yield* seedRunConnection(executor); |
| 1233 | 1257 | ||
| @@ -1367,7 +1391,10 @@ describe("speculative read abandonment", () => { · 133 unmodified lines | |||
| 1367 | it.effect("unknown-tool error surfaces while the speculative reads hang forever", () => | 1391 | it.effect("unknown-tool error surfaces while the speculative reads hang forever", () => |
| 1368 | Effect.gen(function* () { | 1392 | Effect.gen(function* () { |
| 1369 | const faults = makeReadFaults(); | 1393 | const faults = makeReadFaults(); |
| 1370 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 1394 | const config = makeTestConfig({ |
| 1395 | access: testAccess.member(), | ||
| 1396 | plugins: [demoPlugin] as const, | ||
| 1397 | }); | ||
| 1371 | const executor = yield* createExecutor({ | 1398 | const executor = yield* createExecutor({ |
| 1372 | ...config, | 1399 | ...config, |
| 1373 | db: withFaultedReads(config.db, faults.fault), | 1400 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1393,7 +1420,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines | |||
| 1393 | it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () => | 1420 | it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () => |
| 1394 | Effect.gen(function* () { | 1421 | Effect.gen(function* () { |
| 1395 | const faults = makeReadFaults(); | 1422 | const faults = makeReadFaults(); |
| 1396 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 1423 | const config = makeTestConfig({ |
| 1424 | access: testAccess.member(), | ||
| 1425 | plugins: [demoPlugin] as const, | ||
| 1426 | }); | ||
| 1397 | const executor = yield* createExecutor({ | 1427 | const executor = yield* createExecutor({ |
| 1398 | ...config, | 1428 | ...config, |
| 1399 | db: withFaultedReads(config.db, faults.fault), | 1429 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1419,7 +1449,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines | |||
| 1419 | it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () => | 1449 | it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () => |
| 1420 | Effect.gen(function* () { | 1450 | Effect.gen(function* () { |
| 1421 | const faults = makeReadFaults(); | 1451 | const faults = makeReadFaults(); |
| 1422 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 1452 | const config = makeTestConfig({ |
| 1453 | access: testAccess.member(), | ||
| 1454 | plugins: [demoPlugin] as const, | ||
| 1455 | }); | ||
| 1423 | const executor = yield* createExecutor({ | 1456 | const executor = yield* createExecutor({ |
| 1424 | ...config, | 1457 | ...config, |
| 1425 | db: withFaultedReads(config.db, faults.fault), | 1458 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1452,7 +1485,10 @@ describe("speculative read abandonment", () => { · 26 unmodified lines | |||
| 1452 | () => | 1485 | () => |
| 1453 | Effect.gen(function* () { | 1486 | Effect.gen(function* () { |
| 1454 | const faults = makeReadFaults(); | 1487 | const faults = makeReadFaults(); |
| 1455 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 1488 | const config = makeTestConfig({ |
| 1489 | access: testAccess.member(), | ||
| 1490 | plugins: [demoPlugin] as const, | ||
| 1491 | }); | ||
| 1456 | const executor = yield* createExecutor({ | 1492 | const executor = yield* createExecutor({ |
| 1457 | ...config, | 1493 | ...config, |
| 1458 | db: withFaultedReads(config.db, faults.fault), | 1494 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1479,6 +1515,7 @@ describe("speculative read abandonment", () => { · 20 unmodified lines | |||
| 1479 | const faults = makeReadFaults(); | 1515 | const faults = makeReadFaults(); |
| 1480 | const armable = armableFailingProvider(); | 1516 | const armable = armableFailingProvider(); |
| 1481 | const config = makeTestConfig({ | 1517 | const config = makeTestConfig({ |
| 1518 | access: testAccess.member(), | ||
| 1482 | plugins: [invokeConcurrencyPlugin(armable.provider)] as const, | 1519 | plugins: [invokeConcurrencyPlugin(armable.provider)] as const, |
| 1483 | }); | 1520 | }); |
| 1484 | const executor = yield* createExecutor({ | 1521 | const executor = yield* createExecutor({ |
| @@ -62,7 +62,6 @@ import { · 61 unmodified lines | |||
| 62 | type OAuthClientRow, | 62 | type OAuthClientRow, |
| 63 | type ToolInvocationRow, | 63 | type ToolInvocationRow, |
| 64 | type ToolRow, | 64 | type ToolRow, |
| 65 | type ToolPolicyRow, | ||
| 66 | } from "./core-schema"; | 65 | } from "./core-schema"; |
| 67 | import { | 66 | import { |
| 68 | ElicitationDeclinedError, | 67 | ElicitationDeclinedError, |
| @@ -73,7 +72,7 @@ import { · 4 unmodified lines | |||
| 73 | type OnElicitation, | 72 | type OnElicitation, |
| 74 | type InvokeOptions, | 73 | type InvokeOptions, |
| 75 | } from "./elicitation"; | 74 | } from "./elicitation"; |
| 76 | import { currentOrgWriteAccess, type OrgWriteAccess } from "./org-write-access"; | 75 | import { executorAccessViolation, type ExecutorAccess, type ToolPolicyEvaluator } from "./access"; |
| 77 | import { | 76 | import { |
| 78 | restoreCredentialSnapshotsWithRecheck, | 77 | restoreCredentialSnapshotsWithRecheck, |
| 79 | snapshotCredentialWrites, | 78 | snapshotCredentialWrites, |
| @@ -155,9 +154,7 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; · 75 unmodified lines | |||
| 155 | import { | 154 | import { |
| 156 | comparePolicyRow, | 155 | comparePolicyRow, |
| 157 | isValidPattern, | 156 | isValidPattern, |
| 158 | matchPattern, | ||
| 159 | positionForNewPattern, | 157 | positionForNewPattern, |
| 160 | resolveEffectivePolicy, | ||
| 161 | rowToToolPolicy, | 158 | rowToToolPolicy, |
| 162 | type CreateToolPolicyInput, | 159 | type CreateToolPolicyInput, |
| 163 | type EffectivePolicy, | 160 | type EffectivePolicy, |
| @@ -181,7 +178,6 @@ import type { · 17 unmodified lines | |||
| 181 | StaticToolDecl, | 178 | StaticToolDecl, |
| 182 | StorageDeps, | 179 | StorageDeps, |
| 183 | ToolPolicyProvider, | 180 | ToolPolicyProvider, |
| 184 | ToolPolicyProviderRule, | ||
| 185 | ToolInvocationCredential, | 181 | ToolInvocationCredential, |
| 186 | } from "./plugin"; | 182 | } from "./plugin"; |
| 187 | import { | 183 | import { |
| @@ -493,10 +489,11 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines | |||
| 493 | 489 | ||
| 494 | /** | 490 | /** |
| 495 | * The PLATFORM VIEW: read-only, tenant-wide reads across every subject. | 491 | * The PLATFORM VIEW: read-only, tenant-wide reads across every subject. |
| 496 | * Present only when the executor was built with `platformView: true` | 492 | * Present only when the executor was built with a product access granting |
| 497 | * (default off) — every other surface on this executor stays bound to the | 493 | * `capabilities.adminReads` — every other surface on this executor stays |
| 498 | * single `{ tenant, subject }` product view and is unaffected by this one. | 494 | * bound to the single `{ tenant, subject }` product view and is unaffected |
| 499 | * Internal admin surface; the public HTTP shape is a separate concern. | 495 | * by this one. Internal admin surface; the public HTTP shape is a separate |
| 496 | * concern. | ||
| 500 | */ | 497 | */ |
| 501 | readonly admin?: ExecutorAdmin; | 498 | readonly admin?: ExecutorAdmin; |
| 502 | /** Saved generative-UI artifacts, visible to the bound owner scope. */ | 499 | /** Saved generative-UI artifacts, visible to the bound owner scope. */ |
| @@ -810,47 +807,27 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 307 unmodified lines | |||
| 810 | */ | 807 | */ |
| 811 | readonly onIntegrationChange?: (event: IntegrationChangeEvent) => Effect.Effect<void>; | 808 | readonly onIntegrationChange?: (event: IntegrationChangeEvent) => Effect.Effect<void>; |
| 812 | /** | 809 | /** |
| 813 | * Opt into the PLATFORM VIEW: a read-only, tenant-wide `executor.admin` | 810 | * The product's access decisions for this binding — REQUIRED, with no core |
| 814 | * surface that reads across every subject in the tenant (see | 811 | * default. The product (host composition root) states which owner |
| 815 | * {@link ExecutorAdmin}). Default OFF — `admin` is simply absent, so the | 812 | * partitions this binding reads and in what precedence, whether it may |
| 816 | * escape hatch has to be asked for by a host that has authorized an | 813 | * configure workspace-level state (consulted live at every guarded sink), |
| 817 | * org-level caller. | 814 | * and its view capabilities (`capabilities.adminReads` exposes the |
| 815 | * tenant-observing `admin` surface, whose reads span every subject; | ||
| 816 | * `capabilities.storageWrites` sets the storage posture). | ||
| 817 | * Core validates the shape against the binding, enforces the decisions at | ||
| 818 | * its sinks, and clamps everything with the storage owner policy — tenant | ||
| 819 | * isolation is never product-configurable. See {@link ExecutorAccess} and | ||
| 820 | * `@executor-js/product-access` for the product rule implementations. | ||
| 818 | * | 821 | * |
| 819 | * Enabling it makes the WHOLE executor read-only, not just `admin`: the base | 822 | * `capabilities.storageWrites: "denied"` makes the WHOLE executor |
| 820 | * owner context carries `writes: "denied"`, so `connections`, `policies`, | 823 | * read-only, not just `admin`: the base owner context carries |
| 821 | * `integrations` and `oauth` refuse every create/update/delete at the storage | 824 | * `writes: "denied"`, so `connections`, `policies`, `integrations` and |
| 822 | * boundary. An executor built for an org-level caller is an observer, and | 825 | * `oauth` refuse every create/update/delete at the storage boundary. READS |
| 823 | * `admin` being its only tenant-wide surface is not the same as it being its | 826 | * still differ by surface: only `admin` is tenant-wide; every other |
| 824 | * only guarded one. | 827 | * surface stays bound to `{ tenant, subject }` — widening them would |
| 825 | * | 828 | * expose every subject's connection rows, credential item ids included. |
| 826 | * READS still differ by surface: only `admin` is tenant-wide. Every other | ||
| 827 | * surface stays bound to `{ tenant, subject }` — widening them would expose | ||
| 828 | * every subject's connection rows, credential item ids included. | ||
| 829 | */ | ||
| 830 | readonly platformView?: boolean; | ||
| 831 | /** | ||
| 832 | * Whether this binding may CONFIGURE workspace-level state: `owner: "org"` | ||
| 833 | * rows (shared connections, org tool policies, org OAuth clients) and the | ||
| 834 | * tenant-shared integration catalog. Hosts derive it from the acting | ||
| 835 | * member's role — admins bind `"allowed"`, plain members `"denied"`. | ||
| 836 | * `"request"` reads the fiber-local {@link CurrentOrgWriteAccess} at every | ||
| 837 | * guarded sink. Session hosts bind that reference from the freshly | ||
| 838 | * authenticated request without caching a positive authorization decision | ||
| 839 | * for the session lifetime. | ||
| 840 | * Defaults to `"allowed"` for non-session callers with no role model | ||
| 841 | * (local's single user, the CLI, tests). | ||
| 842 | * | ||
| 843 | * `"denied"` gates only the USER-INTENT settings surfaces (`policies`, | ||
| 844 | * `connections` create/update/remove in Workspace scope, `integrations` | ||
| 845 | * update/remove/healthCheck, OAuth client CRUD and connect flows in Workspace | ||
| 846 | * scope, new-integration registration). Members may still create and manage | ||
| 847 | * Personal connections and OAuth apps. They also USE workspace resources: | ||
| 848 | * reads, tool execution over org connections, and the operational writes | ||
| 849 | * those imply (token refresh, tool-catalog re-sync) are deliberately | ||
| 850 | * untouched — which is why this is a surface gate, not a storage-policy axis | ||
| 851 | * like `platformView`'s blanket `writes: "denied"`. | ||
| 852 | */ | 829 | */ |
| 853 | readonly orgWrites?: OrgWriteAccess | "request"; | 830 | readonly access: ExecutorAccess; |
| 854 | } | 831 | } |
| 855 | 832 | ||
| 856 | /** Default freshness window for remote-catalog connections (see | 833 | /** Default freshness window for remote-catalog connections (see |
| @@ -1522,9 +1499,11 @@ const makePluginStorageFacade = (input: { · 665 unmodified lines | |||
| 1522 | readonly core: CoreDb; | 1499 | readonly core: CoreDb; |
| 1523 | readonly pluginId: string; | 1500 | readonly pluginId: string; |
| 1524 | readonly owner: OwnerBinding; | 1501 | readonly owner: OwnerBinding; |
| 1502 | /** Product-supplied owner precedence (`ExecutorAccess.owners`): | ||
| 1503 | * which partitions reads see, first entry shadowing later ones. */ | ||
| 1504 | readonly readOwners: readonly Owner[]; | ||
| 1525 | }): PluginStorageFacade => { | 1505 | }): PluginStorageFacade => { |
| 1526 | // Owner partitions: org always, plus this subject's user partition. | 1506 | const readOwners = input.readOwners; |
| 1527 | const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"]; | ||
| 1528 | 1507 | ||
| 1529 | const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => { | 1508 | const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => { |
| 1530 | if (owner === "org") return { owner: "org", subject: ORG_SUBJECT }; | 1509 | if (owner === "org") return { owner: "org", subject: ORG_SUBJECT }; |
| @@ -1555,7 +1534,12 @@ const makePluginStorageFacade = (input: { · 24 unmodified lines | |||
| 1555 | ); | 1534 | ); |
| 1556 | }; | 1535 | }; |
| 1557 | 1536 | ||
| 1558 | const ownerRank = (owner: Owner): number => readOwners.indexOf(owner); | 1537 | // A row whose owner is outside the view (only reachable if this facade is |
| 1538 | // ever handed a wider db handle) sorts LAST, never shadowing view rows. | ||
| 1539 | const ownerRank = (owner: Owner): number => { | ||
| 1540 | const rank = readOwners.indexOf(owner); | ||
| 1541 | return rank === -1 ? readOwners.length : rank; | ||
| 1542 | }; | ||
| 1559 | 1543 | ||
| 1560 | const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => | 1544 | const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => |
| 1561 | [...rows].sort((left, right) => { | 1545 | [...rows].sort((left, right) => { |
| @@ -1919,6 +1903,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 357 unmodified lines | |||
| 1919 | const tenant = String(config.tenant); | 1903 | const tenant = String(config.tenant); |
| 1920 | const subject = config.subject != null ? String(config.subject) : null; | 1904 | const subject = config.subject != null ? String(config.subject) : null; |
| 1921 | 1905 | ||
| 1906 | // The product's access decisions, validated against the binding before | ||
| 1907 | // anything else is built: a composition root that hands a member-shaped | ||
| 1908 | // access to a subject-less binding (or vice versa) is a programmer error | ||
| 1909 | // that must fail the boot, not silently narrow. | ||
| 1910 | const access = config.access; | ||
| 1911 | const accessViolation = executorAccessViolation(access, subject); | ||
| 1912 | if (accessViolation != null) { | ||
| 1913 | return yield* new StorageError({ message: accessViolation, cause: undefined }); | ||
| 1914 | } | ||
| 1915 | // The product's visible/writable partitions in precedence order — stamped | ||
| 1916 | // onto every storage context and threaded to every owner-ordered read. | ||
| 1917 | const accessOwners = access.owners; | ||
| 1918 | |||
| 1922 | const ownerBinding: OwnerBinding = { | 1919 | const ownerBinding: OwnerBinding = { |
| 1923 | tenant: config.tenant, | 1920 | tenant: config.tenant, |
| 1924 | subject: config.subject ?? null, | 1921 | subject: config.subject ?? null, |
| @@ -1946,17 +1943,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 21 unmodified lines | |||
| 1946 | ) | 1943 | ) |
| 1947 | : Effect.void; | 1944 | : Effect.void; |
| 1948 | 1945 | ||
| 1949 | // Workspace-settings gate (`ExecutorConfig.orgWrites`). Called at the top | 1946 | // Settings gate (`ExecutorAccess.settingsWrite`). Called at the top of |
| 1950 | // of every user-intent workspace-level mutation: with an explicit owner it | 1947 | // every user-intent settings mutation with the TARGET core is about to |
| 1951 | // refuses only `"org"` targets; with no owner it guards a tenant-shared | 1948 | // touch — an owner-scoped row, or (with no owner) a tenant-shared |
| 1952 | // surface outright. Deliberately NOT wired into the storage owner policy — | 1949 | // workspace surface. WHICH targets a given principal may configure is |
| 1953 | // operational org-row writes (token refresh, tool-catalog re-sync) must | 1950 | // entirely the product's rule; core only describes the target, asks |
| 1954 | // keep working for a denied member. | 1951 | // live on every call (a request-bound implementation is re-read after |
| 1952 | // pauses and on resume; nothing is cached), and enforces a denial as | ||
| 1953 | // `OrgWriteDeniedError`. Deliberately NOT wired into the storage owner | ||
| 1954 | // policy — operational org-row writes (token refresh, tool-catalog | ||
| 1955 | // re-sync) must keep working for a settings-denied member. | ||
| 1955 | const guardOrgWrite = (owner?: Owner): Effect.Effect<void, OrgWriteDeniedError> => | 1956 | const guardOrgWrite = (owner?: Owner): Effect.Effect<void, OrgWriteDeniedError> => |
| 1956 | Effect.gen(function* () { | 1957 | Effect.gen(function* () { |
| 1957 | const access = | 1958 | const decision = yield* access.settingsWrite( |
| 1958 | config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites; | 1959 | owner === undefined ? { kind: "workspace" } : { kind: "owner", owner }, |
| 1959 | if (access === "denied" && (owner === undefined || owner === "org")) { | 1960 | ); |
| 1961 | if (decision === "denied") { | ||
| 1960 | return yield* new OrgWriteDeniedError(); | 1962 | return yield* new OrgWriteDeniedError(); |
| 1961 | } | 1963 | } |
| 1962 | }); | 1964 | }); |
| @@ -2010,7 +2012,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 47 unmodified lines | |||
| 2010 | const ownerContext: ExecutorOwnerPolicyContext = { | 2012 | const ownerContext: ExecutorOwnerPolicyContext = { |
| 2011 | tenant, | 2013 | tenant, |
| 2012 | subject, | 2014 | subject, |
| 2013 | ...(config.platformView === true ? { writes: "denied" as const } : {}), | 2015 | // The product's row visibility/write partitions — enforced (never |
| 2016 | // chosen) by the storage owner policy, which clamps them to the | ||
| 2017 | // binding's tenant and subject. | ||
| 2018 | owners: accessOwners, | ||
| 2019 | ...(access.capabilities.storageWrites === "denied" ? { writes: "denied" as const } : {}), | ||
| 2014 | }; | 2020 | }; |
| 2015 | const rootDb = withQueryContext(rootDbUntyped, ownerContext); | 2021 | const rootDb = withQueryContext(rootDbUntyped, ownerContext); |
| 2016 | // Shared across executors over one database, so the gate key must carry the | 2022 | // Shared across executors over one database, so the gate key must carry the |
| @@ -2035,7 +2041,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines | |||
| 2035 | } satisfies ExecutorOwnerPolicyContext, | 2041 | } satisfies ExecutorOwnerPolicyContext, |
| 2036 | }), | 2042 | }), |
| 2037 | ); | 2043 | ); |
| 2038 | const blobs = config.blobs ?? makeFumaBlobStore(fuma); | 2044 | // The blob table sits OUTSIDE the storage owner policy (isolation is the |
| 2045 | // namespace string), and a host-supplied backend (`config.blobs`: R2, …) | ||
| 2046 | // never sees the FumaDB table policies at all. So the read-only posture | ||
| 2047 | // is applied HERE, once, on the base store — pending approvals and every | ||
| 2048 | // plugin namespace inherit it. Per-partition visibility is the plugin | ||
| 2049 | // blob store's own `BlobAccess` clamp below. | ||
| 2050 | const rawBlobs = config.blobs ?? makeFumaBlobStore(fuma); | ||
| 2051 | const readOnlyBlobStore = (store: BlobStore): BlobStore => { | ||
| 2052 | const refuse = (operation: string) => | ||
| 2053 | Effect.fail( | ||
| 2054 | new StorageError({ | ||
| 2055 | message: `Blob ${operation} is not allowed: this executor's storage is read-only.`, | ||
| 2056 | cause: undefined, | ||
| 2057 | }), | ||
| 2058 | ); | ||
| 2059 | return { | ||
| 2060 | get: (namespace, key) => store.get(namespace, key), | ||
| 2061 | getMany: (namespaces, key) => store.getMany(namespaces, key), | ||
| 2062 | has: (namespace, key) => store.has(namespace, key), | ||
| 2063 | put: () => refuse("write"), | ||
| 2064 | delete: () => refuse("delete"), | ||
| 2065 | }; | ||
| 2066 | }; | ||
| 2067 | const blobs = | ||
| 2068 | access.capabilities.storageWrites === "denied" ? readOnlyBlobStore(rawBlobs) : rawBlobs; | ||
| 2039 | const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect); | 2069 | const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect); |
| 2040 | 2070 | ||
| 2041 | // Runtime-observed output shapes ("muscle memory"): learned on the | 2071 | // Runtime-observed output shapes ("muscle memory"): learned on the |
| @@ -2046,6 +2076,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines | |||
| 2046 | core, | 2076 | core, |
| 2047 | pluginId: SHAPE_MEMORY_PLUGIN_ID, | 2077 | pluginId: SHAPE_MEMORY_PLUGIN_ID, |
| 2048 | owner: ownerBinding, | 2078 | owner: ownerBinding, |
| 2079 | readOwners: accessOwners, | ||
| 2049 | }), | 2080 | }), |
| 2050 | ); | 2081 | ); |
| 2051 | 2082 | ||
| @@ -2062,7 +2093,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 10 unmodified lines | |||
| 2062 | // runtime can recognize and retry a row stranded before its write ran. | 2093 | // runtime can recognize and retry a row stranded before its write ran. |
| 2063 | const credentialWriteRuntimeId = crypto.randomUUID(); | 2094 | const credentialWriteRuntimeId = crypto.randomUUID(); |
| 2064 | 2095 | ||
| 2065 | const staticToolOwner = (): Owner => (subject == null ? "org" : "user"); | 2096 | // The partition static tools present under: the product's first-ranked |
| 2097 | // owner (a bound member's "user", a subject-less binding's "org"). | ||
| 2098 | const staticToolOwner = (): Owner => accessOwners[0]!; | ||
| 2066 | const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName => | 2099 | const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName => |
| 2067 | ConnectionName.make(integration.id === EXECUTOR_INTEGRATION_ID ? "coreTools" : "static"); | 2100 | ConnectionName.make(integration.id === EXECUTOR_INTEGRATION_ID ? "coreTools" : "static"); |
| 2068 | 2101 | ||
| @@ -3276,11 +3309,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1207 unmodified lines | |||
| 3276 | const existing = yield* findIntegrationRow(input.slug); | 3309 | const existing = yield* findIntegrationRow(input.slug); |
| 3277 | const config = input.config === undefined ? null : input.config; | 3310 | const config = input.config === undefined ? null : input.config; |
| 3278 | if (existing) { | 3311 | if (existing) { |
| 3279 | // Extension methods also run for subjectless boot/system executors, | 3312 | // Replacement of an existing catalog row is the same workspace |
| 3280 | // which must be able to converge existing catalog rows. A bound | 3313 | // mutation as creation. WHO may perform it — an end-user admin, or |
| 3281 | // subject is an end-user principal, so its replacement is the same | 3314 | // a subject-less boot-convergence service — is entirely the |
| 3282 | // workspace mutation as creation and requires the live role guard. | 3315 | // product's `settingsWrite` rule (workspaceServiceAccess allows |
| 3283 | if (subject !== null) yield* guardOrgWrite(); | 3316 | // it); core keeps no subject-less exemption of its own. |
| 3317 | yield* guardOrgWrite(); | ||
| 3284 | yield* core.updateMany("integration", { | 3318 | yield* core.updateMany("integration", { |
| 3285 | where: (b: AnyCb) => b("slug", "=", String(input.slug)), | 3319 | where: (b: AnyCb) => b("slug", "=", String(input.slug)), |
| 3286 | set: { | 3320 | set: { |
| @@ -5387,103 +5421,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2100 unmodified lines | |||
| 5387 | }); | 5421 | }); |
| 5388 | 5422 | ||
| 5389 | // ------------------------------------------------------------------ | 5423 | // ------------------------------------------------------------------ |
| 5390 | // Active policy source. | 5424 | // Effective tool policy — the PRODUCT's evaluation, core's enforcement. |
| 5425 | // | ||
| 5426 | // Core assembles the rule material (the stored rows as a lazy read, and | ||
| 5427 | // the plugin-registered provider when a toolkit session bound one) and | ||
| 5428 | // hands it to `ExecutorAccess.toolPolicy`, once per surface operation. | ||
| 5429 | // Owner ranking, cross-owner merging, the plugin-default fallback and | ||
| 5430 | // the capability-allowlist default all live in that product hook; core | ||
| 5431 | // only enforces the returned `EffectivePolicy` (block refuses, approval | ||
| 5432 | // gates). | ||
| 5391 | // ------------------------------------------------------------------ | 5433 | // ------------------------------------------------------------------ |
| 5392 | 5434 | ||
| 5393 | type ActivePolicyRuleSet = | 5435 | const makeToolPolicyEvaluator = (): Effect.Effect<ToolPolicyEvaluator, StorageFailure> => |
| 5394 | | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] } | 5436 | access.toolPolicy({ |
| 5395 | | { | 5437 | policyRows: core.findMany("tool_policy", {}), |
| 5396 | readonly kind: "provider"; | 5438 | provider: activeToolPolicyProvider, |
| 5397 | readonly provider: ToolPolicyProvider; | 5439 | }); |
| 5398 | readonly rules: readonly ToolPolicyProviderRule[] | null; | ||
| 5399 | } | ||
| 5400 | | { | ||
| 5401 | readonly kind: "prepared"; | ||
| 5402 | readonly resolve: (input: { | ||
| 5403 | readonly toolId: string; | ||
| 5404 | readonly defaultRequiresApproval?: boolean; | ||
| 5405 | }) => EffectivePolicy; | ||
| 5406 | }; | ||
| 5407 | |||
| 5408 | const compareProviderPolicyRule = ( | ||
| 5409 | a: ToolPolicyProviderRule, | ||
| 5410 | b: ToolPolicyProviderRule, | ||
| 5411 | ): number => { | ||
| 5412 | if (a.position < b.position) return -1; | ||
| 5413 | if (a.position > b.position) return 1; | ||
| 5414 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| 5415 | }; | ||
| 5416 | |||
| 5417 | const resolveProviderPolicyFromRules = ( | ||
| 5418 | toolId: string, | ||
| 5419 | rules: readonly ToolPolicyProviderRule[], | ||
| 5420 | ): EffectivePolicy => { | ||
| 5421 | for (const rule of [...rules].sort(compareProviderPolicyRule)) { | ||
| 5422 | if (!matchPattern(rule.pattern, toolId)) continue; | ||
| 5423 | return { | ||
| 5424 | action: rule.action, | ||
| 5425 | source: "user", | ||
| 5426 | pattern: rule.pattern, | ||
| 5427 | policyId: rule.id, | ||
| 5428 | }; | ||
| 5429 | } | ||
| 5430 | // Toolkit-style providers are capability allowlists. No matching rule | ||
| 5431 | // means the tool is outside the capability boundary. | ||
| 5432 | return { | ||
| 5433 | action: "block", | ||
| 5434 | source: "user", | ||
| 5435 | pattern: "*", | ||
| 5436 | }; | ||
| 5437 | }; | ||
| 5438 | |||
| 5439 | const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> => | ||
| 5440 | activeToolPolicyProvider | ||
| 5441 | ? // Batched per-operation resolver: fetch all policy + connection state | ||
| 5442 | // once, then resolve every tool in this operation against that | ||
| 5443 | // snapshot. Avoids the per-tool resolve N+1 on the list surface. | ||
| 5444 | activeToolPolicyProvider.prepare | ||
| 5445 | ? activeToolPolicyProvider.prepare().pipe( | ||
| 5446 | Effect.map((resolve) => ({ | ||
| 5447 | kind: "prepared" as const, | ||
| 5448 | resolve, | ||
| 5449 | })), | ||
| 5450 | ) | ||
| 5451 | : activeToolPolicyProvider.resolve | ||
| 5452 | ? Effect.succeed({ | ||
| 5453 | kind: "provider" as const, | ||
| 5454 | provider: activeToolPolicyProvider, | ||
| 5455 | rules: null, | ||
| 5456 | }) | ||
| 5457 | : activeToolPolicyProvider.list().pipe( | ||
| 5458 | Effect.map((rules) => ({ | ||
| 5459 | kind: "provider" as const, | ||
| 5460 | provider: activeToolPolicyProvider!, | ||
| 5461 | rules, | ||
| 5462 | })), | ||
| 5463 | ) | ||
| 5464 | : core | ||
| 5465 | .findMany("tool_policy", {}) | ||
| 5466 | .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); | ||
| 5467 | |||
| 5468 | const resolvePolicyFromRuleSet = ( | ||
| 5469 | toolId: string, | ||
| 5470 | ruleSet: ActivePolicyRuleSet, | ||
| 5471 | defaultRequiresApproval?: boolean, | ||
| 5472 | ): Effect.Effect<EffectivePolicy, StorageFailure> => | ||
| 5473 | ruleSet.kind === "prepared" | ||
| 5474 | ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval })) | ||
| 5475 | : ruleSet.kind === "provider" | ||
| 5476 | ? ruleSet.provider.resolve | ||
| 5477 | ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) | ||
| 5478 | : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) | ||
| 5479 | : Effect.succeed( | ||
| 5480 | resolveEffectivePolicy( | ||
| 5481 | toolId, | ||
| 5482 | ruleSet.rows, | ||
| 5483 | ownerRankForRow, | ||
| 5484 | defaultRequiresApproval, | ||
| 5485 | ), | ||
| 5486 | ); | ||
| 5487 | 5440 | ||
| 5488 | // ------------------------------------------------------------------ | 5441 | // ------------------------------------------------------------------ |
| 5489 | // Tools (read surface) | 5442 | // Tools (read surface) |
| @@ -5528,7 +5481,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 38 unmodified lines | |||
| 5528 | // network side effects on a read-only credential. Skip it entirely: | 5481 | // network side effects on a read-only credential. Skip it entirely: |
| 5529 | // read-only-ness of the platform read path is a stated invariant here, | 5482 | // read-only-ness of the platform read path is a stated invariant here, |
| 5530 | // not an accident of the best-effort catch below. | 5483 | // not an accident of the best-effort catch below. |
| 5531 | if (config.platformView === true) return; | 5484 | if (access.capabilities.storageWrites === "denied") return; |
| 5532 | const integrations = yield* core.findMany("integration", {}); | 5485 | const integrations = yield* core.findMany("integration", {}); |
| 5533 | if (integrations.length === 0) return; | 5486 | if (integrations.length === 0) return; |
| 5534 | const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); | 5487 | const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); |
| @@ -5692,7 +5645,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 157 unmodified lines | |||
| 5692 | select: TOOL_INVOCATION_COLUMNS, | 5645 | select: TOOL_INVOCATION_COLUMNS, |
| 5693 | }); | 5646 | }); |
| 5694 | const includeBlocked = filter?.includeBlocked ?? false; | 5647 | const includeBlocked = filter?.includeBlocked ?? false; |
| 5695 | const policyRules = yield* listActivePolicyRuleSet(); | 5648 | const policyEvaluator = yield* makeToolPolicyEvaluator(); |
| 5696 | // Only tools whose integration is still in the catalog. A tool row | 5649 | // Only tools whose integration is still in the catalog. A tool row |
| 5697 | // whose integration was removed is an orphan (a removal that could | 5650 | // whose integration was removed is an orphan (a removal that could |
| 5698 | // not reach this subject's rows): listing it invites an invoke that | 5651 | // not reach this subject's rows): listing it invites an invoke that |
| @@ -5704,11 +5657,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | |||
| 5704 | const tool = rowToTool(row); | 5657 | const tool = rowToTool(row); |
| 5705 | if (!matchesToolFilter(tool, filter)) continue; | 5658 | if (!matchesToolFilter(tool, filter)) continue; |
| 5706 | if (!includeBlocked) { | 5659 | if (!includeBlocked) { |
| 5707 | const effective = yield* resolvePolicyFromRuleSet( | 5660 | const effective = yield* policyEvaluator.resolve({ |
| 5708 | normalizedPolicyId(tool), | 5661 | toolId: normalizedPolicyId(tool), |
| 5709 | policyRules, | 5662 | defaultRequiresApproval: tool.annotations?.requiresApproval, |
| 5710 | tool.annotations?.requiresApproval, | 5663 | }); |
| 5711 | ); | ||
| 5712 | if (effective.action === "block") continue; | 5664 | if (effective.action === "block") continue; |
| 5713 | } | 5665 | } |
| 5714 | tools.push(tool); | 5666 | tools.push(tool); |
| @@ -5717,11 +5669,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | |||
| 5717 | const tool = staticToolToTool(entry); | 5669 | const tool = staticToolToTool(entry); |
| 5718 | if (!matchesToolFilter(tool, filter)) continue; | 5670 | if (!matchesToolFilter(tool, filter)) continue; |
| 5719 | if (!includeBlocked) { | 5671 | if (!includeBlocked) { |
| 5720 | const effective = yield* resolvePolicyFromRuleSet( | 5672 | const effective = yield* policyEvaluator.resolve({ |
| 5721 | normalizedPolicyId(tool), | 5673 | toolId: normalizedPolicyId(tool), |
| 5722 | policyRules, | 5674 | defaultRequiresApproval: tool.annotations?.requiresApproval, |
| 5723 | tool.annotations?.requiresApproval, | 5675 | }); |
| 5724 | ); | ||
| 5725 | if (effective.action === "block") continue; | 5676 | if (effective.action === "block") continue; |
| 5726 | } | 5677 | } |
| 5727 | tools.push(tool); | 5678 | tools.push(tool); |
| @@ -5733,15 +5684,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | |||
| 5733 | address: ToolAddress, | 5684 | address: ToolAddress, |
| 5734 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => | 5685 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => |
| 5735 | Effect.gen(function* () { | 5686 | Effect.gen(function* () { |
| 5736 | const policyRules = yield* listActivePolicyRuleSet(); | 5687 | const policyEvaluator = yield* makeToolPolicyEvaluator(); |
| 5737 | const staticEntry = staticTools.get(String(address)); | 5688 | const staticEntry = staticTools.get(String(address)); |
| 5738 | if (staticEntry) { | 5689 | if (staticEntry) { |
| 5739 | const tool = staticToolToTool(staticEntry); | 5690 | const tool = staticToolToTool(staticEntry); |
| 5740 | const effective = yield* resolvePolicyFromRuleSet( | 5691 | const effective = yield* policyEvaluator.resolve({ |
| 5741 | normalizedPolicyId(tool), | 5692 | toolId: normalizedPolicyId(tool), |
| 5742 | policyRules, | 5693 | defaultRequiresApproval: tool.annotations?.requiresApproval, |
| 5743 | tool.annotations?.requiresApproval, | 5694 | }); |
| 5744 | ); | ||
| 5745 | if (effective.action === "block") return null; | 5695 | if (effective.action === "block") return null; |
| 5746 | const preview = yield* Effect.tryPromise({ | 5696 | const preview = yield* Effect.tryPromise({ |
| 5747 | try: () => | 5697 | try: () => |
| @@ -5779,11 +5729,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines | |||
| 5779 | }); | 5729 | }); |
| 5780 | if (!row) return null; | 5730 | if (!row) return null; |
| 5781 | const tool = rowToTool(row); | 5731 | const tool = rowToTool(row); |
| 5782 | const effective = yield* resolvePolicyFromRuleSet( | 5732 | const effective = yield* policyEvaluator.resolve({ |
| 5783 | normalizedPolicyId(tool), | 5733 | toolId: normalizedPolicyId(tool), |
| 5784 | policyRules, | 5734 | defaultRequiresApproval: tool.annotations?.requiresApproval, |
| 5785 | tool.annotations?.requiresApproval, | 5735 | }); |
| 5786 | ); | ||
| 5787 | if (effective.action === "block") return null; | 5736 | if (effective.action === "block") return null; |
| 5788 | 5737 | ||
| 5789 | const runtime = runtimes.get(row.plugin_id); | 5738 | const runtime = runtimes.get(row.plugin_id); |
| @@ -5941,11 +5890,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 151 unmodified lines | |||
| 5941 | }); | 5890 | }); |
| 5942 | 5891 | ||
| 5943 | // ------------------------------------------------------------------ | 5892 | // ------------------------------------------------------------------ |
| 5944 | // Policies — owner-ranked (user=0 inner, org=1 outer). | 5893 | // Policies — CRUD over the stored rows. LIST ordering follows the |
| 5894 | // product's owner precedence; how rules RESOLVE into an effective | ||
| 5895 | // decision is the product's `ExecutorAccess.toolPolicy` hook. | ||
| 5945 | // ------------------------------------------------------------------ | 5896 | // ------------------------------------------------------------------ |
| 5946 | 5897 | ||
| 5947 | const ownerRankForRow = (row: { readonly owner: string }): number => | 5898 | const ownerListRank = (row: { readonly owner: string }): number => { |
| 5948 | row.owner === "user" ? 0 : 1; | 5899 | const rank = accessOwners.indexOf(row.owner as Owner); |
| 5900 | return rank === -1 ? accessOwners.length : rank; | ||
| 5901 | }; | ||
| 5949 | 5902 | ||
| 5950 | // Tool policies gate by tool identity (`<integration>.<tool>`), independent of | 5903 | // Tool policies gate by tool identity (`<integration>.<tool>`), independent of |
| 5951 | // which connection serves it; the org/user split is handled by owner-scoped | 5904 | // which connection serves it; the org/user split is handled by owner-scoped |
| @@ -5961,7 +5914,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines | |||
| 5961 | .pipe( | 5914 | .pipe( |
| 5962 | Effect.map((rows) => | 5915 | Effect.map((rows) => |
| 5963 | [...rows] | 5916 | [...rows] |
| 5964 | .sort((a, b) => ownerRankForRow(a) - ownerRankForRow(b) || comparePolicyRow(a, b)) | 5917 | .sort((a, b) => ownerListRank(a) - ownerListRank(b) || comparePolicyRow(a, b)) |
| 5965 | .map(rowToToolPolicy), | 5918 | .map(rowToToolPolicy), |
| 5966 | ), | 5919 | ), |
| 5967 | ); | 5920 | ); |
| @@ -6089,7 +6042,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 121 unmodified lines | |||
| 6089 | requiresApproval = annotations?.requiresApproval; | 6042 | requiresApproval = annotations?.requiresApproval; |
| 6090 | } | 6043 | } |
| 6091 | } | 6044 | } |
| 6092 | return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval); | 6045 | // The stored-rules surface: this deliberately resolves the WORKSPACE |
| 6046 | // rules a settings UI edits — a session capability allowlist (the | ||
| 6047 | // plugin provider) is a different question and is not consulted here. | ||
| 6048 | const evaluator = yield* access.toolPolicy({ | ||
| 6049 | policyRows: Effect.succeed(policyRows), | ||
| 6050 | provider: null, | ||
| 6051 | }); | ||
| 6052 | return yield* evaluator.resolve({ toolId, defaultRequiresApproval: requiresApproval }); | ||
| 6093 | }); | 6053 | }); |
| 6094 | 6054 | ||
| 6095 | // ------------------------------------------------------------------ | 6055 | // ------------------------------------------------------------------ |
| @@ -6305,6 +6265,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 209 unmodified lines | |||
| 6305 | } | 6265 | } |
| 6306 | }); | 6266 | }); |
| 6307 | 6267 | ||
| 6268 | // An approval pause can outlive its pre-pause snapshot by minutes or | ||
| 6269 | // days: an admin may rewrite policies while the call waits on a human. | ||
| 6270 | // The granted approval stays granted — it is never asked again — but the | ||
| 6271 | // decision to RUN must be fresh, so after an accepted approval the | ||
| 6272 | // effective policy is re-resolved from the live rule source before any | ||
| 6273 | // credential is resolved or handler executes. A rule that now BLOCKS the | ||
| 6274 | // tool wins over the stale snapshot; a fresh `require_approval` is | ||
| 6275 | // already satisfied by the approval that was just granted. Callers that | ||
| 6276 | // retained storage rows across the pause re-read those themselves. | ||
| 6277 | const recheckPolicyAfterApproval = ( | ||
| 6278 | toolId: string, | ||
| 6279 | address: ToolAddress, | ||
| 6280 | defaultRequiresApproval: boolean | undefined, | ||
| 6281 | ): Effect.Effect<void, ToolBlockedError | StorageFailure> => | ||
| 6282 | Effect.gen(function* () { | ||
| 6283 | const policyEvaluator = yield* makeToolPolicyEvaluator(); | ||
| 6284 | const fresh = yield* policyEvaluator.resolve({ toolId, defaultRequiresApproval }); | ||
| 6285 | if (fresh.action === "block") { | ||
| 6286 | return yield* new ToolBlockedError({ | ||
| 6287 | address, | ||
| 6288 | pattern: fresh.pattern ?? "*", | ||
| 6289 | }); | ||
| 6290 | } | ||
| 6291 | }); | ||
| 6292 | |||
| 6308 | // ------------------------------------------------------------------ | 6293 | // ------------------------------------------------------------------ |
| 6309 | // execute — the invoke path. | 6294 | // execute — the invoke path. |
| 6310 | // ------------------------------------------------------------------ | 6295 | // ------------------------------------------------------------------ |
| @@ -6388,12 +6373,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 77 unmodified lines | |||
| 6388 | // not the 5-segment dynamic form. | 6373 | // not the 5-segment dynamic form. |
| 6389 | const staticEntry = staticTools.get(String(address)); | 6374 | const staticEntry = staticTools.get(String(address)); |
| 6390 | if (staticEntry) { | 6375 | if (staticEntry) { |
| 6391 | const policyRules = yield* listActivePolicyRuleSet(); | 6376 | const policyEvaluator = yield* makeToolPolicyEvaluator(); |
| 6392 | const policy = yield* resolvePolicyFromRuleSet( | 6377 | const policy = yield* policyEvaluator.resolve({ |
| 6393 | String(address), | 6378 | toolId: String(address), |
| 6394 | policyRules, | 6379 | defaultRequiresApproval: staticEntry.tool.annotations?.requiresApproval, |
| 6395 | staticEntry.tool.annotations?.requiresApproval, | 6380 | }); |
| 6396 | ); | ||
| 6397 | if (policy.action === "block") { | 6381 | if (policy.action === "block") { |
| 6398 | return yield* new ToolBlockedError({ | 6382 | return yield* new ToolBlockedError({ |
| 6399 | address, | 6383 | address, |
| @@ -6401,6 +6385,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line | |||
| 6401 | }); | 6385 | }); |
| 6402 | } | 6386 | } |
| 6403 | yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler); | 6387 | yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler); |
| 6388 | if (approvalRequired(staticEntry.tool.annotations, policy)) { | ||
| 6389 | yield* recheckPolicyAfterApproval( | ||
| 6390 | String(address), | ||
| 6391 | address, | ||
| 6392 | staticEntry.tool.annotations?.requiresApproval, | ||
| 6393 | ); | ||
| 6394 | } | ||
| 6404 | return yield* wrapInvocationError( | 6395 | return yield* wrapInvocationError( |
| 6405 | staticEntry.tool.handler({ | 6396 | staticEntry.tool.handler({ |
| 6406 | ctx: staticEntry.ctx, | 6397 | ctx: staticEntry.ctx, |
| @@ -6472,7 +6463,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines | |||
| 6472 | }), | 6463 | }), |
| 6473 | { startImmediately: true }, | 6464 | { startImmediately: true }, |
| 6474 | ); | 6465 | ); |
| 6475 | const policyRulesFiber = yield* Effect.forkChild(listActivePolicyRuleSet()); | 6466 | const policyEvaluatorFiber = yield* Effect.forkChild(makeToolPolicyEvaluator()); |
| 6476 | const connectionRowFiber = yield* Effect.forkChild( | 6467 | const connectionRowFiber = yield* Effect.forkChild( |
| 6477 | findConnectionRow({ | 6468 | findConnectionRow({ |
| 6478 | owner: parsed.owner, | 6469 | owner: parsed.owner, |
| @@ -6508,13 +6499,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | |||
| 6508 | 6499 | ||
| 6509 | // Resolve policy (owner-ranked). | 6500 | // Resolve policy (owner-ranked). |
| 6510 | const toolForPolicy = rowToTool(row); | 6501 | const toolForPolicy = rowToTool(row); |
| 6511 | const policyRules = yield* Fiber.join(policyRulesFiber); | 6502 | const policyEvaluator = yield* Fiber.join(policyEvaluatorFiber); |
| 6512 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; | 6503 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; |
| 6513 | const policy = yield* resolvePolicyFromRuleSet( | 6504 | const policy = yield* policyEvaluator.resolve({ |
| 6514 | normalizedPolicyId(toolForPolicy), | 6505 | toolId: normalizedPolicyId(toolForPolicy), |
| 6515 | policyRules, | 6506 | defaultRequiresApproval: annotations?.requiresApproval, |
| 6516 | annotations?.requiresApproval, | 6507 | }); |
| 6517 | ); | ||
| 6518 | if (policy.action === "block") { | 6508 | if (policy.action === "block") { |
| 6519 | return yield* new ToolBlockedError({ | 6509 | return yield* new ToolBlockedError({ |
| 6520 | address, | 6510 | address, |
| @@ -6571,6 +6561,56 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines | |||
| 6571 | } | 6561 | } |
| 6572 | yield* enforceApproval(resolvedAnnotations, address, args, policy, handler); | 6562 | yield* enforceApproval(resolvedAnnotations, address, args, policy, handler); |
| 6573 | 6563 | ||
| 6564 | // Fresh decisions after the pause (see `recheckPolicyAfterApproval`): | ||
| 6565 | // besides the policy, re-read the rows this call retained across | ||
| 6566 | // the approval. A connection or tool removed while the call waited | ||
| 6567 | // on a human must fail as not-found here — before any credential | ||
| 6568 | // is resolved — not execute against rows that no longer exist. | ||
| 6569 | let approvedToolRow = row; | ||
| 6570 | let approvedConnectionRow = connectionRow; | ||
| 6571 | if (approvalRequired(resolvedAnnotations, policy)) { | ||
| 6572 | yield* recheckPolicyAfterApproval( | ||
| 6573 | normalizedPolicyId(toolForPolicy), | ||
| 6574 | address, | ||
| 6575 | resolvedAnnotations?.requiresApproval, | ||
| 6576 | ); | ||
| 6577 | const freshToolRow = yield* core.findFirst("tool", { | ||
| 6578 | where: (b: AnyCb) => | ||
| 6579 | b.and( | ||
| 6580 | byOwner(parsed.owner)(b), | ||
| 6581 | b("integration", "=", String(parsed.integration)), | ||
| 6582 | b("connection", "=", String(parsed.connection)), | ||
| 6583 | b("name", "=", String(parsed.tool)), | ||
| 6584 | ), | ||
| 6585 | select: TOOL_INVOCATION_COLUMNS, | ||
| 6586 | }); | ||
| 6587 | if (!freshToolRow) { | ||
| 6588 | return yield* new ToolNotFoundError({ address }); | ||
| 6589 | } | ||
| 6590 | // The plugin runtime and handler were bound to the PRE-pause row. | ||
| 6591 | // If the integration was torn down and re-registered under a | ||
| 6592 | // different plugin while the call waited, the tool the user | ||
| 6593 | // approved no longer exists in that form — fail as not-found | ||
| 6594 | // rather than hand the replacement row to the old plugin. | ||
| 6595 | if (freshToolRow.plugin_id !== row.plugin_id) { | ||
| 6596 | return yield* new ToolNotFoundError({ address }); | ||
| 6597 | } | ||
| 6598 | approvedToolRow = freshToolRow; | ||
| 6599 | const freshConnectionRow = yield* findConnectionRow({ | ||
| 6600 | owner: parsed.owner, | ||
| 6601 | integration: parsed.integration, | ||
| 6602 | name: parsed.connection, | ||
| 6603 | }); | ||
| 6604 | if (!freshConnectionRow) { | ||
| 6605 | return yield* new ConnectionNotFoundError({ | ||
| 6606 | owner: parsed.owner, | ||
| 6607 | integration: parsed.integration, | ||
| 6608 | name: parsed.connection, | ||
| 6609 | }); | ||
| 6610 | } | ||
| 6611 | approvedConnectionRow = freshConnectionRow; | ||
| 6612 | } | ||
| 6613 | |||
| 6574 | // Resolve every named credential input (`variable → value`); `value` is | 6614 | // Resolve every named credential input (`variable → value`); `value` is |
| 6575 | // the primary `token` for single-input + OAuth callers. The | 6615 | // the primary `token` for single-input + OAuth callers. The |
| 6576 | // integration-row read is independent of credential resolution, so | 6616 | // integration-row read is independent of credential resolution, so |
| @@ -6594,9 +6634,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines | |||
| 6594 | // integration read cannot gate the credential error and a failed | 6634 | // integration read cannot gate the credential error and a failed |
| 6595 | // one is deliberately abandoned, never silently dropped as an | 6635 | // one is deliberately abandoned, never silently dropped as an |
| 6596 | // unobserved value. | 6636 | // unobserved value. |
| 6597 | const valuesFiber = yield* Effect.forkChild(resolveConnectionValues(connectionRow), { | 6637 | const valuesFiber = yield* Effect.forkChild( |
| 6598 | startImmediately: true, | 6638 | resolveConnectionValues(approvedConnectionRow), |
| 6599 | }); | 6639 | { |
| 6640 | startImmediately: true, | ||
| 6641 | }, | ||
| 6642 | ); | ||
| 6600 | const integrationRowFiber = yield* Effect.forkChild( | 6643 | const integrationRowFiber = yield* Effect.forkChild( |
| 6601 | findIntegrationRow(parsed.integration), | 6644 | findIntegrationRow(parsed.integration), |
| 6602 | ); | 6645 | ); |
| @@ -6611,7 +6654,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines | |||
| 6611 | if (!integrationRow) { | 6654 | if (!integrationRow) { |
| 6612 | return yield* new IntegrationNotFoundError({ slug: parsed.integration }); | 6655 | return yield* new IntegrationNotFoundError({ slug: parsed.integration }); |
| 6613 | } | 6656 | } |
| 6614 | const grantedScopes = grantedScopesFromRow(connectionRow); | 6657 | const grantedScopes = grantedScopesFromRow(approvedConnectionRow); |
| 6615 | const invokeTool = runtime.plugin.invokeTool; | 6658 | const invokeTool = runtime.plugin.invokeTool; |
| 6616 | const invokeWith = ( | 6659 | const invokeWith = ( |
| 6617 | resolved: Record<string, string | null>, | 6660 | resolved: Record<string, string | null>, |
| @@ -6620,7 +6663,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | |||
| 6620 | owner: parsed.owner, | 6663 | owner: parsed.owner, |
| 6621 | integration: parsed.integration, | 6664 | integration: parsed.integration, |
| 6622 | connection: parsed.connection, | 6665 | connection: parsed.connection, |
| 6623 | template: AuthTemplateSlug.make(connectionRow.template), | 6666 | template: AuthTemplateSlug.make(approvedConnectionRow.template), |
| 6624 | value: resolved[PRIMARY_INPUT_VARIABLE] ?? null, | 6667 | value: resolved[PRIMARY_INPUT_VARIABLE] ?? null, |
| 6625 | values: resolved, | 6668 | values: resolved, |
| 6626 | config: decodeJsonColumn(integrationRow.config), | 6669 | config: decodeJsonColumn(integrationRow.config), |
| @@ -6629,7 +6672,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | |||
| 6629 | return wrapInvocationError( | 6672 | return wrapInvocationError( |
| 6630 | invokeTool({ | 6673 | invokeTool({ |
| 6631 | ctx: runtime.ctx, | 6674 | ctx: runtime.ctx, |
| 6632 | toolRow: row, | 6675 | toolRow: approvedToolRow, |
| 6633 | credential, | 6676 | credential, |
| 6634 | args, | 6677 | args, |
| 6635 | elicit: buildElicit(address, args, handler), | 6678 | elicit: buildElicit(address, args, handler), |
| @@ -6656,7 +6699,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines | |||
| 6656 | // guidance rather than a masked one. | 6699 | // guidance rather than a masked one. |
| 6657 | const { result, usedValues } = yield* Effect.gen(function* () { | 6700 | const { result, usedValues } = yield* Effect.gen(function* () { |
| 6658 | if (!isUnauthorizedToolFailure(first)) return { result: first, usedValues: values }; | 6701 | if (!isUnauthorizedToolFailure(first)) return { result: first, usedValues: values }; |
| 6659 | const refreshed = yield* forceRefreshConnectionValues(connectionRow).pipe( | 6702 | const refreshed = yield* forceRefreshConnectionValues(approvedConnectionRow).pipe( |
| 6660 | // A failed re-mint is not this call's failure to report: the upstream | 6703 | // A failed re-mint is not this call's failure to report: the upstream |
| 6661 | // already produced an auth failure with recovery guidance, which is | 6704 | // already produced an auth failure with recovery guidance, which is |
| 6662 | // strictly more actionable than a refresh-plumbing error. Keep it. | 6705 | // strictly more actionable than a refresh-plumbing error. Keep it. |
| @@ -6671,7 +6714,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines | |||
| 6671 | usedValues: refreshed, | 6714 | usedValues: refreshed, |
| 6672 | }; | 6715 | }; |
| 6673 | }); | 6716 | }); |
| 6674 | yield* healPersistedHealthOnUse(connectionRow, result, usedValues); | 6717 | yield* healPersistedHealthOnUse(approvedConnectionRow, result, usedValues); |
| 6675 | return result; | 6718 | return result; |
| 6676 | }); | 6719 | }); |
| 6677 | // Interrupting an already-completed (or already-joined) fiber is a | 6720 | // Interrupting an already-completed (or already-joined) fiber is a |
| @@ -6684,7 +6727,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6 unmodified lines | |||
| 6684 | // so an interruption that lands before the join reaches it promptly. | 6727 | // so an interruption that lands before the join reaches it promptly. |
| 6685 | return yield* Effect.ensuring( | 6728 | return yield* Effect.ensuring( |
| 6686 | invokeDynamicTool, | 6729 | invokeDynamicTool, |
| 6687 | Fiber.interruptAll([toolRowFiber, policyRulesFiber, connectionRowFiber]), | 6730 | Fiber.interruptAll([toolRowFiber, policyEvaluatorFiber, connectionRowFiber]), |
| 6688 | ); | 6731 | ); |
| 6689 | }).pipe( | 6732 | }).pipe( |
| 6690 | // Expected tool failures (`ToolResult.fail`) resolve through the | 6733 | // Expected tool failures (`ToolResult.fail`) resolve through the |
| @@ -6788,16 +6831,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 97 unmodified lines | |||
| 6788 | // register credential providers. | 6831 | // register credential providers. |
| 6789 | // ------------------------------------------------------------------ | 6832 | // ------------------------------------------------------------------ |
| 6790 | 6833 | ||
| 6834 | // IDENTITY partitions, deliberately NOT narrowed by `access.owners`: | ||
| 6835 | // these name where the binding's identity lives, while WHICH of them a | ||
| 6836 | // plugin blob store may read or write is the separate product decision | ||
| 6837 | // (`BlobAccess`) applied per store below. Identity-bound consumers keep | ||
| 6838 | // the full shape — see the pending-approval store. | ||
| 6791 | const blobPartitions: OwnerPartitions = { | 6839 | const blobPartitions: OwnerPartitions = { |
| 6792 | org: `o:${tenant}`, | 6840 | org: `o:${tenant}`, |
| 6793 | user: subject != null ? `u:${tenant}:${subject}` : null, | 6841 | user: subject != null ? `u:${tenant}:${subject}` : null, |
| 6794 | }; | 6842 | }; |
| 6795 | 6843 | ||
| 6796 | // Pending approvals file under the narrowest partition this executor has: | 6844 | // Pending approvals file under the narrowest IDENTITY partition this |
| 6797 | // a subject-bound executor keeps them private to that member, and a pure-org | 6845 | // executor has: a subject-bound executor keeps them private to that |
| 6798 | // executor (no subject) files them at the org. Either way the partition IS | 6846 | // member, and a pure-org executor (no subject) files them at the org. |
| 6799 | // the ownership check — another caller's executor reads a different | 6847 | // The partition IS the ownership check — another caller's executor reads |
| 6800 | // namespace and simply does not see the record. | 6848 | // a different namespace and simply does not see the record. CONTINUATION |
| 6849 | // IDENTITY, not resource visibility: this routing must never follow the | ||
| 6850 | // product's narrowed `access.owners` view — rerouting an org-only | ||
| 6851 | // member's approvals into the SHARED org namespace would let any | ||
| 6852 | // coworker's binding consume them. | ||
| 6801 | const pendingApprovals = makePendingApprovalStore( | 6853 | const pendingApprovals = makePendingApprovalStore( |
| 6802 | blobs, | 6854 | blobs, |
| 6803 | blobPartitions.user ?? blobPartitions.org, | 6855 | blobPartitions.user ?? blobPartitions.org, |
| @@ -6815,10 +6867,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 11 unmodified lines | |||
| 6815 | core, | 6867 | core, |
| 6816 | pluginId: plugin.id, | 6868 | pluginId: plugin.id, |
| 6817 | owner: ownerBinding, | 6869 | owner: ownerBinding, |
| 6870 | readOwners: accessOwners, | ||
| 6818 | }); | 6871 | }); |
| 6819 | const storageDeps: StorageDeps = { | 6872 | const storageDeps: StorageDeps = { |
| 6820 | owner: ownerBinding, | 6873 | owner: ownerBinding, |
| 6821 | blobs: pluginBlobStore(blobs, blobPartitions, plugin.id), | 6874 | blobs: pluginBlobStore(blobs, blobPartitions, plugin.id, { |
| 6875 | owners: accessOwners, | ||
| 6876 | storageWrites: access.capabilities.storageWrites, | ||
| 6877 | }), | ||
| 6822 | pluginStorage, | 6878 | pluginStorage, |
| 6823 | }; | 6879 | }; |
| 6824 | const storage = plugin.storage(storageDeps); | 6880 | const storage = plugin.storage(storageDeps); |
| @@ -7140,7 +7196,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 315 unmodified lines | |||
| 7140 | 7196 | ||
| 7141 | // Default OFF: without the opt-in there is no `admin` key at all, so the | 7197 | // Default OFF: without the opt-in there is no `admin` key at all, so the |
| 7142 | // tenant-wide handle is never even constructed. | 7198 | // tenant-wide handle is never even constructed. |
| 7143 | const admin = config.platformView === true ? makeAdmin() : undefined; | 7199 | const admin = access.capabilities.adminReads ? makeAdmin() : undefined; |
| 7144 | 7200 | ||
| 7145 | // ------------------------------------------------------------------ | 7201 | // ------------------------------------------------------------------ |
| 7146 | // close | 7202 | // close |
| @@ -188,11 +188,12 @@ export { · 187 unmodified lines | |||
| 188 | ownerForItemId, | 188 | ownerForItemId, |
| 189 | } from "./provider-item-owner"; | 189 | } from "./provider-item-owner"; |
| 190 | 190 | ||
| 191 | // Tool policies. | 191 | // Tool policies — vocabulary + matching/placement utilities. Resolution |
| 192 | // semantics live in `@executor-js/product-access/policy`. | ||
| 192 | export { | 193 | export { |
| 193 | matchPattern, | 194 | matchPattern, |
| 194 | isValidPattern, | 195 | isValidPattern, |
| 195 | effectivePolicyFromSorted, | 196 | comparePolicyRow, |
| 196 | ToolPolicyActionSchema, | 197 | ToolPolicyActionSchema, |
| 197 | type ToolPolicy, | 198 | type ToolPolicy, |
| 198 | type CreateToolPolicyInput, | 199 | type CreateToolPolicyInput, |
| @@ -446,10 +447,18 @@ export { · 247 unmodified lines | |||
| 446 | export { | 447 | export { |
| 447 | CurrentOrgWriteAccess, | 448 | CurrentOrgWriteAccess, |
| 448 | currentOrgWriteAccess, | 449 | currentOrgWriteAccess, |
| 450 | executorAccessViolation, | ||
| 449 | makeOrgWriteAccessState, | 451 | makeOrgWriteAccessState, |
| 452 | type AccessDecision, | ||
| 453 | type ExecutorAccess, | ||
| 454 | type ExecutorAccessCapabilities, | ||
| 450 | type OrgWriteAccess, | 455 | type OrgWriteAccess, |
| 451 | type OrgWriteAccessState, | 456 | type OrgWriteAccessState, |
| 452 | } from "./org-write-access"; | 457 | type SettingsWriteTarget, |
| 458 | type ToolPolicyEvaluationInput, | ||
| 459 | type ToolPolicyEvaluator, | ||
| 460 | type ToolPolicySources, | ||
| 461 | } from "./access"; | ||
| 453 | 462 | ||
| 454 | // CLI / runtime config. | 463 | // CLI / runtime config. |
| 455 | export { | 464 | export { |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | |||
| 16 | import { definePlugin } from "./plugin"; | 16 | import { definePlugin } from "./plugin"; |
| 17 | import type { CredentialProvider } from "./provider"; | 17 | import type { CredentialProvider } from "./provider"; |
| 18 | import { makeTestConfig } from "./testing"; | 18 | import { makeTestConfig } from "./testing"; |
| 19 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 19 | 20 | ||
| 20 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| 21 | // Removing an integration must cascade to EVERY member's rows under it, not | 22 | // Removing an integration must cascade to EVERY member's rows under it, not |
| @@ -62,7 +63,11 @@ const demoPlugin = definePlugin(() => ({ · 40 unmodified lines | |||
| 62 | 63 | ||
| 63 | const setup = () => | 64 | const setup = () => |
| 64 | Effect.gen(function* () { | 65 | Effect.gen(function* () { |
| 65 | const config = makeTestConfig({ plugins: [demoPlugin] as const, subject: ALICE }); | 66 | const config = makeTestConfig({ |
| 67 | access: testAccess.member(), | ||
| 68 | plugins: [demoPlugin] as const, | ||
| 69 | subject: ALICE, | ||
| 70 | }); | ||
| 66 | const alice = yield* createExecutor(config); | 71 | const alice = yield* createExecutor(config); |
| 67 | const bob = yield* createExecutor({ | 72 | const bob = yield* createExecutor({ |
| 68 | ...config, | 73 | ...config, |
| @@ -119,7 +124,7 @@ describe("integrations.remove cascade", () => { · 50 unmodified lines | |||
| 119 | it.effect("a member (org writes denied) still cannot remove", () => | 124 | it.effect("a member (org writes denied) still cannot remove", () => |
| 120 | Effect.gen(function* () { | 125 | Effect.gen(function* () { |
| 121 | const { config } = yield* setup(); | 126 | const { config } = yield* setup(); |
| 122 | const member = yield* createExecutor({ ...config, orgWrites: "denied" }); | 127 | const member = yield* createExecutor({ ...config, access: testAccess.member("denied") }); |
| 123 | yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); | 128 | yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); |
| 124 | const error = yield* Effect.flip(member.integrations.remove(INTEG)); | 129 | const error = yield* Effect.flip(member.integrations.remove(INTEG)); |
| 125 | expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true); | 130 | expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true); |
| @@ -129,7 +134,7 @@ describe("integrations.remove cascade", () => { · 3 unmodified lines | |||
| 129 | it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () => | 134 | it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () => |
| 130 | Effect.gen(function* () { | 135 | Effect.gen(function* () { |
| 131 | const { config } = yield* setup(); | 136 | const { config } = yield* setup(); |
| 132 | const platform = yield* createExecutor({ ...config, platformView: true }); | 137 | const platform = yield* createExecutor({ ...config, access: testAccess.platform() }); |
| 133 | yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore)); | 138 | yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore)); |
| 134 | yield* platform.integrations.remove(INTEG).pipe( | 139 | yield* platform.integrations.remove(INTEG).pipe( |
| 135 | Effect.flatMap(() => Effect.die("expected the platform view to refuse the removal")), | 140 | Effect.flatMap(() => Effect.die("expected the platform view to refuse the removal")), |
| @@ -170,6 +175,7 @@ describe("orphaned rows are not served", () => { · 34 unmodified lines | |||
| 170 | withQueryContext(config.testDb.db, { | 175 | withQueryContext(config.testDb.db, { |
| 171 | tenant: String(config.tenant), | 176 | tenant: String(config.tenant), |
| 172 | subject: BOB, | 177 | subject: BOB, |
| 178 | owners: ["user", "org"], | ||
| 173 | }).findMany("tool", {}), | 179 | }).findMany("tool", {}), |
| 174 | ); | 180 | ); |
| 175 | expect(stored.map((row) => String(row.integration)).sort()).toEqual([ | 181 | expect(stored.map((row) => String(row.integration)).sort()).toEqual([ |
| @@ -24,6 +24,7 @@ import type { OAuthService } from "./oauth-client"; · 23 unmodified lines | |||
| 24 | import { definePlugin } from "./plugin"; | 24 | import { definePlugin } from "./plugin"; |
| 25 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 25 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 26 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; | 26 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; |
| 27 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 27 | 28 | ||
| 28 | const INTEG = IntegrationSlug.make("acme"); | 29 | const INTEG = IntegrationSlug.make("acme"); |
| 29 | const TEMPLATE = AuthTemplateSlug.make("oauth"); | 30 | const TEMPLATE = AuthTemplateSlug.make("oauth"); |
| @@ -157,7 +158,10 @@ describe("enterprise-managed connections", () => { · 127 unmodified lines | |||
| 157 | Effect.scoped( | 158 | Effect.scoped( |
| 158 | Effect.gen(function* () { | 159 | Effect.gen(function* () { |
| 159 | const servers = yield* enterpriseServers({}); | 160 | const servers = yield* enterpriseServers({}); |
| 160 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 161 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 162 | access: testAccess.member(), | ||
| 163 | plugins, | ||
| 164 | }); | ||
| 161 | yield* executor.acme.seed(); | 165 | yield* executor.acme.seed(); |
| 162 | yield* registerClients(executor.oauth.createClient, servers); | 166 | yield* registerClients(executor.oauth.createClient, servers); |
| 163 | 167 | ||
| @@ -182,7 +186,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines | |||
| 182 | Effect.scoped( | 186 | Effect.scoped( |
| 183 | Effect.gen(function* () { | 187 | Effect.gen(function* () { |
| 184 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); | 188 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); |
| 185 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 189 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 190 | access: testAccess.member(), | ||
| 191 | plugins, | ||
| 192 | }); | ||
| 186 | yield* executor.acme.seed(); | 193 | yield* executor.acme.seed(); |
| 187 | yield* registerClients(executor.oauth.createClient, servers); | 194 | yield* registerClients(executor.oauth.createClient, servers); |
| 188 | yield* executor.oauth.start(startEnterpriseConnect(servers)); | 195 | yield* executor.oauth.start(startEnterpriseConnect(servers)); |
| @@ -207,7 +214,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines | |||
| 207 | Effect.scoped( | 214 | Effect.scoped( |
| 208 | Effect.gen(function* () { | 215 | Effect.gen(function* () { |
| 209 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); | 216 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); |
| 210 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 217 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 218 | access: testAccess.member(), | ||
| 219 | plugins, | ||
| 220 | }); | ||
| 211 | yield* executor.acme.seed(); | 221 | yield* executor.acme.seed(); |
| 212 | yield* registerClients(executor.oauth.createClient, servers); | 222 | yield* registerClients(executor.oauth.createClient, servers); |
| 213 | yield* executor.oauth.start(startEnterpriseConnect(servers)); | 223 | yield* executor.oauth.start(startEnterpriseConnect(servers)); |
| @@ -241,7 +251,10 @@ describe("enterprise-managed connections", () => { · 27 unmodified lines | |||
| 241 | Effect.scoped( | 251 | Effect.scoped( |
| 242 | Effect.gen(function* () { | 252 | Effect.gen(function* () { |
| 243 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); | 253 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); |
| 244 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 254 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 255 | access: testAccess.member(), | ||
| 256 | plugins, | ||
| 257 | }); | ||
| 245 | yield* executor.acme.seed(); | 258 | yield* executor.acme.seed(); |
| 246 | yield* registerClients(executor.oauth.createClient, servers); | 259 | yield* registerClients(executor.oauth.createClient, servers); |
| 247 | yield* executor.oauth.start(startEnterpriseConnect(servers)); | 260 | yield* executor.oauth.start(startEnterpriseConnect(servers)); |
| @@ -287,7 +300,10 @@ describe("enterprise-managed connections", () => { · 39 unmodified lines | |||
| 287 | errorDescription: "This client is not approved for the requested MCP server.", | 300 | errorDescription: "This client is not approved for the requested MCP server.", |
| 288 | }, | 301 | }, |
| 289 | }); | 302 | }); |
| 290 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 303 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 304 | access: testAccess.member(), | ||
| 305 | plugins, | ||
| 306 | }); | ||
| 291 | yield* executor.acme.seed(); | 307 | yield* executor.acme.seed(); |
| 292 | yield* registerClients(executor.oauth.createClient, servers); | 308 | yield* registerClients(executor.oauth.createClient, servers); |
| 293 | 309 | ||
| @@ -316,7 +332,10 @@ describe("enterprise-managed connections", () => { · 22 unmodified lines | |||
| 316 | Effect.scoped( | 332 | Effect.scoped( |
| 317 | Effect.gen(function* () { | 333 | Effect.gen(function* () { |
| 318 | const servers = yield* enterpriseServers({ advertiseProfile: false }); | 334 | const servers = yield* enterpriseServers({ advertiseProfile: false }); |
| 319 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 335 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 336 | access: testAccess.member(), | ||
| 337 | plugins, | ||
| 338 | }); | ||
| 320 | yield* executor.acme.seed(); | 339 | yield* executor.acme.seed(); |
| 321 | yield* registerClients(executor.oauth.createClient, servers); | 340 | yield* registerClients(executor.oauth.createClient, servers); |
| 322 | 341 | ||
| @@ -34,6 +34,7 @@ import type { · 33 unmodified lines | |||
| 34 | import { definePlugin } from "./plugin"; | 34 | import { definePlugin } from "./plugin"; |
| 35 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 35 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 36 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; | 36 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; |
| 37 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 37 | 38 | ||
| 38 | const INTEG = IntegrationSlug.make("acme"); | 39 | const INTEG = IntegrationSlug.make("acme"); |
| 39 | const TEMPLATE = AuthTemplateSlug.make("oauth"); | 40 | const TEMPLATE = AuthTemplateSlug.make("oauth"); |
| @@ -233,6 +234,7 @@ const tokenExchangeCount = (servers: EnterpriseServers) => · 193 unmodified lines | |||
| 233 | 234 | ||
| 234 | const harness = (rollout: EnterpriseManagedRollout | undefined) => | 235 | const harness = (rollout: EnterpriseManagedRollout | undefined) => |
| 235 | makeTestWorkspaceHarness({ | 236 | makeTestWorkspaceHarness({ |
| 237 | access: testAccess.member(), | ||
| 236 | plugins, | 238 | plugins, |
| 237 | tenant: TENANT, | 239 | tenant: TENANT, |
| 238 | subject: SUBJECT, | 240 | subject: SUBJECT, |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | import { definePlugin } from "./plugin"; | 18 | import { definePlugin } from "./plugin"; |
| 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 20 | import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; | 20 | import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 21 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 21 | 22 | ||
| 22 | // First-party OAuth clients: host-operated apps declared in executor config | 23 | // First-party OAuth clients: host-operated apps declared in executor config |
| 23 | // (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from | 24 | // (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from |
| @@ -119,6 +120,7 @@ describe("first-party oauth clients", () => { · 95 unmodified lines | |||
| 119 | Effect.gen(function* () { | 120 | Effect.gen(function* () { |
| 120 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 121 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 121 | const { executor } = yield* makeTestWorkspaceHarness({ | 122 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 123 | access: testAccess.member(), | ||
| 122 | plugins, | 124 | plugins, |
| 123 | firstPartyOAuthClients: [firstPartyClientFor(server)], | 125 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 124 | }); | 126 | }); |
| @@ -170,6 +172,7 @@ describe("first-party oauth clients", () => { · 45 unmodified lines | |||
| 170 | Effect.gen(function* () { | 172 | Effect.gen(function* () { |
| 171 | const server = yield* serveOAuthTestServer({ scopes: ["repo"] }); | 173 | const server = yield* serveOAuthTestServer({ scopes: ["repo"] }); |
| 172 | const { executor } = yield* makeTestWorkspaceHarness({ | 174 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 175 | access: testAccess.member(), | ||
| 173 | plugins, | 176 | plugins, |
| 174 | firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }], | 177 | firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }], |
| 175 | }); | 178 | }); |
| @@ -204,6 +207,7 @@ describe("first-party oauth clients", () => { · 28 unmodified lines | |||
| 204 | Effect.gen(function* () { | 207 | Effect.gen(function* () { |
| 205 | const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); | 208 | const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); |
| 206 | const { executor } = yield* makeTestWorkspaceHarness({ | 209 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 210 | access: testAccess.member(), | ||
| 207 | plugins, | 211 | plugins, |
| 208 | firstPartyOAuthClients: [ | 212 | firstPartyOAuthClients: [ |
| 209 | { | 213 | { |
| @@ -240,6 +244,7 @@ describe("first-party oauth clients", () => { · 30 unmodified lines | |||
| 240 | Effect.gen(function* () { | 244 | Effect.gen(function* () { |
| 241 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 245 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 242 | const harness = yield* makeTestWorkspaceHarness({ | 246 | const harness = yield* makeTestWorkspaceHarness({ |
| 247 | access: testAccess.member(), | ||
| 243 | plugins, | 248 | plugins, |
| 244 | firstPartyOAuthClients: [firstPartyClientFor(server)], | 249 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 245 | }); | 250 | }); |
| @@ -288,6 +293,7 @@ describe("first-party oauth clients", () => { · 42 unmodified lines | |||
| 288 | Effect.gen(function* () { | 293 | Effect.gen(function* () { |
| 289 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 294 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 290 | const { executor } = yield* makeTestWorkspaceHarness({ | 295 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 296 | access: testAccess.member(), | ||
| 291 | plugins, | 297 | plugins, |
| 292 | firstPartyOAuthClients: [ | 298 | firstPartyOAuthClients: [ |
| 293 | { | 299 | { |
| @@ -330,6 +336,7 @@ describe("first-party oauth clients", () => { · 36 unmodified lines | |||
| 330 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 336 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 331 | let enabled = true; | 337 | let enabled = true; |
| 332 | const { executor } = yield* makeTestWorkspaceHarness({ | 338 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 339 | access: testAccess.member(), | ||
| 333 | plugins, | 340 | plugins, |
| 334 | subject: "review-user", | 341 | subject: "review-user", |
| 335 | tenant: "review-org", | 342 | tenant: "review-org", |
| @@ -368,6 +375,7 @@ describe("first-party oauth clients", () => { · 32 unmodified lines | |||
| 368 | Effect.gen(function* () { | 375 | Effect.gen(function* () { |
| 369 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 376 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 370 | const harness = yield* makeTestWorkspaceHarness({ | 377 | const harness = yield* makeTestWorkspaceHarness({ |
| 378 | access: testAccess.member(), | ||
| 371 | plugins, | 379 | plugins, |
| 372 | firstPartyOAuthClients: [ | 380 | firstPartyOAuthClients: [ |
| 373 | { | 381 | { |
| @@ -439,6 +447,7 @@ describe("first-party oauth clients", () => { · 65 unmodified lines | |||
| 439 | Effect.gen(function* () { | 447 | Effect.gen(function* () { |
| 440 | const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); | 448 | const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); |
| 441 | const { executor } = yield* makeTestWorkspaceHarness({ | 449 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 450 | access: testAccess.member(), | ||
| 442 | plugins, | 451 | plugins, |
| 443 | firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }], | 452 | firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }], |
| 444 | }); | 453 | }); |
| @@ -467,6 +476,7 @@ describe("first-party oauth clients", () => { · 22 unmodified lines | |||
| 467 | Effect.gen(function* () { | 476 | Effect.gen(function* () { |
| 468 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 477 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 469 | const { executor } = yield* makeTestWorkspaceHarness({ | 478 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 479 | access: testAccess.member(), | ||
| 470 | plugins, | 480 | plugins, |
| 471 | firstPartyOAuthClients: [firstPartyClientFor(server)], | 481 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 472 | }); | 482 | }); |
| @@ -496,7 +506,10 @@ describe("first-party oauth clients", () => { · 23 unmodified lines | |||
| 496 | Effect.scoped( | 506 | Effect.scoped( |
| 497 | Effect.gen(function* () { | 507 | Effect.gen(function* () { |
| 498 | yield* serveOAuthTestServer({ scopes: ["read"] }); | 508 | yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 499 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 509 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 510 | access: testAccess.member(), | ||
| 511 | plugins, | ||
| 512 | }); | ||
| 500 | yield* executor.acme.seed(); | 513 | yield* executor.acme.seed(); |
| 501 | 514 | ||
| 502 | const error = yield* executor.oauth | 515 | const error = yield* executor.oauth |
| @@ -523,6 +536,7 @@ describe("first-party oauth clients", () => { · 20 unmodified lines | |||
| 523 | Effect.gen(function* () { | 536 | Effect.gen(function* () { |
| 524 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 537 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 525 | const { executor } = yield* makeTestWorkspaceHarness({ | 538 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 539 | access: testAccess.member(), | ||
| 526 | plugins, | 540 | plugins, |
| 527 | firstPartyOAuthClients: [firstPartyClientFor(server)], | 541 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 528 | }); | 542 | }); |
| @@ -24,6 +24,7 @@ import type { CredentialProvider } from "./provider"; · 23 unmodified lines | |||
| 24 | import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 24 | import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 25 | import { ToolResult } from "./tool-result"; | 25 | import { ToolResult } from "./tool-result"; |
| 26 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 26 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 27 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 27 | 28 | ||
| 28 | // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow | 29 | // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow |
| 29 | // and OAuth access-token refresh end to end against the test authorization | 30 | // and OAuth access-token refresh end to end against the test authorization |
| @@ -197,7 +198,10 @@ describe("oauth.start / oauth.complete", () => { · 167 unmodified lines | |||
| 197 | Effect.scoped( | 198 | Effect.scoped( |
| 198 | Effect.gen(function* () { | 199 | Effect.gen(function* () { |
| 199 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 200 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 200 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 201 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 202 | access: testAccess.member(), | ||
| 203 | plugins, | ||
| 204 | }); | ||
| 201 | yield* executor.acme.seed(); | 205 | yield* executor.acme.seed(); |
| 202 | 206 | ||
| 203 | yield* executor.oauth.createClient({ | 207 | yield* executor.oauth.createClient({ |
| @@ -302,6 +306,7 @@ describe("oauth.start / oauth.complete", () => { · 98 unmodified lines | |||
| 302 | }))(); | 306 | }))(); |
| 303 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 307 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 304 | const { executor } = yield* makeTestWorkspaceHarness({ | 308 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 309 | access: testAccess.member(), | ||
| 305 | plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const, | 310 | plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const, |
| 306 | waitUntil: (promise) => keptAlive.push(promise), | 311 | waitUntil: (promise) => keptAlive.push(promise), |
| 307 | }); | 312 | }); |
| @@ -363,7 +368,10 @@ describe("oauth.start / oauth.complete", () => { · 55 unmodified lines | |||
| 363 | scopes: ["read"], | 368 | scopes: ["read"], |
| 364 | defaultTokenEndpointAuthMethod: "client_secret_basic", | 369 | defaultTokenEndpointAuthMethod: "client_secret_basic", |
| 365 | }); | 370 | }); |
| 366 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 371 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 372 | access: testAccess.member(), | ||
| 373 | plugins, | ||
| 374 | }); | ||
| 367 | yield* executor.acme.seed(); | 375 | yield* executor.acme.seed(); |
| 368 | 376 | ||
| 369 | yield* executor.oauth.createClient({ | 377 | yield* executor.oauth.createClient({ |
| @@ -428,7 +436,10 @@ describe("oauth.start / oauth.complete", () => { · 58 unmodified lines | |||
| 428 | scopes: ["read"], | 436 | scopes: ["read"], |
| 429 | defaultTokenEndpointAuthMethod: "client_secret_basic", | 437 | defaultTokenEndpointAuthMethod: "client_secret_basic", |
| 430 | }); | 438 | }); |
| 431 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 439 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 440 | access: testAccess.member(), | ||
| 441 | plugins, | ||
| 442 | }); | ||
| 432 | yield* executor.acme.seed(); | 443 | yield* executor.acme.seed(); |
| 433 | 444 | ||
| 434 | yield* executor.oauth.createClient({ | 445 | yield* executor.oauth.createClient({ |
| @@ -486,6 +497,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines | |||
| 486 | Effect.gen(function* () { | 497 | Effect.gen(function* () { |
| 487 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 498 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 488 | const { executor } = yield* makeTestWorkspaceHarness({ | 499 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 500 | access: testAccess.member(), | ||
| 489 | plugins, | 501 | plugins, |
| 490 | oauthCallbackStateOrgSlug: "acme", | 502 | oauthCallbackStateOrgSlug: "acme", |
| 491 | }); | 503 | }); |
| @@ -546,7 +558,10 @@ describe("oauth.start / oauth.complete", () => { · 54 unmodified lines | |||
| 546 | scopes: ["offline_access", "read"], | 558 | scopes: ["offline_access", "read"], |
| 547 | omitTokenResponseScopes: ["offline_access"], | 559 | omitTokenResponseScopes: ["offline_access"], |
| 548 | }); | 560 | }); |
| 549 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 561 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 562 | access: testAccess.member(), | ||
| 563 | plugins, | ||
| 564 | }); | ||
| 550 | yield* executor.acme.seed(["offline_access", "read"]); | 565 | yield* executor.acme.seed(["offline_access", "read"]); |
| 551 | 566 | ||
| 552 | yield* executor.oauth.createClient({ | 567 | yield* executor.oauth.createClient({ |
| @@ -590,7 +605,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines | |||
| 590 | scopes: ["openid", "email", "profile", "read"], | 605 | scopes: ["openid", "email", "profile", "read"], |
| 591 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | 606 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 592 | }); | 607 | }); |
| 593 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 608 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 609 | access: testAccess.member(), | ||
| 610 | plugins, | ||
| 611 | }); | ||
| 594 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | 612 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 595 | 613 | ||
| 596 | yield* executor.oauth.createClient({ | 614 | yield* executor.oauth.createClient({ |
| @@ -634,7 +652,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines | |||
| 634 | scopes: ["openid", "email", "profile", "read"], | 652 | scopes: ["openid", "email", "profile", "read"], |
| 635 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | 653 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 636 | }); | 654 | }); |
| 637 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 655 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 656 | access: testAccess.member(), | ||
| 657 | plugins, | ||
| 658 | }); | ||
| 638 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | 659 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 639 | 660 | ||
| 640 | yield* executor.oauth.createClient({ | 661 | yield* executor.oauth.createClient({ |
| @@ -679,7 +700,10 @@ describe("oauth.start / oauth.complete", () => { · 38 unmodified lines | |||
| 679 | scopes: ["openid", "email", "profile", "read"], | 700 | scopes: ["openid", "email", "profile", "read"], |
| 680 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | 701 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 681 | }); | 702 | }); |
| 682 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 703 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 704 | access: testAccess.member(), | ||
| 705 | plugins, | ||
| 706 | }); | ||
| 683 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | 707 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 684 | 708 | ||
| 685 | yield* executor.oauth.createClient({ | 709 | yield* executor.oauth.createClient({ |
| @@ -739,7 +763,10 @@ describe("oauth.start / oauth.complete", () => { · 53 unmodified lines | |||
| 739 | scopes: ["openid", "email", "profile", "read"], | 763 | scopes: ["openid", "email", "profile", "read"], |
| 740 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | 764 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 741 | }); | 765 | }); |
| 742 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 766 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 767 | access: testAccess.member(), | ||
| 768 | plugins, | ||
| 769 | }); | ||
| 743 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | 770 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 744 | 771 | ||
| 745 | yield* executor.oauth.createClient({ | 772 | yield* executor.oauth.createClient({ |
| @@ -797,7 +824,10 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines | |||
| 797 | scopes: ["openid", "email", "profile", "read"], | 824 | scopes: ["openid", "email", "profile", "read"], |
| 798 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | 825 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 799 | }); | 826 | }); |
| 800 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 827 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 828 | access: testAccess.member(), | ||
| 829 | plugins, | ||
| 830 | }); | ||
| 801 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | 831 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 802 | 832 | ||
| 803 | yield* executor.oauth.createClient({ | 833 | yield* executor.oauth.createClient({ |
| @@ -852,7 +882,7 @@ describe("oauth.start / oauth.complete", () => { · 48 unmodified lines | |||
| 852 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | 882 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 853 | refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" }, | 883 | refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" }, |
| 854 | }); | 884 | }); |
| 855 | const harness = yield* makeTestWorkspaceHarness({ plugins }); | 885 | const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); |
| 856 | const { executor, config } = harness; | 886 | const { executor, config } = harness; |
| 857 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | 887 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 858 | 888 | ||
| @@ -910,6 +940,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines | |||
| 910 | // is no silent localhost default. The redirect flow must fail loudly | 940 | // is no silent localhost default. The redirect flow must fail loudly |
| 911 | // rather than handing the provider a wrong `http://127.0.0.1/callback`. | 941 | // rather than handing the provider a wrong `http://127.0.0.1/callback`. |
| 912 | const { executor } = yield* makeTestWorkspaceHarness({ | 942 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 943 | access: testAccess.member(), | ||
| 913 | plugins, | 944 | plugins, |
| 914 | redirectUri: null, | 945 | redirectUri: null, |
| 915 | }); | 946 | }); |
| @@ -951,6 +982,7 @@ describe("oauth.start / oauth.complete", () => { · 35 unmodified lines | |||
| 951 | // No redirectUri configured, but client_credentials never redirects — | 982 | // No redirectUri configured, but client_credentials never redirects — |
| 952 | // it must still mint the connection inline. | 983 | // it must still mint the connection inline. |
| 953 | const { executor } = yield* makeTestWorkspaceHarness({ | 984 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 985 | access: testAccess.member(), | ||
| 954 | plugins, | 986 | plugins, |
| 955 | redirectUri: null, | 987 | redirectUri: null, |
| 956 | }); | 988 | }); |
| @@ -983,7 +1015,10 @@ describe("oauth.start / oauth.complete", () => { · 26 unmodified lines | |||
| 983 | Effect.scoped( | 1015 | Effect.scoped( |
| 984 | Effect.gen(function* () { | 1016 | Effect.gen(function* () { |
| 985 | const server = yield* serveOAuthTestServer(); | 1017 | const server = yield* serveOAuthTestServer(); |
| 986 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 1018 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 1019 | access: testAccess.member(), | ||
| 1020 | plugins, | ||
| 1021 | }); | ||
| 987 | yield* executor.acme.seed(); | 1022 | yield* executor.acme.seed(); |
| 988 | yield* executor.oauth.createClient({ | 1023 | yield* executor.oauth.createClient({ |
| 989 | owner: "org", | 1024 | owner: "org", |
| @@ -1011,7 +1046,10 @@ describe("oauth.start / oauth.complete", () => { · 21 unmodified lines | |||
| 1011 | Effect.scoped( | 1046 | Effect.scoped( |
| 1012 | Effect.gen(function* () { | 1047 | Effect.gen(function* () { |
| 1013 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1048 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1014 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 1049 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 1050 | access: testAccess.member(), | ||
| 1051 | plugins, | ||
| 1052 | }); | ||
| 1015 | yield* executor.acme.seed(); | 1053 | yield* executor.acme.seed(); |
| 1016 | 1054 | ||
| 1017 | // The app is registered under the WORKSPACE (org) — "shared with | 1055 | // The app is registered under the WORKSPACE (org) — "shared with |
| @@ -1062,7 +1100,10 @@ describe("oauth.start / oauth.complete", () => { · 44 unmodified lines | |||
| 1062 | Effect.scoped( | 1100 | Effect.scoped( |
| 1063 | Effect.gen(function* () { | 1101 | Effect.gen(function* () { |
| 1064 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1102 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1065 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 1103 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 1104 | access: testAccess.member(), | ||
| 1105 | plugins, | ||
| 1106 | }); | ||
| 1066 | yield* executor.acme.seed(); | 1107 | yield* executor.acme.seed(); |
| 1067 | 1108 | ||
| 1068 | // A PRIVATE app owned by the member. | 1109 | // A PRIVATE app owned by the member. |
| @@ -1100,7 +1141,10 @@ describe("oauth.start / oauth.complete", () => { · 31 unmodified lines | |||
| 1100 | Effect.scoped( | 1141 | Effect.scoped( |
| 1101 | Effect.gen(function* () { | 1142 | Effect.gen(function* () { |
| 1102 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1143 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1103 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 1144 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 1145 | access: testAccess.member(), | ||
| 1146 | plugins, | ||
| 1147 | }); | ||
| 1104 | // Deliberately NOT seeded: the slug names nothing in the catalog — the | 1148 | // Deliberately NOT seeded: the slug names nothing in the catalog — the |
| 1105 | // shape of a reconnect against a connection whose integration was | 1149 | // shape of a reconnect against a connection whose integration was |
| 1106 | // removed, or an agent replaying a stale slug. | 1150 | // removed, or an agent replaying a stale slug. |
| @@ -1141,7 +1185,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 34 unmodified lines | |||
| 1141 | Effect.scoped( | 1185 | Effect.scoped( |
| 1142 | Effect.gen(function* () { | 1186 | Effect.gen(function* () { |
| 1143 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1187 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1144 | const harness = yield* makeTestWorkspaceHarness({ plugins }); | 1188 | const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); |
| 1145 | const { executor, config } = harness; | 1189 | const { executor, config } = harness; |
| 1146 | yield* executor.acme.seed(); | 1190 | yield* executor.acme.seed(); |
| 1147 | 1191 | ||
| @@ -1225,7 +1269,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 77 unmodified lines | |||
| 1225 | 1269 | ||
| 1226 | // One database handle and one credential store under two execution | 1270 | // One database handle and one credential store under two execution |
| 1227 | // stacks — what a self-host holds while two MCP sessions are open. | 1271 | // stacks — what a self-host holds while two MCP sessions are open. |
| 1228 | const config = { ...makeTestConfig({ plugins }), fetch: park.fetch }; | 1272 | const config = { |
| 1273 | ...makeTestConfig({ access: testAccess.member(), plugins }), | ||
| 1274 | fetch: park.fetch, | ||
| 1275 | }; | ||
| 1229 | const sessionA = yield* createExecutor(config); | 1276 | const sessionA = yield* createExecutor(config); |
| 1230 | const sessionB = yield* createExecutor(config); | 1277 | const sessionB = yield* createExecutor(config); |
| 1231 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); | 1278 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); |
| @@ -1322,7 +1369,12 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 90 unmodified lines | |||
| 1322 | // the store layer and mask the gate-key collision this test is about. | 1369 | // the store layer and mask the gate-key collision this test is about. |
| 1323 | const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const; | 1370 | const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const; |
| 1324 | const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const; | 1371 | const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const; |
| 1325 | const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" }); | 1372 | const shared = makeTestConfig({ |
| 1373 | access: testAccess.member(), | ||
| 1374 | plugins: pluginsA, | ||
| 1375 | tenant: "a", | ||
| 1376 | subject: "user:b", | ||
| 1377 | }); | ||
| 1326 | const configA = { ...shared, fetch: parkA.fetch }; | 1378 | const configA = { ...shared, fetch: parkA.fetch }; |
| 1327 | const configB = { | 1379 | const configB = { |
| 1328 | ...shared, | 1380 | ...shared, |
| @@ -1379,7 +1431,11 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines | |||
| 1379 | 1431 | ||
| 1380 | // Expire BOTH rows so both tenants must refresh. `shared.db` is bound | 1432 | // Expire BOTH rows so both tenants must refresh. `shared.db` is bound |
| 1381 | // to tenant A; tenant B's partition needs its own scoped handle. | 1433 | // to tenant A; tenant B's partition needs its own scoped handle. |
| 1382 | const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" }); | 1434 | const dbB = withQueryContext(shared.testDb.db, { |
| 1435 | tenant: "a:user", | ||
| 1436 | subject: "b", | ||
| 1437 | owners: ["user", "org"], | ||
| 1438 | }); | ||
| 1383 | yield* Effect.promise(() => | 1439 | yield* Effect.promise(() => |
| 1384 | shared.db.updateMany("connection", { | 1440 | shared.db.updateMany("connection", { |
| 1385 | where: (b) => b("name", "=", "mine"), | 1441 | where: (b) => b("name", "=", "mine"), |
| @@ -1452,7 +1508,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 66 unmodified lines | |||
| 1452 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1508 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1453 | const park = makeTokenRequestPark(); | 1509 | const park = makeTokenRequestPark(); |
| 1454 | 1510 | ||
| 1455 | const config = { ...makeTestConfig({ plugins }), fetch: park.fetch }; | 1511 | const config = { |
| 1512 | ...makeTestConfig({ access: testAccess.member(), plugins }), | ||
| 1513 | fetch: park.fetch, | ||
| 1514 | }; | ||
| 1456 | const sessionA = yield* createExecutor(config); | 1515 | const sessionA = yield* createExecutor(config); |
| 1457 | const sessionB = yield* createExecutor(config); | 1516 | const sessionB = yield* createExecutor(config); |
| 1458 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); | 1517 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); |
| @@ -1598,6 +1657,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 139 unmodified lines | |||
| 1598 | // handle, which is what the extra `withQueryContext` wrapper is. | 1657 | // handle, which is what the extra `withQueryContext` wrapper is. |
| 1599 | const config = { | 1658 | const config = { |
| 1600 | ...makeTestConfig({ | 1659 | ...makeTestConfig({ |
| 1660 | access: testAccess.member(), | ||
| 1601 | plugins: [oauthPlugin] as const, | 1661 | plugins: [oauthPlugin] as const, |
| 1602 | tenant: SHARED_STORE_TENANT, | 1662 | tenant: SHARED_STORE_TENANT, |
| 1603 | subject: SHARED_STORE_SUBJECT, | 1663 | subject: SHARED_STORE_SUBJECT, |
| @@ -1722,7 +1782,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 118 unmodified lines | |||
| 1722 | Effect.scoped( | 1782 | Effect.scoped( |
| 1723 | Effect.gen(function* () { | 1783 | Effect.gen(function* () { |
| 1724 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 1784 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1725 | const harness = yield* makeTestWorkspaceHarness({ plugins }); | 1785 | const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); |
| 1726 | const { executor, config } = harness; | 1786 | const { executor, config } = harness; |
| 1727 | yield* executor.acme.seed(); | 1787 | yield* executor.acme.seed(); |
| 1728 | 1788 | ||
| @@ -1790,7 +1850,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 61 unmodified lines | |||
| 1790 | tokenExpiresInSeconds: 0, | 1850 | tokenExpiresInSeconds: 0, |
| 1791 | invalidRefreshTokenDescription: "Grant not found", | 1851 | invalidRefreshTokenDescription: "Grant not found", |
| 1792 | }); | 1852 | }); |
| 1793 | const harness = yield* makeTestWorkspaceHarness({ plugins }); | 1853 | const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); |
| 1794 | const { executor, config } = harness; | 1854 | const { executor, config } = harness; |
| 1795 | yield* executor.acme.seed(); | 1855 | yield* executor.acme.seed(); |
| 1796 | 1856 | ||
| @@ -1876,7 +1936,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 79 unmodified lines | |||
| 1876 | tokenExpiresInSeconds: 0, | 1936 | tokenExpiresInSeconds: 0, |
| 1877 | invalidRefreshTokenDescription: "Grant revoked", | 1937 | invalidRefreshTokenDescription: "Grant revoked", |
| 1878 | }); | 1938 | }); |
| 1879 | const harness = yield* makeTestWorkspaceHarness({ plugins }); | 1939 | const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); |
| 1880 | const { executor, config } = harness; | 1940 | const { executor, config } = harness; |
| 1881 | yield* executor.acme.seed(); | 1941 | yield* executor.acme.seed(); |
| 1882 | 1942 | ||
| @@ -1983,7 +2043,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 100 unmodified lines | |||
| 1983 | Effect.scoped( | 2043 | Effect.scoped( |
| 1984 | Effect.gen(function* () { | 2044 | Effect.gen(function* () { |
| 1985 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 2045 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1986 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 2046 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 2047 | access: testAccess.member(), | ||
| 2048 | plugins, | ||
| 2049 | }); | ||
| 1987 | yield* executor.acme.seed(); | 2050 | yield* executor.acme.seed(); |
| 1988 | 2051 | ||
| 1989 | yield* executor.oauth.createClient({ | 2052 | yield* executor.oauth.createClient({ |
| @@ -2040,7 +2103,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines | |||
| 2040 | scopes: ["openid", "email", "profile", "offline_access", "read", "write"], | 2103 | scopes: ["openid", "email", "profile", "offline_access", "read", "write"], |
| 2041 | omitTokenResponseScopes: ["email", "profile", "write"], | 2104 | omitTokenResponseScopes: ["email", "profile", "write"], |
| 2042 | }); | 2105 | }); |
| 2043 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 2106 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 2107 | access: testAccess.member(), | ||
| 2108 | plugins, | ||
| 2109 | }); | ||
| 2044 | yield* executor.acme.seed([ | 2110 | yield* executor.acme.seed([ |
| 2045 | "openid", | 2111 | "openid", |
| 2046 | "email", | 2112 | "email", |
| @@ -2098,7 +2164,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 51 unmodified lines | |||
| 2098 | Effect.scoped( | 2164 | Effect.scoped( |
| 2099 | Effect.gen(function* () { | 2165 | Effect.gen(function* () { |
| 2100 | const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); | 2166 | const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); |
| 2101 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 2167 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 2168 | access: testAccess.member(), | ||
| 2169 | plugins, | ||
| 2170 | }); | ||
| 2102 | yield* executor.acme.seed(["read", "write"]); | 2171 | yield* executor.acme.seed(["read", "write"]); |
| 2103 | 2172 | ||
| 2104 | yield* executor.oauth.createClient({ | 2173 | yield* executor.oauth.createClient({ |
| @@ -2159,7 +2228,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 54 unmodified lines | |||
| 2159 | Effect.scoped( | 2228 | Effect.scoped( |
| 2160 | Effect.gen(function* () { | 2229 | Effect.gen(function* () { |
| 2161 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 2230 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 2162 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 2231 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 2232 | access: testAccess.member(), | ||
| 2233 | plugins, | ||
| 2234 | }); | ||
| 2163 | yield* executor.acme.seed(); | 2235 | yield* executor.acme.seed(); |
| 2164 | 2236 | ||
| 2165 | // Reroute the https regional/advertised hosts back to the loopback test | 2237 | // Reroute the https regional/advertised hosts back to the loopback test |
| @@ -2248,7 +2320,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 82 unmodified lines | |||
| 2248 | Effect.scoped( | 2320 | Effect.scoped( |
| 2249 | Effect.gen(function* () { | 2321 | Effect.gen(function* () { |
| 2250 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 2322 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 2251 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 2323 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 2324 | access: testAccess.member(), | ||
| 2325 | plugins, | ||
| 2326 | }); | ||
| 2252 | yield* executor.acme.seed(); | 2327 | yield* executor.acme.seed(); |
| 2253 | 2328 | ||
| 2254 | const tokenCalls: TokenEndpointCall[] = []; | 2329 | const tokenCalls: TokenEndpointCall[] = []; |
| @@ -2423,6 +2498,7 @@ const connectRejecting = (options?: { readonly tokenExpiresInSeconds?: number }) · 168 unmodified lines | |||
| 2423 | return issued.length > 0 ? issued[issued.length - 1]! : null; | 2498 | return issued.length > 0 ? issued[issued.length - 1]! : null; |
| 2424 | }); | 2499 | }); |
| 2425 | const harness = yield* makeTestWorkspaceHarness({ | 2500 | const harness = yield* makeTestWorkspaceHarness({ |
| 2501 | access: testAccess.member(), | ||
| 2426 | plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const, | 2502 | plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const, |
| 2427 | }); | 2503 | }); |
| 2428 | const { executor, config } = harness; | 2504 | const { executor, config } = harness; |
| @@ -2612,6 +2688,7 @@ describe("reactive OAuth refresh on upstream 401", () => { · 183 unmodified lines | |||
| 2612 | }))(); | 2688 | }))(); |
| 2613 | 2689 | ||
| 2614 | const { executor } = yield* makeTestWorkspaceHarness({ | 2690 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 2691 | access: testAccess.member(), | ||
| 2615 | plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const, | 2692 | plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const, |
| 2616 | }); | 2693 | }); |
| 2617 | yield* executor.acme.seed(); | 2694 | yield* executor.acme.seed(); |
| @@ -2692,7 +2769,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 74 unmodified lines | |||
| 2692 | Effect.scoped( | 2769 | Effect.scoped( |
| 2693 | Effect.gen(function* () { | 2770 | Effect.gen(function* () { |
| 2694 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 2771 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 2695 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 2772 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 2773 | access: testAccess.member(), | ||
| 2774 | plugins, | ||
| 2775 | }); | ||
| 2696 | yield* executor.acme.seed(); | 2776 | yield* executor.acme.seed(); |
| 2697 | 2777 | ||
| 2698 | // `resource: null` — explicitly none, not merely unset. | 2778 | // `resource: null` — explicitly none, not merely unset. |
| @@ -2761,7 +2841,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 62 unmodified lines | |||
| 2761 | Effect.scoped( | 2841 | Effect.scoped( |
| 2762 | Effect.gen(function* () { | 2842 | Effect.gen(function* () { |
| 2763 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 2843 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 2764 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 2844 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 2845 | access: testAccess.member(), | ||
| 2846 | plugins, | ||
| 2847 | }); | ||
| 2765 | yield* executor.acme.seed(); | 2848 | yield* executor.acme.seed(); |
| 2766 | 2849 | ||
| 2767 | yield* executor.oauth.createClient({ | 2850 | yield* executor.oauth.createClient({ |
| @@ -2799,7 +2882,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 31 unmodified lines | |||
| 2799 | Effect.scoped( | 2882 | Effect.scoped( |
| 2800 | Effect.gen(function* () { | 2883 | Effect.gen(function* () { |
| 2801 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 2884 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 2802 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 2885 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 2886 | access: testAccess.member(), | ||
| 2887 | plugins, | ||
| 2888 | }); | ||
| 2803 | yield* executor.acme.seed(); | 2889 | yield* executor.acme.seed(); |
| 2804 | 2890 | ||
| 2805 | yield* executor.oauth.createClient({ | 2891 | yield* executor.oauth.createClient({ |
| @@ -7,6 +7,7 @@ import { Effect, Predicate } from "effect"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import { OAuthClientSlug } from "./ids"; | 8 | import { OAuthClientSlug } from "./ids"; |
| 9 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 9 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 10 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 10 | 11 | ||
| 11 | // listClients returns metadata-only summaries of the clients visible to the | 12 | // listClients returns metadata-only summaries of the clients visible to the |
| 12 | // caller — the tenant's org clients plus the caller's own user clients — and | 13 | // caller — the tenant's org clients plus the caller's own user clients — and |
| @@ -21,7 +22,10 @@ describe("oauth.listClients", () => { · 8 unmodified lines | |||
| 21 | it.effect("rejects metadata URLs persisted as authorization URLs", () => | 22 | it.effect("rejects metadata URLs persisted as authorization URLs", () => |
| 22 | Effect.scoped( | 23 | Effect.scoped( |
| 23 | Effect.gen(function* () { | 24 | Effect.gen(function* () { |
| 24 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 25 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 26 | access: testAccess.member(), | ||
| 27 | plugins, | ||
| 28 | }); | ||
| 25 | 29 | ||
| 26 | const error = yield* Effect.flip( | 30 | const error = yield* Effect.flip( |
| 27 | executor.oauth.createClient({ | 31 | executor.oauth.createClient({ |
| @@ -50,7 +54,10 @@ describe("oauth.listClients", () => { · 22 unmodified lines | |||
| 50 | it.effect("returns owner-visible clients as summaries without the secret", () => | 54 | it.effect("returns owner-visible clients as summaries without the secret", () => |
| 51 | Effect.scoped( | 55 | Effect.scoped( |
| 52 | Effect.gen(function* () { | 56 | Effect.gen(function* () { |
| 53 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 57 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 58 | access: testAccess.member(), | ||
| 59 | plugins, | ||
| 60 | }); | ||
| 54 | 61 | ||
| 55 | yield* executor.oauth.createClient({ | 62 | yield* executor.oauth.createClient({ |
| 56 | owner: "org", | 63 | owner: "org", |
| @@ -113,7 +120,10 @@ describe("oauth.listClients", () => { · 56 unmodified lines | |||
| 113 | it.effect("rejects HTTP Basic authentication without a client secret", () => | 120 | it.effect("rejects HTTP Basic authentication without a client secret", () => |
| 114 | Effect.scoped( | 121 | Effect.scoped( |
| 115 | Effect.gen(function* () { | 122 | Effect.gen(function* () { |
| 116 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 123 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 124 | access: testAccess.member(), | ||
| 125 | plugins, | ||
| 126 | }); | ||
| 117 | 127 | ||
| 118 | const error = yield* Effect.flip( | 128 | const error = yield* Effect.flip( |
| 119 | executor.oauth.createClient({ | 129 | executor.oauth.createClient({ |
| @@ -147,6 +157,7 @@ describe("oauth.listClients", () => { · 27 unmodified lines | |||
| 147 | const tenant = "shared-tenant"; | 157 | const tenant = "shared-tenant"; |
| 148 | 158 | ||
| 149 | const a = yield* makeTestWorkspaceHarness({ | 159 | const a = yield* makeTestWorkspaceHarness({ |
| 160 | access: testAccess.member(), | ||
| 150 | plugins, | 161 | plugins, |
| 151 | tenant, | 162 | tenant, |
| 152 | subject: "subject-a", | 163 | subject: "subject-a", |
| @@ -172,6 +183,7 @@ describe("oauth.listClients", () => { · 19 unmodified lines | |||
| 172 | }); | 183 | }); |
| 173 | 184 | ||
| 174 | const b = yield* makeTestWorkspaceHarness({ | 185 | const b = yield* makeTestWorkspaceHarness({ |
| 186 | access: testAccess.member(), | ||
| 175 | plugins, | 187 | plugins, |
| 176 | tenant, | 188 | tenant, |
| 177 | subject: "subject-b", | 189 | subject: "subject-b", |
| @@ -190,7 +202,10 @@ describe("oauth.listClients", () => { · 12 unmodified lines | |||
| 190 | it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () => | 202 | it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () => |
| 191 | Effect.scoped( | 203 | Effect.scoped( |
| 192 | Effect.gen(function* () { | 204 | Effect.gen(function* () { |
| 193 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | 205 | const { config, executor } = yield* makeTestWorkspaceHarness({ |
| 206 | access: testAccess.member(), | ||
| 207 | plugins, | ||
| 208 | }); | ||
| 194 | 209 | ||
| 195 | yield* executor.oauth.createClient({ | 210 | yield* executor.oauth.createClient({ |
| 196 | owner: "org", | 211 | owner: "org", |
| @@ -12,6 +12,7 @@ import { OAuthRegisterDynamicError } from "./oauth-client"; · 11 unmodified lines | |||
| 12 | import { definePlugin } from "./plugin"; | 12 | import { definePlugin } from "./plugin"; |
| 13 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 13 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 14 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 14 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 15 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 15 | 16 | ||
| 16 | // RFC 7591 Dynamic Client Registration, end to end: | 17 | // RFC 7591 Dynamic Client Registration, end to end: |
| 17 | // probe → registerDynamicClient (no pasted client id/secret) → listClients | 18 | // probe → registerDynamicClient (no pasted client id/secret) → listClients |
| @@ -64,7 +65,7 @@ describe("oauth.registerDynamicClient", () => { · 46 unmodified lines | |||
| 64 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 65 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 65 | const { executor } = yield* makeTestWorkspaceHarness({ | 66 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 66 | plugins, | 67 | plugins, |
| 67 | orgWrites: "denied", | 68 | access: testAccess.member("denied"), |
| 68 | }); | 69 | }); |
| 69 | 70 | ||
| 70 | const error = yield* executor.oauth | 71 | const error = yield* executor.oauth |
| @@ -94,7 +95,10 @@ describe("oauth.registerDynamicClient", () => { · 23 unmodified lines | |||
| 94 | Effect.scoped( | 95 | Effect.scoped( |
| 95 | Effect.gen(function* () { | 96 | Effect.gen(function* () { |
| 96 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 97 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 97 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 98 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 99 | access: testAccess.member(), | ||
| 100 | plugins, | ||
| 101 | }); | ||
| 98 | yield* executor.acme.seed(); | 102 | yield* executor.acme.seed(); |
| 99 | 103 | ||
| 100 | // Probe surfaces the registration endpoint + advertised auth methods so | 104 | // Probe surfaces the registration endpoint + advertised auth methods so |
| @@ -197,7 +201,10 @@ describe("oauth.registerDynamicClient", () => { · 96 unmodified lines | |||
| 197 | Effect.scoped( | 201 | Effect.scoped( |
| 198 | Effect.gen(function* () { | 202 | Effect.gen(function* () { |
| 199 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 203 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 200 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 204 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 205 | access: testAccess.member(), | ||
| 206 | plugins, | ||
| 207 | }); | ||
| 201 | yield* executor.acme.seed(); | 208 | yield* executor.acme.seed(); |
| 202 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 209 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 203 | 210 | ||
| @@ -230,7 +237,10 @@ describe("oauth.registerDynamicClient", () => { · 26 unmodified lines | |||
| 230 | Effect.scoped( | 237 | Effect.scoped( |
| 231 | Effect.gen(function* () { | 238 | Effect.gen(function* () { |
| 232 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 239 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 233 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 240 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 241 | access: testAccess.member(), | ||
| 242 | plugins, | ||
| 243 | }); | ||
| 234 | yield* executor.acme.seed(); | 244 | yield* executor.acme.seed(); |
| 235 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 245 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 236 | 246 | ||
| @@ -276,7 +286,10 @@ describe("oauth.registerDynamicClient", () => { · 39 unmodified lines | |||
| 276 | Effect.scoped( | 286 | Effect.scoped( |
| 277 | Effect.gen(function* () { | 287 | Effect.gen(function* () { |
| 278 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 288 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 279 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 289 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 290 | access: testAccess.member(), | ||
| 291 | plugins, | ||
| 292 | }); | ||
| 280 | yield* executor.acme.seed(); | 293 | yield* executor.acme.seed(); |
| 281 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 294 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 282 | 295 | ||
| @@ -327,7 +340,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines | |||
| 327 | // reused, so a fresh DCR registration happens. The GC migration then | 340 | // reused, so a fresh DCR registration happens. The GC migration then |
| 328 | // backfills/GCs any duplicate this transient window mints. | 341 | // backfills/GCs any duplicate this transient window mints. |
| 329 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 342 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 330 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | 343 | const { config, executor } = yield* makeTestWorkspaceHarness({ |
| 344 | access: testAccess.member(), | ||
| 345 | plugins, | ||
| 346 | }); | ||
| 331 | yield* executor.acme.seed(); | 347 | yield* executor.acme.seed(); |
| 332 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 348 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 333 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); | 349 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); |
| @@ -379,7 +395,10 @@ describe("oauth.registerDynamicClient", () => { · 45 unmodified lines | |||
| 379 | const server = yield* serveOAuthTestServer({ | 395 | const server = yield* serveOAuthTestServer({ |
| 380 | scopes: ["openid", "offline_access"], | 396 | scopes: ["openid", "offline_access"], |
| 381 | }); | 397 | }); |
| 382 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 398 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 399 | access: testAccess.member(), | ||
| 400 | plugins, | ||
| 401 | }); | ||
| 383 | yield* executor.acme.seed(); | 402 | yield* executor.acme.seed(); |
| 384 | 403 | ||
| 385 | yield* executor.oauth.registerDynamicClient({ | 404 | yield* executor.oauth.registerDynamicClient({ |
| @@ -413,7 +432,10 @@ describe("oauth.registerDynamicClient", () => { · 27 unmodified lines | |||
| 413 | // row's origin_issuer, the reuse lookup keys on it and mints no | 432 | // row's origin_issuer, the reuse lookup keys on it and mints no |
| 414 | // duplicate. This is the steady state the migration establishes. | 433 | // duplicate. This is the steady state the migration establishes. |
| 415 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 434 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 416 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | 435 | const { config, executor } = yield* makeTestWorkspaceHarness({ |
| 436 | access: testAccess.member(), | ||
| 437 | plugins, | ||
| 438 | }); | ||
| 417 | yield* executor.acme.seed(); | 439 | yield* executor.acme.seed(); |
| 418 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 440 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 419 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); | 441 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); |
| @@ -467,7 +489,10 @@ describe("oauth.registerDynamicClient", () => { · 47 unmodified lines | |||
| 467 | Effect.scoped( | 489 | Effect.scoped( |
| 468 | Effect.gen(function* () { | 490 | Effect.gen(function* () { |
| 469 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 491 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 470 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 492 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 493 | access: testAccess.member(), | ||
| 494 | plugins, | ||
| 495 | }); | ||
| 471 | yield* executor.acme.seed(); | 496 | yield* executor.acme.seed(); |
| 472 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 497 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 473 | const resourceA = `${server.issuerUrl}/mcp/a`; | 498 | const resourceA = `${server.issuerUrl}/mcp/a`; |
| @@ -533,7 +558,10 @@ describe("oauth.registerDynamicClient", () => { · 59 unmodified lines | |||
| 533 | Effect.scoped( | 558 | Effect.scoped( |
| 534 | Effect.gen(function* () { | 559 | Effect.gen(function* () { |
| 535 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 560 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 536 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 561 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 562 | access: testAccess.member(), | ||
| 563 | plugins, | ||
| 564 | }); | ||
| 537 | yield* executor.acme.seed(); | 565 | yield* executor.acme.seed(); |
| 538 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 566 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 539 | const resourceA = `${server.issuerUrl}/mcp/a`; | 567 | const resourceA = `${server.issuerUrl}/mcp/a`; |
| @@ -638,7 +666,10 @@ describe("oauth.registerDynamicClient", () => { · 98 unmodified lines | |||
| 638 | Effect.scoped( | 666 | Effect.scoped( |
| 639 | Effect.gen(function* () { | 667 | Effect.gen(function* () { |
| 640 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 668 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 641 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 669 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 670 | access: testAccess.member(), | ||
| 671 | plugins, | ||
| 672 | }); | ||
| 642 | yield* executor.acme.seed(); | 673 | yield* executor.acme.seed(); |
| 643 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 674 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 644 | 675 | ||
| @@ -733,7 +764,10 @@ describe("oauth.registerDynamicClient", () => { · 88 unmodified lines | |||
| 733 | Effect.scoped( | 764 | Effect.scoped( |
| 734 | Effect.gen(function* () { | 765 | Effect.gen(function* () { |
| 735 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 766 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 736 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 767 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 768 | access: testAccess.member(), | ||
| 769 | plugins, | ||
| 770 | }); | ||
| 737 | yield* executor.acme.seed(); | 771 | yield* executor.acme.seed(); |
| 738 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 772 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 739 | 773 | ||
| @@ -784,7 +818,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines | |||
| 784 | // Mirror Mercury: reject any client_name containing the brand. | 818 | // Mirror Mercury: reject any client_name containing the brand. |
| 785 | approveClientName: (name) => !name.includes("Acme"), | 819 | approveClientName: (name) => !name.includes("Acme"), |
| 786 | }); | 820 | }); |
| 787 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 821 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 822 | access: testAccess.member(), | ||
| 823 | plugins, | ||
| 824 | }); | ||
| 788 | yield* executor.acme.seed(); | 825 | yield* executor.acme.seed(); |
| 789 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 826 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 790 | 827 | ||
| @@ -821,7 +858,10 @@ describe("oauth.registerDynamicClient", () => { · 30 unmodified lines | |||
| 821 | scopes: ["read"], | 858 | scopes: ["read"], |
| 822 | approveClientName: () => false, | 859 | approveClientName: () => false, |
| 823 | }); | 860 | }); |
| 824 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 861 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 862 | access: testAccess.member(), | ||
| 863 | plugins, | ||
| 864 | }); | ||
| 825 | yield* executor.acme.seed(); | 865 | yield* executor.acme.seed(); |
| 826 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 866 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 827 | 867 | ||
| @@ -866,7 +906,10 @@ describe("oauth.registerDynamicClient", () => { · 38 unmodified lines | |||
| 866 | approveRedirectUri: (uri) => | 906 | approveRedirectUri: (uri) => |
| 867 | uri.startsWith("http://localhost") || uri.startsWith("http://127."), | 907 | uri.startsWith("http://localhost") || uri.startsWith("http://127."), |
| 868 | }); | 908 | }); |
| 869 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 909 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 910 | access: testAccess.member(), | ||
| 911 | plugins, | ||
| 912 | }); | ||
| 870 | yield* executor.acme.seed(); | 913 | yield* executor.acme.seed(); |
| 871 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 914 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 872 | 915 | ||
| @@ -910,7 +953,10 @@ describe("oauth.registerDynamicClient", () => { · 37 unmodified lines | |||
| 910 | scopes: ["read"], | 953 | scopes: ["read"], |
| 911 | approveRedirectUri: () => false, // reject every redirect URI, even loopback | 954 | approveRedirectUri: () => false, // reject every redirect URI, even loopback |
| 912 | }); | 955 | }); |
| 913 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 956 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 957 | access: testAccess.member(), | ||
| 958 | plugins, | ||
| 959 | }); | ||
| 914 | yield* executor.acme.seed(); | 960 | yield* executor.acme.seed(); |
| 915 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 961 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 916 | 962 | ||
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | import { definePlugin } from "./plugin"; | 18 | import { definePlugin } from "./plugin"; |
| 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 20 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 20 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 21 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 21 | 22 | ||
| 22 | // removeClient permanently deletes an owner-scoped oauth_client row, keyed by | 23 | // removeClient permanently deletes an owner-scoped oauth_client row, keyed by |
| 23 | // (owner, slug). The owner policy on `oauth_client` prevents removing another | 24 | // (owner, slug). The owner policy on `oauth_client` prevents removing another |
| @@ -32,7 +33,10 @@ describe("oauth.removeClient", () => { · 8 unmodified lines | |||
| 32 | it.effect("removes a client so it no longer lists", () => | 33 | it.effect("removes a client so it no longer lists", () => |
| 33 | Effect.scoped( | 34 | Effect.scoped( |
| 34 | Effect.gen(function* () { | 35 | Effect.gen(function* () { |
| 35 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 36 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 37 | access: testAccess.member(), | ||
| 38 | plugins, | ||
| 39 | }); | ||
| 36 | 40 | ||
| 37 | yield* executor.oauth.createClient({ | 41 | yield* executor.oauth.createClient({ |
| 38 | owner: "user", | 42 | owner: "user", |
| @@ -58,7 +62,10 @@ describe("oauth.removeClient", () => { · 19 unmodified lines | |||
| 58 | it.effect("is idempotent — removing a non-existent client succeeds", () => | 62 | it.effect("is idempotent — removing a non-existent client succeeds", () => |
| 59 | Effect.scoped( | 63 | Effect.scoped( |
| 60 | Effect.gen(function* () { | 64 | Effect.gen(function* () { |
| 61 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 65 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 66 | access: testAccess.member(), | ||
| 67 | plugins, | ||
| 68 | }); | ||
| 62 | 69 | ||
| 63 | // No client was ever created; removing it must not error. | 70 | // No client was ever created; removing it must not error. |
| 64 | yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed")); | 71 | yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed")); |
| @@ -72,7 +79,10 @@ describe("oauth.removeClient", () => { · 7 unmodified lines | |||
| 72 | it.effect("removing an org client leaves a user client intact (and vice versa)", () => | 79 | it.effect("removing an org client leaves a user client intact (and vice versa)", () => |
| 73 | Effect.scoped( | 80 | Effect.scoped( |
| 74 | Effect.gen(function* () { | 81 | Effect.gen(function* () { |
| 75 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 82 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 83 | access: testAccess.member(), | ||
| 84 | plugins, | ||
| 85 | }); | ||
| 76 | 86 | ||
| 77 | yield* executor.oauth.createClient({ | 87 | yield* executor.oauth.createClient({ |
| 78 | owner: "org", | 88 | owner: "org", |
| @@ -115,6 +125,7 @@ describe("oauth.removeClient", () => { · 36 unmodified lines | |||
| 115 | const tenant = "shared-tenant"; | 125 | const tenant = "shared-tenant"; |
| 116 | 126 | ||
| 117 | const a = yield* makeTestWorkspaceHarness({ | 127 | const a = yield* makeTestWorkspaceHarness({ |
| 128 | access: testAccess.member(), | ||
| 118 | plugins, | 129 | plugins, |
| 119 | tenant, | 130 | tenant, |
| 120 | subject: "subject-a", | 131 | subject: "subject-a", |
| @@ -131,6 +142,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines | |||
| 131 | }); | 142 | }); |
| 132 | 143 | ||
| 133 | const b = yield* makeTestWorkspaceHarness({ | 144 | const b = yield* makeTestWorkspaceHarness({ |
| 145 | access: testAccess.member(), | ||
| 134 | plugins, | 146 | plugins, |
| 135 | tenant, | 147 | tenant, |
| 136 | subject: "subject-b", | 148 | subject: "subject-b", |
| @@ -155,6 +167,7 @@ describe("oauth.removeClient", () => { · 18 unmodified lines | |||
| 155 | const tenant = "shared-tenant"; | 167 | const tenant = "shared-tenant"; |
| 156 | 168 | ||
| 157 | const a = yield* makeTestWorkspaceHarness({ | 169 | const a = yield* makeTestWorkspaceHarness({ |
| 170 | access: testAccess.member(), | ||
| 158 | plugins, | 171 | plugins, |
| 159 | tenant, | 172 | tenant, |
| 160 | subject: "subject-a", | 173 | subject: "subject-a", |
| @@ -171,6 +184,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines | |||
| 171 | }); | 184 | }); |
| 172 | 185 | ||
| 173 | const b = yield* makeTestWorkspaceHarness({ | 186 | const b = yield* makeTestWorkspaceHarness({ |
| 187 | access: testAccess.member(), | ||
| 174 | plugins, | 188 | plugins, |
| 175 | tenant, | 189 | tenant, |
| 176 | subject: "subject-b", | 190 | subject: "subject-b", |
| @@ -194,6 +208,7 @@ describe("oauth.removeClient", () => { · 17 unmodified lines | |||
| 194 | Effect.scoped( | 208 | Effect.scoped( |
| 195 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 196 | const { executor } = yield* makeTestWorkspaceHarness({ | 210 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 211 | access: testAccess.member(), | ||
| 197 | plugins, | 212 | plugins, |
| 198 | coreTools: {}, | 213 | coreTools: {}, |
| 199 | }); | 214 | }); |
| @@ -306,6 +321,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 106 unmodified lines | |||
| 306 | Effect.gen(function* () { | 321 | Effect.gen(function* () { |
| 307 | const store = new Map<string, string>(); | 322 | const store = new Map<string, string>(); |
| 308 | const { executor } = yield* makeTestWorkspaceHarness({ | 323 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 324 | access: testAccess.member(), | ||
| 309 | plugins: [txPlugin(store)] as const, | 325 | plugins: [txPlugin(store)] as const, |
| 310 | }); | 326 | }); |
| 311 | yield* executor.oauth.createClient(userClient("user-secret")); | 327 | yield* executor.oauth.createClient(userClient("user-secret")); |
| @@ -339,6 +355,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 27 unmodified lines | |||
| 339 | Effect.gen(function* () { | 355 | Effect.gen(function* () { |
| 340 | const store = new Map<string, string>(); | 356 | const store = new Map<string, string>(); |
| 341 | const { executor } = yield* makeTestWorkspaceHarness({ | 357 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 358 | access: testAccess.member(), | ||
| 342 | plugins: [txPlugin(store)] as const, | 359 | plugins: [txPlugin(store)] as const, |
| 343 | }); | 360 | }); |
| 344 | yield* executor.oauth.createClient(userClient("user-secret")); | 361 | yield* executor.oauth.createClient(userClient("user-secret")); |
| @@ -360,6 +377,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 15 unmodified lines | |||
| 360 | Effect.gen(function* () { | 377 | Effect.gen(function* () { |
| 361 | const store = new Map<string, string>(); | 378 | const store = new Map<string, string>(); |
| 362 | const { executor } = yield* makeTestWorkspaceHarness({ | 379 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 380 | access: testAccess.member(), | ||
| 363 | plugins: [txPlugin(store)] as const, | 381 | plugins: [txPlugin(store)] as const, |
| 364 | }); | 382 | }); |
| 365 | yield* executor.oauth.createClient(userClient("user-secret")); | 383 | yield* executor.oauth.createClient(userClient("user-secret")); |
| @@ -385,6 +403,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 19 unmodified lines | |||
| 385 | const tenant = "shared-tenant"; | 403 | const tenant = "shared-tenant"; |
| 386 | 404 | ||
| 387 | const a = yield* makeTestWorkspaceHarness({ | 405 | const a = yield* makeTestWorkspaceHarness({ |
| 406 | access: testAccess.member(), | ||
| 388 | plugins, | 407 | plugins, |
| 389 | tenant, | 408 | tenant, |
| 390 | subject: "subject-a", | 409 | subject: "subject-a", |
| @@ -394,6 +413,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 3 unmodified lines | |||
| 394 | expect(secretValue(store)).toBe("a-secret"); | 413 | expect(secretValue(store)).toBe("a-secret"); |
| 395 | 414 | ||
| 396 | const b = yield* makeTestWorkspaceHarness({ | 415 | const b = yield* makeTestWorkspaceHarness({ |
| 416 | access: testAccess.member(), | ||
| 397 | plugins, | 417 | plugins, |
| 398 | tenant, | 418 | tenant, |
| 399 | subject: "subject-b", | 419 | subject: "subject-b", |
| @@ -454,6 +474,7 @@ describe("removing a client does not delete a recreated client's secret", () => · 54 unmodified lines | |||
| 454 | }); | 474 | }); |
| 455 | const store = new Map<string, string>(); | 475 | const store = new Map<string, string>(); |
| 456 | const { executor } = yield* makeTestWorkspaceHarness({ | 476 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 477 | access: testAccess.member(), | ||
| 457 | plugins: [txPlugin(store), integrationPlugin] as const, | 478 | plugins: [txPlugin(store), integrationPlugin] as const, |
| 458 | redirectUri: null, | 479 | redirectUri: null, |
| 459 | }); | 480 | }); |
| @@ -16,6 +16,7 @@ import { definePlugin, type IntegrationRecord } from "./plugin"; · 15 unmodified lines | |||
| 16 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 16 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 17 | import { serveTestHttpApp } from "./testing"; | 17 | import { serveTestHttpApp } from "./testing"; |
| 18 | import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; | 18 | import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 19 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 19 | 20 | ||
| 20 | // Integration-driven scopes: at connect, `oauth.start` requests the integration's | 21 | // Integration-driven scopes: at connect, `oauth.start` requests the integration's |
| 21 | // scopes — its DECLARED oauth scopes when it has any, otherwise the scopes | 22 | // scopes — its DECLARED oauth scopes when it has any, otherwise the scopes |
| @@ -187,7 +188,7 @@ const setupMcpScopeClient = ( · 165 unmodified lines | |||
| 187 | ) => | 188 | ) => |
| 188 | Effect.gen(function* () { | 189 | Effect.gen(function* () { |
| 189 | const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const; | 190 | const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const; |
| 190 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 191 | const { executor } = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); |
| 191 | yield* executor.mcp.seed(); | 192 | yield* executor.mcp.seed(); |
| 192 | yield* executor.oauth.createClient({ | 193 | yield* executor.oauth.createClient({ |
| 193 | owner: "org", | 194 | owner: "org", |
| @@ -213,7 +214,10 @@ describe("oauth.start integration-driven scopes", () => { · 19 unmodified lines | |||
| 213 | memoryCredentialsPlugin(), | 214 | memoryCredentialsPlugin(), |
| 214 | makeScopePlugin({ scopes: DECLARED_SCOPES }), | 215 | makeScopePlugin({ scopes: DECLARED_SCOPES }), |
| 215 | ] as const; | 216 | ] as const; |
| 216 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 217 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 218 | access: testAccess.member(), | ||
| 219 | plugins, | ||
| 220 | }); | ||
| 217 | yield* executor.acme.seed(); | 221 | yield* executor.acme.seed(); |
| 218 | 222 | ||
| 219 | // The app is pure identity — no scope set. | 223 | // The app is pure identity — no scope set. |
| @@ -265,7 +269,10 @@ describe("oauth.start integration-driven scopes", () => { · 45 unmodified lines | |||
| 265 | authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, | 269 | authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, |
| 266 | }), | 270 | }), |
| 267 | ] as const; | 271 | ] as const; |
| 268 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 272 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 273 | access: testAccess.member(), | ||
| 274 | plugins, | ||
| 275 | }); | ||
| 269 | yield* executor.acme.seed(); | 276 | yield* executor.acme.seed(); |
| 270 | yield* executor.oauth.createClient({ | 277 | yield* executor.oauth.createClient({ |
| 271 | owner: "org", | 278 | owner: "org", |
| @@ -303,7 +310,10 @@ describe("oauth.start integration-driven scopes", () => { · 31 unmodified lines | |||
| 303 | memoryCredentialsPlugin(), | 310 | memoryCredentialsPlugin(), |
| 304 | makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }), | 311 | makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }), |
| 305 | ] as const; | 312 | ] as const; |
| 306 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 313 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 314 | access: testAccess.member(), | ||
| 315 | plugins, | ||
| 316 | }); | ||
| 307 | yield* executor.acme.seed(); | 317 | yield* executor.acme.seed(); |
| 308 | 318 | ||
| 309 | yield* executor.oauth.createClient({ | 319 | yield* executor.oauth.createClient({ |
| @@ -340,7 +350,10 @@ describe("oauth.start integration-driven scopes", () => { · 30 unmodified lines | |||
| 340 | // `scopes: null` ⇒ the integration declares an oauth method with no | 350 | // `scopes: null` ⇒ the integration declares an oauth method with no |
| 341 | // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested. | 351 | // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested. |
| 342 | const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const; | 352 | const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const; |
| 343 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 353 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 354 | access: testAccess.member(), | ||
| 355 | plugins, | ||
| 356 | }); | ||
| 344 | yield* executor.acme.seed(); | 357 | yield* executor.acme.seed(); |
| 345 | 358 | ||
| 346 | yield* executor.oauth.createClient({ | 359 | yield* executor.oauth.createClient({ |
| @@ -411,7 +424,10 @@ describe("oauth.start integration-driven scopes", () => { · 64 unmodified lines | |||
| 411 | memoryCredentialsPlugin(), | 424 | memoryCredentialsPlugin(), |
| 412 | makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }), | 425 | makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }), |
| 413 | ] as const; | 426 | ] as const; |
| 414 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 427 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 428 | access: testAccess.member(), | ||
| 429 | plugins, | ||
| 430 | }); | ||
| 415 | yield* executor.acme.seed(); | 431 | yield* executor.acme.seed(); |
| 416 | 432 | ||
| 417 | yield* executor.oauth.createClient({ | 433 | yield* executor.oauth.createClient({ |
| @@ -635,7 +651,10 @@ describe("oauth.start integration-driven scopes", () => { · 217 unmodified lines | |||
| 635 | { discoversScopes: true, discoveryUrl: server.mcpResourceUrl }, | 651 | { discoversScopes: true, discoveryUrl: server.mcpResourceUrl }, |
| 636 | ), | 652 | ), |
| 637 | ] as const; | 653 | ] as const; |
| 638 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | 654 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 655 | access: testAccess.member(), | ||
| 656 | plugins, | ||
| 657 | }); | ||
| 639 | yield* executor.mcp.seed(); | 658 | yield* executor.mcp.seed(); |
| 640 | 659 | ||
| 641 | // No `resource` on the client — the wire parameter is absent by | 660 | // No `resource` on the client — the wire parameter is absent by |
| @@ -681,6 +700,7 @@ describe("oauth.start integration-driven scopes", () => { · 39 unmodified lines | |||
| 681 | makeMcpScopePlugin({ scopes: null }), | 700 | makeMcpScopePlugin({ scopes: null }), |
| 682 | ] as const; | 701 | ] as const; |
| 683 | const { executor } = yield* makeTestWorkspaceHarness({ | 702 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 703 | access: testAccess.member(), | ||
| 684 | plugins, | 704 | plugins, |
| 685 | firstPartyOAuthClients: [ | 705 | firstPartyOAuthClients: [ |
| 686 | { | 706 | { |
| @@ -835,7 +855,10 @@ describe("oauth.start recorded scope fallback", () => { · 148 unmodified lines | |||
| 835 | memoryCredentialsPlugin(), | 855 | memoryCredentialsPlugin(), |
| 836 | makeScopePlugin({ scopes: DECLARED_SCOPES }), | 856 | makeScopePlugin({ scopes: DECLARED_SCOPES }), |
| 837 | ] as const; | 857 | ] as const; |
| 838 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 858 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 859 | access: testAccess.member(), | ||
| 860 | plugins, | ||
| 861 | }); | ||
| 839 | yield* executor.acme.seed(); | 862 | yield* executor.acme.seed(); |
| 840 | 863 | ||
| 841 | // A client_credentials client so `start` mints inline (no redirect), | 864 | // A client_credentials client so `start` mints inline (no redirect), |
| @@ -889,7 +912,10 @@ describe("oauth.start recorded scope fallback", () => { · 47 unmodified lines | |||
| 889 | memoryCredentialsPlugin(), | 912 | memoryCredentialsPlugin(), |
| 890 | makeMcpScopePlugin({ scopes: null }), | 913 | makeMcpScopePlugin({ scopes: null }), |
| 891 | ] as const; | 914 | ] as const; |
| 892 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | 915 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 916 | access: testAccess.member(), | ||
| 917 | plugins, | ||
| 918 | }); | ||
| 893 | yield* executor.mcp.seed(); | 919 | yield* executor.mcp.seed(); |
| 894 | 920 | ||
| 895 | yield* executor.oauth.createClient({ | 921 | yield* executor.oauth.createClient({ |
| @@ -232,9 +232,9 @@ export interface OAuthServiceDeps { · 231 unmodified lines | |||
| 232 | readonly owner: Owner; | 232 | readonly owner: Owner; |
| 233 | readonly subject: string; | 233 | readonly subject: string; |
| 234 | }; | 234 | }; |
| 235 | /** Workspace-settings gate from the executor binding | 235 | /** Settings gate from the executor binding (`ExecutorAccess.settingsWrite`): |
| 236 | * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the | 236 | * enforces the product's decision for the owner targeted by a user-intent |
| 237 | * user-intent client/connect surfaces. */ | 237 | * client/connect operation. */ |
| 238 | readonly guardOrgWrite: (owner: Owner) => Effect.Effect<void, OrgWriteDeniedError>; | 238 | readonly guardOrgWrite: (owner: Owner) => Effect.Effect<void, OrgWriteDeniedError>; |
| 239 | readonly defaultWritableProvider: () => CredentialProvider | null; | 239 | readonly defaultWritableProvider: () => CredentialProvider | null; |
| 240 | /** Write the connection row with OAuth lifecycle fields + produce its tools. */ | 240 | /** Write the connection row with OAuth lifecycle fields + produce its tools. */ |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | |||
| 20 | import { definePlugin } from "./plugin"; | 20 | import { definePlugin } from "./plugin"; |
| 21 | import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config"; | 21 | import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config"; |
| 22 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 22 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 23 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 23 | 24 | ||
| 24 | // An in-flight authorization flow parks its PKCE verifier in `oauth_session` in | 25 | // An in-flight authorization flow parks its PKCE verifier in `oauth_session` in |
| 25 | // plaintext, which is fine while the flow can still spend it. What is not fine is | 26 | // plaintext, which is fine while the flow can still spend it. What is not fine is |
| @@ -127,6 +128,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 101 unmodified lines | |||
| 127 | Effect.gen(function* () { | 128 | Effect.gen(function* () { |
| 128 | const server = yield* serveOAuthTestServer({}); | 129 | const server = yield* serveOAuthTestServer({}); |
| 129 | const { executor, config } = yield* makeTestWorkspaceHarness({ | 130 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 131 | access: testAccess.member(), | ||
| 130 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, | 132 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, |
| 131 | }); | 133 | }); |
| 132 | yield* executor.acme.seed(); | 134 | yield* executor.acme.seed(); |
| @@ -205,6 +207,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 72 unmodified lines | |||
| 205 | Effect.gen(function* () { | 207 | Effect.gen(function* () { |
| 206 | const server = yield* serveOAuthTestServer({}); | 208 | const server = yield* serveOAuthTestServer({}); |
| 207 | const { executor, config } = yield* makeTestWorkspaceHarness({ | 209 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 210 | access: testAccess.member(), | ||
| 208 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, | 211 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, |
| 209 | }); | 212 | }); |
| 210 | yield* executor.acme.seed(); | 213 | yield* executor.acme.seed(); |
| @@ -280,6 +283,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 69 unmodified lines | |||
| 280 | const plugins = [memoryCredentialsPlugin, acmePlugin] as const; | 283 | const plugins = [memoryCredentialsPlugin, acmePlugin] as const; |
| 281 | 284 | ||
| 282 | const a = yield* makeTestWorkspaceHarness({ | 285 | const a = yield* makeTestWorkspaceHarness({ |
| 286 | access: testAccess.member(), | ||
| 283 | plugins, | 287 | plugins, |
| 284 | tenant, | 288 | tenant, |
| 285 | subject: "subject-a", | 289 | subject: "subject-a", |
| @@ -298,6 +302,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 12 unmodified lines | |||
| 298 | 302 | ||
| 299 | // A second member of the same tenant, on the same database. | 303 | // A second member of the same tenant, on the same database. |
| 300 | const b = yield* makeTestWorkspaceHarness({ | 304 | const b = yield* makeTestWorkspaceHarness({ |
| 305 | access: testAccess.member(), | ||
| 301 | plugins, | 306 | plugins, |
| 302 | tenant, | 307 | tenant, |
| 303 | subject: "subject-b", | 308 | subject: "subject-b", |
| @@ -363,6 +368,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 59 unmodified lines | |||
| 363 | Effect.gen(function* () { | 368 | Effect.gen(function* () { |
| 364 | const server = yield* serveOAuthTestServer({}); | 369 | const server = yield* serveOAuthTestServer({}); |
| 365 | const base = makeTestConfig({ | 370 | const base = makeTestConfig({ |
| 371 | access: testAccess.member(), | ||
| 366 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, | 372 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, |
| 367 | }); | 373 | }); |
| 368 | const executor = yield* createExecutor({ | 374 | const executor = yield* createExecutor({ |
| @@ -1,33 +0,0 @@ | |||
| 1 | import { Context, Effect, Ref } from "effect"; | ||
| 2 | |||
| 3 | /** Workspace-settings authorization bound to the currently executing request. */ | ||
| 4 | export type OrgWriteAccess = "allowed" | "denied"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Fiber-local workspace-settings authorization for request-bound executors. | ||
| 8 | * | ||
| 9 | * The denied default makes a missing request binding fail closed. Non-session | ||
| 10 | * executors continue to use their explicit {@link ExecutorConfig.orgWrites} | ||
| 11 | * value (or the allowed default) and never consult this reference. | ||
| 12 | */ | ||
| 13 | export interface OrgWriteAccessState { | ||
| 14 | /** Mutable value inherited by a detached execution and refreshed on resume. */ | ||
| 15 | readonly current: Ref.Ref<OrgWriteAccess>; | ||
| 16 | } | ||
| 17 | |||
| 18 | /** Create an isolated request/execution authorization state. */ | ||
| 19 | export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ | ||
| 20 | current: Ref.makeUnsafe(access), | ||
| 21 | }); | ||
| 22 | |||
| 23 | /** Request-local workspace-write authorization inherited by child fibers. */ | ||
| 24 | export const CurrentOrgWriteAccess = Context.Reference<OrgWriteAccessState>( | ||
| 25 | "@executor-js/sdk/CurrentOrgWriteAccess", | ||
| 26 | { defaultValue: () => makeOrgWriteAccessState("denied") }, | ||
| 27 | ); | ||
| 28 | |||
| 29 | /** Read the effective authorization at a workspace-write sink. */ | ||
| 30 | export const currentOrgWriteAccess: Effect.Effect<OrgWriteAccess> = Effect.gen(function* () { | ||
| 31 | const state = yield* CurrentOrgWriteAccess; | ||
| 32 | return yield* Ref.get(state.current); | ||
| 33 | }); | ||
| @@ -12,14 +12,15 @@ import { · 11 unmodified lines | |||
| 12 | ToolName, | 12 | ToolName, |
| 13 | } from "./ids"; | 13 | } from "./ids"; |
| 14 | import { createExecutor } from "./executor"; | 14 | import { createExecutor } from "./executor"; |
| 15 | import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./org-write-access"; | 15 | import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./access"; |
| 16 | import { definePlugin } from "./plugin"; | 16 | import { definePlugin } from "./plugin"; |
| 17 | import type { CredentialProvider } from "./provider"; | 17 | import type { CredentialProvider } from "./provider"; |
| 18 | import { makeTestConfig } from "./testing"; | 18 | import { makeTestConfig } from "./testing"; |
| 19 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 19 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 20 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 20 | 21 | ||
| 21 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| 22 | // `ExecutorConfig.orgWrites` — the workspace-settings gate. | 23 | // `ExecutorAccess.workspaceWrites` — the workspace-settings gate. |
| 23 | // | 24 | // |
| 24 | // A `"denied"` binding (a plain member) may USE workspace resources — read | 25 | // A `"denied"` binding (a plain member) may USE workspace resources — read |
| 25 | // them, execute tools over org connections — but every user-intent | 26 | // them, execute tools over org connections — but every user-intent |
| @@ -29,8 +30,8 @@ import { serveOAuthTestServer } from "./testing/oauth-test-server"; · 3 unmodified lines | |||
| 29 | // `"allowed"` (admins, and hosts with no role model) behaves exactly as before. | 30 | // `"allowed"` (admins, and hosts with no role model) behaves exactly as before. |
| 30 | // | 31 | // |
| 31 | // The fixtures build TWO executors over ONE test database: an admin | 32 | // The fixtures build TWO executors over ONE test database: an admin |
| 32 | // (default `orgWrites`) that seeds the workspace, and a member | 33 | // (workspace writes allowed) that seeds the workspace, and a member |
| 33 | // (`orgWrites: "denied"`) that the assertions run against. | 34 | // (workspace writes denied) that the assertions run against. |
| 34 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 35 | 36 | ||
| 36 | const memoryProvider = (): CredentialProvider => { | 37 | const memoryProvider = (): CredentialProvider => { |
| @@ -83,9 +84,9 @@ const demoPlugin = definePlugin(() => ({ · 46 unmodified lines | |||
| 83 | 84 | ||
| 84 | const setup = () => | 85 | const setup = () => |
| 85 | Effect.gen(function* () { | 86 | Effect.gen(function* () { |
| 86 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 87 | const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const }); |
| 87 | const admin = yield* createExecutor(config); | 88 | const admin = yield* createExecutor(config); |
| 88 | const member = yield* createExecutor({ ...config, orgWrites: "denied" }); | 89 | const member = yield* createExecutor({ ...config, access: testAccess.member("denied") }); |
| 89 | yield* Effect.addFinalizer(() => | 90 | yield* Effect.addFinalizer(() => |
| 90 | admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), | 91 | admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), |
| 91 | ); | 92 | ); |
| @@ -101,13 +102,16 @@ const expectOrgWriteDenied = <A, R>(effect: Effect.Effect<A, unknown, R>) => · 9 unmodified lines | |||
| 101 | }), | 102 | }), |
| 102 | ); | 103 | ); |
| 103 | 104 | ||
| 104 | describe("orgWrites: denied", () => { | 105 | describe("workspace writes denied", () => { |
| 105 | it.effect("reads a live session binding at every workspace-write sink", () => | 106 | it.effect("reads a live session binding at every workspace-write sink", () => |
| 106 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| 107 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 108 | const config = makeTestConfig({ |
| 109 | access: testAccess.member(), | ||
| 110 | plugins: [demoPlugin] as const, | ||
| 111 | }); | ||
| 108 | const executor = yield* createExecutor({ | 112 | const executor = yield* createExecutor({ |
| 109 | ...config, | 113 | ...config, |
| 110 | orgWrites: "request", | 114 | access: testAccess.requestBound(), |
| 111 | }); | 115 | }); |
| 112 | yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | 116 | yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); |
| 113 | const policy = yield* executor.demo.seed().pipe( | 117 | const policy = yield* executor.demo.seed().pipe( |
| @@ -249,12 +253,18 @@ describe("orgWrites: denied", () => { · 135 unmodified lines | |||
| 249 | 253 | ||
| 250 | it.effect("allows subjectless system re-registration during boot convergence", () => | 254 | it.effect("allows subjectless system re-registration during boot convergence", () => |
| 251 | Effect.gen(function* () { | 255 | Effect.gen(function* () { |
| 252 | const config = makeTestConfig({ plugins: [demoPlugin] as const }); | 256 | const config = makeTestConfig({ |
| 257 | access: testAccess.member(), | ||
| 258 | plugins: [demoPlugin] as const, | ||
| 259 | }); | ||
| 253 | const admin = yield* createExecutor(config); | 260 | const admin = yield* createExecutor(config); |
| 254 | const { subject: _subject, ...systemConfig } = config; | 261 | const { subject: _subject, ...systemConfig } = config; |
| 262 | // Boot convergence is the PRODUCT's rule now: the workspace-service | ||
| 263 | // posture allows it. A subject-less binding whose product DENIES | ||
| 264 | // settings writes is refused instead — see settings-authority.test.ts. | ||
| 255 | const system = yield* createExecutor({ | 265 | const system = yield* createExecutor({ |
| 256 | ...systemConfig, | 266 | ...systemConfig, |
| 257 | orgWrites: "denied", | 267 | access: testAccess.org(), |
| 258 | }); | 268 | }); |
| 259 | yield* Effect.addFinalizer(() => | 269 | yield* Effect.addFinalizer(() => |
| 260 | admin.close().pipe(Effect.andThen(system.close()), Effect.ignore), | 270 | admin.close().pipe(Effect.andThen(system.close()), Effect.ignore), |
| @@ -357,7 +367,7 @@ describe("orgWrites: denied", () => { · 96 unmodified lines | |||
| 357 | ); | 367 | ); |
| 358 | }); | 368 | }); |
| 359 | 369 | ||
| 360 | describe("orgWrites: default (allowed)", () => { | 370 | describe("workspace writes allowed", () => { |
| 361 | it.effect("admin bindings mutate workspace-level state as before", () => | 371 | it.effect("admin bindings mutate workspace-level state as before", () => |
| 362 | Effect.gen(function* () { | 372 | Effect.gen(function* () { |
| 363 | const { admin } = yield* setup(); | 373 | const { admin } = yield* setup(); |
| @@ -95,7 +95,11 @@ describe("owner policy reach", () => { · 94 unmodified lines | |||
| 95 | withDb(async (db) => { | 95 | withDb(async (db) => { |
| 96 | await seedTwoSubjects(db); | 96 | await seedTwoSubjects(db); |
| 97 | 97 | ||
| 98 | const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); | 98 | const bound = withQueryContext(db.db, { |
| 99 | tenant: TENANT, | ||
| 100 | subject: SUBJECT_A, | ||
| 101 | owners: ["user", "org"], | ||
| 102 | }); | ||
| 99 | const rows = await bound.findMany("connection", {}); | 103 | const rows = await bound.findMany("connection", {}); |
| 100 | 104 | ||
| 101 | expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]); | 105 | expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]); |
| @@ -111,6 +115,7 @@ describe("owner policy reach", () => { · 9 unmodified lines | |||
| 111 | const platform = withQueryContext(db.db, { | 115 | const platform = withQueryContext(db.db, { |
| 112 | tenant: TENANT, | 116 | tenant: TENANT, |
| 113 | subject: SUBJECT_A, | 117 | subject: SUBJECT_A, |
| 118 | owners: ["user", "org"], | ||
| 114 | reach: "tenant", | 119 | reach: "tenant", |
| 115 | }); | 120 | }); |
| 116 | const rows = await platform.findMany("connection", {}); | 121 | const rows = await platform.findMany("connection", {}); |
| @@ -129,6 +134,7 @@ describe("owner policy reach", () => { · 12 unmodified lines | |||
| 129 | const platform = withQueryContext(db.db, { | 134 | const platform = withQueryContext(db.db, { |
| 130 | tenant: TENANT, | 135 | tenant: TENANT, |
| 131 | subject: SUBJECT_A, | 136 | subject: SUBJECT_A, |
| 137 | owners: ["user", "org"], | ||
| 132 | reach: "tenant", | 138 | reach: "tenant", |
| 133 | }); | 139 | }); |
| 134 | const rows = await platform.findMany("connection", {}); | 140 | const rows = await platform.findMany("connection", {}); |
| @@ -149,6 +155,7 @@ describe("owner policy reach", () => { · 14 unmodified lines | |||
| 149 | const platform = withQueryContext(db.db, { | 155 | const platform = withQueryContext(db.db, { |
| 150 | tenant: TENANT, | 156 | tenant: TENANT, |
| 151 | subject: null, | 157 | subject: null, |
| 158 | owners: ["org"], | ||
| 152 | reach: "tenant", | 159 | reach: "tenant", |
| 153 | }); | 160 | }); |
| 154 | const rows = await platform.findMany("connection", {}); | 161 | const rows = await platform.findMany("connection", {}); |
| @@ -168,6 +175,7 @@ describe("owner policy reach", () => { · 13 unmodified lines | |||
| 168 | const platform = withQueryContext(db.db, { | 175 | const platform = withQueryContext(db.db, { |
| 169 | tenant: TENANT, | 176 | tenant: TENANT, |
| 170 | subject: SUBJECT_A, | 177 | subject: SUBJECT_A, |
| 178 | owners: ["user", "org"], | ||
| 171 | reach: "tenant", | 179 | reach: "tenant", |
| 172 | }); | 180 | }); |
| 173 | 181 | ||
| @@ -197,6 +205,7 @@ describe("owner policy reach", () => { · 23 unmodified lines | |||
| 197 | const platform = withQueryContext(db.db, { | 205 | const platform = withQueryContext(db.db, { |
| 198 | tenant: TENANT, | 206 | tenant: TENANT, |
| 199 | subject: SUBJECT_A, | 207 | subject: SUBJECT_A, |
| 208 | owners: ["user", "org"], | ||
| 200 | reach: "tenant", | 209 | reach: "tenant", |
| 201 | }); | 210 | }); |
| 202 | 211 | ||
| @@ -225,6 +234,7 @@ describe("owner policy reach", () => { · 22 unmodified lines | |||
| 225 | const platform = withQueryContext(db.db, { | 234 | const platform = withQueryContext(db.db, { |
| 226 | tenant: TENANT, | 235 | tenant: TENANT, |
| 227 | subject: SUBJECT_A, | 236 | subject: SUBJECT_A, |
| 237 | owners: ["user", "org"], | ||
| 228 | reach: "tenant", | 238 | reach: "tenant", |
| 229 | }); | 239 | }); |
| 230 | 240 | ||
| @@ -249,6 +259,7 @@ describe("owner policy reach", () => { · 18 unmodified lines | |||
| 249 | const platform = withQueryContext(db.db, { | 259 | const platform = withQueryContext(db.db, { |
| 250 | tenant: TENANT, | 260 | tenant: TENANT, |
| 251 | subject: SUBJECT_A, | 261 | subject: SUBJECT_A, |
| 262 | owners: ["user", "org"], | ||
| 252 | reach: "tenant", | 263 | reach: "tenant", |
| 253 | }); | 264 | }); |
| 254 | 265 | ||
| @@ -272,12 +283,17 @@ describe("owner policy reach", () => { · 17 unmodified lines | |||
| 272 | withDb(async (db) => { | 283 | withDb(async (db) => { |
| 273 | // The guard must not disturb the product view: an ordinary bound | 284 | // The guard must not disturb the product view: an ordinary bound |
| 274 | // context writes exactly as before. | 285 | // context writes exactly as before. |
| 275 | const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); | 286 | const bound = withQueryContext(db.db, { |
| 287 | tenant: TENANT, | ||
| 288 | subject: SUBJECT_A, | ||
| 289 | owners: ["user", "org"], | ||
| 290 | }); | ||
| 276 | 291 | ||
| 277 | await bound.create("connection", { | 292 | await bound.create("connection", { |
| 278 | tenant: TENANT, | 293 | tenant: TENANT, |
| 279 | owner: "user", | 294 | owner: "user", |
| 280 | subject: SUBJECT_A, | 295 | subject: SUBJECT_A, |
| 296 | owners: ["user", "org"], | ||
| 281 | integration: "github", | 297 | integration: "github", |
| 282 | name: "written-by-bound-view", | 298 | name: "written-by-bound-view", |
| 283 | template: "apiKey", | 299 | template: "apiKey", |
| @@ -296,13 +312,18 @@ describe("owner policy reach", () => { · 12 unmodified lines | |||
| 296 | it.effect("a bound context still cannot write outside its own subject", () => | 312 | it.effect("a bound context still cannot write outside its own subject", () => |
| 297 | Effect.promise(() => | 313 | Effect.promise(() => |
| 298 | withDb(async (db) => { | 314 | withDb(async (db) => { |
| 299 | const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); | 315 | const bound = withQueryContext(db.db, { |
| 316 | tenant: TENANT, | ||
| 317 | subject: SUBJECT_A, | ||
| 318 | owners: ["user", "org"], | ||
| 319 | }); | ||
| 300 | 320 | ||
| 301 | await expect( | 321 | await expect( |
| 302 | bound.create("connection", { | 322 | bound.create("connection", { |
| 303 | tenant: TENANT, | 323 | tenant: TENANT, |
| 304 | owner: "user", | 324 | owner: "user", |
| 305 | subject: SUBJECT_B, | 325 | subject: SUBJECT_B, |
| 326 | owners: ["user", "org"], | ||
| 306 | integration: "github", | 327 | integration: "github", |
| 307 | name: "cross-subject", | 328 | name: "cross-subject", |
| 308 | template: "apiKey", | 329 | template: "apiKey", |
| @@ -2,6 +2,7 @@ import { type Condition, type ConditionBuilder } from "@executor-js/fumadb/query · 1 unmodified line | |||
| 2 | import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema"; | 2 | import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema"; |
| 3 | 3 | ||
| 4 | import { StorageError } from "./fuma-runtime"; | 4 | import { StorageError } from "./fuma-runtime"; |
| 5 | import type { Owner } from "./ids"; | ||
| 5 | 6 | ||
| 6 | /* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned | 7 | /* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned |
| 7 | * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the | 8 | * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the |
| @@ -53,6 +54,19 @@ export interface ExecutorOwnerPolicyContext { · 45 unmodified lines | |||
| 53 | /** The acting member, or null for a pure-org executor (no `owner:"user"` | 54 | /** The acting member, or null for a pure-org executor (no `owner:"user"` |
| 54 | * reads/writes are allowed when null). */ | 55 | * reads/writes are allowed when null). */ |
| 55 | readonly subject: string | null; | 56 | readonly subject: string | null; |
| 57 | /** | ||
| 58 | * The row-visibility/write decision (`ExecutorAccess.owners` for | ||
| 59 | * principal contexts): which owner partitions this context sees, and may | ||
| 60 | * create/patch rows in. REQUIRED — there is no full-view default; a | ||
| 61 | * context that reaches the policy without it fails closed (reads match | ||
| 62 | * nothing, writes are refused). Host-internal contexts that only touch | ||
| 63 | * tenant-scoped tables state `[]` explicitly. | ||
| 64 | * | ||
| 65 | * This NARROWS, never widens: whatever it lists, the tenant clause and the | ||
| 66 | * bound-subject clause on user rows still apply, and `reach: "tenant"` | ||
| 67 | * contexts remain read-only (or delete-only) by construction. | ||
| 68 | */ | ||
| 69 | readonly owners: readonly Owner[]; | ||
| 56 | /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the | 70 | /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the |
| 57 | * platform view: it sees the whole tenant but writes exactly as a bound | 71 | * platform view: it sees the whole tenant but writes exactly as a bound |
| 58 | * context does, so it can never mutate another subject's rows. */ | 72 | * context does, so it can never mutate another subject's rows. */ |
| @@ -106,17 +120,29 @@ export const ownerVisibilityCondition = ( · 47 unmodified lines | |||
| 106 | // The platform view: partition by tenant alone. Still never cross-tenant — | 120 | // The platform view: partition by tenant alone. Still never cross-tenant — |
| 107 | // `tenant` is the one clause that is NEVER relaxed, at any reach. | 121 | // `tenant` is the one clause that is NEVER relaxed, at any reach. |
| 108 | if (context.reach === "tenant") return builder("tenant", "=", context.tenant); | 122 | if (context.reach === "tenant") return builder("tenant", "=", context.tenant); |
| 109 | const orgClause = builder.and( | 123 | // The product's visible partitions. A malformed context that reaches the |
| 110 | builder("tenant", "=", context.tenant), | 124 | // policy without them (only possible past the type system) fails CLOSED: |
| 111 | builder("owner", "=", "org"), | 125 | // no partitions means no rows. Core contributes only the clamps inside |
| 112 | ); | 126 | // each clause — never a partition of its own. |
| 113 | if (context.subject == null) return orgClause; | 127 | const owners: readonly Owner[] = Array.isArray(context.owners) ? context.owners : []; |
| 114 | const userClause = builder.and( | 128 | const clauses: (Condition | boolean)[] = []; |
| 115 | builder("tenant", "=", context.tenant), | 129 | if (owners.includes("org")) { |
| 116 | builder("owner", "=", "user"), | 130 | clauses.push(builder.and(builder("tenant", "=", context.tenant), builder("owner", "=", "org"))); |
| 117 | builder("subject", "=", context.subject), | 131 | } |
| 118 | ); | 132 | if (owners.includes("user") && context.subject != null) { |
| 119 | return builder.or(orgClause, userClause); | 133 | clauses.push( |
| 134 | builder.and( | ||
| 135 | builder("tenant", "=", context.tenant), | ||
| 136 | builder("owner", "=", "user"), | ||
| 137 | builder("subject", "=", context.subject), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | // A context whose product view lists no partition this binding can carry | ||
| 142 | // (personal-only with no subject) sees nothing — fail closed, not open. | ||
| 143 | if (clauses.length === 0) return false; | ||
| 144 | if (clauses.length === 1) return clauses[0]!; | ||
| 145 | return builder.or(...clauses); | ||
| 120 | }; | 146 | }; |
| 121 | 147 | ||
| 122 | /** | 148 | /** |
| @@ -172,6 +198,21 @@ export const assertOwnerWritable = ( · 49 unmodified lines | |||
| 172 | if (values.tenant !== ctx.tenant) { | 198 | if (values.tenant !== ctx.tenant) { |
| 173 | policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`); | 199 | policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`); |
| 174 | } | 200 | } |
| 201 | // The product's write partitions: a row may only be created in a partition | ||
| 202 | // the product view lists (update/delete are already filtered by | ||
| 203 | // `ownerVisibilityCondition`). A context missing the decision fails | ||
| 204 | // closed. Purely narrowing — the per-owner clamps below still apply to | ||
| 205 | // whatever the product allows. | ||
| 206 | if (!Array.isArray(ctx.owners)) { | ||
| 207 | policyViolation( | ||
| 208 | `Storage write on table "${tableName}" is missing the context's owner partitions.`, | ||
| 209 | ); | ||
| 210 | } | ||
| 211 | if ((values.owner === "org" || values.owner === "user") && !ctx.owners.includes(values.owner)) { | ||
| 212 | policyViolation( | ||
| 213 | `Storage write on table "${tableName}" targets the "${values.owner}" partition, which this product view does not include.`, | ||
| 214 | ); | ||
| 215 | } | ||
| 175 | if (values.owner === "org") { | 216 | if (values.owner === "org") { |
| 176 | if (values.subject !== ORG_SUBJECT) { | 217 | if (values.subject !== ORG_SUBJECT) { |
| 177 | policyViolation(`Storage write on table "${tableName}" set a subject on an org row.`); | 218 | policyViolation(`Storage write on table "${tableName}" set a subject on an org row.`); |
| @@ -204,6 +245,14 @@ export const assertOwnerPatch = ( · 26 unmodified lines | |||
| 204 | if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) { | 245 | if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) { |
| 205 | policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`); | 246 | policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`); |
| 206 | } | 247 | } |
| 248 | if ( | ||
| 249 | (patch.owner === "org" || patch.owner === "user") && | ||
| 250 | !(Array.isArray(ctx.owners) && ctx.owners.includes(patch.owner)) | ||
| 251 | ) { | ||
| 252 | policyViolation( | ||
| 253 | `Storage write on table "${tableName}" cannot move a row into the "${patch.owner}" partition, which this product view does not include.`, | ||
| 254 | ); | ||
| 255 | } | ||
| 207 | if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) { | 256 | if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) { |
| 208 | policyViolation( | 257 | policyViolation( |
| 209 | `Storage write on table "${tableName}" cannot move a row outside the bound subject.`, | 258 | `Storage write on table "${tableName}" cannot move a row outside the bound subject.`, |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | |||
| 22 | } from "./ids"; | 22 | } from "./ids"; |
| 23 | import { definePlugin } from "./plugin"; | 23 | import { definePlugin } from "./plugin"; |
| 24 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; | 24 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; |
| 25 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 25 | 26 | ||
| 26 | // The platform view: `executor.admin`, an OPT-IN read-only surface that reads | 27 | // The platform view: `executor.admin`, an OPT-IN read-only surface that reads |
| 27 | // across every subject in the tenant. Written against the real SQLite bring-up | 28 | // across every subject in the tenant. Written against the real SQLite bring-up |
| @@ -254,7 +255,12 @@ const makePlatformExecutor = ( · 226 unmodified lines | |||
| 254 | db: db.db, | 255 | db: db.db, |
| 255 | plugins: [providerPlugin], | 256 | plugins: [providerPlugin], |
| 256 | onElicitation: "accept-all", | 257 | onElicitation: "accept-all", |
| 257 | ...(options?.platformView === false ? {} : { platformView: true }), | 258 | access: |
| 259 | options?.platformView === false | ||
| 260 | ? options?.subject === null | ||
| 261 | ? testAccess.org() | ||
| 262 | : testAccess.member() | ||
| 263 | : testAccess.platform({ subject: options?.subject !== null }), | ||
| 258 | }).pipe(Effect.orDie); | 264 | }).pipe(Effect.orDie); |
| 259 | 265 | ||
| 260 | const requireAdmin = (executor: Executor) => { | 266 | const requireAdmin = (executor: Executor) => { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | import { definePlugin } from "./plugin"; | 13 | import { definePlugin } from "./plugin"; |
| 14 | import { makeTestExecutor } from "./test-config"; | 14 | import { makeTestExecutor } from "./test-config"; |
| 15 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 15 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 16 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 16 | 17 | ||
| 17 | // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is | 18 | // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is |
| 18 | // what makes its database work atomic with the row deletions. The same property | 19 | // what makes its database work atomic with the row deletions. The same property |
| @@ -98,9 +99,10 @@ const revokingPlugin = (revoked: string[]) => · 79 unmodified lines | |||
| 98 | })(); | 99 | })(); |
| 99 | 100 | ||
| 100 | const setup = (revoked: string[]) => | 101 | const setup = (revoked: string[]) => |
| 101 | makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe( | 102 | makeTestExecutor({ |
| 102 | Effect.tap((executor) => executor.demo.seed()), | 103 | access: testAccess.member(), |
| 103 | ); | 104 | plugins: [revokingPlugin(revoked)] as const, |
| 105 | }).pipe(Effect.tap((executor) => executor.demo.seed())); | ||
| 104 | 106 | ||
| 105 | const REF = { | 107 | const REF = { |
| 106 | owner: "org", | 108 | owner: "org", |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | type PluginStorageCollectionWhere, | 12 | type PluginStorageCollectionWhere, |
| 13 | } from "./plugin-storage"; | 13 | } from "./plugin-storage"; |
| 14 | import { makeTestConfig, makeTestExecutor } from "./testing"; | 14 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 15 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 15 | 16 | ||
| 16 | const ToolCall = Schema.Struct({ | 17 | const ToolCall = Schema.Struct({ |
| 17 | runId: Schema.String, | 18 | runId: Schema.String, |
| @@ -167,6 +168,7 @@ describe("plugin storage collections", () => { · 149 unmodified lines | |||
| 167 | it.effect("queries declared indexes through the executor's SQLite FumaDB target", () => | 168 | it.effect("queries declared indexes through the executor's SQLite FumaDB target", () => |
| 168 | Effect.gen(function* () { | 169 | Effect.gen(function* () { |
| 169 | const executor = yield* makeTestExecutor({ | 170 | const executor = yield* makeTestExecutor({ |
| 171 | access: testAccess.member(), | ||
| 170 | backend: "sqlite", | 172 | backend: "sqlite", |
| 171 | plugins: [executionHistoryPlugin] as const, | 173 | plugins: [executionHistoryPlugin] as const, |
| 172 | }); | 174 | }); |
| @@ -229,6 +231,7 @@ describe("plugin storage collections", () => { · 56 unmodified lines | |||
| 229 | it.effect("bulk puts and removes plugin storage rows", () => | 231 | it.effect("bulk puts and removes plugin storage rows", () => |
| 230 | Effect.gen(function* () { | 232 | Effect.gen(function* () { |
| 231 | const executor = yield* makeTestExecutor({ | 233 | const executor = yield* makeTestExecutor({ |
| 234 | access: testAccess.member(), | ||
| 232 | backend: "sqlite", | 235 | backend: "sqlite", |
| 233 | plugins: [executionHistoryPlugin] as const, | 236 | plugins: [executionHistoryPlugin] as const, |
| 234 | }); | 237 | }); |
| @@ -275,6 +278,7 @@ describe("plugin storage collections", () => { · 40 unmodified lines | |||
| 275 | it.effect("stores and overwrites every row when a bulk write spans multiple batches", () => | 278 | it.effect("stores and overwrites every row when a bulk write spans multiple batches", () => |
| 276 | Effect.gen(function* () { | 279 | Effect.gen(function* () { |
| 277 | const executor = yield* makeTestExecutor({ | 280 | const executor = yield* makeTestExecutor({ |
| 281 | access: testAccess.member(), | ||
| 278 | backend: "sqlite", | 282 | backend: "sqlite", |
| 279 | plugins: [executionHistoryPlugin] as const, | 283 | plugins: [executionHistoryPlugin] as const, |
| 280 | }); | 284 | }); |
| @@ -312,6 +316,7 @@ describe("plugin storage collections", () => { · 31 unmodified lines | |||
| 312 | it.effect("rolls back every plugin storage row when a bulk write fails", () => | 316 | it.effect("rolls back every plugin storage row when a bulk write fails", () => |
| 313 | Effect.gen(function* () { | 317 | Effect.gen(function* () { |
| 314 | const config = makeTestConfig({ | 318 | const config = makeTestConfig({ |
| 319 | access: testAccess.member(), | ||
| 315 | backend: "sqlite", | 320 | backend: "sqlite", |
| 316 | plugins: [executionHistoryPlugin] as const, | 321 | plugins: [executionHistoryPlugin] as const, |
| 317 | }); | 322 | }); |
| @@ -369,6 +374,7 @@ describe("plugin storage collections", () => { · 51 unmodified lines | |||
| 369 | it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () => | 374 | it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () => |
| 370 | Effect.gen(function* () { | 375 | Effect.gen(function* () { |
| 371 | const config = makeTestConfig({ | 376 | const config = makeTestConfig({ |
| 377 | access: testAccess.member(), | ||
| 372 | backend: "sqlite", | 378 | backend: "sqlite", |
| 373 | plugins: [executionHistoryPlugin] as const, | 379 | plugins: [executionHistoryPlugin] as const, |
| 374 | }); | 380 | }); |
| @@ -435,6 +441,7 @@ describe("plugin storage collections", () => { · 60 unmodified lines | |||
| 435 | () => | 441 | () => |
| 436 | Effect.gen(function* () { | 442 | Effect.gen(function* () { |
| 437 | const executor = yield* makeTestExecutor({ | 443 | const executor = yield* makeTestExecutor({ |
| 444 | access: testAccess.member(), | ||
| 438 | backend: "sqlite", | 445 | backend: "sqlite", |
| 439 | plugins: [executionHistoryPlugin] as const, | 446 | plugins: [executionHistoryPlugin] as const, |
| 440 | }); | 447 | }); |
| @@ -463,6 +470,7 @@ describe("plugin storage collections", () => { · 22 unmodified lines | |||
| 463 | // One executor bound to a subject sees both org and user owner rows; a | 470 | // One executor bound to a subject sees both org and user owner rows; a |
| 464 | // user-owned row shadows an org-owned row under the same key on read. | 471 | // user-owned row shadows an org-owned row under the same key on read. |
| 465 | const executor = yield* makeTestExecutor({ | 472 | const executor = yield* makeTestExecutor({ |
| 473 | access: testAccess.member(), | ||
| 466 | backend: "sqlite", | 474 | backend: "sqlite", |
| 467 | plugins: [executionHistoryPlugin] as const, | 475 | plugins: [executionHistoryPlugin] as const, |
| 468 | }); | 476 | }); |
| @@ -508,6 +516,7 @@ describe("plugin storage collections", () => { · 39 unmodified lines | |||
| 508 | it.effect("rejects runtime queries against undeclared index fields", () => | 516 | it.effect("rejects runtime queries against undeclared index fields", () => |
| 509 | Effect.gen(function* () { | 517 | Effect.gen(function* () { |
| 510 | const executor = yield* makeTestExecutor({ | 518 | const executor = yield* makeTestExecutor({ |
| 519 | access: testAccess.member(), | ||
| 511 | backend: "sqlite", | 520 | backend: "sqlite", |
| 512 | plugins: [executionHistoryPlugin] as const, | 521 | plugins: [executionHistoryPlugin] as const, |
| 513 | }); | 522 | }); |
| @@ -168,9 +168,9 @@ export interface PluginCtx<TStore = unknown> { · 167 unmodified lines | |||
| 168 | * external work or writes to storage outside the catalog transaction. */ | 168 | * external work or writes to storage outside the catalog transaction. */ |
| 169 | readonly authorizeWrite: () => Effect.Effect<void, OrgWriteDeniedError>; | 169 | readonly authorizeWrite: () => Effect.Effect<void, OrgWriteDeniedError>; |
| 170 | /** Register / replace this plugin's integration in the catalog. Both | 170 | /** Register / replace this plugin's integration in the catalog. Both |
| 171 | * operations are workspace-level changes gated by the executor's | 171 | * operations are workspace-level changes gated by the product's |
| 172 | * `orgWrites` binding for end-user principals. Subjectless system | 172 | * `ExecutorAccess.settingsWrite` decision, including replacement by |
| 173 | * executors may re-register an existing row during boot convergence. */ | 173 | * subjectless system executors during boot convergence. */ |
| 174 | readonly register: ( | 174 | readonly register: ( |
| 175 | input: RegisterIntegrationInput, | 175 | input: RegisterIntegrationInput, |
| 176 | ) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>; | 176 | ) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>; |
| @@ -15,15 +15,15 @@ import { · 14 unmodified lines | |||
| 15 | import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; | 15 | import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; |
| 16 | import { createExecutor } from "./executor"; | 16 | import { createExecutor } from "./executor"; |
| 17 | import type { FumaDb } from "./fuma-runtime"; | 17 | import type { FumaDb } from "./fuma-runtime"; |
| 18 | import { | 18 | // Resolution semantics are PRODUCT rules — these tests exercise the real |
| 19 | effectivePolicyFromSorted, | 19 | // implementations from @executor-js/product-access (via the workspace root |
| 20 | isValidPattern, | 20 | // devDependency), never a copy. |
| 21 | matchPattern, | 21 | import { effectivePolicyFromSorted, resolveToolPolicy } from "@executor-js/product-access/policy"; |
| 22 | resolveToolPolicy, | 22 | import { isValidPattern, matchPattern } from "./policies"; |
| 23 | } from "./policies"; | ||
| 24 | import { definePlugin, tool } from "./plugin"; | 23 | import { definePlugin, tool } from "./plugin"; |
| 25 | import type { CredentialProvider } from "./provider"; | 24 | import type { CredentialProvider } from "./provider"; |
| 26 | import { makeTestConfig, makeTestExecutor } from "./testing"; | 25 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 26 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 27 | 27 | ||
| 28 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| 29 | // Pure unit tests — pattern matcher + resolution. No executor required. | 29 | // Pure unit tests — pattern matcher + resolution. No executor required. |
| @@ -332,7 +332,7 @@ const addr = (integration: IntegrationSlug, tool: string): ToolAddress => · 302 unmodified lines | |||
| 332 | ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`); | 332 | ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`); |
| 333 | 333 | ||
| 334 | const setupExecutor = () => | 334 | const setupExecutor = () => |
| 335 | makeTestExecutor({ plugins: [policyTestPlugin()] as const }).pipe( | 335 | makeTestExecutor({ access: testAccess.member(), plugins: [policyTestPlugin()] as const }).pipe( |
| 336 | Effect.tap((executor) => | 336 | Effect.tap((executor) => |
| 337 | Effect.gen(function* () { | 337 | Effect.gen(function* () { |
| 338 | yield* executor.ptest.seed(); | 338 | yield* executor.ptest.seed(); |
| @@ -533,7 +533,10 @@ describe("executor.policies", () => { · 194 unmodified lines | |||
| 533 | it.effect("fails when the policy vanishes during update", () => | 533 | it.effect("fails when the policy vanishes during update", () => |
| 534 | Effect.gen(function* () { | 534 | Effect.gen(function* () { |
| 535 | let armed = false; | 535 | let armed = false; |
| 536 | const config = makeTestConfig({ plugins: [policyTestPlugin()] as const }); | 536 | const config = makeTestConfig({ |
| 537 | access: testAccess.member(), | ||
| 538 | plugins: [policyTestPlugin()] as const, | ||
| 539 | }); | ||
| 537 | const executor = yield* createExecutor({ | 540 | const executor = yield* createExecutor({ |
| 538 | ...config, | 541 | ...config, |
| 539 | db: removePolicyAfterUpdate(config.db, () => armed), | 542 | db: removePolicyAfterUpdate(config.db, () => armed), |
| @@ -685,6 +688,7 @@ describe("active tool-policy provider", () => { · 145 unmodified lines | |||
| 685 | it.effect("uses provider rules as an allowlist for list, schema, and execute", () => | 688 | it.effect("uses provider rules as an allowlist for list, schema, and execute", () => |
| 686 | Effect.gen(function* () { | 689 | Effect.gen(function* () { |
| 687 | const executor = yield* makeTestExecutor({ | 690 | const executor = yield* makeTestExecutor({ |
| 691 | access: testAccess.member(), | ||
| 688 | plugins: [staticPlugin, policyProviderPlugin] as const, | 692 | plugins: [staticPlugin, policyProviderPlugin] as const, |
| 689 | }); | 693 | }); |
| 690 | 694 | ||
| @@ -1,15 +1,17 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Tool policies — pattern matcher + policy resolution. Pure functions; the | 2 | // Tool policies — the decision VOCABULARY (types, schemas, projections) plus |
| 3 | // executor stitches them into `tools.list`, `execute`, and the public | 3 | // the pure pattern-matching and rule-placement utilities the executor's CRUD |
| 4 | // `executor.policies` CRUD surface. Plugins consume the same surface. | 4 | // surface needs. Policies are owner-scoped (org | user) rows. |
| 5 | // | 5 | // |
| 6 | // v2: policies are owner-scoped (org | user) instead of scope-stacked. Each | 6 | // HOW rules resolve into an effective decision — owner ranking, the |
| 7 | // owner contributes its first matching rule by local position; the final answer | 7 | // most-restrictive merge across owners, the plugin-default fallback and the |
| 8 | // is the most restrictive matched action across owners, so a user preference | 8 | // capability-allowlist default — is a PRODUCT rule: it lives in |
| 9 | // cannot weaken an org guardrail (org = outer, user = inner). | 9 | // `@executor-js/product-access/policy` and reaches core only through the |
| 10 | // `ExecutorAccess.toolPolicy` hook, whose `EffectivePolicy` answers core | ||
| 11 | // enforces at list/schema/invoke/approval-recheck. | ||
| 10 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| 11 | 13 | ||
| 12 | import { Match, Schema } from "effect"; | 14 | import { Schema } from "effect"; |
| 13 | import { generateKeyBetween } from "fractional-indexing"; | 15 | import { generateKeyBetween } from "fractional-indexing"; |
| 14 | 16 | ||
| 15 | import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; | 17 | import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; |
| @@ -121,10 +123,11 @@ export const isValidPattern = (pattern: string): boolean => { · 105 unmodified lines | |||
| 121 | }; | 123 | }; |
| 122 | 124 | ||
| 123 | // --------------------------------------------------------------------------- | 125 | // --------------------------------------------------------------------------- |
| 124 | // Resolution — each owner contributes its first matching rule by local | 126 | // Ordering / placement utilities. HOW matched rules combine into an |
| 125 | // position; the most restrictive matched action across owners wins. Caller | 127 | // effective decision (owner ranking, most-restrictive merge, fallbacks) is a |
| 126 | // passes an `ownerRank` so the resolver doesn't need to know which owner is | 128 | // PRODUCT rule and lives in `@executor-js/product-access/policy`; core only |
| 127 | // the outer guardrail. | 129 | // consumes the resulting `EffectivePolicy` through |
| 130 | // `ExecutorAccess.toolPolicy` and enforces it. | ||
| 128 | // --------------------------------------------------------------------------- | 131 | // --------------------------------------------------------------------------- |
| 129 | 132 | ||
| 130 | export const comparePolicyRow = ( | 133 | export const comparePolicyRow = ( |
| @@ -180,106 +183,6 @@ export const positionForNewPattern = ( · 49 unmodified lines | |||
| 180 | return generateKeyBetween(prev, next); | 183 | return generateKeyBetween(prev, next); |
| 181 | }; | 184 | }; |
| 182 | 185 | ||
| 183 | const actionRestrictionRank = (action: ToolPolicyAction): number => | ||
| 184 | Match.value(action).pipe( | ||
| 185 | Match.when("block", () => 3), | ||
| 186 | Match.when("require_approval", () => 2), | ||
| 187 | Match.when("approve", () => 1), | ||
| 188 | Match.exhaustive, | ||
| 189 | ); | ||
| 190 | |||
| 191 | const moreRestrictive = <T extends { readonly action: ToolPolicyAction }>( | ||
| 192 | current: T | undefined, | ||
| 193 | candidate: T, | ||
| 194 | ): T => { | ||
| 195 | if (!current) return candidate; | ||
| 196 | const currentRank = actionRestrictionRank(current.action); | ||
| 197 | const candidateRank = actionRestrictionRank(candidate.action); | ||
| 198 | return candidateRank > currentRank ? candidate : current; | ||
| 199 | }; | ||
| 200 | |||
| 201 | export const resolveToolPolicy = ( | ||
| 202 | toolId: string, | ||
| 203 | policies: readonly ToolPolicyRow[], | ||
| 204 | ownerRank: (row: Pick<ToolPolicyRow, "owner">) => number, | ||
| 205 | ): PolicyMatch | undefined => { | ||
| 206 | if (policies.length === 0) return undefined; | ||
| 207 | const sorted = [...policies].sort((a, b) => { | ||
| 208 | const sa = ownerRank(a); | ||
| 209 | const sb = ownerRank(b); | ||
| 210 | if (sa !== sb) return sa - sb; | ||
| 211 | return comparePolicyRow(a, b); | ||
| 212 | }); | ||
| 213 | const firstMatchByOwner = new Map<string, PolicyMatch>(); | ||
| 214 | for (const row of sorted) { | ||
| 215 | if (firstMatchByOwner.has(row.owner)) continue; | ||
| 216 | if (matchPattern(row.pattern, toolId)) { | ||
| 217 | firstMatchByOwner.set(row.owner, { | ||
| 218 | action: row.action as ToolPolicyAction, | ||
| 219 | pattern: row.pattern, | ||
| 220 | policyId: row.id, | ||
| 221 | }); | ||
| 222 | } | ||
| 223 | } | ||
| 224 | let selected: PolicyMatch | undefined; | ||
| 225 | for (const match of firstMatchByOwner.values()) { | ||
| 226 | selected = moreRestrictive(selected, match); | ||
| 227 | } | ||
| 228 | return selected; | ||
| 229 | }; | ||
| 230 | |||
| 231 | // --------------------------------------------------------------------------- | ||
| 232 | // Layered resolution — user-authored rules + plugin default `requiresApproval`. | ||
| 233 | // --------------------------------------------------------------------------- | ||
| 234 | |||
| 235 | const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => | ||
| 236 | defaultRequiresApproval | ||
| 237 | ? { action: "require_approval", source: "plugin-default" } | ||
| 238 | : { action: "approve", source: "plugin-default" }; | ||
| 239 | |||
| 240 | const liftUser = (match: PolicyMatch): EffectivePolicy => ({ | ||
| 241 | action: match.action, | ||
| 242 | source: "user", | ||
| 243 | pattern: match.pattern, | ||
| 244 | policyId: match.policyId, | ||
| 245 | }); | ||
| 246 | |||
| 247 | export const resolveEffectivePolicy = ( | ||
| 248 | toolId: string, | ||
| 249 | policies: readonly ToolPolicyRow[], | ||
| 250 | ownerRank: (row: Pick<ToolPolicyRow, "owner">) => number, | ||
| 251 | defaultRequiresApproval?: boolean, | ||
| 252 | ): EffectivePolicy => { | ||
| 253 | const match = resolveToolPolicy(toolId, policies, ownerRank); | ||
| 254 | return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); | ||
| 255 | }; | ||
| 256 | |||
| 257 | export const effectivePolicyFromSorted = ( | ||
| 258 | toolId: string, | ||
| 259 | sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> & | ||
| 260 | Partial<Pick<ToolPolicy, "owner">>)[], | ||
| 261 | defaultRequiresApproval?: boolean, | ||
| 262 | ): EffectivePolicy => { | ||
| 263 | const firstMatchByOwner = new Map<string, EffectivePolicy>(); | ||
| 264 | for (const p of sortedPolicies) { | ||
| 265 | const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__"; | ||
| 266 | if (firstMatchByOwner.has(ownerKey)) continue; | ||
| 267 | if (matchPattern(p.pattern, toolId)) { | ||
| 268 | firstMatchByOwner.set(ownerKey, { | ||
| 269 | action: p.action, | ||
| 270 | source: "user", | ||
| 271 | pattern: p.pattern, | ||
| 272 | policyId: p.id, | ||
| 273 | }); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | let selected: EffectivePolicy | undefined; | ||
| 277 | for (const match of firstMatchByOwner.values()) { | ||
| 278 | selected = moreRestrictive(selected, match); | ||
| 279 | } | ||
| 280 | return selected ?? liftPlugin(defaultRequiresApproval); | ||
| 281 | }; | ||
| 282 | |||
| 283 | // --------------------------------------------------------------------------- | 186 | // --------------------------------------------------------------------------- |
| 284 | // Row → public projection. | 187 | // Row → public projection. |
| 285 | // --------------------------------------------------------------------------- | 188 | // --------------------------------------------------------------------------- |
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | |||
| 21 | type InvokeOptions as EffectInvokeOptions, | 21 | type InvokeOptions as EffectInvokeOptions, |
| 22 | type OnElicitation, | 22 | type OnElicitation, |
| 23 | } from "./executor"; | 23 | } from "./executor"; |
| 24 | import type { ExecutorAccess } from "./access"; | ||
| 24 | import type { ElicitationContext, ElicitationResponse } from "./elicitation"; | 25 | import type { ElicitationContext, ElicitationResponse } from "./elicitation"; |
| 25 | import type { FumaDb, FumaTables } from "./fuma-runtime"; | 26 | import type { FumaDb, FumaTables } from "./fuma-runtime"; |
| 26 | import { Subject, Tenant } from "./ids"; | 27 | import { Subject, Tenant } from "./ids"; |
| @@ -127,6 +128,15 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 100 unmodified lines | |||
| 127 | * an options arg. | 128 | * an options arg. |
| 128 | */ | 129 | */ |
| 129 | readonly onElicitation: PromiseOnElicitation; | 130 | readonly onElicitation: PromiseOnElicitation; |
| 131 | /** | ||
| 132 | * Product access decisions for the binding — REQUIRED, exactly as on the | ||
| 133 | * Effect SDK's `ExecutorConfig.access`. This façade is core SDK, not a | ||
| 134 | * product: it supplies no posture of its own. Embedders state theirs | ||
| 135 | * explicitly — `@executor-js/product-access` ships the standard ones | ||
| 136 | * (`singleUserAccess()` for a subject-bound single owner, | ||
| 137 | * `workspaceServiceAccess()` for a subject-less workspace binding). | ||
| 138 | */ | ||
| 139 | readonly access: ExecutorAccess; | ||
| 130 | } | 140 | } |
| 131 | 141 | ||
| 132 | // --------------------------------------------------------------------------- | 142 | // --------------------------------------------------------------------------- |
| @@ -213,6 +223,7 @@ export const createExecutor = async <const TPlugins extends readonly AnyPlugin[] · 80 unmodified lines | |||
| 213 | plugins, | 223 | plugins, |
| 214 | ...(config.providers ? { providers: config.providers } : {}), | 224 | ...(config.providers ? { providers: config.providers } : {}), |
| 215 | onElicitation: toEffectOnElicitation(config.onElicitation), | 225 | onElicitation: toEffectOnElicitation(config.onElicitation), |
| 226 | access: config.access, | ||
| 216 | ...(db ? { db } : {}), | 227 | ...(db ? { db } : {}), |
| 217 | }; | 228 | }; |
| 218 | 229 | ||
| @@ -5,6 +5,7 @@ import { definePlugin, tool } from "./plugin"; · 4 unmodified lines | |||
| 5 | import type { ToolDef } from "./tool"; | 5 | import type { ToolDef } from "./tool"; |
| 6 | import { IntegrationSlug, ToolName } from "./ids"; | 6 | import { IntegrationSlug, ToolName } from "./ids"; |
| 7 | import { Effect, Schema } from "effect"; | 7 | import { Effect, Schema } from "effect"; |
| 8 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 8 | 9 | ||
| 9 | // A minimal static-tool plugin built on the Effect surface, consumed | 10 | // A minimal static-tool plugin built on the Effect surface, consumed |
| 10 | // through the Promise façade. Exercises the proxy's ability to promisify | 11 | // through the Promise façade. Exercises the proxy's ability to promisify |
| @@ -41,6 +42,7 @@ describe("promise/createExecutor", () => { · 30 unmodified lines | |||
| 41 | it("returns Promise-shaped executor and invokes static tools", async () => { | 42 | it("returns Promise-shaped executor and invokes static tools", async () => { |
| 42 | const plugins = [echoPlugin()] as const; | 43 | const plugins = [echoPlugin()] as const; |
| 43 | const executor = await createExecutor({ | 44 | const executor = await createExecutor({ |
| 45 | access: testAccess.org(), | ||
| 44 | plugins, | 46 | plugins, |
| 45 | onElicitation: "accept-all", | 47 | onElicitation: "accept-all", |
| 46 | }); | 48 | }); |
| @@ -54,6 +56,7 @@ describe("promise/createExecutor", () => { · 7 unmodified lines | |||
| 54 | it("promisifies plugin extension methods", async () => { | 56 | it("promisifies plugin extension methods", async () => { |
| 55 | const plugins = [echoPlugin()] as const; | 57 | const plugins = [echoPlugin()] as const; |
| 56 | const executor = await createExecutor({ | 58 | const executor = await createExecutor({ |
| 59 | access: testAccess.org(), | ||
| 57 | plugins, | 60 | plugins, |
| 58 | onElicitation: "accept-all", | 61 | onElicitation: "accept-all", |
| 59 | }); | 62 | }); |
| @@ -94,6 +97,7 @@ describe("promise/createExecutor", () => { · 34 unmodified lines | |||
| 94 | 97 | ||
| 95 | const plugins = [approvedPlugin()] as const; | 98 | const plugins = [approvedPlugin()] as const; |
| 96 | const executor = await createExecutor({ | 99 | const executor = await createExecutor({ |
| 100 | access: testAccess.org(), | ||
| 97 | plugins, | 101 | plugins, |
| 98 | onElicitation: "accept-all", // default → auto-approve | 102 | onElicitation: "accept-all", // default → auto-approve |
| 99 | }); | 103 | }); |
| @@ -157,6 +161,7 @@ describe("promise/createExecutor", () => { · 57 unmodified lines | |||
| 157 | 161 | ||
| 158 | const plugins = [inventoryPlugin()] as const; | 162 | const plugins = [inventoryPlugin()] as const; |
| 159 | const executor = await createExecutor({ | 163 | const executor = await createExecutor({ |
| 164 | access: testAccess.org(), | ||
| 160 | plugins, | 165 | plugins, |
| 161 | providers: [memoryProvider], | 166 | providers: [memoryProvider], |
| 162 | onElicitation: "accept-all", | 167 | onElicitation: "accept-all", |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | |||
| 29 | import { definePlugin } from "./plugin"; | 29 | import { definePlugin } from "./plugin"; |
| 30 | import type { CredentialProvider } from "./provider"; | 30 | import type { CredentialProvider } from "./provider"; |
| 31 | import { makeTestConfig } from "./test-config"; | 31 | import { makeTestConfig } from "./test-config"; |
| 32 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 32 | 33 | ||
| 33 | const STORE = ProviderKey.make("remote-store"); | 34 | const STORE = ProviderKey.make("remote-store"); |
| 34 | const INTEG = IntegrationSlug.make("acme"); | 35 | const INTEG = IntegrationSlug.make("acme"); |
| @@ -55,7 +56,7 @@ const plugin = (provider: CredentialProvider) => · 20 unmodified lines | |||
| 55 | const executorWithConnection = (provider: CredentialProvider) => | 56 | const executorWithConnection = (provider: CredentialProvider) => |
| 56 | Effect.gen(function* () { | 57 | Effect.gen(function* () { |
| 57 | const executor = yield* createExecutor( | 58 | const executor = yield* createExecutor( |
| 58 | makeTestConfig({ plugins: [plugin(provider)] as const }), | 59 | makeTestConfig({ access: testAccess.member(), plugins: [plugin(provider)] as const }), |
| 59 | ); | 60 | ); |
| 60 | yield* executor.acme.seed(); | 61 | yield* executor.acme.seed(); |
| 61 | yield* executor.connections.create({ | 62 | yield* executor.connections.create({ |
| @@ -110,7 +111,9 @@ describe("a credential provider that stops answering", () => { · 48 unmodified lines | |||
| 110 | get: (id) => Effect.sync(() => items.get(String(id)) ?? null), | 111 | get: (id) => Effect.sync(() => items.get(String(id)) ?? null), |
| 111 | set: (id, value) => Effect.sync(() => void items.set(String(id), value)), | 112 | set: (id, value) => Effect.sync(() => void items.set(String(id), value)), |
| 112 | }; | 113 | }; |
| 113 | const executor = yield* createExecutor(makeTestConfig({ plugins: [plugin(lit)] as const })); | 114 | const executor = yield* createExecutor( |
| 115 | makeTestConfig({ access: testAccess.member(), plugins: [plugin(lit)] as const }), | ||
| 116 | ); | ||
| 114 | yield* executor.acme.seed(); | 117 | yield* executor.acme.seed(); |
| 115 | yield* executor.connections.create({ | 118 | yield* executor.connections.create({ |
| 116 | owner: "org", | 119 | owner: "org", |
| @@ -144,7 +147,10 @@ describe("a credential provider that stops answering", () => { · 27 unmodified lines | |||
| 144 | } | 147 | } |
| 145 | 148 | ||
| 146 | const executor = yield* createExecutor( | 149 | const executor = yield* createExecutor( |
| 147 | makeTestConfig({ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const }), | 150 | makeTestConfig({ |
| 151 | access: testAccess.member(), | ||
| 152 | plugins: [plugin(new ClassProvider() as CredentialProvider)] as const, | ||
| 153 | }), | ||
| 148 | ); | 154 | ); |
| 149 | yield* executor.acme.seed(); | 155 | yield* executor.acme.seed(); |
| 150 | yield* executor.connections.create({ | 156 | yield* executor.connections.create({ |
| @@ -184,6 +190,7 @@ describe("a credential provider that stops answering", () => { · 33 unmodified lines | |||
| 184 | 190 | ||
| 185 | const executor = yield* createExecutor( | 191 | const executor = yield* createExecutor( |
| 186 | makeTestConfig({ | 192 | makeTestConfig({ |
| 193 | access: testAccess.member(), | ||
| 187 | plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const, | 194 | plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const, |
| 188 | }), | 195 | }), |
| 189 | ); | 196 | ); |
| @@ -0,0 +1,118 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Result } from "effect"; | ||
| 3 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 4 | |||
| 5 | import type { ExecutorAccess } from "./access"; | ||
| 6 | import { createExecutor } from "./executor"; | ||
| 7 | import { IntegrationSlug, Subject } from "./ids"; | ||
| 8 | import { definePlugin } from "./plugin"; | ||
| 9 | import { makeTestConfig } from "./testing"; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | // `ExecutorAccess.settingsWrite` is the product's decision, and core enforces | ||
| 13 | // it VERBATIM at every user-intent settings sink — including catalog | ||
| 14 | // replacement from a SUBJECT-LESS executor. A host that hands a subject-less | ||
| 15 | // binding a denying `settingsWrite` has stated its rule; core must not keep a | ||
| 16 | // residual rule of its own ("subject-less replacement of an existing row is | ||
| 17 | // always boot convergence") that overrides the supplied denial. Products that | ||
| 18 | // WANT boot convergence say so through their access implementation | ||
| 19 | // (`workspaceServiceAccess`), which these tests also pin. | ||
| 20 | // | ||
| 21 | // The fixtures build executors over ONE test database: a workspace-service | ||
| 22 | // seeder that registers the catalog row, then differently-postured executors | ||
| 23 | // that attempt to replace it through the same plugin-extension path. | ||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | |||
| 26 | const SLUG = IntegrationSlug.make("settings-authority"); | ||
| 27 | const SEEDED_DESCRIPTION = "seeded by the workspace service"; | ||
| 28 | const REPLACED_DESCRIPTION = "replaced despite a denied settings decision"; | ||
| 29 | |||
| 30 | const catalogPlugin = definePlugin(() => ({ | ||
| 31 | id: "settings-authority-demo" as const, | ||
| 32 | storage: () => ({}), | ||
| 33 | extension: (ctx) => ({ | ||
| 34 | register: (description: string) => | ||
| 35 | ctx.core.integrations.register({ slug: SLUG, description, config: {} }), | ||
| 36 | }), | ||
| 37 | }))(); | ||
| 38 | |||
| 39 | /** The workspace-service posture with the product's settings decision | ||
| 40 | * replaced by an unconditional denial — the host's explicit word. */ | ||
| 41 | const denyingServiceAccess = (): ExecutorAccess => ({ | ||
| 42 | ...testAccess.org(), | ||
| 43 | settingsWrite: () => Effect.succeed("denied" as const), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const setup = () => | ||
| 47 | Effect.gen(function* () { | ||
| 48 | const config = makeTestConfig({ | ||
| 49 | subject: null, | ||
| 50 | access: testAccess.org(), | ||
| 51 | plugins: [catalogPlugin] as const, | ||
| 52 | }); | ||
| 53 | const seeder = yield* createExecutor(config); | ||
| 54 | yield* Effect.addFinalizer(() => | ||
| 55 | seeder | ||
| 56 | .close() | ||
| 57 | .pipe( | ||
| 58 | Effect.ignore, | ||
| 59 | Effect.andThen(Effect.promise(() => config.testDb.close()).pipe(Effect.ignore)), | ||
| 60 | ), | ||
| 61 | ); | ||
| 62 | yield* seeder["settings-authority-demo"].register(SEEDED_DESCRIPTION); | ||
| 63 | return { config, seeder }; | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("settingsWrite authority over catalog replacement", () => { | ||
| 67 | it.effect("a supplied denial refuses replacement from a subject-less executor", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const { config, seeder } = yield* setup(); | ||
| 70 | const denied = yield* createExecutor({ ...config, access: denyingServiceAccess() }); | ||
| 71 | yield* Effect.addFinalizer(() => denied.close().pipe(Effect.ignore)); | ||
| 72 | |||
| 73 | const outcome = yield* Effect.result( | ||
| 74 | denied["settings-authority-demo"].register(REPLACED_DESCRIPTION), | ||
| 75 | ); | ||
| 76 | // The product said "denied"; core must enforce it even with no bound | ||
| 77 | // subject — there is no core-owned boot-convergence exemption. | ||
| 78 | expect(Result.isFailure(outcome)).toBe(true); | ||
| 79 | expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } }); | ||
| 80 | |||
| 81 | const after = yield* seeder.integrations.get(SLUG); | ||
| 82 | expect(after?.description).toBe(SEEDED_DESCRIPTION); | ||
| 83 | }).pipe(Effect.scoped), | ||
| 84 | ); | ||
| 85 | |||
| 86 | it.effect("boot convergence stays available when the product allows it", () => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const { seeder } = yield* setup(); | ||
| 89 | // The same subject-less posture with the product's own (allowing) | ||
| 90 | // decision converges the existing row — the rule lives in the product, | ||
| 91 | // not in core. | ||
| 92 | yield* seeder["settings-authority-demo"].register("converged at boot"); | ||
| 93 | const after = yield* seeder.integrations.get(SLUG); | ||
| 94 | expect(after?.description).toBe("converged at boot"); | ||
| 95 | }).pipe(Effect.scoped), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("baseline: a bound member's denial already refuses replacement", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const { config, seeder } = yield* setup(); | ||
| 101 | const member = yield* createExecutor({ | ||
| 102 | ...config, | ||
| 103 | subject: Subject.make("member-1"), | ||
| 104 | access: testAccess.member("denied"), | ||
| 105 | }); | ||
| 106 | yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); | ||
| 107 | |||
| 108 | const outcome = yield* Effect.result( | ||
| 109 | member["settings-authority-demo"].register(REPLACED_DESCRIPTION), | ||
| 110 | ); | ||
| 111 | expect(Result.isFailure(outcome)).toBe(true); | ||
| 112 | expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } }); | ||
| 113 | |||
| 114 | const after = yield* seeder.integrations.get(SLUG); | ||
| 115 | expect(after?.description).toBe(SEEDED_DESCRIPTION); | ||
| 116 | }).pipe(Effect.scoped), | ||
| 117 | ); | ||
| 118 | }); | ||
| @@ -88,10 +88,11 @@ export { · 87 unmodified lines | |||
| 88 | } from "./elicitation"; | 88 | } from "./elicitation"; |
| 89 | 89 | ||
| 90 | // Tool-policy helpers + projections (pure functions / Schema). | 90 | // Tool-policy helpers + projections (pure functions / Schema). |
| 91 | // Resolution semantics (owner ranking, most-restrictive merge, fallbacks) | ||
| 92 | // live in `@executor-js/product-access/policy`, which builds on these. | ||
| 91 | export { | 93 | export { |
| 92 | matchPattern, | 94 | matchPattern, |
| 93 | isValidPattern, | 95 | isValidPattern, |
| 94 | effectivePolicyFromSorted, | ||
| 95 | comparePolicyRow, | 96 | comparePolicyRow, |
| 96 | patternSpecificity, | 97 | patternSpecificity, |
| 97 | positionForNewPattern, | 98 | positionForNewPattern, |
| @@ -88,7 +88,11 @@ describe("legacy bigint storage class migration", () => { · 87 unmodified lines | |||
| 88 | }); | 88 | }); |
| 89 | expect(await storageClassOf(db, "c_legacy")).toBe("integer"); | 89 | expect(await storageClassOf(db, "c_legacy")).toBe("integer"); |
| 90 | 90 | ||
| 91 | const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); | 91 | const scoped = withQueryContext(db.db, { |
| 92 | tenant: TENANT, | ||
| 93 | subject: SUBJECT, | ||
| 94 | owners: ["user", "org"], | ||
| 95 | }); | ||
| 92 | // Not "returns a bad value" — the read THROWS, which is why the gateway | 96 | // Not "returns a bad value" — the read THROWS, which is why the gateway |
| 93 | // lost every saved integration rather than one field of one row. | 97 | // lost every saved integration rather than one field of one row. |
| 94 | await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); | 98 | await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); |
| @@ -118,7 +122,11 @@ describe("legacy bigint storage class migration", () => { · 23 unmodified lines | |||
| 118 | expect(await storageClassOf(db, "c_healthy")).toBe("blob"); | 122 | expect(await storageClassOf(db, "c_healthy")).toBe("blob"); |
| 119 | expect(await storageClassOf(db, "c_null")).toBe("null"); | 123 | expect(await storageClassOf(db, "c_null")).toBe("null"); |
| 120 | 124 | ||
| 121 | const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); | 125 | const scoped = withQueryContext(db.db, { |
| 126 | tenant: TENANT, | ||
| 127 | subject: SUBJECT, | ||
| 128 | owners: ["user", "org"], | ||
| 129 | }); | ||
| 122 | const rows = await scoped.findMany("connection", {}); | 130 | const rows = await scoped.findMany("connection", {}); |
| 123 | expect( | 131 | expect( |
| 124 | rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]), | 132 | rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]), |
| @@ -143,7 +151,11 @@ describe("legacy bigint storage class migration", () => { · 18 unmodified lines | |||
| 143 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1); | 151 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1); |
| 144 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0); | 152 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0); |
| 145 | 153 | ||
| 146 | const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); | 154 | const scoped = withQueryContext(db.db, { |
| 155 | tenant: TENANT, | ||
| 156 | subject: SUBJECT, | ||
| 157 | owners: ["user", "org"], | ||
| 158 | }); | ||
| 147 | const rows = await scoped.findMany("connection", {}); | 159 | const rows = await scoped.findMany("connection", {}); |
| 148 | expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); | 160 | expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); |
| 149 | }), | 161 | }), |
| @@ -169,7 +181,11 @@ describe("legacy bigint storage class migration", () => { · 19 unmodified lines | |||
| 169 | 181 | ||
| 170 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2); | 182 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2); |
| 171 | 183 | ||
| 172 | const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); | 184 | const scoped = withQueryContext(db.db, { |
| 185 | tenant: TENANT, | ||
| 186 | subject: SUBJECT, | ||
| 187 | owners: ["user", "org"], | ||
| 188 | }); | ||
| 173 | const integrations = await scoped.findMany("integration", {}); | 189 | const integrations = await scoped.findMany("integration", {}); |
| 174 | expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([ | 190 | expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([ |
| 175 | LEGACY_EXPIRES_AT, | 191 | LEGACY_EXPIRES_AT, |
| @@ -9,6 +9,7 @@ import { definePlugin } from "./plugin"; · 8 unmodified lines | |||
| 9 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | 9 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; |
| 10 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; | 10 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; |
| 11 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 11 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 12 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 12 | 13 | ||
| 13 | // `touchSubject` is the only writer of the `subject` table. Written against the | 14 | // `touchSubject` is the only writer of the `subject` table. Written against the |
| 14 | // real SQLite bring-up (the same statements local/self-host/D1 boot with) so | 15 | // real SQLite bring-up (the same statements local/self-host/D1 boot with) so |
| @@ -32,6 +33,7 @@ const scopedTo = (db: SqliteTestFumaDb, tenant: string) => · 17 unmodified lines | |||
| 32 | withQueryContext(db.db, { | 33 | withQueryContext(db.db, { |
| 33 | tenant, | 34 | tenant, |
| 34 | subject: null, | 35 | subject: null, |
| 36 | owners: [], | ||
| 35 | } satisfies ExecutorOwnerPolicyContext); | 37 | } satisfies ExecutorOwnerPolicyContext); |
| 36 | 38 | ||
| 37 | const storedSubjects = ( | 39 | const storedSubjects = ( |
| @@ -345,6 +347,7 @@ const subjectsOf = ( · 307 unmodified lines | |||
| 345 | const rows = await withQueryContext(harness.config.db as never, { | 347 | const rows = await withQueryContext(harness.config.db as never, { |
| 346 | tenant, | 348 | tenant, |
| 347 | subject: null, | 349 | subject: null, |
| 350 | owners: [], | ||
| 348 | } satisfies ExecutorOwnerPolicyContext).findMany("subject", {}); | 351 | } satisfies ExecutorOwnerPolicyContext).findMany("subject", {}); |
| 349 | return rows.map((row) => String(row.external_id)); | 352 | return rows.map((row) => String(row.external_id)); |
| 350 | }); | 353 | }); |
| @@ -354,6 +357,7 @@ describe("connections.create subject sighting", () => { · 3 unmodified lines | |||
| 354 | Effect.scoped( | 357 | Effect.scoped( |
| 355 | Effect.gen(function* () { | 358 | Effect.gen(function* () { |
| 356 | const harness = yield* makeTestWorkspaceHarness({ | 359 | const harness = yield* makeTestWorkspaceHarness({ |
| 360 | access: testAccess.member(), | ||
| 357 | plugins, | 361 | plugins, |
| 358 | tenant: TENANT, | 362 | tenant: TENANT, |
| 359 | }); | 363 | }); |
| @@ -377,6 +381,7 @@ describe("connections.create subject sighting", () => { · 17 unmodified lines | |||
| 377 | // The row names the acting principal, not the connection's owner tier: | 381 | // The row names the acting principal, not the connection's owner tier: |
| 378 | // a member who only ever creates org connections still exists. | 382 | // a member who only ever creates org connections still exists. |
| 379 | const harness = yield* makeTestWorkspaceHarness({ | 383 | const harness = yield* makeTestWorkspaceHarness({ |
| 384 | access: testAccess.member(), | ||
| 380 | plugins, | 385 | plugins, |
| 381 | tenant: TENANT, | 386 | tenant: TENANT, |
| 382 | }); | 387 | }); |
| @@ -398,6 +403,7 @@ describe("connections.create subject sighting", () => { · 15 unmodified lines | |||
| 398 | Effect.scoped( | 403 | Effect.scoped( |
| 399 | Effect.gen(function* () { | 404 | Effect.gen(function* () { |
| 400 | const harness = yield* makeTestWorkspaceHarness({ | 405 | const harness = yield* makeTestWorkspaceHarness({ |
| 406 | access: testAccess.org(), | ||
| 401 | plugins, | 407 | plugins, |
| 402 | tenant: TENANT, | 408 | tenant: TENANT, |
| 403 | subject: null, | 409 | subject: null, |
| @@ -133,6 +133,9 @@ export const touchSubject = (db: FumaDb<any>, input: TouchSubjectInput): Effect. · 132 unmodified lines | |||
| 133 | withQueryContext(db, { | 133 | withQueryContext(db, { |
| 134 | tenant: input.tenant, | 134 | tenant: input.tenant, |
| 135 | subject: externalId, | 135 | subject: externalId, |
| 136 | // This host-internal writer touches ONLY the tenant-scoped `subject` | ||
| 137 | // table; it holds no owner-scoped view at all, stated explicitly. | ||
| 138 | owners: [], | ||
| 136 | } satisfies ExecutorOwnerPolicyContext), | 139 | } satisfies ExecutorOwnerPolicyContext), |
| 137 | ); | 140 | ); |
| 138 | // No `tenant` clause: the tenant policy adds it to every read/update. | 141 | // No `tenant` clause: the tenant policy adds it to every read/update. |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { withQueryContext } from "@executor-js/fumadb/query"; | 2 | import { withQueryContext } from "@executor-js/fumadb/query"; |
| 3 | import type { ExecutorAccess } from "./access"; | ||
| 3 | import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor"; | 4 | import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor"; |
| 4 | import type { FumaDb } from "./fuma-runtime"; | 5 | import type { FumaDb } from "./fuma-runtime"; |
| 5 | import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids"; | 6 | import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids"; |
| @@ -134,15 +135,18 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 128 unmodified lines | |||
| 134 | readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"]; | 135 | readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"]; |
| 135 | readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"]; | 136 | readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"]; |
| 136 | readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"]; | 137 | readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"]; |
| 137 | /** Workspace-settings permission for the test binding (see | 138 | /** Product access decisions for the test binding (see |
| 138 | * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts | 139 | * `ExecutorConfig.access`). REQUIRED — this fixture is generic and has no |
| 139 | * with no role model. */ | 140 | * posture of its own. Tests state one explicitly, normally from |
| 140 | readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"]; | 141 | * `@executor-js/product-access/testing` (`testAccess.member()`, …; the |
| 142 | * sdk's own tests resolve that package through the workspace ROOT | ||
| 143 | * devDependency, keeping the package graph acyclic). */ | ||
| 144 | readonly access: ExecutorAccess; | ||
| 141 | readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"]; | 145 | readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"]; |
| 142 | }; | 146 | }; |
| 143 | 147 | ||
| 144 | export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>( | 148 | export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 145 | options?: TestConfigOptions<TPlugins>, | 149 | options: TestConfigOptions<TPlugins>, |
| 146 | ): Omit<ExecutorConfig<TPlugins>, "db"> & { | 150 | ): Omit<ExecutorConfig<TPlugins>, "db"> & { |
| 147 | readonly db: FumaDb; | 151 | readonly db: FumaDb; |
| 148 | readonly testDb: TestFumaDb; | 152 | readonly testDb: TestFumaDb; |
| @@ -156,9 +160,14 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | |||
| 156 | backend: options?.backend ?? "sqlite", | 160 | backend: options?.backend ?? "sqlite", |
| 157 | dataDir: options?.dataDir, | 161 | dataDir: options?.dataDir, |
| 158 | }); | 162 | }); |
| 163 | // The fixture's SEEDING/INSPECTION handle: tests read and write rows | ||
| 164 | // through it directly, so it carries the binding's full identity view, | ||
| 165 | // stated explicitly. The executor built from this config re-binds its own | ||
| 166 | // context from `options.access` and never inherits this one. | ||
| 159 | const db = withQueryContext(testDb.db, { | 167 | const db = withQueryContext(testDb.db, { |
| 160 | tenant, | 168 | tenant, |
| 161 | subject, | 169 | subject, |
| 170 | owners: subject != null ? ["user", "org"] : ["org"], | ||
| 162 | } satisfies ExecutorOwnerPolicyContext); | 171 | } satisfies ExecutorOwnerPolicyContext); |
| 163 | 172 | ||
| 164 | // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow | 173 | // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow |
| @@ -178,7 +187,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 13 unmodified lines | |||
| 178 | onElicitation: "accept-all", | 187 | onElicitation: "accept-all", |
| 179 | onIntegrationChange: options?.onIntegrationChange, | 188 | onIntegrationChange: options?.onIntegrationChange, |
| 180 | ...(redirectUri != null ? { redirectUri } : {}), | 189 | ...(redirectUri != null ? { redirectUri } : {}), |
| 181 | ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), | 190 | access: options.access, |
| 182 | oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug, | 191 | oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug, |
| 183 | firstPartyOAuthClients: options?.firstPartyOAuthClients, | 192 | firstPartyOAuthClients: options?.firstPartyOAuthClients, |
| 184 | enterpriseManagedRollout: options?.enterpriseManagedRollout, | 193 | enterpriseManagedRollout: options?.enterpriseManagedRollout, |
| @@ -209,7 +218,7 @@ export class TestWorkspace extends Context.Service<TestWorkspace, TestWorkspaceH · 24 unmodified lines | |||
| 209 | } | 218 | } |
| 210 | 219 | ||
| 211 | export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>( | 220 | export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 212 | options?: TestConfigOptions<TPlugins>, | 221 | options: TestConfigOptions<TPlugins>, |
| 213 | ) => | 222 | ) => |
| 214 | Effect.acquireRelease( | 223 | Effect.acquireRelease( |
| 215 | Effect.gen(function* () { | 224 | Effect.gen(function* () { |
| @@ -233,7 +242,7 @@ export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlug · 17 unmodified lines | |||
| 233 | ); | 242 | ); |
| 234 | 243 | ||
| 235 | export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>( | 244 | export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 236 | options?: TestConfigOptions<TPlugins>, | 245 | options: TestConfigOptions<TPlugins>, |
| 237 | ) => | 246 | ) => |
| 238 | Layer.effect(TestWorkspace)( | 247 | Layer.effect(TestWorkspace)( |
| 239 | makeTestWorkspaceHarness(options).pipe( | 248 | makeTestWorkspaceHarness(options).pipe( |
| @@ -242,7 +251,7 @@ export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin · 2 unmodified lines | |||
| 242 | ); | 251 | ); |
| 243 | 252 | ||
| 244 | export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>( | 253 | export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 245 | options?: TestConfigOptions<TPlugins>, | 254 | options: TestConfigOptions<TPlugins>, |
| 246 | ) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); | 255 | ) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); |
| 247 | 256 | ||
| 248 | /** Built-in in-memory writable credential provider, contributed as a plugin | 257 | /** Built-in in-memory writable credential provider, contributed as a plugin |
| @@ -7,10 +7,14 @@ import { · 6 unmodified lines | |||
| 7 | OAuthTestServer, | 7 | OAuthTestServer, |
| 8 | TestWorkspace, | 8 | TestWorkspace, |
| 9 | } from "./testing"; | 9 | } from "./testing"; |
| 10 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 10 | 11 | ||
| 11 | const plugins = [memoryCredentialsPlugin()] as const; | 12 | const plugins = [memoryCredentialsPlugin()] as const; |
| 12 | 13 | ||
| 13 | const TestLayer = Layer.mergeAll(makeTestWorkspaceLayer({ plugins }), OAuthTestServer.layer()); | 14 | const TestLayer = Layer.mergeAll( |
| 15 | makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), | ||
| 16 | OAuthTestServer.layer(), | ||
| 17 | ); | ||
| 14 | 18 | ||
| 15 | layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { | 19 | layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { |
| 16 | it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () => | 20 | it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () => |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | |||
| 30 | import { definePlugin } from "./plugin"; | 30 | import { definePlugin } from "./plugin"; |
| 31 | import type { CredentialProvider } from "./provider"; | 31 | import type { CredentialProvider } from "./provider"; |
| 32 | import { makeTestConfig } from "./test-config"; | 32 | import { makeTestConfig } from "./test-config"; |
| 33 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 33 | 34 | ||
| 34 | const STORE = ProviderKey.make("memory"); | 35 | const STORE = ProviderKey.make("memory"); |
| 35 | const INTEG = IntegrationSlug.make("demo"); | 36 | const INTEG = IntegrationSlug.make("demo"); |
| @@ -65,7 +66,9 @@ const POPULATED = pluginWith([ · 29 unmodified lines | |||
| 65 | 66 | ||
| 66 | const failInvoking = (plugin: ReturnType<typeof pluginWith>, tool: string) => | 67 | const failInvoking = (plugin: ReturnType<typeof pluginWith>, tool: string) => |
| 67 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
| 68 | const executor = yield* createExecutor({ ...makeTestConfig({ plugins: [plugin] as const }) }); | 69 | const executor = yield* createExecutor({ |
| 70 | ...makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const }), | ||
| 71 | }); | ||
| 69 | yield* executor.demo.seed(); | 72 | yield* executor.demo.seed(); |
| 70 | yield* executor.connections.create({ | 73 | yield* executor.connections.create({ |
| 71 | owner: "org", | 74 | owner: "org", |
| @@ -4,6 +4,7 @@ import { Effect } from "effect"; · 3 unmodified lines | |||
| 4 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; | 4 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; |
| 5 | import { definePlugin } from "./plugin"; | 5 | import { definePlugin } from "./plugin"; |
| 6 | import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; | 6 | import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; |
| 7 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 7 | 8 | ||
| 8 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 9 | // Cross-owner merge regression (the "Axiom shows 0 tools" bug). | 10 | // Cross-owner merge regression (the "Axiom shows 0 tools" bug). |
| @@ -55,9 +56,10 @@ const demoPlugin = definePlugin(() => ({ · 45 unmodified lines | |||
| 55 | // Default test executor binds `subject: "test-subject"`, so user-owned | 56 | // Default test executor binds `subject: "test-subject"`, so user-owned |
| 56 | // connections/tools can be created and merge with org rows. | 57 | // connections/tools can be created and merge with org rows. |
| 57 | const setup = () => | 58 | const setup = () => |
| 58 | makeTestExecutor({ plugins: [memoryCredentialsPlugin(), demoPlugin] as const }).pipe( | 59 | makeTestExecutor({ |
| 59 | Effect.tap((executor) => executor.demo.seed()), | 60 | access: testAccess.member(), |
| 60 | ); | 61 | plugins: [memoryCredentialsPlugin(), demoPlugin] as const, |
| 62 | }).pipe(Effect.tap((executor) => executor.demo.seed())); | ||
| 61 | 63 | ||
| 62 | describe("tools.list cross-owner merge", () => { | 64 | describe("tools.list cross-owner merge", () => { |
| 63 | it.effect("lists user-owned tools when owner is omitted (the fix)", () => | 65 | it.effect("lists user-owned tools when owner is omitted (the fix)", () => |
| @@ -93,7 +93,10 @@ describe("makeR2BlobStore", () => { · 92 unmodified lines | |||
| 93 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 94 | const { bucket } = makeFakeBucket(); | 94 | const { bucket } = makeFakeBucket(); |
| 95 | const store = makeR2BlobStore(bucket); | 95 | const store = makeR2BlobStore(bucket); |
| 96 | const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi"); | 96 | const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi", { |
| 97 | owners: ["user", "org"], | ||
| 98 | storageWrites: "allowed", | ||
| 99 | }); | ||
| 97 | 100 | ||
| 98 | yield* plugin.put("spec/h1", "org-spec", { owner: "org" }); | 101 | yield* plugin.put("spec/h1", "org-spec", { owner: "org" }); |
| 99 | expect(yield* plugin.get("spec/h1")).toBe("org-spec"); | 102 | expect(yield* plugin.get("spec/h1")).toBe("org-spec"); |
| @@ -79,6 +79,7 @@ · 78 unmodified lines | |||
| 79 | "@effect/vitest": "catalog:", | 79 | "@effect/vitest": "catalog:", |
| 80 | "@executor-js/execution": "workspace:*", | 80 | "@executor-js/execution": "workspace:*", |
| 81 | "@executor-js/host-mcp": "workspace:*", | 81 | "@executor-js/host-mcp": "workspace:*", |
| 82 | "@executor-js/product-access": "workspace:*", | ||
| 82 | "@executor-js/sdk": "workspace:*", | 83 | "@executor-js/sdk": "workspace:*", |
| 83 | "@tailwindcss/browser": "^4.2.2", | 84 | "@tailwindcss/browser": "^4.2.2", |
| 84 | "@tailwindcss/vite": "catalog:", | 85 | "@tailwindcss/vite": "catalog:", |
| @@ -41,6 +41,7 @@ import type * as Cause from "effect/Cause"; · 40 unmodified lines | |||
| 41 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | 41 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 42 | 42 | ||
| 43 | import { loadMcpAppsShellHtml } from "../shell-html"; | 43 | import { loadMcpAppsShellHtml } from "../shell-html"; |
| 44 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 44 | 45 | ||
| 45 | type ShellServer = { | 46 | type ShellServer = { |
| 46 | readonly url: string; | 47 | readonly url: string; |
| @@ -1305,7 +1306,10 @@ const startMcpHarness = async (openApi: OpenApiServer): Promise<McpHarness> => { · 1258 unmodified lines | |||
| 1305 | const executor = await Effect.runPromise( | 1306 | const executor = await Effect.runPromise( |
| 1306 | Effect.gen(function* () { | 1307 | Effect.gen(function* () { |
| 1307 | const built = yield* createExecutor( | 1308 | const built = yield* createExecutor( |
| 1308 | makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }), | 1309 | makeTestConfig({ |
| 1310 | access: testAccess.member(), | ||
| 1311 | plugins: [inventoryPlugin(openApi.postRequests)], | ||
| 1312 | }), | ||
| 1309 | ); | 1313 | ); |
| 1310 | // Tools only exist per connection, so register the integration and open | 1314 | // Tools only exist per connection, so register the integration and open |
| 1311 | // one org `main` connection — that is what makes | 1315 | // one org `main` connection — that is what makes |
| @@ -46,6 +46,7 @@ · 45 unmodified lines | |||
| 46 | "dependencies": { | 46 | "dependencies": { |
| 47 | "@cfworker/json-schema": "^4.1.1", | 47 | "@cfworker/json-schema": "^4.1.1", |
| 48 | "@executor-js/execution": "workspace:*", | 48 | "@executor-js/execution": "workspace:*", |
| 49 | "@executor-js/product-access": "workspace:*", | ||
| 49 | "@executor-js/sdk": "workspace:*", | 50 | "@executor-js/sdk": "workspace:*", |
| 50 | "@modelcontextprotocol/ext-apps": "^1.7.4", | 51 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 51 | "@modelcontextprotocol/sdk": "^1.29.0", | 52 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | } from "./in-memory-session-store"; | 13 | } from "./in-memory-session-store"; |
| 14 | import { defaultMcpResource, type Principal } from "./seams"; | 14 | import { defaultMcpResource, type Principal } from "./seams"; |
| 15 | import { createExecutorMcpServer } from "./tool-server"; | 15 | import { createExecutorMcpServer } from "./tool-server"; |
| 16 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 16 | 17 | ||
| 17 | const TEST_PRINCIPAL: Principal = { | 18 | const TEST_PRINCIPAL: Principal = { |
| 18 | accountId: "acct_test", | 19 | accountId: "acct_test", |
| @@ -161,7 +162,10 @@ const openSession = async ( · 142 unmodified lines | |||
| 161 | 162 | ||
| 162 | it("keeps overlapping warm-session workspace writes bound to their request roles", async () => { | 163 | it("keeps overlapping warm-session workspace writes bound to their request roles", async () => { |
| 163 | const executor = await Effect.runPromise( | 164 | const executor = await Effect.runPromise( |
| 164 | createExecutor({ ...makeTestConfig(), orgWrites: "request" }), | 165 | createExecutor({ |
| 166 | ...makeTestConfig({ access: testAccess.member() }), | ||
| 167 | access: testAccess.requestBound(), | ||
| 168 | }), | ||
| 165 | ); | 169 | ); |
| 166 | const started = new Map<string, () => void>(); | 170 | const started = new Map<string, () => void>(); |
| 167 | const startedPromises = ["member", "admin"].map( | 171 | const startedPromises = ["member", "admin"].map( |
| @@ -240,7 +244,10 @@ it("keeps overlapping warm-session workspace writes bound to their request roles · 72 unmodified lines | |||
| 240 | 244 | ||
| 241 | it("binds a paused workspace write to the resuming principal after demotion", async () => { | 245 | it("binds a paused workspace write to the resuming principal after demotion", async () => { |
| 242 | const executor = await Effect.runPromise( | 246 | const executor = await Effect.runPromise( |
| 243 | createExecutor({ ...makeTestConfig(), orgWrites: "request" }), | 247 | createExecutor({ |
| 248 | ...makeTestConfig({ access: testAccess.member() }), | ||
| 249 | access: testAccess.requestBound(), | ||
| 250 | }), | ||
| 244 | ); | 251 | ); |
| 245 | const executionId = "exec_resume_demotion"; | 252 | const executionId = "exec_resume_demotion"; |
| 246 | const pattern = "paused-resume-demotion.*"; | 253 | const pattern = "paused-resume-demotion.*"; |
| @@ -313,7 +320,10 @@ it("binds a paused workspace write to the resuming principal after demotion", as · 66 unmodified lines | |||
| 313 | 320 | ||
| 314 | it("uses the browser approver's demoted role after an admin starts waiting", async () => { | 321 | it("uses the browser approver's demoted role after an admin starts waiting", async () => { |
| 315 | const executor = await Effect.runPromise( | 322 | const executor = await Effect.runPromise( |
| 316 | createExecutor({ ...makeTestConfig({ coreTools: {} }), orgWrites: "request" }), | 323 | createExecutor({ |
| 324 | ...makeTestConfig({ access: testAccess.member(), coreTools: {} }), | ||
| 325 | access: testAccess.requestBound(), | ||
| 326 | }), | ||
| 317 | ); | 327 | ); |
| 318 | const executionId = "exec_browser_resume_demotion"; | 328 | const executionId = "exec_browser_resume_demotion"; |
| 319 | const pattern = "browser-resume-demotion.*"; | 329 | const pattern = "browser-resume-demotion.*"; |
| @@ -695,7 +705,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 375 unmodified lines | |||
| 695 | it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => { | 705 | it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => { |
| 696 | let executorClosed = 0; | 706 | let executorClosed = 0; |
| 697 | let customClosed = 0; | 707 | let customClosed = 0; |
| 698 | const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig())); | 708 | const realExecutor = await Effect.runPromise( |
| 709 | createExecutor(makeTestConfig({ access: testAccess.member() })), | ||
| 710 | ); | ||
| 699 | const testExecutor = { | 711 | const testExecutor = { |
| 700 | ...realExecutor, | 712 | ...realExecutor, |
| 701 | close: () => | 713 | close: () => |
| @@ -743,7 +755,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 41 unmodified lines | |||
| 743 | 755 | ||
| 744 | it("shuts down the scoped executor when sessions.close() is called", async () => { | 756 | it("shuts down the scoped executor when sessions.close() is called", async () => { |
| 745 | let executorClosed = 0; | 757 | let executorClosed = 0; |
| 746 | const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig())); | 758 | const realExecutor = await Effect.runPromise( |
| 759 | createExecutor(makeTestConfig({ access: testAccess.member() })), | ||
| 760 | ); | ||
| 747 | const testExecutor = { | 761 | const testExecutor = { |
| 748 | ...realExecutor, | 762 | ...realExecutor, |
| 749 | close: () => | 763 | close: () => |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { Context, Effect, Layer, Schema } from "effect"; | 1 | import { Context, Effect, Layer, Schema } from "effect"; |
| 2 | import type { Cause } from "effect"; | 2 | import type { Cause } from "effect"; |
| 3 | 3 | ||
| 4 | import { orgWriteAccessForRole } from "@executor-js/product-access"; | ||
| 4 | import type { OrgWriteAccess } from "@executor-js/sdk"; | 5 | import type { OrgWriteAccess } from "@executor-js/sdk"; |
| 5 | 6 | ||
| 6 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| @@ -72,9 +73,11 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 65 unmodified lines | |||
| 72 | /** Internal header overwritten by a trusted session store before MCP dispatch. */ | 73 | /** Internal header overwritten by a trusted session store before MCP dispatch. */ |
| 73 | export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access"; | 74 | export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access"; |
| 74 | 75 | ||
| 75 | /** Derive the effective workspace-write access for one authenticated request. */ | 76 | /** Derive the effective workspace-write access for one authenticated request |
| 77 | * — the product's role rule (`@executor-js/product-access`), applied here at | ||
| 78 | * the serving boundary that stamps the request-bound carrier. */ | ||
| 76 | export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess => | 79 | export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess => |
| 77 | principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied"; | 80 | orgWriteAccessForRole(principal); |
| 78 | 81 | ||
| 79 | /** | 82 | /** |
| 80 | * Stamp request-bound workspace-write access for the MCP SDK request handler. | 83 | * Stamp request-bound workspace-write access for the MCP SDK request handler. |
| @@ -25,6 +25,7 @@ · 24 unmodified lines | |||
| 25 | "@executor-js/execution": "workspace:*", | 25 | "@executor-js/execution": "workspace:*", |
| 26 | "@executor-js/fumadb": "workspace:*", | 26 | "@executor-js/fumadb": "workspace:*", |
| 27 | "@executor-js/plugin-openapi": "workspace:*", | 27 | "@executor-js/plugin-openapi": "workspace:*", |
| 28 | "@executor-js/product-access": "workspace:*", | ||
| 28 | "@executor-js/sdk": "workspace:*", | 29 | "@executor-js/sdk": "workspace:*", |
| 29 | "@types/node": "catalog:", | 30 | "@types/node": "catalog:", |
| 30 | "drizzle-orm": "catalog:", | 31 | "drizzle-orm": "catalog:", |
| @@ -45,6 +45,7 @@ import { · 44 unmodified lines | |||
| 45 | type FumaDb, | 45 | type FumaDb, |
| 46 | type FumaTables, | 46 | type FumaTables, |
| 47 | } from "@executor-js/sdk"; | 47 | } from "@executor-js/sdk"; |
| 48 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 48 | import { makeExecutorToolInvoker } from "@executor-js/execution"; | 49 | import { makeExecutorToolInvoker } from "@executor-js/execution"; |
| 49 | import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi"; | 50 | import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi"; |
| 50 | 51 | ||
| @@ -231,6 +232,7 @@ const buildSandboxBridge = (spec: string, slug: string, baseUrl = "https://upstr · 180 unmodified lines | |||
| 231 | providers: [memoryProvider()], | 232 | providers: [memoryProvider()], |
| 232 | plugins, | 233 | plugins, |
| 233 | onElicitation: "accept-all", | 234 | onElicitation: "accept-all", |
| 235 | access: testAccess.member(), | ||
| 234 | }); | 236 | }); |
| 235 | // v2: addSpec registers the integration; tools are produced per-connection, | 237 | // v2: addSpec registers the integration; tools are produced per-connection, |
| 236 | // so an org `main` connection is required for the operation to be callable. | 238 | // so an org `main` connection is required for the operation to be callable. |
| @@ -47,6 +47,7 @@ · 46 unmodified lines | |||
| 47 | "react": "catalog:" | 47 | "react": "catalog:" |
| 48 | }, | 48 | }, |
| 49 | "devDependencies": { | 49 | "devDependencies": { |
| 50 | "@executor-js/product-access": "workspace:*", | ||
| 50 | "@types/react": "catalog:", | 51 | "@types/react": "catalog:", |
| 51 | "tsup": "catalog:", | 52 | "tsup": "catalog:", |
| 52 | "typescript": "catalog:" | 53 | "typescript": "catalog:" |
| @@ -5,11 +5,13 @@ import { ToolAddress } from "@executor-js/sdk"; · 4 unmodified lines | |||
| 5 | import { makeTestExecutor } from "@executor-js/sdk/testing"; | 5 | import { makeTestExecutor } from "@executor-js/sdk/testing"; |
| 6 | 6 | ||
| 7 | import { desktopSettingsPlugin } from "./server"; | 7 | import { desktopSettingsPlugin } from "./server"; |
| 8 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 8 | 9 | ||
| 9 | describe("desktopSettingsPlugin", () => { | 10 | describe("desktopSettingsPlugin", () => { |
| 10 | it.effect("returns a browser handoff URL for Desktop-only settings", () => | 11 | it.effect("returns a browser handoff URL for Desktop-only settings", () => |
| 11 | Effect.gen(function* () { | 12 | Effect.gen(function* () { |
| 12 | const executor = yield* makeTestExecutor({ | 13 | const executor = yield* makeTestExecutor({ |
| 14 | access: testAccess.member(), | ||
| 13 | plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const, | 15 | plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const, |
| 14 | }); | 16 | }); |
| 15 | 17 | ||
| @@ -47,6 +47,7 @@ · 46 unmodified lines | |||
| 47 | "@executor-js/sdk": "workspace:*" | 47 | "@executor-js/sdk": "workspace:*" |
| 48 | }, | 48 | }, |
| 49 | "devDependencies": { | 49 | "devDependencies": { |
| 50 | "@executor-js/product-access": "workspace:*", | ||
| 50 | "@types/node": "catalog:", | 51 | "@types/node": "catalog:", |
| 51 | "bun-types": "catalog:", | 52 | "bun-types": "catalog:", |
| 52 | "effect": "catalog:", | 53 | "effect": "catalog:", |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | |||
| 14 | import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; | 14 | import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; |
| 15 | 15 | ||
| 16 | import { fileSecretsPlugin } from "./index"; | 16 | import { fileSecretsPlugin } from "./index"; |
| 17 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 17 | 18 | ||
| 18 | const INTEGRATION = IntegrationSlug.make("durable-secrets"); | 19 | const INTEGRATION = IntegrationSlug.make("durable-secrets"); |
| 19 | const CONNECTION = ConnectionName.make("main"); | 20 | const CONNECTION = ConnectionName.make("main"); |
| @@ -69,7 +70,11 @@ describe("file secrets data directory", () => { · 49 unmodified lines | |||
| 69 | vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome); | 70 | vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome); |
| 70 | const firstAuthPath = yield* Effect.scoped( | 71 | const firstAuthPath = yield* Effect.scoped( |
| 71 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| 72 | const first = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() }); | 73 | const first = yield* makeTestWorkspaceHarness({ |
| 74 | access: testAccess.member(), | ||
| 75 | dataDir, | ||
| 76 | plugins: plugins(), | ||
| 77 | }); | ||
| 73 | yield* first.executor.connectionFixture.registerIntegration(); | 78 | yield* first.executor.connectionFixture.registerIntegration(); |
| 74 | const connection = yield* first.executor.connections.create({ | 79 | const connection = yield* first.executor.connections.create({ |
| 75 | owner: "org", | 80 | owner: "org", |
| @@ -96,7 +101,11 @@ describe("file secrets data directory", () => { · 20 unmodified lines | |||
| 96 | vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome); | 101 | vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome); |
| 97 | yield* Effect.scoped( | 102 | yield* Effect.scoped( |
| 98 | Effect.gen(function* () { | 103 | Effect.gen(function* () { |
| 99 | const recreated = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() }); | 104 | const recreated = yield* makeTestWorkspaceHarness({ |
| 105 | access: testAccess.member(), | ||
| 106 | dataDir, | ||
| 107 | plugins: plugins(), | ||
| 108 | }); | ||
| 100 | const connections = yield* recreated.executor.connections.list({ | 109 | const connections = yield* recreated.executor.connections.list({ |
| 101 | integration: INTEGRATION, | 110 | integration: INTEGRATION, |
| 102 | }); | 111 | }); |
| @@ -17,13 +17,17 @@ import { ProviderKey } from "@executor-js/sdk"; · 16 unmodified lines | |||
| 17 | import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; | 17 | import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; |
| 18 | 18 | ||
| 19 | import { fileSecretsPlugin } from "./index"; | 19 | import { fileSecretsPlugin } from "./index"; |
| 20 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 20 | 21 | ||
| 21 | const FILE_PROVIDER = ProviderKey.make("file"); | 22 | const FILE_PROVIDER = ProviderKey.make("file"); |
| 22 | 23 | ||
| 23 | const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) => | 24 | const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) => |
| 24 | Effect.scoped( | 25 | Effect.scoped( |
| 25 | Effect.gen(function* () { | 26 | Effect.gen(function* () { |
| 26 | const workspace = yield* makeTestWorkspaceHarness({ plugins: [plugin] as const }); | 27 | const workspace = yield* makeTestWorkspaceHarness({ |
| 28 | access: testAccess.member(), | ||
| 29 | plugins: [plugin] as const, | ||
| 30 | }); | ||
| 27 | const items = yield* workspace.executor.providers.items(FILE_PROVIDER); | 31 | const items = yield* workspace.executor.providers.items(FILE_PROVIDER); |
| 28 | return { | 32 | return { |
| 29 | filePath: workspace.executor.fileSecrets.filePath, | 33 | filePath: workspace.executor.fileSecrets.filePath, |
| @@ -136,6 +140,7 @@ describe("file secrets auth location", () => { · 106 unmodified lines | |||
| 136 | vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); | 140 | vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); |
| 137 | writeAuthFile(legacyFilePath, "not-json"); | 141 | writeAuthFile(legacyFilePath, "not-json"); |
| 138 | const workspace = yield* makeTestWorkspaceHarness({ | 142 | const workspace = yield* makeTestWorkspaceHarness({ |
| 143 | access: testAccess.member(), | ||
| 139 | plugins: [fileSecretsPlugin()] as const, | 144 | plugins: [fileSecretsPlugin()] as const, |
| 140 | }); | 145 | }); |
| 141 | 146 | ||
| @@ -158,6 +163,7 @@ describe("file secrets auth location", () => { · 16 unmodified lines | |||
| 158 | vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); | 163 | vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); |
| 159 | mkdirSync(legacyFilePath, { recursive: true }); | 164 | mkdirSync(legacyFilePath, { recursive: true }); |
| 160 | const workspace = yield* makeTestWorkspaceHarness({ | 165 | const workspace = yield* makeTestWorkspaceHarness({ |
| 166 | access: testAccess.member(), | ||
| 161 | plugins: [fileSecretsPlugin()] as const, | 167 | plugins: [fileSecretsPlugin()] as const, |
| 162 | }); | 168 | }); |
| 163 | 169 | ||
| @@ -185,6 +191,7 @@ describe("file secrets auth location", () => { · 21 unmodified lines | |||
| 185 | const legacyContents = '{"legacy-token":"legacy-secret"}'; | 191 | const legacyContents = '{"legacy-token":"legacy-secret"}'; |
| 186 | writeAuthFile(legacyFilePath, legacyContents); | 192 | writeAuthFile(legacyFilePath, legacyContents); |
| 187 | const workspace = yield* makeTestWorkspaceHarness({ | 193 | const workspace = yield* makeTestWorkspaceHarness({ |
| 194 | access: testAccess.member(), | ||
| 188 | plugins: [fileSecretsPlugin()] as const, | 195 | plugins: [fileSecretsPlugin()] as const, |
| 189 | }); | 196 | }); |
| 190 | 197 | ||
| @@ -71,6 +71,7 @@ · 70 unmodified lines | |||
| 71 | "@effect/atom-react": "catalog:", | 71 | "@effect/atom-react": "catalog:", |
| 72 | "@effect/vitest": "catalog:", | 72 | "@effect/vitest": "catalog:", |
| 73 | "@executor-js/api": "workspace:*", | 73 | "@executor-js/api": "workspace:*", |
| 74 | "@executor-js/product-access": "workspace:*", | ||
| 74 | "@executor-js/react": "workspace:*", | 75 | "@executor-js/react": "workspace:*", |
| 75 | "@types/node": "catalog:", | 76 | "@types/node": "catalog:", |
| 76 | "@types/react": "catalog:", | 77 | "@types/react": "catalog:", |
| @@ -21,10 +21,12 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | import { graphqlPlugin } from "./plugin"; | 22 | import { graphqlPlugin } from "./plugin"; |
| 23 | import type { GraphqlAuthMethod } from "./types"; | 23 | import type { GraphqlAuthMethod } from "./types"; |
| 24 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 24 | 25 | ||
| 25 | const makeExecutor = () => | 26 | const makeExecutor = () => |
| 26 | createExecutor( | 27 | createExecutor( |
| 27 | makeTestConfig({ | 28 | makeTestConfig({ |
| 29 | access: testAccess.member(), | ||
| 28 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | 30 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, |
| 29 | }), | 31 | }), |
| 30 | ); | 32 | ); |
| @@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { makeGreetingGraphqlSchema } from "../testing"; | 17 | import { makeGreetingGraphqlSchema } from "../testing"; |
| 18 | import { graphqlPlugin } from "./plugin"; | 18 | import { graphqlPlugin } from "./plugin"; |
| 19 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 19 | 20 | ||
| 20 | const INVOCATION_TIMEOUT_MS = 100; | 21 | const INVOCATION_TIMEOUT_MS = 100; |
| 21 | 22 | ||
| @@ -61,6 +62,7 @@ describe("GraphQL invocation timeout", () => { · 39 unmodified lines | |||
| 61 | const server = yield* startHangingResponseServer(closed); | 62 | const server = yield* startHangingResponseServer(closed); |
| 62 | const executor = yield* createExecutor( | 63 | const executor = yield* createExecutor( |
| 63 | makeTestConfig({ | 64 | makeTestConfig({ |
| 65 | access: testAccess.member(), | ||
| 64 | plugins: [ | 66 | plugins: [ |
| 65 | memoryCredentialsPlugin(), | 67 | memoryCredentialsPlugin(), |
| 66 | graphqlPlugin({ | 68 | graphqlPlugin({ |
| @@ -23,12 +23,16 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 22 unmodified lines | |||
| 23 | import { graphqlPlugin } from "./plugin"; | 23 | import { graphqlPlugin } from "./plugin"; |
| 24 | import { variable } from "@executor-js/sdk/http-auth"; | 24 | import { variable } from "@executor-js/sdk/http-auth"; |
| 25 | import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; | 25 | import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; |
| 26 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 26 | 27 | ||
| 27 | const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); | 28 | const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); |
| 28 | 29 | ||
| 29 | const makeExecutor = () => | 30 | const makeExecutor = () => |
| 30 | createExecutor( | 31 | createExecutor( |
| 31 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | 32 | makeTestConfig({ |
| 33 | access: testAccess.member(), | ||
| 34 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | ||
| 35 | }), | ||
| 32 | ); | 36 | ); |
| 33 | 37 | ||
| 34 | const toolAddr = (integration: string, connection: string, tool: string): ToolAddress => | 38 | const toolAddr = (integration: string, connection: string, tool: string): ToolAddress => |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | |||
| 35 | serveGraphqlTestServer, | 35 | serveGraphqlTestServer, |
| 36 | waitForRecordedRequests, | 36 | waitForRecordedRequests, |
| 37 | } from "../testing"; | 37 | } from "../testing"; |
| 38 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 38 | 39 | ||
| 39 | // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/ | 40 | // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/ |
| 40 | // usagesForConnection, multi-scope shadowing, and `executor.sources.*` / | 41 | // usagesForConnection, multi-scope shadowing, and `executor.sources.*` / |
| @@ -118,7 +119,10 @@ const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphql · 77 unmodified lines | |||
| 118 | 119 | ||
| 119 | const makeExecutor = () => | 120 | const makeExecutor = () => |
| 120 | createExecutor( | 121 | createExecutor( |
| 121 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | 122 | makeTestConfig({ |
| 123 | access: testAccess.member(), | ||
| 124 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | ||
| 125 | }), | ||
| 122 | ); | 126 | ); |
| 123 | 127 | ||
| 124 | const recordingBlobStore = () => { | 128 | const recordingBlobStore = () => { |
| @@ -160,11 +164,14 @@ describe("graphqlPlugin real protocol server", () => { · 35 unmodified lines | |||
| 160 | it.effect("denies member schema persistence before writing an org blob", () => | 164 | it.effect("denies member schema persistence before writing an org blob", () => |
| 161 | Effect.gen(function* () { | 165 | Effect.gen(function* () { |
| 162 | const blobs = recordingBlobStore(); | 166 | const blobs = recordingBlobStore(); |
| 163 | const config = makeTestConfig({ plugins: [graphqlPlugin()] as const }); | 167 | const config = makeTestConfig({ |
| 168 | access: testAccess.member(), | ||
| 169 | plugins: [graphqlPlugin()] as const, | ||
| 170 | }); | ||
| 164 | const member = yield* createExecutor({ | 171 | const member = yield* createExecutor({ |
| 165 | ...config, | 172 | ...config, |
| 166 | blobs: blobs.store, | 173 | blobs: blobs.store, |
| 167 | orgWrites: "denied", | 174 | access: testAccess.member("denied"), |
| 168 | }); | 175 | }); |
| 169 | 176 | ||
| 170 | const error = yield* member.graphql | 177 | const error = yield* member.graphql |
| @@ -420,6 +427,7 @@ describe("graphqlPlugin real protocol server", () => { · 249 unmodified lines | |||
| 420 | }), | 427 | }), |
| 421 | ); | 428 | ); |
| 422 | const config = makeTestConfig({ | 429 | const config = makeTestConfig({ |
| 430 | access: testAccess.member(), | ||
| 423 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | 431 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, |
| 424 | }); | 432 | }); |
| 425 | const executor = yield* createExecutor({ ...config, httpClientLayer }); | 433 | const executor = yield* createExecutor({ ...config, httpClientLayer }); |
| @@ -1236,6 +1244,7 @@ describe("graphqlPlugin", () => { · 810 unmodified lines | |||
| 1236 | it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () => | 1244 | it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () => |
| 1237 | Effect.gen(function* () { | 1245 | Effect.gen(function* () { |
| 1238 | const config = makeTestConfig({ | 1246 | const config = makeTestConfig({ |
| 1247 | access: testAccess.member(), | ||
| 1239 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | 1248 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, |
| 1240 | }); | 1249 | }); |
| 1241 | const executor = yield* createExecutor(config); | 1250 | const executor = yield* createExecutor(config); |
| @@ -49,6 +49,7 @@ · 48 unmodified lines | |||
| 49 | }, | 49 | }, |
| 50 | "devDependencies": { | 50 | "devDependencies": { |
| 51 | "@effect/vitest": "catalog:", | 51 | "@effect/vitest": "catalog:", |
| 52 | "@executor-js/product-access": "workspace:*", | ||
| 52 | "@types/node": "catalog:", | 53 | "@types/node": "catalog:", |
| 53 | "bun-types": "catalog:", | 54 | "bun-types": "catalog:", |
| 54 | "effect": "catalog:", | 55 | "effect": "catalog:", |
| @@ -5,6 +5,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 4 unmodified lines | |||
| 5 | import { keychainPlugin } from "./index"; | 5 | import { keychainPlugin } from "./index"; |
| 6 | import { makeKeychainProvider } from "./provider"; | 6 | import { makeKeychainProvider } from "./provider"; |
| 7 | import { setPassword, deletePassword } from "./keyring"; | 7 | import { setPassword, deletePassword } from "./keyring"; |
| 8 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 8 | 9 | ||
| 9 | // removed: v1 tests routed through `executor.secrets.set/get/remove` with | 10 | // removed: v1 tests routed through `executor.secrets.set/get/remove` with |
| 10 | // `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived | 11 | // `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived |
| @@ -33,9 +34,7 @@ describe("keychain plugin", () => { · 22 unmodified lines | |||
| 33 | it.effect("exposes keychain metadata and registers a provider when reachable", () => | 34 | it.effect("exposes keychain metadata and registers a provider when reachable", () => |
| 34 | Effect.gen(function* () { | 35 | Effect.gen(function* () { |
| 35 | const executor = yield* createExecutor( | 36 | const executor = yield* createExecutor( |
| 36 | makeTestConfig({ | 37 | makeTestConfig({ access: testAccess.member(), plugins: [keychainPlugin()] as const }), |
| 37 | plugins: [keychainPlugin()] as const, | ||
| 38 | }), | ||
| 39 | ); | 38 | ); |
| 40 | 39 | ||
| 41 | expect(executor.keychain.displayName).toBeTypeOf("string"); | 40 | expect(executor.keychain.displayName).toBeTypeOf("string"); |
| @@ -60,6 +59,7 @@ describe("keychain plugin", () => { · 18 unmodified lines | |||
| 60 | 59 | ||
| 61 | const executor = yield* createExecutor( | 60 | const executor = yield* createExecutor( |
| 62 | makeTestConfig({ | 61 | makeTestConfig({ |
| 62 | access: testAccess.member(), | ||
| 63 | plugins: [keychainPlugin({ serviceName })] as const, | 63 | plugins: [keychainPlugin({ serviceName })] as const, |
| 64 | }), | 64 | }), |
| 65 | ); | 65 | ); |
| @@ -86,6 +86,7 @@ describe("keychain plugin", () => { · 20 unmodified lines | |||
| 86 | Effect.gen(function* () { | 86 | Effect.gen(function* () { |
| 87 | const executor = yield* createExecutor( | 87 | const executor = yield* createExecutor( |
| 88 | makeTestConfig({ | 88 | makeTestConfig({ |
| 89 | access: testAccess.member(), | ||
| 89 | plugins: [keychainPlugin({ serviceName: "executor-test" })] as const, | 90 | plugins: [keychainPlugin({ serviceName: "executor-test" })] as const, |
| 90 | }), | 91 | }), |
| 91 | ); | 92 | ); |
| @@ -76,6 +76,7 @@ · 75 unmodified lines | |||
| 76 | "@effect/atom-react": "catalog:", | 76 | "@effect/atom-react": "catalog:", |
| 77 | "@effect/vitest": "catalog:", | 77 | "@effect/vitest": "catalog:", |
| 78 | "@executor-js/api": "workspace:*", | 78 | "@executor-js/api": "workspace:*", |
| 79 | "@executor-js/product-access": "workspace:*", | ||
| 79 | "@executor-js/react": "workspace:*", | 80 | "@executor-js/react": "workspace:*", |
| 80 | "@modelcontextprotocol/server": "2.0.0", | 81 | "@modelcontextprotocol/server": "2.0.0", |
| 81 | "@types/node": "catalog:", | 82 | "@types/node": "catalog:", |
| @@ -35,6 +35,7 @@ import { mcpPlugin } from "./plugin"; · 34 unmodified lines | |||
| 35 | import { createMcpConnector } from "./connection"; | 35 | import { createMcpConnector } from "./connection"; |
| 36 | import { discoverTools } from "./discover"; | 36 | import { discoverTools } from "./discover"; |
| 37 | import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing"; | 37 | import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing"; |
| 38 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 38 | 39 | ||
| 39 | const INTEG = IntegrationSlug.make("catalog_mcp"); | 40 | const INTEG = IntegrationSlug.make("catalog_mcp"); |
| 40 | const CONNECTION = ConnectionName.make("main"); | 41 | const CONNECTION = ConnectionName.make("main"); |
| @@ -48,7 +49,10 @@ const makeCatalogTestExecutor = ( · 7 unmodified lines | |||
| 48 | }, | 49 | }, |
| 49 | ) => | 50 | ) => |
| 50 | createExecutor({ | 51 | createExecutor({ |
| 51 | ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 52 | ...makeTestConfig({ |
| 53 | access: testAccess.member(), | ||
| 54 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 55 | }), | ||
| 52 | ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }), | 56 | ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }), |
| 53 | ...(options?.toolsSyncGraceMs === undefined | 57 | ...(options?.toolsSyncGraceMs === undefined |
| 54 | ? {} | 58 | ? {} |
| @@ -357,7 +361,10 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines | |||
| 357 | Effect.gen(function* () { | 361 | Effect.gen(function* () { |
| 358 | const fixture = yield* serveLatchedListServer(); | 362 | const fixture = yield* serveLatchedListServer(); |
| 359 | const executor = yield* createExecutor({ | 363 | const executor = yield* createExecutor({ |
| 360 | ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 364 | ...makeTestConfig({ |
| 365 | access: testAccess.member(), | ||
| 366 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 367 | }), | ||
| 361 | // Everything is expired on every read, so a single tools read has the | 368 | // Everything is expired on every read, so a single tools read has the |
| 362 | // whole set to rebuild. | 369 | // whole set to rebuild. |
| 363 | toolsSyncTtlMs: 0, | 370 | toolsSyncTtlMs: 0, |
| @@ -23,6 +23,7 @@ import { · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { mcpPlugin } from "./plugin"; | 24 | import { mcpPlugin } from "./plugin"; |
| 25 | import { makeElicitationMcpServer, serveMcpServer } from "../testing"; | 25 | import { makeElicitationMcpServer, serveMcpServer } from "../testing"; |
| 26 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 26 | 27 | ||
| 27 | const isFormElicitation = Schema.is(FormElicitation); | 28 | const isFormElicitation = Schema.is(FormElicitation); |
| 28 | 29 | ||
| @@ -60,7 +61,12 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 31 unmodified lines | |||
| 60 | 61 | ||
| 61 | const makeTestExecutor = (serverUrl: string) => | 62 | const makeTestExecutor = (serverUrl: string) => |
| 62 | Effect.acquireRelease( | 63 | Effect.acquireRelease( |
| 63 | createExecutor(makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const })), | 64 | createExecutor( |
| 65 | makeTestConfig({ | ||
| 66 | access: testAccess.member(), | ||
| 67 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 68 | }), | ||
| 69 | ), | ||
| 64 | (executor) => executor.close().pipe(Effect.ignore), | 70 | (executor) => executor.close().pipe(Effect.ignore), |
| 65 | ).pipe( | 71 | ).pipe( |
| 66 | Effect.tap((executor) => | 72 | Effect.tap((executor) => |
| @@ -373,7 +379,9 @@ describe("MCP elicitation (end-to-end)", () => { · 306 unmodified lines | |||
| 373 | it.effect("addServer preserves the configured display name as the integration description", () => | 379 | it.effect("addServer preserves the configured display name as the integration description", () => |
| 374 | Effect.gen(function* () { | 380 | Effect.gen(function* () { |
| 375 | const server = yield* serveElicitationTestServer; | 381 | const server = yield* serveElicitationTestServer; |
| 376 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 382 | const executor = yield* createExecutor( |
| 383 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 384 | ); | ||
| 377 | 385 | ||
| 378 | yield* executor.mcp.addServer({ | 386 | yield* executor.mcp.addServer({ |
| 379 | name: "Gmail", | 387 | name: "Gmail", |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | |||
| 17 | makeImageMcpServer, | 17 | makeImageMcpServer, |
| 18 | serveMcpServer, | 18 | serveMcpServer, |
| 19 | } from "../testing"; | 19 | } from "../testing"; |
| 20 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 20 | 21 | ||
| 21 | const INTEGRATION = IntegrationSlug.make("image_mcp"); | 22 | const INTEGRATION = IntegrationSlug.make("image_mcp"); |
| 22 | const TEMPLATE = AuthTemplateSlug.make("none"); | 23 | const TEMPLATE = AuthTemplateSlug.make("none"); |
| @@ -32,7 +33,10 @@ describe("MCP image content", () => { · 9 unmodified lines | |||
| 32 | Effect.gen(function* () { | 33 | Effect.gen(function* () { |
| 33 | const server = yield* serveMcpServer(makeImageMcpServer); | 34 | const server = yield* serveMcpServer(makeImageMcpServer); |
| 34 | const executor = yield* createExecutor( | 35 | const executor = yield* createExecutor( |
| 35 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 36 | makeTestConfig({ |
| 37 | access: testAccess.member(), | ||
| 38 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 39 | }), | ||
| 36 | ); | 40 | ); |
| 37 | 41 | ||
| 38 | yield* executor.mcp.addServer({ | 42 | yield* executor.mcp.addServer({ |
| @@ -24,6 +24,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 23 unmodified lines | |||
| 24 | import { mcpPlugin } from "./plugin"; | 24 | import { mcpPlugin } from "./plugin"; |
| 25 | import { variable } from "@executor-js/sdk/http-auth"; | 25 | import { variable } from "@executor-js/sdk/http-auth"; |
| 26 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; | 26 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; |
| 27 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 27 | 28 | ||
| 28 | const serveRecordingServer = serveMcpServer(() => | 29 | const serveRecordingServer = serveMcpServer(() => |
| 29 | makeEchoMcpServer({ | 30 | makeEchoMcpServer({ |
| @@ -40,7 +41,10 @@ describe("MCP multi-placement auth", () => { · 10 unmodified lines | |||
| 40 | Effect.gen(function* () { | 41 | Effect.gen(function* () { |
| 41 | const server = yield* serveRecordingServer; | 42 | const server = yield* serveRecordingServer; |
| 42 | const executor = yield* createExecutor( | 43 | const executor = yield* createExecutor( |
| 43 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 44 | makeTestConfig({ |
| 45 | access: testAccess.member(), | ||
| 46 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 47 | }), | ||
| 44 | ); | 48 | ); |
| 45 | 49 | ||
| 46 | yield* executor.mcp.addServer({ | 50 | yield* executor.mcp.addServer({ |
| @@ -88,7 +92,10 @@ describe("MCP multi-placement auth", () => { · 41 unmodified lines | |||
| 88 | Effect.gen(function* () { | 92 | Effect.gen(function* () { |
| 89 | const server = yield* serveRecordingServer; | 93 | const server = yield* serveRecordingServer; |
| 90 | const executor = yield* createExecutor( | 94 | const executor = yield* createExecutor( |
| 91 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 95 | makeTestConfig({ |
| 96 | access: testAccess.member(), | ||
| 97 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 98 | }), | ||
| 92 | ); | 99 | ); |
| 93 | 100 | ||
| 94 | yield* executor.mcp.addServer({ | 101 | yield* executor.mcp.addServer({ |
| @@ -126,7 +133,10 @@ describe("MCP multi-placement auth", () => { · 31 unmodified lines | |||
| 126 | Effect.gen(function* () { | 133 | Effect.gen(function* () { |
| 127 | const server = yield* serveRecordingServer; | 134 | const server = yield* serveRecordingServer; |
| 128 | const executor = yield* createExecutor( | 135 | const executor = yield* createExecutor( |
| 129 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 136 | makeTestConfig({ |
| 137 | access: testAccess.member(), | ||
| 138 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 139 | }), | ||
| 130 | ); | 140 | ); |
| 131 | 141 | ||
| 132 | // Two declared methods; each connection picks one by template slug. | 142 | // Two declared methods; each connection picks one by template slug. |
| @@ -193,7 +203,10 @@ describe("MCP multi-placement auth", () => { · 60 unmodified lines | |||
| 193 | Effect.gen(function* () { | 203 | Effect.gen(function* () { |
| 194 | const server = yield* serveRecordingServer; | 204 | const server = yield* serveRecordingServer; |
| 195 | const executor = yield* createExecutor( | 205 | const executor = yield* createExecutor( |
| 196 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 206 | makeTestConfig({ |
| 207 | access: testAccess.member(), | ||
| 208 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 209 | }), | ||
| 197 | ); | 210 | ); |
| 198 | 211 | ||
| 199 | yield* executor.mcp.addServer({ | 212 | yield* executor.mcp.addServer({ |
| @@ -23,6 +23,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { mcpPlugin } from "./plugin"; | 24 | import { mcpPlugin } from "./plugin"; |
| 25 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; | 25 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; |
| 26 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 26 | 27 | ||
| 27 | const INTEG = IntegrationSlug.make("iso_mcp"); | 28 | const INTEG = IntegrationSlug.make("iso_mcp"); |
| 28 | const TEMPLATE = AuthTemplateSlug.make("bearer"); | 29 | const TEMPLATE = AuthTemplateSlug.make("bearer"); |
| @@ -43,7 +44,10 @@ describe("MCP owner isolation", () => { · 14 unmodified lines | |||
| 43 | Effect.gen(function* () { | 44 | Effect.gen(function* () { |
| 44 | const server = yield* serveAuthRecordingMcpServer; | 45 | const server = yield* serveAuthRecordingMcpServer; |
| 45 | const executor = yield* createExecutor( | 46 | const executor = yield* createExecutor( |
| 46 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 47 | makeTestConfig({ |
| 48 | access: testAccess.member(), | ||
| 49 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 50 | }), | ||
| 47 | ); | 51 | ); |
| 48 | 52 | ||
| 49 | // One integration with a Bearer header auth template; the connection's | 53 | // One integration with a Bearer header auth template; the connection's |
| @@ -29,6 +29,7 @@ import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin · 28 unmodified lines | |||
| 29 | import { McpInvocationError } from "./errors"; | 29 | import { McpInvocationError } from "./errors"; |
| 30 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; | 30 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; |
| 31 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; | 31 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; |
| 32 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 32 | 33 | ||
| 33 | // removed: the v1 addSource / scopes / secrets / credential-binding / usages / | 34 | // removed: the v1 addSource / scopes / secrets / credential-binding / usages / |
| 34 | // sources.configure / multi-scope shadowing suites. v2 has no scope stack, no | 35 | // sources.configure / multi-scope shadowing suites. v2 has no scope stack, no |
| @@ -261,6 +262,7 @@ const seedCallToolExecutor = (input: { · 226 unmodified lines | |||
| 261 | Effect.gen(function* () { | 262 | Effect.gen(function* () { |
| 262 | const server = yield* serveCallToolServer(input.callTool); | 263 | const server = yield* serveCallToolServer(input.callTool); |
| 263 | const config = makeTestConfig({ | 264 | const config = makeTestConfig({ |
| 265 | access: testAccess.member(), | ||
| 264 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | 266 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, |
| 265 | }); | 267 | }); |
| 266 | const executor = yield* createExecutor(config); | 268 | const executor = yield* createExecutor(config); |
| @@ -590,9 +592,7 @@ describe("mcpPlugin", () => { · 323 unmodified lines | |||
| 590 | it.effect("creates executor with mcp plugin", () => | 592 | it.effect("creates executor with mcp plugin", () => |
| 591 | Effect.gen(function* () { | 593 | Effect.gen(function* () { |
| 592 | const executor = yield* createExecutor( | 594 | const executor = yield* createExecutor( |
| 593 | makeTestConfig({ | 595 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), |
| 594 | plugins: [mcpPlugin()] as const, | ||
| 595 | }), | ||
| 596 | ); | 596 | ); |
| 597 | 597 | ||
| 598 | expect(executor.mcp).toBeDefined(); | 598 | expect(executor.mcp).toBeDefined(); |
| @@ -631,7 +631,9 @@ describe("mcpPlugin", () => { · 32 unmodified lines | |||
| 631 | 631 | ||
| 632 | it.effect("integration catalog has no configured MCP integrations initially", () => | 632 | it.effect("integration catalog has no configured MCP integrations initially", () => |
| 633 | Effect.gen(function* () { | 633 | Effect.gen(function* () { |
| 634 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 634 | const executor = yield* createExecutor( |
| 635 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 636 | ); | ||
| 635 | const integrations = yield* executor.integrations.list(); | 637 | const integrations = yield* executor.integrations.list(); |
| 636 | expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0); | 638 | expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0); |
| 637 | }), | 639 | }), |
| @@ -639,7 +641,9 @@ describe("mcpPlugin", () => { · 1 unmodified line | |||
| 639 | 641 | ||
| 640 | it.effect("projects an MCP server family into the integration catalog", () => | 642 | it.effect("projects an MCP server family into the integration catalog", () => |
| 641 | Effect.gen(function* () { | 643 | Effect.gen(function* () { |
| 642 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 644 | const executor = yield* createExecutor( |
| 645 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 646 | ); | ||
| 643 | yield* executor.mcp.addServer({ | 647 | yield* executor.mcp.addServer({ |
| 644 | name: "Cloudflare Docs", | 648 | name: "Cloudflare Docs", |
| 645 | family: "cloudflare", | 649 | family: "cloudflare", |
| @@ -656,7 +660,9 @@ describe("mcpPlugin", () => { · 10 unmodified lines | |||
| 656 | 660 | ||
| 657 | it.effect("connection tools list is empty until a connection is created", () => | 661 | it.effect("connection tools list is empty until a connection is created", () => |
| 658 | Effect.gen(function* () { | 662 | Effect.gen(function* () { |
| 659 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 663 | const executor = yield* createExecutor( |
| 664 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 665 | ); | ||
| 660 | const tools = yield* executor.tools.list(); | 666 | const tools = yield* executor.tools.list(); |
| 661 | expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0); | 667 | expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0); |
| 662 | }), | 668 | }), |
| @@ -667,7 +673,10 @@ describe("mcpPlugin", () => { · 4 unmodified lines | |||
| 667 | Effect.gen(function* () { | 673 | Effect.gen(function* () { |
| 668 | const server = yield* serveOAuthTestServer({ scopes: [] }); | 674 | const server = yield* serveOAuthTestServer({ scopes: [] }); |
| 669 | const executor = yield* createExecutor( | 675 | const executor = yield* createExecutor( |
| 670 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 676 | makeTestConfig({ |
| 677 | access: testAccess.member(), | ||
| 678 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 679 | }), | ||
| 671 | ); | 680 | ); |
| 672 | const attachedClient = OAuthClientSlug.make("axiom-mcp"); | 681 | const attachedClient = OAuthClientSlug.make("axiom-mcp"); |
| 673 | const unrelatedClient = OAuthClientSlug.make("manual-app"); | 682 | const unrelatedClient = OAuthClientSlug.make("manual-app"); |
| @@ -726,7 +735,10 @@ describe("mcpPlugin", () => { · 52 unmodified lines | |||
| 726 | Effect.scoped( | 735 | Effect.scoped( |
| 727 | Effect.gen(function* () { | 736 | Effect.gen(function* () { |
| 728 | const executor = yield* createExecutor( | 737 | const executor = yield* createExecutor( |
| 729 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 738 | makeTestConfig({ |
| 739 | access: testAccess.member(), | ||
| 740 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 741 | }), | ||
| 730 | ); | 742 | ); |
| 731 | 743 | ||
| 732 | yield* executor.mcp.addServer({ | 744 | yield* executor.mcp.addServer({ |
| @@ -769,7 +781,9 @@ describe("mcpPlugin", () => { · 36 unmodified lines | |||
| 769 | // adding an API key to an OAuth server must NOT displace the OAuth method. | 781 | // adding an API key to an OAuth server must NOT displace the OAuth method. |
| 770 | it.effect("configureAuth merge-appends a custom method without clobbering oauth", () => | 782 | it.effect("configureAuth merge-appends a custom method without clobbering oauth", () => |
| 771 | Effect.gen(function* () { | 783 | Effect.gen(function* () { |
| 772 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 784 | const executor = yield* createExecutor( |
| 785 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 786 | ); | ||
| 773 | 787 | ||
| 774 | yield* executor.mcp.addServer({ | 788 | yield* executor.mcp.addServer({ |
| 775 | name: "OAuth MCP", | 789 | name: "OAuth MCP", |
| @@ -796,7 +810,9 @@ describe("mcpPlugin", () => { · 20 unmodified lines | |||
| 796 | 810 | ||
| 797 | it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () => | 811 | it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () => |
| 798 | Effect.gen(function* () { | 812 | Effect.gen(function* () { |
| 799 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 813 | const executor = yield* createExecutor( |
| 814 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 815 | ); | ||
| 800 | 816 | ||
| 801 | yield* executor.mcp.addServer({ | 817 | yield* executor.mcp.addServer({ |
| 802 | name: "Open MCP", | 818 | name: "Open MCP", |
| @@ -826,7 +842,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines | |||
| 826 | scopes: ["channels:history", "users:read"], | 842 | scopes: ["channels:history", "users:read"], |
| 827 | }); | 843 | }); |
| 828 | const executor = yield* createExecutor( | 844 | const executor = yield* createExecutor( |
| 829 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 845 | makeTestConfig({ |
| 846 | access: testAccess.member(), | ||
| 847 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 848 | }), | ||
| 830 | ); | 849 | ); |
| 831 | 850 | ||
| 832 | yield* executor.mcp.addServer({ | 851 | yield* executor.mcp.addServer({ |
| @@ -870,7 +889,10 @@ describe("mcpPlugin", () => { · 37 unmodified lines | |||
| 870 | Effect.gen(function* () { | 889 | Effect.gen(function* () { |
| 871 | const server = yield* serveOAuthTestServer({ scopes: ["mcp"] }); | 890 | const server = yield* serveOAuthTestServer({ scopes: ["mcp"] }); |
| 872 | const executor = yield* createExecutor( | 891 | const executor = yield* createExecutor( |
| 873 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 892 | makeTestConfig({ |
| 893 | access: testAccess.member(), | ||
| 894 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 895 | }), | ||
| 874 | ); | 896 | ); |
| 875 | 897 | ||
| 876 | yield* executor.mcp.addServer({ | 898 | yield* executor.mcp.addServer({ |
| @@ -911,7 +933,10 @@ describe("mcpPlugin", () => { · 34 unmodified lines | |||
| 911 | it.effect("registers integration + connection with 0 tools when discovery fails", () => | 933 | it.effect("registers integration + connection with 0 tools when discovery fails", () => |
| 912 | Effect.gen(function* () { | 934 | Effect.gen(function* () { |
| 913 | const executor = yield* createExecutor( | 935 | const executor = yield* createExecutor( |
| 914 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 936 | makeTestConfig({ |
| 937 | access: testAccess.member(), | ||
| 938 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 939 | }), | ||
| 915 | ); | 940 | ); |
| 916 | 941 | ||
| 917 | const slugStr = "broken_source"; | 942 | const slugStr = "broken_source"; |
| @@ -941,7 +966,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines | |||
| 941 | 966 | ||
| 942 | it.effect("static probeEndpoint returns actionable tool failures", () => | 967 | it.effect("static probeEndpoint returns actionable tool failures", () => |
| 943 | Effect.gen(function* () { | 968 | Effect.gen(function* () { |
| 944 | const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); | 969 | const config = makeTestConfig({ |
| 970 | access: testAccess.member(), | ||
| 971 | plugins: [mcpPlugin()] as const, | ||
| 972 | }); | ||
| 945 | const executor = yield* createExecutor(config); | 973 | const executor = yield* createExecutor(config); |
| 946 | 974 | ||
| 947 | const result = yield* executor.execute(ToolAddress.make("executor.mcp.probeEndpoint"), { | 975 | const result = yield* executor.execute(ToolAddress.make("executor.mcp.probeEndpoint"), { |
| @@ -1294,7 +1322,10 @@ describe("mcpPlugin", () => { · 346 unmodified lines | |||
| 1294 | ), | 1322 | ), |
| 1295 | ), | 1323 | ), |
| 1296 | ); | 1324 | ); |
| 1297 | const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); | 1325 | const config = makeTestConfig({ |
| 1326 | access: testAccess.member(), | ||
| 1327 | plugins: [mcpPlugin()] as const, | ||
| 1328 | }); | ||
| 1298 | const executor = yield* createExecutor(config); | 1329 | const executor = yield* createExecutor(config); |
| 1299 | 1330 | ||
| 1300 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); | 1331 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); |
| @@ -1330,7 +1361,10 @@ describe("mcpPlugin", () => { · 29 unmodified lines | |||
| 1330 | : HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }), | 1361 | : HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }), |
| 1331 | ), | 1362 | ), |
| 1332 | ); | 1363 | ); |
| 1333 | const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); | 1364 | const config = makeTestConfig({ |
| 1365 | access: testAccess.member(), | ||
| 1366 | plugins: [mcpPlugin()] as const, | ||
| 1367 | }); | ||
| 1334 | const executor = yield* createExecutor(config); | 1368 | const executor = yield* createExecutor(config); |
| 1335 | 1369 | ||
| 1336 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); | 1370 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); |
| @@ -1372,7 +1406,10 @@ describe("mcpPlugin", () => { · 35 unmodified lines | |||
| 1372 | return HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }); | 1406 | return HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }); |
| 1373 | }), | 1407 | }), |
| 1374 | ); | 1408 | ); |
| 1375 | const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); | 1409 | const config = makeTestConfig({ |
| 1410 | access: testAccess.member(), | ||
| 1411 | plugins: [mcpPlugin()] as const, | ||
| 1412 | }); | ||
| 1376 | const executor = yield* createExecutor(config); | 1413 | const executor = yield* createExecutor(config); |
| 1377 | 1414 | ||
| 1378 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); | 1415 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); |
| @@ -1400,7 +1437,10 @@ const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); · 21 unmodified lines | |||
| 1400 | 1437 | ||
| 1401 | const seedAnnotationsExecutor = (serverUrl: string) => | 1438 | const seedAnnotationsExecutor = (serverUrl: string) => |
| 1402 | createExecutor( | 1439 | createExecutor( |
| 1403 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | 1440 | makeTestConfig({ |
| 1441 | access: testAccess.member(), | ||
| 1442 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | ||
| 1443 | }), | ||
| 1404 | ).pipe( | 1444 | ).pipe( |
| 1405 | Effect.tap((executor) => | 1445 | Effect.tap((executor) => |
| 1406 | Effect.gen(function* () { | 1446 | Effect.gen(function* () { |
| @@ -1508,7 +1548,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 101 unmodified lines | |||
| 1508 | // a candidate. | 1548 | // a candidate. |
| 1509 | it.effect("returns low-confidence candidate when path has /mcp segment", () => | 1549 | it.effect("returns low-confidence candidate when path has /mcp segment", () => |
| 1510 | Effect.gen(function* () { | 1550 | Effect.gen(function* () { |
| 1511 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 1551 | const executor = yield* createExecutor( |
| 1552 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 1553 | ); | ||
| 1512 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp"); | 1554 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp"); |
| 1513 | const mcp = results.find((r) => r.kind === "mcp"); | 1555 | const mcp = results.find((r) => r.kind === "mcp"); |
| 1514 | expect(mcp).toBeDefined(); | 1556 | expect(mcp).toBeDefined(); |
| @@ -1518,7 +1560,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines | |||
| 1518 | 1560 | ||
| 1519 | it.effect("matches mcp on hostname label", () => | 1561 | it.effect("matches mcp on hostname label", () => |
| 1520 | Effect.gen(function* () { | 1562 | Effect.gen(function* () { |
| 1521 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 1563 | const executor = yield* createExecutor( |
| 1564 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 1565 | ); | ||
| 1522 | const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/"); | 1566 | const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/"); |
| 1523 | const mcp = results.find((r) => r.kind === "mcp"); | 1567 | const mcp = results.find((r) => r.kind === "mcp"); |
| 1524 | expect(mcp?.confidence).toBe("low"); | 1568 | expect(mcp?.confidence).toBe("low"); |
| @@ -1527,7 +1571,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 2 unmodified lines | |||
| 1527 | 1571 | ||
| 1528 | it.effect("does not match mcp as a substring", () => | 1572 | it.effect("does not match mcp as a substring", () => |
| 1529 | Effect.gen(function* () { | 1573 | Effect.gen(function* () { |
| 1530 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 1574 | const executor = yield* createExecutor( |
| 1575 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 1576 | ); | ||
| 1531 | // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so | 1577 | // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so |
| 1532 | // the URL-token fallback must not fire. | 1578 | // the URL-token fallback must not fire. |
| 1533 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/mcpstore"); | 1579 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/mcpstore"); |
| @@ -1537,7 +1583,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines | |||
| 1537 | 1583 | ||
| 1538 | it.effect("returns null when no token match and no wire-shape match", () => | 1584 | it.effect("returns null when no token match and no wire-shape match", () => |
| 1539 | Effect.gen(function* () { | 1585 | Effect.gen(function* () { |
| 1540 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 1586 | const executor = yield* createExecutor( |
| 1587 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 1588 | ); | ||
| 1541 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1"); | 1589 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1"); |
| 1542 | expect(results.find((r) => r.kind === "mcp")).toBeUndefined(); | 1590 | expect(results.find((r) => r.kind === "mcp")).toBeUndefined(); |
| 1543 | }), | 1591 | }), |
| @@ -1590,7 +1638,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 46 unmodified lines | |||
| 1590 | it.effect("stamps a sanitized endpoint on the detect span", () => | 1638 | it.effect("stamps a sanitized endpoint on the detect span", () => |
| 1591 | Effect.gen(function* () { | 1639 | Effect.gen(function* () { |
| 1592 | const spans: Array<Tracer.NativeSpan> = []; | 1640 | const spans: Array<Tracer.NativeSpan> = []; |
| 1593 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 1641 | const executor = yield* createExecutor( |
| 1642 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 1643 | ); | ||
| 1594 | 1644 | ||
| 1595 | yield* executor.integrations | 1645 | yield* executor.integrations |
| 1596 | .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`) | 1646 | .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`) |
| @@ -1620,7 +1670,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 23 unmodified lines | |||
| 1620 | it.effect("stamps a sanitized endpoint on the probe_endpoint span", () => | 1670 | it.effect("stamps a sanitized endpoint on the probe_endpoint span", () => |
| 1621 | Effect.gen(function* () { | 1671 | Effect.gen(function* () { |
| 1622 | const spans: Array<Tracer.NativeSpan> = []; | 1672 | const spans: Array<Tracer.NativeSpan> = []; |
| 1623 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 1673 | const executor = yield* createExecutor( |
| 1674 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 1675 | ); | ||
| 1624 | 1676 | ||
| 1625 | yield* executor.mcp | 1677 | yield* executor.mcp |
| 1626 | .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`) | 1678 | .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`) |
| @@ -1649,6 +1701,7 @@ describe("stdio static env", () => { · 22 unmodified lines | |||
| 1649 | Effect.scoped( | 1701 | Effect.scoped( |
| 1650 | Effect.gen(function* () { | 1702 | Effect.gen(function* () { |
| 1651 | const config = makeTestConfig({ | 1703 | const config = makeTestConfig({ |
| 1704 | access: testAccess.member(), | ||
| 1652 | plugins: [ | 1705 | plugins: [ |
| 1653 | memoryCredentialsPlugin(), | 1706 | memoryCredentialsPlugin(), |
| 1654 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), | 1707 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -1725,6 +1778,7 @@ describe("stdio static env", () => { · 70 unmodified lines | |||
| 1725 | Effect.scoped( | 1778 | Effect.scoped( |
| 1726 | Effect.gen(function* () { | 1779 | Effect.gen(function* () { |
| 1727 | const config = makeTestConfig({ | 1780 | const config = makeTestConfig({ |
| 1781 | access: testAccess.member(), | ||
| 1728 | plugins: [ | 1782 | plugins: [ |
| 1729 | memoryCredentialsPlugin(), | 1783 | memoryCredentialsPlugin(), |
| 1730 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), | 1784 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -1777,6 +1831,7 @@ describe("stdio static env", () => { · 46 unmodified lines | |||
| 1777 | Effect.scoped( | 1831 | Effect.scoped( |
| 1778 | Effect.gen(function* () { | 1832 | Effect.gen(function* () { |
| 1779 | const config = makeTestConfig({ | 1833 | const config = makeTestConfig({ |
| 1834 | access: testAccess.member(), | ||
| 1780 | plugins: [ | 1835 | plugins: [ |
| 1781 | memoryCredentialsPlugin(), | 1836 | memoryCredentialsPlugin(), |
| 1782 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), | 1837 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -1832,6 +1887,7 @@ describe("stdio static env", () => { · 49 unmodified lines | |||
| 1832 | Effect.scoped( | 1887 | Effect.scoped( |
| 1833 | Effect.gen(function* () { | 1888 | Effect.gen(function* () { |
| 1834 | const config = makeTestConfig({ | 1889 | const config = makeTestConfig({ |
| 1890 | access: testAccess.member(), | ||
| 1835 | plugins: [ | 1891 | plugins: [ |
| 1836 | memoryCredentialsPlugin(), | 1892 | memoryCredentialsPlugin(), |
| 1837 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), | 1893 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -29,6 +29,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 28 unmodified lines | |||
| 29 | 29 | ||
| 30 | import { mcpPlugin } from "./plugin"; | 30 | import { mcpPlugin } from "./plugin"; |
| 31 | import { probeMcpEndpointShape } from "./probe-shape"; | 31 | import { probeMcpEndpointShape } from "./probe-shape"; |
| 32 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 32 | 33 | ||
| 33 | const MCP_INITIALIZE_BODY = JSON.stringify({ | 34 | const MCP_INITIALIZE_BODY = JSON.stringify({ |
| 34 | jsonrpc: "2.0", | 35 | jsonrpc: "2.0", |
| @@ -176,7 +177,9 @@ const messageFromUnknown = (cause: unknown): string => · 141 unmodified lines | |||
| 176 | 177 | ||
| 177 | const runEndpointProbe = (url: string): Effect.Effect<EndpointProbeOutcome> => | 178 | const runEndpointProbe = (url: string): Effect.Effect<EndpointProbeOutcome> => |
| 178 | Effect.gen(function* () { | 179 | Effect.gen(function* () { |
| 179 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | 180 | const executor = yield* createExecutor( |
| 181 | makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | ||
| 182 | ); | ||
| 180 | return yield* executor.mcp.probeEndpoint(url).pipe( | 183 | return yield* executor.mcp.probeEndpoint(url).pipe( |
| 181 | Effect.map( | 184 | Effect.map( |
| 182 | (r) => | 185 | (r) => |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 12 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 13 | 13 | ||
| 14 | import { mcpPlugin } from "./plugin"; | 14 | import { mcpPlugin } from "./plugin"; |
| 15 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 15 | 16 | ||
| 16 | const FILE_ID = "F012ABC3456"; | 17 | const FILE_ID = "F012ABC3456"; |
| 17 | const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); | 18 | const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); |
| @@ -128,6 +129,7 @@ describe("Slack Connect file fallback", () => { · 110 unmodified lines | |||
| 128 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 129 | const config = { | 130 | const config = { |
| 130 | ...makeTestConfig({ | 131 | ...makeTestConfig({ |
| 132 | access: testAccess.member(), | ||
| 131 | plugins: [ | 133 | plugins: [ |
| 132 | memoryCredentialsPlugin(), | 134 | memoryCredentialsPlugin(), |
| 133 | mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }), | 135 | mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }), |
| @@ -60,6 +60,7 @@ · 59 unmodified lines | |||
| 60 | "devDependencies": { | 60 | "devDependencies": { |
| 61 | "@effect/vitest": "catalog:", | 61 | "@effect/vitest": "catalog:", |
| 62 | "@executor-js/api": "workspace:*", | 62 | "@executor-js/api": "workspace:*", |
| 63 | "@executor-js/product-access": "workspace:*", | ||
| 63 | "@types/node": "catalog:", | 64 | "@types/node": "catalog:", |
| 64 | "@types/react": "catalog:", | 65 | "@types/react": "catalog:", |
| 65 | "bun-types": "catalog:", | 66 | "bun-types": "catalog:", |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | |||
| 15 | import type { OnePasswordService } from "./service"; | 15 | import type { OnePasswordService } from "./service"; |
| 16 | import { OnePasswordError } from "./errors"; | 16 | import { OnePasswordError } from "./errors"; |
| 17 | import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types"; | 17 | import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types"; |
| 18 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 18 | 19 | ||
| 19 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` | 20 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` |
| 20 | // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and | 21 | // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and |
| @@ -63,7 +64,7 @@ describe("onepassword plugin", () => { · 42 unmodified lines | |||
| 63 | it.effect("registers onepassword as a credential provider", () => | 64 | it.effect("registers onepassword as a credential provider", () => |
| 64 | Effect.gen(function* () { | 65 | Effect.gen(function* () { |
| 65 | const executor = yield* createExecutor( | 66 | const executor = yield* createExecutor( |
| 66 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 67 | makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), |
| 67 | ); | 68 | ); |
| 68 | const providers = yield* executor.providers.list(); | 69 | const providers = yield* executor.providers.list(); |
| 69 | expect(providers).toContain(ONEPASSWORD); | 70 | expect(providers).toContain(ONEPASSWORD); |
| @@ -73,7 +74,7 @@ describe("onepassword plugin", () => { · 3 unmodified lines | |||
| 73 | it.effect("configure upserts accounts by id and removeConfig removes them one by one", () => | 74 | it.effect("configure upserts accounts by id and removeConfig removes them one by one", () => |
| 74 | Effect.gen(function* () { | 75 | Effect.gen(function* () { |
| 75 | const executor = yield* createExecutor( | 76 | const executor = yield* createExecutor( |
| 76 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 77 | makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), |
| 77 | ); | 78 | ); |
| 78 | 79 | ||
| 79 | const initial = yield* executor.onepassword.getConfig(); | 80 | const initial = yield* executor.onepassword.getConfig(); |
| @@ -124,7 +125,7 @@ describe("onepassword plugin", () => { · 44 unmodified lines | |||
| 124 | it.effect("removeConfig without an id removes everything", () => | 125 | it.effect("removeConfig without an id removes everything", () => |
| 125 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 126 | const executor = yield* createExecutor( | 127 | const executor = yield* createExecutor( |
| 127 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 128 | makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), |
| 128 | ); | 129 | ); |
| 129 | yield* executor.onepassword.configure({ | 130 | yield* executor.onepassword.configure({ |
| 130 | name: "Work", | 131 | name: "Work", |
| @@ -144,7 +145,7 @@ describe("onepassword plugin", () => { · 13 unmodified lines | |||
| 144 | it.effect("getConfig redacts every account's service-account token", () => | 145 | it.effect("getConfig redacts every account's service-account token", () => |
| 145 | Effect.gen(function* () { | 146 | Effect.gen(function* () { |
| 146 | const executor = yield* createExecutor( | 147 | const executor = yield* createExecutor( |
| 147 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 148 | makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), |
| 148 | ); | 149 | ); |
| 149 | 150 | ||
| 150 | yield* executor.onepassword.configure({ | 151 | yield* executor.onepassword.configure({ |
| @@ -171,7 +172,7 @@ describe("onepassword plugin", () => { · 20 unmodified lines | |||
| 171 | it.effect("exposes provider configuration as agent-callable static tools", () => | 172 | it.effect("exposes provider configuration as agent-callable static tools", () => |
| 172 | Effect.gen(function* () { | 173 | Effect.gen(function* () { |
| 173 | const executor = yield* createExecutor( | 174 | const executor = yield* createExecutor( |
| 174 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 175 | makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), |
| 175 | ); | 176 | ); |
| 176 | 177 | ||
| 177 | const configured = yield* executor.execute( | 178 | const configured = yield* executor.execute( |
| @@ -225,7 +226,7 @@ describe("onepassword plugin", () => { · 47 unmodified lines | |||
| 225 | it.effect("status reports not-configured before configure", () => | 226 | it.effect("status reports not-configured before configure", () => |
| 226 | Effect.gen(function* () { | 227 | Effect.gen(function* () { |
| 227 | const executor = yield* createExecutor( | 228 | const executor = yield* createExecutor( |
| 228 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 229 | makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), |
| 229 | ); | 230 | ); |
| 230 | const status = yield* executor.onepassword.status(); | 231 | const status = yield* executor.onepassword.status(); |
| 231 | expect(status.connected).toBe(false); | 232 | expect(status.connected).toBe(false); |
| @@ -248,6 +249,7 @@ describe("onepassword store", () => { · 16 unmodified lines | |||
| 248 | makeInMemoryBlobStore(), | 249 | makeInMemoryBlobStore(), |
| 249 | { org: "org_test", user: null }, | 250 | { org: "org_test", user: null }, |
| 250 | "onepassword", | 251 | "onepassword", |
| 252 | { owners: ["org"], storageWrites: "allowed" }, | ||
| 251 | ); | 253 | ); |
| 252 | return { blobs, store: makeOnePasswordStore(blobs) }; | 254 | return { blobs, store: makeOnePasswordStore(blobs) }; |
| 253 | }; | 255 | }; |
| @@ -86,6 +86,7 @@ · 85 unmodified lines | |||
| 86 | "@effect/atom-react": "catalog:", | 86 | "@effect/atom-react": "catalog:", |
| 87 | "@effect/vitest": "catalog:", | 87 | "@effect/vitest": "catalog:", |
| 88 | "@executor-js/api": "workspace:*", | 88 | "@executor-js/api": "workspace:*", |
| 89 | "@executor-js/product-access": "workspace:*", | ||
| 89 | "@executor-js/react": "workspace:*", | 90 | "@executor-js/react": "workspace:*", |
| 90 | "@types/js-yaml": "4.0.9", | 91 | "@types/js-yaml": "4.0.9", |
| 91 | "@types/node": "catalog:", | 92 | "@types/node": "catalog:", |
| @@ -9,6 +9,7 @@ import type { AuthenticationInput } from "@executor-js/plugin-openapi"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; | 10 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| 11 | import { googleCatalog } from "./presets"; | 11 | import { googleCatalog } from "./presets"; |
| 12 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 12 | 13 | ||
| 13 | const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; | 14 | const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; |
| 14 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; | 15 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; |
| @@ -179,6 +180,7 @@ it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived i · 164 unmodified lines | |||
| 179 | Effect.gen(function* () { | 180 | Effect.gen(function* () { |
| 180 | const executor = yield* createExecutor( | 181 | const executor = yield* createExecutor( |
| 181 | makeTestConfig({ | 182 | makeTestConfig({ |
| 183 | access: testAccess.member(), | ||
| 182 | plugins: [ | 184 | plugins: [ |
| 183 | openApiPlugin({ | 185 | openApiPlugin({ |
| 184 | httpClientLayer: discoveryHttpClientLayer, | 186 | httpClientLayer: discoveryHttpClientLayer, |
| @@ -211,6 +213,7 @@ it.effect( · 26 unmodified lines | |||
| 211 | ); | 213 | ); |
| 212 | const executor = yield* createExecutor( | 214 | const executor = yield* createExecutor( |
| 213 | makeTestConfig({ | 215 | makeTestConfig({ |
| 216 | access: testAccess.member(), | ||
| 214 | plugins: [ | 217 | plugins: [ |
| 215 | openApiPlugin({ | 218 | openApiPlugin({ |
| 216 | httpClientLayer: discoveryHttpClientLayer, | 219 | httpClientLayer: discoveryHttpClientLayer, |
| @@ -257,6 +260,7 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 40 unmodified lines | |||
| 257 | ); | 260 | ); |
| 258 | const executor = yield* createExecutor( | 261 | const executor = yield* createExecutor( |
| 259 | makeTestConfig({ | 262 | makeTestConfig({ |
| 263 | access: testAccess.member(), | ||
| 260 | plugins: [ | 264 | plugins: [ |
| 261 | openApiPlugin({ | 265 | openApiPlugin({ |
| 262 | httpClientLayer: gmailDiscoveryHttpClientLayer, | 266 | httpClientLayer: gmailDiscoveryHttpClientLayer, |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | |||
| 35 | serveOpenApiHttpApiTestServer, | 35 | serveOpenApiHttpApiTestServer, |
| 36 | unwrapInvocation, | 36 | unwrapInvocation, |
| 37 | } from "../testing"; | 37 | } from "../testing"; |
| 38 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 38 | 39 | ||
| 39 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => | 40 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => |
| 40 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; | 41 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -90,7 +91,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 49 unmodified lines | |||
| 90 | it.effect("adds a custom apiKey method and getConfig reflects it", () => | 91 | it.effect("adds a custom apiKey method and getConfig reflects it", () => |
| 91 | Effect.scoped( | 92 | Effect.scoped( |
| 92 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 93 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 94 | const executor = yield* createExecutor( |
| 95 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 96 | ); | ||
| 94 | 97 | ||
| 95 | yield* executor.openapi.addSpec({ | 98 | yield* executor.openapi.addSpec({ |
| 96 | spec: { kind: "blob", value: specText() }, | 99 | spec: { kind: "blob", value: specText() }, |
| @@ -119,7 +122,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 22 unmodified lines | |||
| 119 | it.effect("appends to an existing spec-derived template without dropping entries", () => | 122 | it.effect("appends to an existing spec-derived template without dropping entries", () => |
| 120 | Effect.scoped( | 123 | Effect.scoped( |
| 121 | Effect.gen(function* () { | 124 | Effect.gen(function* () { |
| 122 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 125 | const executor = yield* createExecutor( |
| 126 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 127 | ); | ||
| 123 | 128 | ||
| 124 | const seedTemplate: AuthenticationInput = { | 129 | const seedTemplate: AuthenticationInput = { |
| 125 | slug: "seed", | 130 | slug: "seed", |
| @@ -145,7 +150,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 19 unmodified lines | |||
| 145 | it.effect("generates a custom_<id> slug for a method submitted without one", () => | 150 | it.effect("generates a custom_<id> slug for a method submitted without one", () => |
| 146 | Effect.scoped( | 151 | Effect.scoped( |
| 147 | Effect.gen(function* () { | 152 | Effect.gen(function* () { |
| 148 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 153 | const executor = yield* createExecutor( |
| 154 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 155 | ); | ||
| 149 | 156 | ||
| 150 | yield* executor.openapi.addSpec({ | 157 | yield* executor.openapi.addSpec({ |
| 151 | spec: { kind: "blob", value: specText() }, | 158 | spec: { kind: "blob", value: specText() }, |
| @@ -167,7 +174,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 15 unmodified lines | |||
| 167 | it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () => | 174 | it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () => |
| 168 | Effect.scoped( | 175 | Effect.scoped( |
| 169 | Effect.gen(function* () { | 176 | Effect.gen(function* () { |
| 170 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 177 | const executor = yield* createExecutor( |
| 178 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 179 | ); | ||
| 171 | 180 | ||
| 172 | yield* executor.openapi.addSpec({ | 181 | yield* executor.openapi.addSpec({ |
| 173 | spec: { kind: "blob", value: specText() }, | 182 | spec: { kind: "blob", value: specText() }, |
| @@ -208,7 +217,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 34 unmodified lines | |||
| 208 | it.effect("replace mode overwrites the auth template array", () => | 217 | it.effect("replace mode overwrites the auth template array", () => |
| 209 | Effect.scoped( | 218 | Effect.scoped( |
| 210 | Effect.gen(function* () { | 219 | Effect.gen(function* () { |
| 211 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 220 | const executor = yield* createExecutor( |
| 221 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 222 | ); | ||
| 212 | 223 | ||
| 213 | const seedTemplate: AuthenticationInput = { | 224 | const seedTemplate: AuthenticationInput = { |
| 214 | slug: "seed", | 225 | slug: "seed", |
| @@ -236,7 +247,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 21 unmodified lines | |||
| 236 | Effect.scoped( | 247 | Effect.scoped( |
| 237 | Effect.gen(function* () { | 248 | Effect.gen(function* () { |
| 238 | const server = yield* servePluginTestApi(); | 249 | const server = yield* servePluginTestApi(); |
| 239 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 250 | const executor = yield* createExecutor( |
| 251 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 252 | ); | ||
| 240 | 253 | ||
| 241 | yield* executor.openapi.addSpec({ | 254 | yield* executor.openapi.addSpec({ |
| 242 | spec: { kind: "blob", value: server.specJson }, | 255 | spec: { kind: "blob", value: server.specJson }, |
| @@ -272,7 +285,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 29 unmodified lines | |||
| 272 | Effect.scoped( | 285 | Effect.scoped( |
| 273 | Effect.gen(function* () { | 286 | Effect.gen(function* () { |
| 274 | const server = yield* servePluginTestApi(); | 287 | const server = yield* servePluginTestApi(); |
| 275 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 288 | const executor = yield* createExecutor( |
| 289 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 290 | ); | ||
| 276 | 291 | ||
| 277 | yield* executor.openapi.addSpec({ | 292 | yield* executor.openapi.addSpec({ |
| 278 | spec: { kind: "blob", value: server.specJson }, | 293 | spec: { kind: "blob", value: server.specJson }, |
| @@ -320,7 +335,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 41 unmodified lines | |||
| 320 | it.effect("getConfig returns null for an unknown integration", () => | 335 | it.effect("getConfig returns null for an unknown integration", () => |
| 321 | Effect.scoped( | 336 | Effect.scoped( |
| 322 | Effect.gen(function* () { | 337 | Effect.gen(function* () { |
| 323 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 338 | const executor = yield* createExecutor( |
| 339 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 340 | ); | ||
| 324 | expect(yield* executor.openapi.getConfig("nope")).toBeNull(); | 341 | expect(yield* executor.openapi.getConfig("nope")).toBeNull(); |
| 325 | }), | 342 | }), |
| 326 | ), | 343 | ), |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | |||
| 30 | } from "@executor-js/plugin-openapi/testing"; | 30 | } from "@executor-js/plugin-openapi/testing"; |
| 31 | 31 | ||
| 32 | import { openApiPlugin } from "./plugin"; | 32 | import { openApiPlugin } from "./plugin"; |
| 33 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 33 | 34 | ||
| 34 | type Captured = { | 35 | type Captured = { |
| 35 | contentType: string; | 36 | contentType: string; |
| @@ -76,30 +77,28 @@ const plugins = [ · 40 unmodified lines | |||
| 76 | memoryCredentialsPlugin(), | 77 | memoryCredentialsPlugin(), |
| 77 | ] as const; | 78 | ] as const; |
| 78 | 79 | ||
| 79 | layer( | 80 | layer(makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), { timeout: "15 seconds" })( |
| 80 | makeTestWorkspaceLayer({ | 81 | "OpenAPI non-JSON request body serialization", |
| 81 | plugins, | 82 | (it) => { |
| 82 | }), | 83 | it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => |
| 83 | { timeout: "15 seconds" }, | 84 | Effect.gen(function* () { |
| 84 | )("OpenAPI non-JSON request body serialization", (it) => { | 85 | const { server, captured } = yield* startEchoServer(); |
| 85 | it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => | 86 | const { config } = yield* TestWorkspace; |
| 86 | Effect.gen(function* () { | 87 | const executor = yield* createExecutor({ ...config, plugins }); |
| 87 | const { server, captured } = yield* startEchoServer(); | ||
| 88 | const { config } = yield* TestWorkspace; | ||
| 89 | const executor = yield* createExecutor({ ...config, plugins }); | ||
| 90 | 88 | ||
| 91 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" }); | 89 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" }); |
| 92 | 90 | ||
| 93 | yield* executor.execute(conn.address("forms.submit"), { | 91 | yield* executor.execute(conn.address("forms.submit"), { |
| 94 | body: { name: "Acme", email: "a@b.com" }, | 92 | body: { name: "Acme", email: "a@b.com" }, |
| 95 | }); | 93 | }); |
| 96 | 94 | ||
| 97 | expect(captured.contentType).toBe("application/x-www-form-urlencoded"); | 95 | expect(captured.contentType).toBe("application/x-www-form-urlencoded"); |
| 98 | expect(captured.body).not.toBe("[object Object]"); | 96 | expect(captured.body).not.toBe("[object Object]"); |
| 99 | 97 | ||
| 100 | const parsed = new URLSearchParams(captured.body); | 98 | const parsed = new URLSearchParams(captured.body); |
| 101 | expect(parsed.get("name")).toBe("Acme"); | 99 | expect(parsed.get("name")).toBe("Acme"); |
| 102 | expect(parsed.get("email")).toBe("a@b.com"); | 100 | expect(parsed.get("email")).toBe("a@b.com"); |
| 103 | }), | 101 | }), |
| 104 | ); | 102 | ); |
| 105 | }); | 103 | }, |
| 104 | ); | ||
| @@ -34,6 +34,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 33 unmodified lines | |||
| 34 | 34 | ||
| 35 | import { serveOpenApiHttpApiTestServer } from "../testing"; | 35 | import { serveOpenApiHttpApiTestServer } from "../testing"; |
| 36 | import { openApiPlugin } from "./plugin"; | 36 | import { openApiPlugin } from "./plugin"; |
| 37 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 37 | 38 | ||
| 38 | /** The credential the connection authenticates with. Longer than the sample's | 39 | /** The credential the connection authenticates with. Longer than the sample's |
| 39 | * 120-char value cap on purpose: truncating before scrubbing would leave a | 40 | * 120-char value cap on purpose: truncating before scrubbing would leave a |
| @@ -84,6 +85,7 @@ const withProbe = <A, E, R>( · 44 unmodified lines | |||
| 84 | }); | 85 | }); |
| 85 | const executor = yield* createExecutor( | 86 | const executor = yield* createExecutor( |
| 86 | makeTestConfig({ | 87 | makeTestConfig({ |
| 88 | access: testAccess.member(), | ||
| 87 | plugins: [ | 89 | plugins: [ |
| 88 | openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), | 90 | openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), |
| 89 | memoryCredentialsPlugin(), | 91 | memoryCredentialsPlugin(), |
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | |||
| 37 | 37 | ||
| 38 | import { openApiPlugin } from "./plugin"; | 38 | import { openApiPlugin } from "./plugin"; |
| 39 | import { type AuthenticationInput } from "./types"; | 39 | import { type AuthenticationInput } from "./types"; |
| 40 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 40 | 41 | ||
| 41 | // --------------------------------------------------------------------------- | 42 | // --------------------------------------------------------------------------- |
| 42 | // Test API — a single endpoint that echoes the Authorization header so the | 43 | // Test API — a single endpoint that echoes the Authorization header so the |
| @@ -86,7 +87,9 @@ describe("OpenAPI owner-isolated bearer", () => { · 43 unmodified lines | |||
| 86 | Effect.scoped( | 87 | Effect.scoped( |
| 87 | Effect.gen(function* () { | 88 | Effect.gen(function* () { |
| 88 | const server = yield* serveVercel(); | 89 | const server = yield* serveVercel(); |
| 89 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 90 | const executor = yield* createExecutor( |
| 91 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 92 | ); | ||
| 90 | 93 | ||
| 91 | // Admin uploads the spec once; the integration declares a bearer | 94 | // Admin uploads the spec once; the integration declares a bearer |
| 92 | // template but no token value. | 95 | // template but no token value. |
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | |||
| 32 | } from "@executor-js/plugin-openapi/testing"; | 32 | } from "@executor-js/plugin-openapi/testing"; |
| 33 | 33 | ||
| 34 | import { openApiPlugin } from "./plugin"; | 34 | import { openApiPlugin } from "./plugin"; |
| 35 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 35 | 36 | ||
| 36 | const JsonNameBody = Schema.fromJsonString( | 37 | const JsonNameBody = Schema.fromJsonString( |
| 37 | Schema.Struct({ | 38 | Schema.Struct({ |
| @@ -172,7 +173,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 134 unmodified lines | |||
| 172 | payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), | 173 | payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), |
| 173 | }); | 174 | }); |
| 174 | 175 | ||
| 175 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 176 | const executor = yield* createExecutor( |
| 177 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 178 | ); | ||
| 176 | 179 | ||
| 177 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" }); | 180 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" }); |
| 178 | 181 | ||
| @@ -222,7 +225,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 43 unmodified lines | |||
| 222 | ), | 225 | ), |
| 223 | }); | 226 | }); |
| 224 | 227 | ||
| 225 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 228 | const executor = yield* createExecutor( |
| 229 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 230 | ); | ||
| 226 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" }); | 231 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" }); |
| 227 | 232 | ||
| 228 | const schema = yield* executor.tools.schema(conn.address("body.upload")); | 233 | const schema = yield* executor.tools.schema(conn.address("body.upload")); |
| @@ -298,7 +303,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 69 unmodified lines | |||
| 298 | ), | 303 | ), |
| 299 | }); | 304 | }); |
| 300 | 305 | ||
| 301 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 306 | const executor = yield* createExecutor( |
| 307 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 308 | ); | ||
| 302 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" }); | 309 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" }); |
| 303 | 310 | ||
| 304 | const schema = yield* executor.tools.schema(conn.address("body.uploadPages")); | 311 | const schema = yield* executor.tools.schema(conn.address("body.uploadPages")); |
| @@ -378,7 +385,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 73 unmodified lines | |||
| 378 | }), | 385 | }), |
| 379 | }); | 386 | }); |
| 380 | 387 | ||
| 381 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 388 | const executor = yield* createExecutor( |
| 389 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 390 | ); | ||
| 382 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" }); | 391 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" }); |
| 383 | 392 | ||
| 384 | const exit = yield* executor | 393 | const exit = yield* executor |
| @@ -424,7 +433,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines | |||
| 424 | }), | 433 | }), |
| 425 | }); | 434 | }); |
| 426 | 435 | ||
| 427 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 436 | const executor = yield* createExecutor( |
| 437 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 438 | ); | ||
| 428 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" }); | 439 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" }); |
| 429 | 440 | ||
| 430 | // The rewrite is scoped to multipart bodies: a JSON body keeps its | 441 | // The rewrite is scoped to multipart bodies: a JSON body keeps its |
| @@ -465,7 +476,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 34 unmodified lines | |||
| 465 | }), | 476 | }), |
| 466 | }); | 477 | }); |
| 467 | 478 | ||
| 468 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 479 | const executor = yield* createExecutor( |
| 480 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 481 | ); | ||
| 469 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" }); | 482 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" }); |
| 470 | 483 | ||
| 471 | yield* executor.execute(conn.address("body.createNote"), { | 484 | yield* executor.execute(conn.address("body.createNote"), { |
| @@ -511,7 +524,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines | |||
| 511 | }), | 524 | }), |
| 512 | }); | 525 | }); |
| 513 | 526 | ||
| 514 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 527 | const executor = yield* createExecutor( |
| 528 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 529 | ); | ||
| 515 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" }); | 530 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" }); |
| 516 | 531 | ||
| 517 | const schema = yield* executor.tools.schema(conn.address("body.uploadMixed")); | 532 | const schema = yield* executor.tools.schema(conn.address("body.uploadMixed")); |
| @@ -564,7 +579,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 46 unmodified lines | |||
| 564 | }, | 579 | }, |
| 565 | }); | 580 | }); |
| 566 | 581 | ||
| 567 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 582 | const executor = yield* createExecutor( |
| 583 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 584 | ); | ||
| 568 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" }); | 585 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" }); |
| 569 | 586 | ||
| 570 | // Documented limitation: component schemas are carried through | 587 | // Documented limitation: component schemas are carried through |
| @@ -584,7 +601,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines | |||
| 584 | payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })), | 601 | payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })), |
| 585 | }); | 602 | }); |
| 586 | 603 | ||
| 587 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 604 | const executor = yield* createExecutor( |
| 605 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 606 | ); | ||
| 588 | 607 | ||
| 589 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "xml" }); | 608 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "xml" }); |
| 590 | 609 | ||
| @@ -603,7 +622,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines | |||
| 603 | transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")), | 622 | transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")), |
| 604 | }); | 623 | }); |
| 605 | 624 | ||
| 606 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 625 | const executor = yield* createExecutor( |
| 626 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 627 | ); | ||
| 607 | 628 | ||
| 608 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tx" }); | 629 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tx" }); |
| 609 | 630 | ||
| @@ -622,7 +643,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines | |||
| 622 | payload: Schema.String.pipe(HttpApiSchema.asText()), | 643 | payload: Schema.String.pipe(HttpApiSchema.asText()), |
| 623 | }); | 644 | }); |
| 624 | 645 | ||
| 625 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 646 | const executor = yield* createExecutor( |
| 647 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 648 | ); | ||
| 626 | 649 | ||
| 627 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tp" }); | 650 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tp" }); |
| 628 | 651 | ||
| @@ -639,7 +662,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 10 unmodified lines | |||
| 639 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 662 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 640 | }); | 663 | }); |
| 641 | 664 | ||
| 642 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 665 | const executor = yield* createExecutor( |
| 666 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 667 | ); | ||
| 643 | 668 | ||
| 644 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin" }); | 669 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin" }); |
| 645 | 670 | ||
| @@ -658,7 +683,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines | |||
| 658 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 683 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 659 | }); | 684 | }); |
| 660 | 685 | ||
| 661 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 686 | const executor = yield* createExecutor( |
| 687 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 688 | ); | ||
| 662 | 689 | ||
| 663 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" }); | 690 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" }); |
| 664 | 691 | ||
| @@ -677,7 +704,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines | |||
| 677 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 704 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 678 | }); | 705 | }); |
| 679 | 706 | ||
| 680 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 707 | const executor = yield* createExecutor( |
| 708 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 709 | ); | ||
| 681 | 710 | ||
| 682 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" }); | 711 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" }); |
| 683 | 712 | ||
| @@ -699,7 +728,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 15 unmodified lines | |||
| 699 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 728 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 700 | }); | 729 | }); |
| 701 | 730 | ||
| 702 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 731 | const executor = yield* createExecutor( |
| 732 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 733 | ); | ||
| 703 | 734 | ||
| 704 | const conn = yield* addOpenApiTestConnection(executor, server, { | 735 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 705 | slug: "bin_nested_b64_bad", | 736 | slug: "bin_nested_b64_bad", |
| @@ -723,7 +754,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines | |||
| 723 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 754 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 724 | }); | 755 | }); |
| 725 | 756 | ||
| 726 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 757 | const executor = yield* createExecutor( |
| 758 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 759 | ); | ||
| 727 | 760 | ||
| 728 | const conn = yield* addOpenApiTestConnection(executor, server, { | 761 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 729 | slug: "bin_b64_missing", | 762 | slug: "bin_b64_missing", |
| @@ -743,7 +776,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines | |||
| 743 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 776 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 744 | }); | 777 | }); |
| 745 | 778 | ||
| 746 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 779 | const executor = yield* createExecutor( |
| 780 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 781 | ); | ||
| 747 | 782 | ||
| 748 | const conn = yield* addOpenApiTestConnection(executor, server, { | 783 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 749 | slug: "bin_object_body", | 784 | slug: "bin_object_body", |
| @@ -767,7 +802,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines | |||
| 767 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 802 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 768 | }); | 803 | }); |
| 769 | 804 | ||
| 770 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 805 | const executor = yield* createExecutor( |
| 806 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 807 | ); | ||
| 771 | 808 | ||
| 772 | const conn = yield* addOpenApiTestConnection(executor, server, { | 809 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 773 | slug: "bin_string_body", | 810 | slug: "bin_string_body", |
| @@ -837,7 +874,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 63 unmodified lines | |||
| 837 | }), | 874 | }), |
| 838 | }); | 875 | }); |
| 839 | 876 | ||
| 840 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 877 | const executor = yield* createExecutor( |
| 878 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 879 | ); | ||
| 841 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" }); | 880 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" }); |
| 842 | 881 | ||
| 843 | const schema = yield* executor.tools.schema(conn.address("gmail.getAttachment")); | 882 | const schema = yield* executor.tools.schema(conn.address("gmail.getAttachment")); |
| @@ -921,7 +960,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 77 unmodified lines | |||
| 921 | }), | 960 | }), |
| 922 | }); | 961 | }); |
| 923 | 962 | ||
| 924 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 963 | const executor = yield* createExecutor( |
| 964 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 965 | ); | ||
| 925 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" }); | 966 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" }); |
| 926 | 967 | ||
| 927 | const result = yield* executor.execute(conn.address("gmailText.getAttachment"), { | 968 | const result = yield* executor.execute(conn.address("gmailText.getAttachment"), { |
| @@ -989,7 +1030,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 61 unmodified lines | |||
| 989 | }), | 1030 | }), |
| 990 | }); | 1031 | }); |
| 991 | 1032 | ||
| 992 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1033 | const executor = yield* createExecutor( |
| 1034 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1035 | ); | ||
| 993 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" }); | 1036 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" }); |
| 994 | 1037 | ||
| 995 | const result = yield* executor.execute(conn.address("zipAttachment.getAttachment"), { | 1038 | const result = yield* executor.execute(conn.address("zipAttachment.getAttachment"), { |
| @@ -1107,7 +1150,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 111 unmodified lines | |||
| 1107 | ), | 1150 | ), |
| 1108 | }); | 1151 | }); |
| 1109 | 1152 | ||
| 1110 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1153 | const executor = yield* createExecutor( |
| 1154 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1155 | ); | ||
| 1111 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" }); | 1156 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" }); |
| 1112 | 1157 | ||
| 1113 | const result = yield* executor.execute(conn.address("gmailMeta.getAttachment"), { | 1158 | const result = yield* executor.execute(conn.address("gmailMeta.getAttachment"), { |
| @@ -1156,7 +1201,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 42 unmodified lines | |||
| 1156 | }), | 1201 | }), |
| 1157 | }); | 1202 | }); |
| 1158 | 1203 | ||
| 1159 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1204 | const executor = yield* createExecutor( |
| 1205 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1206 | ); | ||
| 1160 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); | 1207 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); |
| 1161 | 1208 | ||
| 1162 | const schema = yield* executor.tools.schema(conn.address("files.download")); | 1209 | const schema = yield* executor.tools.schema(conn.address("files.download")); |
| @@ -1210,7 +1257,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 47 unmodified lines | |||
| 1210 | }), | 1257 | }), |
| 1211 | }); | 1258 | }); |
| 1212 | 1259 | ||
| 1213 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1260 | const executor = yield* createExecutor( |
| 1261 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1262 | ); | ||
| 1214 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); | 1263 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); |
| 1215 | 1264 | ||
| 1216 | const result = yield* executor.execute(conn.address("files.download"), { | 1265 | const result = yield* executor.execute(conn.address("files.download"), { |
| @@ -1247,7 +1296,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 30 unmodified lines | |||
| 1247 | payload: multiContentPayload, | 1296 | payload: multiContentPayload, |
| 1248 | }); | 1297 | }); |
| 1249 | 1298 | ||
| 1250 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1299 | const executor = yield* createExecutor( |
| 1300 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1301 | ); | ||
| 1251 | 1302 | ||
| 1252 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc" }); | 1303 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc" }); |
| 1253 | 1304 | ||
| @@ -1265,7 +1316,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 11 unmodified lines | |||
| 1265 | payload: multiContentPayload, | 1316 | payload: multiContentPayload, |
| 1266 | }); | 1317 | }); |
| 1267 | 1318 | ||
| 1268 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1319 | const executor = yield* createExecutor( |
| 1320 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1321 | ); | ||
| 1269 | 1322 | ||
| 1270 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc2" }); | 1323 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc2" }); |
| 1271 | 1324 | ||
| @@ -1295,7 +1348,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 23 unmodified lines | |||
| 1295 | }), | 1348 | }), |
| 1296 | }); | 1349 | }); |
| 1297 | 1350 | ||
| 1298 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1351 | const executor = yield* createExecutor( |
| 1352 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1353 | ); | ||
| 1299 | 1354 | ||
| 1300 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" }); | 1355 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" }); |
| 1301 | 1356 | ||
| @@ -1322,7 +1377,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 20 unmodified lines | |||
| 1322 | }), | 1377 | }), |
| 1323 | }); | 1378 | }); |
| 1324 | 1379 | ||
| 1325 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1380 | const executor = yield* createExecutor( |
| 1381 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1382 | ); | ||
| 1326 | 1383 | ||
| 1327 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" }); | 1384 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" }); |
| 1328 | 1385 | ||
| @@ -1352,7 +1409,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 23 unmodified lines | |||
| 1352 | }, | 1409 | }, |
| 1353 | }), | 1410 | }), |
| 1354 | }); | 1411 | }); |
| 1355 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1412 | const executor = yield* createExecutor( |
| 1413 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1414 | ); | ||
| 1356 | 1415 | ||
| 1357 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" }); | 1416 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" }); |
| 1358 | 1417 | ||
| @@ -1375,7 +1434,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 16 unmodified lines | |||
| 1375 | const { server } = yield* startEchoServer({ | 1434 | const { server } = yield* startEchoServer({ |
| 1376 | payload: multiContentPayload, | 1435 | payload: multiContentPayload, |
| 1377 | }); | 1436 | }); |
| 1378 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1437 | const executor = yield* createExecutor( |
| 1438 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1439 | ); | ||
| 1379 | 1440 | ||
| 1380 | const conn = yield* addOpenApiTestConnection(executor, server, { | 1441 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 1381 | slug: "mc3", | 1442 | slug: "mc3", |
| @@ -1404,7 +1465,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 22 unmodified lines | |||
| 1404 | const { server } = yield* startEchoServer({ | 1465 | const { server } = yield* startEchoServer({ |
| 1405 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), | 1466 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 1406 | }); | 1467 | }); |
| 1407 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1468 | const executor = yield* createExecutor( |
| 1469 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1470 | ); | ||
| 1408 | 1471 | ||
| 1409 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" }); | 1472 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" }); |
| 1410 | 1473 | ||
| @@ -1450,7 +1513,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines | |||
| 1450 | ), | 1513 | ), |
| 1451 | }); | 1514 | }); |
| 1452 | 1515 | ||
| 1453 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1516 | const executor = yield* createExecutor( |
| 1517 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1518 | ); | ||
| 1454 | 1519 | ||
| 1455 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mpe" }); | 1520 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mpe" }); |
| 1456 | 1521 | ||
| @@ -1492,7 +1557,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 35 unmodified lines | |||
| 1492 | ), | 1557 | ), |
| 1493 | }); | 1558 | }); |
| 1494 | 1559 | ||
| 1495 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1560 | const executor = yield* createExecutor( |
| 1561 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1562 | ); | ||
| 1496 | 1563 | ||
| 1497 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fe" }); | 1564 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fe" }); |
| 1498 | 1565 | ||
| @@ -1523,7 +1590,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 24 unmodified lines | |||
| 1523 | ), | 1590 | ), |
| 1524 | }); | 1591 | }); |
| 1525 | 1592 | ||
| 1526 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1593 | const executor = yield* createExecutor( |
| 1594 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1595 | ); | ||
| 1527 | 1596 | ||
| 1528 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fd" }); | 1597 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fd" }); |
| 1529 | 1598 | ||
| @@ -1550,7 +1619,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 20 unmodified lines | |||
| 1550 | ), | 1619 | ), |
| 1551 | }); | 1620 | }); |
| 1552 | 1621 | ||
| 1553 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1622 | const executor = yield* createExecutor( |
| 1623 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1624 | ); | ||
| 1554 | 1625 | ||
| 1555 | // No encoding → OAS3 defaults: style=form, explode=true. | 1626 | // No encoding → OAS3 defaults: style=form, explode=true. |
| 1556 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" }); | 1627 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" }); |
| @@ -49,6 +49,7 @@ import { · 48 unmodified lines | |||
| 49 | serveOpenApiHttpApiTestServer, | 49 | serveOpenApiHttpApiTestServer, |
| 50 | unwrapInvocation, | 50 | unwrapInvocation, |
| 51 | } from "../testing"; | 51 | } from "../testing"; |
| 52 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 52 | 53 | ||
| 53 | const TOOL_ERROR_TYPESCRIPT = | 54 | const TOOL_ERROR_TYPESCRIPT = |
| 54 | "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; | 55 | "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; |
| @@ -393,7 +394,9 @@ describe("OpenAPI Plugin", () => { · 338 unmodified lines | |||
| 393 | Effect.gen(function* () { | 394 | Effect.gen(function* () { |
| 394 | const server = yield* servePluginTestApi(); | 395 | const server = yield* servePluginTestApi(); |
| 395 | 396 | ||
| 396 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 397 | const executor = yield* createExecutor( |
| 398 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 399 | ); | ||
| 397 | 400 | ||
| 398 | const preview = yield* executor.openapi.previewSpec(server.specJson); | 401 | const preview = yield* executor.openapi.previewSpec(server.specJson); |
| 399 | 402 | ||
| @@ -408,7 +411,10 @@ describe("OpenAPI Plugin", () => { · 8 unmodified lines | |||
| 408 | Effect.gen(function* () { | 411 | Effect.gen(function* () { |
| 409 | const server = yield* serveOAuthDiscoverableOpenApiSpec(); | 412 | const server = yield* serveOAuthDiscoverableOpenApiSpec(); |
| 410 | const executor = yield* createExecutor( | 413 | const executor = yield* createExecutor( |
| 411 | makeTestConfig({ plugins: testPlugins(server.httpClientLayer) }), | 414 | makeTestConfig({ |
| 415 | access: testAccess.member(), | ||
| 416 | plugins: testPlugins(server.httpClientLayer), | ||
| 417 | }), | ||
| 412 | ); | 418 | ); |
| 413 | 419 | ||
| 414 | const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/")); | 420 | const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/")); |
| @@ -456,7 +462,9 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines | |||
| 456 | 462 | ||
| 457 | it.effect("exposes static openapi executor control tools via execute", () => | 463 | it.effect("exposes static openapi executor control tools via execute", () => |
| 458 | Effect.gen(function* () { | 464 | Effect.gen(function* () { |
| 459 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 465 | const executor = yield* createExecutor( |
| 466 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 467 | ); | ||
| 460 | 468 | ||
| 461 | // v2: static control tools are NOT part of `tools.list()` (that's the | 469 | // v2: static control tools are NOT part of `tools.list()` (that's the |
| 462 | // persisted per-connection catalog) and aren't `tools.schema()`-resolvable; | 470 | // persisted per-connection catalog) and aren't `tools.schema()`-resolvable; |
| @@ -473,7 +481,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines | |||
| 473 | 481 | ||
| 474 | it.effect("invokes static previewSpec through executor.execute", () => | 482 | it.effect("invokes static previewSpec through executor.execute", () => |
| 475 | Effect.gen(function* () { | 483 | Effect.gen(function* () { |
| 476 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 484 | const executor = yield* createExecutor( |
| 485 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 486 | ); | ||
| 477 | 487 | ||
| 478 | const preview = unwrapInvocation( | 488 | const preview = unwrapInvocation( |
| 479 | yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { | 489 | yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { |
| @@ -495,7 +505,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines | |||
| 495 | 505 | ||
| 496 | it.effect("invokes static addSpec through executor.execute", () => | 506 | it.effect("invokes static addSpec through executor.execute", () => |
| 497 | Effect.gen(function* () { | 507 | Effect.gen(function* () { |
| 498 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 508 | const executor = yield* createExecutor( |
| 509 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 510 | ); | ||
| 499 | 511 | ||
| 500 | const result = unwrapInvocation( | 512 | const result = unwrapInvocation( |
| 501 | yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), { | 513 | yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), { |
| @@ -517,7 +529,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines | |||
| 517 | Effect.scoped( | 529 | Effect.scoped( |
| 518 | Effect.gen(function* () { | 530 | Effect.gen(function* () { |
| 519 | const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); | 531 | const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); |
| 520 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 532 | const executor = yield* createExecutor( |
| 533 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 534 | ); | ||
| 521 | 535 | ||
| 522 | yield* executor.openapi.addSpec({ | 536 | yield* executor.openapi.addSpec({ |
| 523 | spec: { kind: "url", url: specServer.specUrl }, | 537 | spec: { kind: "url", url: specServer.specUrl }, |
| @@ -565,7 +579,10 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines | |||
| 565 | 579 | ||
| 566 | it.effect("static previewSpec returns actionable tool failures", () => | 580 | it.effect("static previewSpec returns actionable tool failures", () => |
| 567 | Effect.gen(function* () { | 581 | Effect.gen(function* () { |
| 568 | const config = makeTestConfig({ plugins: [openApiPlugin()] as const }); | 582 | const config = makeTestConfig({ |
| 583 | access: testAccess.member(), | ||
| 584 | plugins: [openApiPlugin()] as const, | ||
| 585 | }); | ||
| 569 | const executor = yield* createExecutor(config); | 586 | const executor = yield* createExecutor(config); |
| 570 | 587 | ||
| 571 | const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { | 588 | const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { |
| @@ -587,7 +604,7 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines | |||
| 587 | it.effect("requires approval before adding an integration through the runtime tool", () => | 604 | it.effect("requires approval before adding an integration through the runtime tool", () => |
| 588 | Effect.gen(function* () { | 605 | Effect.gen(function* () { |
| 589 | const executor = yield* createExecutor( | 606 | const executor = yield* createExecutor( |
| 590 | makeTestConfig({ plugins: [openApiPlugin()] as const }), | 607 | makeTestConfig({ access: testAccess.member(), plugins: [openApiPlugin()] as const }), |
| 591 | ); | 608 | ); |
| 592 | 609 | ||
| 593 | const declined = yield* executor | 610 | const declined = yield* executor |
| @@ -607,7 +624,9 @@ describe("OpenAPI Plugin", () => { · 13 unmodified lines | |||
| 607 | Effect.scoped( | 624 | Effect.scoped( |
| 608 | Effect.gen(function* () { | 625 | Effect.gen(function* () { |
| 609 | const server = yield* servePluginTestApi(); | 626 | const server = yield* servePluginTestApi(); |
| 610 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 627 | const executor = yield* createExecutor( |
| 628 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 629 | ); | ||
| 611 | 630 | ||
| 612 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 631 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 613 | 632 | ||
| @@ -625,7 +644,9 @@ describe("OpenAPI Plugin", () => { · 11 unmodified lines | |||
| 625 | Effect.scoped( | 644 | Effect.scoped( |
| 626 | Effect.gen(function* () { | 645 | Effect.gen(function* () { |
| 627 | const server = yield* servePluginTestApi(); | 646 | const server = yield* servePluginTestApi(); |
| 628 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 647 | const executor = yield* createExecutor( |
| 648 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 649 | ); | ||
| 629 | 650 | ||
| 630 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 651 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 631 | 652 | ||
| @@ -642,7 +663,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines | |||
| 642 | Effect.scoped( | 663 | Effect.scoped( |
| 643 | Effect.gen(function* () { | 664 | Effect.gen(function* () { |
| 644 | const server = yield* servePluginTestApi(); | 665 | const server = yield* servePluginTestApi(); |
| 645 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 666 | const executor = yield* createExecutor( |
| 667 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 668 | ); | ||
| 646 | 669 | ||
| 647 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 670 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 648 | const calls = { count: 0 }; | 671 | const calls = { count: 0 }; |
| @@ -671,7 +694,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines | |||
| 671 | Effect.scoped( | 694 | Effect.scoped( |
| 672 | Effect.gen(function* () { | 695 | Effect.gen(function* () { |
| 673 | const server = yield* servePluginTestApi(); | 696 | const server = yield* servePluginTestApi(); |
| 674 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 697 | const executor = yield* createExecutor( |
| 698 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 699 | ); | ||
| 675 | 700 | ||
| 676 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 701 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 677 | const calls = { count: 0 }; | 702 | const calls = { count: 0 }; |
| @@ -705,7 +730,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines | |||
| 705 | Effect.scoped( | 730 | Effect.scoped( |
| 706 | Effect.gen(function* () { | 731 | Effect.gen(function* () { |
| 707 | const server = yield* servePluginTestApi(); | 732 | const server = yield* servePluginTestApi(); |
| 708 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 733 | const executor = yield* createExecutor( |
| 734 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 735 | ); | ||
| 709 | 736 | ||
| 710 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 737 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 711 | const calls = { count: 0 }; | 738 | const calls = { count: 0 }; |
| @@ -738,7 +765,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines | |||
| 738 | Effect.scoped( | 765 | Effect.scoped( |
| 739 | Effect.gen(function* () { | 766 | Effect.gen(function* () { |
| 740 | const server = yield* servePluginTestApi(); | 767 | const server = yield* servePluginTestApi(); |
| 741 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 768 | const executor = yield* createExecutor( |
| 769 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 770 | ); | ||
| 742 | 771 | ||
| 743 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 772 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 744 | const calls = { count: 0 }; | 773 | const calls = { count: 0 }; |
| @@ -771,7 +800,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines | |||
| 771 | Effect.scoped( | 800 | Effect.scoped( |
| 772 | Effect.gen(function* () { | 801 | Effect.gen(function* () { |
| 773 | const server = yield* servePluginTestApi(); | 802 | const server = yield* servePluginTestApi(); |
| 774 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 803 | const executor = yield* createExecutor( |
| 804 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 805 | ); | ||
| 775 | 806 | ||
| 776 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 807 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 777 | 808 | ||
| @@ -812,7 +843,9 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | |||
| 812 | Effect.scoped( | 843 | Effect.scoped( |
| 813 | Effect.gen(function* () { | 844 | Effect.gen(function* () { |
| 814 | const server = yield* servePluginTestApi(); | 845 | const server = yield* servePluginTestApi(); |
| 815 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 846 | const executor = yield* createExecutor( |
| 847 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 848 | ); | ||
| 816 | 849 | ||
| 817 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 850 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 818 | 851 | ||
| @@ -829,7 +862,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines | |||
| 829 | Effect.scoped( | 862 | Effect.scoped( |
| 830 | Effect.gen(function* () { | 863 | Effect.gen(function* () { |
| 831 | const server = yield* servePluginTestApi(); | 864 | const server = yield* servePluginTestApi(); |
| 832 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 865 | const executor = yield* createExecutor( |
| 866 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 867 | ); | ||
| 833 | 868 | ||
| 834 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); | 869 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 835 | const failure = yield* executor | 870 | const failure = yield* executor |
| @@ -853,7 +888,9 @@ describe("OpenAPI Plugin", () => { · 17 unmodified lines | |||
| 853 | Effect.scoped( | 888 | Effect.scoped( |
| 854 | Effect.gen(function* () { | 889 | Effect.gen(function* () { |
| 855 | const server = yield* servePluginTestApi(); | 890 | const server = yield* servePluginTestApi(); |
| 856 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 891 | const executor = yield* createExecutor( |
| 892 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 893 | ); | ||
| 857 | const multiServer = { | 894 | const multiServer = { |
| 858 | ...server, | 895 | ...server, |
| 859 | specJson: makeOpenApiTestSpecJson(TestApi, { | 896 | specJson: makeOpenApiTestSpecJson(TestApi, { |
| @@ -889,7 +926,9 @@ describe("OpenAPI Plugin", () => { · 29 unmodified lines | |||
| 889 | const server = yield* serveOpenApiEchoTestServer({ | 926 | const server = yield* serveOpenApiEchoTestServer({ |
| 890 | transformSpec: withEchoCookieParameter, | 927 | transformSpec: withEchoCookieParameter, |
| 891 | }); | 928 | }); |
| 892 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 929 | const executor = yield* createExecutor( |
| 930 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 931 | ); | ||
| 893 | const conn = yield* addOpenApiTestConnection(executor, server, { | 932 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 894 | slug: "cookie_params", | 933 | slug: "cookie_params", |
| 895 | headers: { Cookie: "configured=yes" }, | 934 | headers: { Cookie: "configured=yes" }, |
| @@ -923,7 +962,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines | |||
| 923 | const server = yield* serveOpenApiEchoTestServer({ | 962 | const server = yield* serveOpenApiEchoTestServer({ |
| 924 | transformSpec: withEchoCookieParameter, | 963 | transformSpec: withEchoCookieParameter, |
| 925 | }); | 964 | }); |
| 926 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 965 | const executor = yield* createExecutor( |
| 966 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 967 | ); | ||
| 927 | const conn = yield* addOpenApiTestConnection(executor, server, { | 968 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 928 | slug: "cookie_unknown", | 969 | slug: "cookie_unknown", |
| 929 | }); | 970 | }); |
| @@ -951,7 +992,9 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines | |||
| 951 | Effect.scoped( | 992 | Effect.scoped( |
| 952 | Effect.gen(function* () { | 993 | Effect.gen(function* () { |
| 953 | const server = yield* servePluginTestApi(); | 994 | const server = yield* servePluginTestApi(); |
| 954 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 995 | const executor = yield* createExecutor( |
| 996 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 997 | ); | ||
| 955 | 998 | ||
| 956 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" }); | 999 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" }); |
| 957 | 1000 | ||
| @@ -980,7 +1023,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines | |||
| 980 | Effect.scoped( | 1023 | Effect.scoped( |
| 981 | Effect.gen(function* () { | 1024 | Effect.gen(function* () { |
| 982 | const server = yield* servePluginTestApi(); | 1025 | const server = yield* servePluginTestApi(); |
| 983 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1026 | const executor = yield* createExecutor( |
| 1027 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1028 | ); | ||
| 984 | 1029 | ||
| 985 | yield* executor.openapi.addSpec({ | 1030 | yield* executor.openapi.addSpec({ |
| 986 | spec: { kind: "blob", value: server.specJson }, | 1031 | spec: { kind: "blob", value: server.specJson }, |
| @@ -1012,7 +1057,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines | |||
| 1012 | Effect.scoped( | 1057 | Effect.scoped( |
| 1013 | Effect.gen(function* () { | 1058 | Effect.gen(function* () { |
| 1014 | const server = yield* servePluginTestApi(); | 1059 | const server = yield* servePluginTestApi(); |
| 1015 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1060 | const executor = yield* createExecutor( |
| 1061 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1062 | ); | ||
| 1016 | 1063 | ||
| 1017 | yield* executor.openapi.addSpec({ | 1064 | yield* executor.openapi.addSpec({ |
| 1018 | spec: { kind: "blob", value: server.specJson }, | 1065 | spec: { kind: "blob", value: server.specJson }, |
| @@ -1044,7 +1091,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines | |||
| 1044 | Effect.scoped( | 1091 | Effect.scoped( |
| 1045 | Effect.gen(function* () { | 1092 | Effect.gen(function* () { |
| 1046 | const server = yield* servePluginTestApi(); | 1093 | const server = yield* servePluginTestApi(); |
| 1047 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1094 | const executor = yield* createExecutor( |
| 1095 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1096 | ); | ||
| 1048 | 1097 | ||
| 1049 | // The spec declares bearer auth; the caller passes NO template — the | 1098 | // The spec declares bearer auth; the caller passes NO template — the |
| 1050 | // agentic add path (MCP/API) does exactly this. Without server-side | 1099 | // agentic add path (MCP/API) does exactly this. Without server-side |
| @@ -1097,7 +1146,9 @@ describe("OpenAPI Plugin", () => { · 46 unmodified lines | |||
| 1097 | Effect.scoped( | 1146 | Effect.scoped( |
| 1098 | Effect.gen(function* () { | 1147 | Effect.gen(function* () { |
| 1099 | const server = yield* servePluginTestApi(); | 1148 | const server = yield* servePluginTestApi(); |
| 1100 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1149 | const executor = yield* createExecutor( |
| 1150 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1151 | ); | ||
| 1101 | 1152 | ||
| 1102 | // No baseUrl override: the spec declares `servers`, so the host is | 1153 | // No baseUrl override: the spec declares `servers`, so the host is |
| 1103 | // resolved per call from the operation's servers rather than baked into | 1154 | // resolved per call from the operation's servers rather than baked into |
| @@ -1127,7 +1178,9 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines | |||
| 1127 | Effect.scoped( | 1178 | Effect.scoped( |
| 1128 | Effect.gen(function* () { | 1179 | Effect.gen(function* () { |
| 1129 | const server = yield* servePluginTestApi(); | 1180 | const server = yield* servePluginTestApi(); |
| 1130 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1181 | const executor = yield* createExecutor( |
| 1182 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1183 | ); | ||
| 1131 | 1184 | ||
| 1132 | // The add page sends [] when the user deletes every detected method. | 1185 | // The add page sends [] when the user deletes every detected method. |
| 1133 | // That intent must survive — deriving methods back from the spec here | 1186 | // That intent must survive — deriving methods back from the spec here |
| @@ -1158,7 +1211,9 @@ describe("OpenAPI Plugin", () => { · 24 unmodified lines | |||
| 1158 | 1211 | ||
| 1159 | it.effect("addSpec accepts Graph-sized OpenAPI blobs", () => | 1212 | it.effect("addSpec accepts Graph-sized OpenAPI blobs", () => |
| 1160 | Effect.gen(function* () { | 1213 | Effect.gen(function* () { |
| 1161 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1214 | const executor = yield* createExecutor( |
| 1215 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1216 | ); | ||
| 1162 | const largeDescription = "x".repeat(36 * 1024 * 1024); | 1217 | const largeDescription = "x".repeat(36 * 1024 * 1024); |
| 1163 | 1218 | ||
| 1164 | const added = yield* executor.openapi.addSpec({ | 1219 | const added = yield* executor.openapi.addSpec({ |
| @@ -1191,7 +1246,9 @@ paths: · 26 unmodified lines | |||
| 1191 | "addSpec accepts Microsoft Graph-scale operation catalogs from one spec", | 1246 | "addSpec accepts Microsoft Graph-scale operation catalogs from one spec", |
| 1192 | () => | 1247 | () => |
| 1193 | Effect.gen(function* () { | 1248 | Effect.gen(function* () { |
| 1194 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1249 | const executor = yield* createExecutor( |
| 1250 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1251 | ); | ||
| 1195 | 1252 | ||
| 1196 | const added = yield* executor.openapi.addSpec({ | 1253 | const added = yield* executor.openapi.addSpec({ |
| 1197 | spec: { kind: "blob", value: microsoftGraphScaleSpecText() }, | 1254 | spec: { kind: "blob", value: microsoftGraphScaleSpecText() }, |
| @@ -1208,7 +1265,9 @@ paths: · 10 unmodified lines | |||
| 1208 | Effect.scoped( | 1265 | Effect.scoped( |
| 1209 | Effect.gen(function* () { | 1266 | Effect.gen(function* () { |
| 1210 | const server = yield* servePluginTestApi(); | 1267 | const server = yield* servePluginTestApi(); |
| 1211 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1268 | const executor = yield* createExecutor( |
| 1269 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1270 | ); | ||
| 1212 | 1271 | ||
| 1213 | yield* addOpenApiTestConnection(executor, server, { slug: "removable" }); | 1272 | yield* addOpenApiTestConnection(executor, server, { slug: "removable" }); |
| 1214 | expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain( | 1273 | expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain( |
| @@ -1232,7 +1291,9 @@ paths: · 17 unmodified lines | |||
| 1232 | Effect.scoped( | 1291 | Effect.scoped( |
| 1233 | Effect.gen(function* () { | 1292 | Effect.gen(function* () { |
| 1234 | const server = yield* servePluginTestApi(); | 1293 | const server = yield* servePluginTestApi(); |
| 1235 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1294 | const executor = yield* createExecutor( |
| 1295 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1296 | ); | ||
| 1236 | 1297 | ||
| 1237 | // First add carries an apiKey auth template + a distinctive description. | 1298 | // First add carries an apiKey auth template + a distinctive description. |
| 1238 | // A silent upsert on re-add would clobber both. | 1299 | // A silent upsert on re-add would clobber both. |
| @@ -1294,7 +1355,9 @@ paths: · 55 unmodified lines | |||
| 1294 | // The mutable spec server is a real 127.0.0.1 listener — reach it over | 1355 | // The mutable spec server is a real 127.0.0.1 listener — reach it over |
| 1295 | // the default fetch-based client, like production would. | 1356 | // the default fetch-based client, like production would. |
| 1296 | const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); | 1357 | const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); |
| 1297 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1358 | const executor = yield* createExecutor( |
| 1359 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1360 | ); | ||
| 1298 | 1361 | ||
| 1299 | const added = yield* executor.openapi.addSpec({ | 1362 | const added = yield* executor.openapi.addSpec({ |
| 1300 | spec: { kind: "url", url: specServer.specUrl }, | 1363 | spec: { kind: "url", url: specServer.specUrl }, |
| @@ -1370,7 +1433,7 @@ paths: · 69 unmodified lines | |||
| 1370 | it.effect("denies member updateSpec before writing an org blob", () => | 1433 | it.effect("denies member updateSpec before writing an org blob", () => |
| 1371 | Effect.gen(function* () { | 1434 | Effect.gen(function* () { |
| 1372 | const blobs = recordingBlobStore(); | 1435 | const blobs = recordingBlobStore(); |
| 1373 | const config = makeTestConfig({ plugins: testPlugins() }); | 1436 | const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); |
| 1374 | const admin = yield* createExecutor({ ...config, blobs: blobs.store }); | 1437 | const admin = yield* createExecutor({ ...config, blobs: blobs.store }); |
| 1375 | yield* admin.openapi.addSpec({ | 1438 | yield* admin.openapi.addSpec({ |
| 1376 | spec: { kind: "blob", value: testApiSpecText() }, | 1439 | spec: { kind: "blob", value: testApiSpecText() }, |
| @@ -1380,7 +1443,7 @@ paths: · 3 unmodified lines | |||
| 1380 | const member = yield* createExecutor({ | 1443 | const member = yield* createExecutor({ |
| 1381 | ...config, | 1444 | ...config, |
| 1382 | blobs: blobs.store, | 1445 | blobs: blobs.store, |
| 1383 | orgWrites: "denied", | 1446 | access: testAccess.member("denied"), |
| 1384 | }); | 1447 | }); |
| 1385 | 1448 | ||
| 1386 | const error = yield* member.openapi | 1449 | const error = yield* member.openapi |
| @@ -1398,7 +1461,9 @@ paths: · 11 unmodified lines | |||
| 1398 | Effect.scoped( | 1461 | Effect.scoped( |
| 1399 | Effect.gen(function* () { | 1462 | Effect.gen(function* () { |
| 1400 | const server = yield* servePluginTestApi(); | 1463 | const server = yield* servePluginTestApi(); |
| 1401 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1464 | const executor = yield* createExecutor( |
| 1465 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1466 | ); | ||
| 1402 | yield* addOpenApiTestConnection(executor, server, { slug: "pasted" }); | 1467 | yield* addOpenApiTestConnection(executor, server, { slug: "pasted" }); |
| 1403 | 1468 | ||
| 1404 | // A pasted-blob integration has no source URL — re-fetch must say so. | 1469 | // A pasted-blob integration has no source URL — re-fetch must say so. |
| @@ -1450,6 +1515,7 @@ paths: · 45 unmodified lines | |||
| 1450 | const plugins = testPlugins(); | 1515 | const plugins = testPlugins(); |
| 1451 | 1516 | ||
| 1452 | const alice = yield* makeTestWorkspaceHarness({ | 1517 | const alice = yield* makeTestWorkspaceHarness({ |
| 1518 | access: testAccess.member(), | ||
| 1453 | plugins, | 1519 | plugins, |
| 1454 | tenant, | 1520 | tenant, |
| 1455 | subject: "alice", | 1521 | subject: "alice", |
| @@ -1470,6 +1536,7 @@ paths: · 14 unmodified lines | |||
| 1470 | }); | 1536 | }); |
| 1471 | 1537 | ||
| 1472 | const bob = yield* makeTestWorkspaceHarness({ | 1538 | const bob = yield* makeTestWorkspaceHarness({ |
| 1539 | access: testAccess.member(), | ||
| 1473 | plugins, | 1540 | plugins, |
| 1474 | tenant, | 1541 | tenant, |
| 1475 | subject: "bob", | 1542 | subject: "bob", |
| @@ -1513,7 +1580,9 @@ paths: · 37 unmodified lines | |||
| 1513 | 1580 | ||
| 1514 | it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () => | 1581 | it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () => |
| 1515 | Effect.gen(function* () { | 1582 | Effect.gen(function* () { |
| 1516 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 1583 | const executor = yield* createExecutor( |
| 1584 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 1585 | ); | ||
| 1517 | const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip); | 1586 | const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip); |
| 1518 | expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true); | 1587 | expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true); |
| 1519 | }), | 1588 | }), |
| @@ -28,6 +28,7 @@ import { openApiPlugin } from "./plugin"; · 27 unmodified lines | |||
| 28 | import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; | 28 | import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; |
| 29 | import { previewSpec as previewSpecRaw } from "./preview"; | 29 | import { previewSpec as previewSpecRaw } from "./preview"; |
| 30 | import type { ExtractionResult } from "./types"; | 30 | import type { ExtractionResult } from "./types"; |
| 31 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 31 | 32 | ||
| 32 | const previewSpec = (input: string) => | 33 | const previewSpec = (input: string) => |
| 33 | previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer)); | 34 | previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer)); |
| @@ -91,6 +92,7 @@ const getRegisteredToolSchema = (slug: string, specText: string, toolName: strin · 57 unmodified lines | |||
| 91 | 92 | ||
| 92 | const executor = yield* createExecutor( | 93 | const executor = yield* createExecutor( |
| 93 | makeTestConfig({ | 94 | makeTestConfig({ |
| 95 | access: testAccess.member(), | ||
| 94 | plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const, | 96 | plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const, |
| 95 | }), | 97 | }), |
| 96 | ); | 98 | ); |
| @@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 15 unmodified lines | |||
| 16 | import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing"; | 16 | import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing"; |
| 17 | 17 | ||
| 18 | import { openApiPlugin } from "./plugin"; | 18 | import { openApiPlugin } from "./plugin"; |
| 19 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 19 | 20 | ||
| 20 | const BODY_READ_DEADLINE_MS = 15_000; | 21 | const BODY_READ_DEADLINE_MS = 15_000; |
| 21 | const RESPONSE_BODY_TIMEOUT_MS = 100; | 22 | const RESPONSE_BODY_TIMEOUT_MS = 100; |
| @@ -42,7 +43,9 @@ const testPlugins = () => · 20 unmodified lines | |||
| 42 | 43 | ||
| 43 | const buildExecutor = (baseUrl: string) => | 44 | const buildExecutor = (baseUrl: string) => |
| 44 | Effect.gen(function* () { | 45 | Effect.gen(function* () { |
| 45 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 46 | const executor = yield* createExecutor( |
| 47 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 48 | ); | ||
| 46 | yield* executor.openapi.addSpec( | 49 | yield* executor.openapi.addSpec( |
| 47 | makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { | 50 | makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { |
| 48 | slug: "body_timeout", | 51 | slug: "body_timeout", |
| @@ -18,6 +18,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 17 unmodified lines | |||
| 18 | import { invokeWithLayer } from "./invoke"; | 18 | import { invokeWithLayer } from "./invoke"; |
| 19 | import { openApiPlugin } from "./plugin"; | 19 | import { openApiPlugin } from "./plugin"; |
| 20 | import type { OperationBinding } from "./types"; | 20 | import type { OperationBinding } from "./types"; |
| 21 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 21 | 22 | ||
| 22 | const RESPONSE_HEADERS_TIMEOUT_MS = 100; | 23 | const RESPONSE_HEADERS_TIMEOUT_MS = 100; |
| 23 | const STREAM_TOOL = "logs.getLogs"; | 24 | const STREAM_TOOL = "logs.getLogs"; |
| @@ -44,7 +45,9 @@ const testPlugins = () => · 20 unmodified lines | |||
| 44 | 45 | ||
| 45 | const buildExecutor = (baseUrl: string) => | 46 | const buildExecutor = (baseUrl: string) => |
| 46 | Effect.gen(function* () { | 47 | Effect.gen(function* () { |
| 47 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 48 | const executor = yield* createExecutor( |
| 49 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 50 | ); | ||
| 48 | yield* executor.openapi.addSpec( | 51 | yield* executor.openapi.addSpec( |
| 49 | makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }), | 52 | makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }), |
| 50 | ); | 53 | ); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | |||
| 38 | serveOpenApiHttpApiTestServer, | 38 | serveOpenApiHttpApiTestServer, |
| 39 | unwrapInvocation, | 39 | unwrapInvocation, |
| 40 | } from "../testing"; | 40 | } from "../testing"; |
| 41 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 41 | 42 | ||
| 42 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => | 43 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => |
| 43 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; | 44 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -113,7 +114,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 69 unmodified lines | |||
| 113 | it.effect("addSpec stores a content pointer, not the inline spec text", () => | 114 | it.effect("addSpec stores a content pointer, not the inline spec text", () => |
| 114 | Effect.scoped( | 115 | Effect.scoped( |
| 115 | Effect.gen(function* () { | 116 | Effect.gen(function* () { |
| 116 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 117 | const executor = yield* createExecutor( |
| 118 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 119 | ); | ||
| 117 | const text = specText(); | 120 | const text = specText(); |
| 118 | 121 | ||
| 119 | yield* executor.openapi.addSpec({ | 122 | yield* executor.openapi.addSpec({ |
| @@ -135,7 +138,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 15 unmodified lines | |||
| 135 | api: TestApi, | 138 | api: TestApi, |
| 136 | handlersLayer: EchoGroupLive, | 139 | handlersLayer: EchoGroupLive, |
| 137 | }); | 140 | }); |
| 138 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 141 | const executor = yield* createExecutor( |
| 142 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 143 | ); | ||
| 139 | 144 | ||
| 140 | yield* executor.openapi.addSpec({ | 145 | yield* executor.openapi.addSpec({ |
| 141 | spec: { kind: "blob", value: server.specJson }, | 146 | spec: { kind: "blob", value: server.specJson }, |
| @@ -168,7 +173,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 26 unmodified lines | |||
| 168 | () => | 173 | () => |
| 169 | Effect.scoped( | 174 | Effect.scoped( |
| 170 | Effect.gen(function* () { | 175 | Effect.gen(function* () { |
| 171 | const config = makeTestConfig({ plugins: testPlugins() }); | 176 | const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); |
| 172 | const executor = yield* createExecutor(config); | 177 | const executor = yield* createExecutor(config); |
| 173 | const text = specTextWithDefinition(); | 178 | const text = specTextWithDefinition(); |
| 174 | const hash = yield* sha256Hex(text); | 179 | const hash = yield* sha256Hex(text); |
| @@ -245,7 +250,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 70 unmodified lines | |||
| 245 | it.effect("stale sync preserves tools and definitions when the spec blob is missing", () => | 250 | it.effect("stale sync preserves tools and definitions when the spec blob is missing", () => |
| 246 | Effect.scoped( | 251 | Effect.scoped( |
| 247 | Effect.gen(function* () { | 252 | Effect.gen(function* () { |
| 248 | const config = makeTestConfig({ plugins: testPlugins() }); | 253 | const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); |
| 249 | const executor = yield* createExecutor(config); | 254 | const executor = yield* createExecutor(config); |
| 250 | const text = specTextWithDefinition(); | 255 | const text = specTextWithDefinition(); |
| 251 | const hash = yield* sha256Hex(text); | 256 | const hash = yield* sha256Hex(text); |
| @@ -318,7 +323,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 66 unmodified lines | |||
| 318 | it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () => | 323 | it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () => |
| 319 | Effect.scoped( | 324 | Effect.scoped( |
| 320 | Effect.gen(function* () { | 325 | Effect.gen(function* () { |
| 321 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 326 | const executor = yield* createExecutor( |
| 327 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 328 | ); | ||
| 322 | const emptySpec = JSON.stringify({ | 329 | const emptySpec = JSON.stringify({ |
| 323 | openapi: "3.1.0", | 330 | openapi: "3.1.0", |
| 324 | info: { title: "Empty", version: "1.0.0" }, | 331 | info: { title: "Empty", version: "1.0.0" }, |
| @@ -409,7 +416,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 84 unmodified lines | |||
| 409 | it.effect("remove + re-add of the same spec is idempotent over the shared blob", () => | 416 | it.effect("remove + re-add of the same spec is idempotent over the shared blob", () => |
| 410 | Effect.scoped( | 417 | Effect.scoped( |
| 411 | Effect.gen(function* () { | 418 | Effect.gen(function* () { |
| 412 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 419 | const executor = yield* createExecutor( |
| 420 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 421 | ); | ||
| 413 | const text = specText(); | 422 | const text = specText(); |
| 414 | 423 | ||
| 415 | yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" }); | 424 | yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" }); |
| @@ -9,6 +9,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 8 unmodified lines | |||
| 9 | import { openApiPlugin } from "./plugin"; | 9 | import { openApiPlugin } from "./plugin"; |
| 10 | import { applySpecOverrides, type SpecOverrides } from "./spec-overrides"; | 10 | import { applySpecOverrides, type SpecOverrides } from "./spec-overrides"; |
| 11 | import { serveMutableOpenApiSpecTestServer } from "../testing"; | 11 | import { serveMutableOpenApiSpecTestServer } from "../testing"; |
| 12 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 12 | 13 | ||
| 13 | const testPlugins = () => | 14 | const testPlugins = () => |
| 14 | [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; | 15 | [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; |
| @@ -60,7 +61,9 @@ describe("OpenAPI spec override lifecycle", () => { · 45 unmodified lines | |||
| 60 | it.effect("applies scope overrides to preview and persists raw and patched hashes", () => | 61 | it.effect("applies scope overrides to preview and persists raw and patched hashes", () => |
| 61 | Effect.scoped( | 62 | Effect.scoped( |
| 62 | Effect.gen(function* () { | 63 | Effect.gen(function* () { |
| 63 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 64 | const executor = yield* createExecutor( |
| 65 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 66 | ); | ||
| 64 | const sourceText = encodeJsonText(oauthSpec); | 67 | const sourceText = encodeJsonText(oauthSpec); |
| 65 | 68 | ||
| 66 | const preview = yield* executor.openapi.previewSpec({ | 69 | const preview = yield* executor.openapi.previewSpec({ |
| @@ -93,7 +96,9 @@ describe("OpenAPI spec override lifecycle", () => { · 26 unmodified lines | |||
| 93 | it.effect("changes and clears overrides for an inline spec without repasting the source", () => | 96 | it.effect("changes and clears overrides for an inline spec without repasting the source", () => |
| 94 | Effect.scoped( | 97 | Effect.scoped( |
| 95 | Effect.gen(function* () { | 98 | Effect.gen(function* () { |
| 96 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 99 | const executor = yield* createExecutor( |
| 100 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 101 | ); | ||
| 97 | const sourceText = encodeJsonText(oauthSpec); | 102 | const sourceText = encodeJsonText(oauthSpec); |
| 98 | yield* executor.openapi.addSpec({ | 103 | yield* executor.openapi.addSpec({ |
| 99 | spec: { kind: "blob", value: sourceText }, | 104 | spec: { kind: "blob", value: sourceText }, |
| @@ -130,7 +135,9 @@ describe("OpenAPI spec override lifecycle", () => { · 30 unmodified lines | |||
| 130 | .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String })); | 135 | .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String })); |
| 131 | const InitialApi = HttpApi.make("initial").add(Items); | 136 | const InitialApi = HttpApi.make("initial").add(Items); |
| 132 | const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi }); | 137 | const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi }); |
| 133 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 138 | const executor = yield* createExecutor( |
| 139 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 140 | ); | ||
| 134 | const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }]; | 141 | const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }]; |
| 135 | 142 | ||
| 136 | yield* executor.openapi.addSpec({ | 143 | yield* executor.openapi.addSpec({ |
| @@ -17,6 +17,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke"; | 18 | import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke"; |
| 19 | import { openApiPlugin } from "./plugin"; | 19 | import { openApiPlugin } from "./plugin"; |
| 20 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 20 | 21 | ||
| 21 | const testPlugins = () => | 22 | const testPlugins = () => |
| 22 | [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; | 23 | [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; |
| @@ -35,7 +36,9 @@ const StreamingApi = HttpApi.make("streamingResponseTest") · 12 unmodified lines | |||
| 35 | 36 | ||
| 36 | const buildExecutor = (baseUrl: string) => | 37 | const buildExecutor = (baseUrl: string) => |
| 37 | Effect.gen(function* () { | 38 | Effect.gen(function* () { |
| 38 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 39 | const executor = yield* createExecutor( |
| 40 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 41 | ); | ||
| 39 | yield* executor.openapi.addSpec( | 42 | yield* executor.openapi.addSpec( |
| 40 | makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }), | 43 | makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }), |
| 41 | ); | 44 | ); |
| @@ -293,7 +296,9 @@ describe("OpenAPI streaming responses", () => { · 251 unmodified lines | |||
| 293 | ); | 296 | ); |
| 294 | }); | 297 | }); |
| 295 | 298 | ||
| 296 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 299 | const executor = yield* createExecutor( |
| 300 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 301 | ); | ||
| 297 | yield* executor.openapi.addSpec({ | 302 | yield* executor.openapi.addSpec({ |
| 298 | spec: { | 303 | spec: { |
| 299 | kind: "blob", | 304 | kind: "blob", |
| @@ -25,6 +25,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 24 unmodified lines | |||
| 25 | 25 | ||
| 26 | import { openApiPlugin } from "./plugin"; | 26 | import { openApiPlugin } from "./plugin"; |
| 27 | import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing"; | 27 | import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing"; |
| 28 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 28 | 29 | ||
| 29 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => | 30 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => |
| 30 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; | 31 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -58,7 +59,9 @@ const setup = Effect.gen(function* () { · 27 unmodified lines | |||
| 58 | api: TestApi, | 59 | api: TestApi, |
| 59 | handlersLayer: EchoGroupLive, | 60 | handlersLayer: EchoGroupLive, |
| 60 | }); | 61 | }); |
| 61 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 62 | const executor = yield* createExecutor( |
| 63 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 64 | ); | ||
| 62 | yield* executor.openapi.addSpec({ | 65 | yield* executor.openapi.addSpec({ |
| 63 | spec: { kind: "blob", value: server.specJson }, | 66 | spec: { kind: "blob", value: server.specJson }, |
| 64 | slug: "proj_api", | 67 | slug: "proj_api", |
| @@ -48,6 +48,7 @@ import { · 47 unmodified lines | |||
| 48 | } from "../testing"; | 48 | } from "../testing"; |
| 49 | 49 | ||
| 50 | import { openApiPlugin } from "./plugin"; | 50 | import { openApiPlugin } from "./plugin"; |
| 51 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 51 | 52 | ||
| 52 | class AdapterDefect extends Data.TaggedError("AdapterDefect") {} | 53 | class AdapterDefect extends Data.TaggedError("AdapterDefect") {} |
| 53 | 54 | ||
| @@ -149,7 +150,7 @@ const FailureApi = HttpApi.make("failuresTest") · 95 unmodified lines | |||
| 149 | const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) => | 150 | const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) => |
| 150 | Effect.gen(function* () { | 151 | Effect.gen(function* () { |
| 151 | const executor = yield* createExecutor( | 152 | const executor = yield* createExecutor( |
| 152 | makeTestConfig({ plugins: testPlugins(httpClientLayer) }), | 153 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins(httpClientLayer) }), |
| 153 | ); | 154 | ); |
| 154 | yield* executor.openapi.addSpec( | 155 | yield* executor.openapi.addSpec( |
| 155 | makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }), | 156 | makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }), |
| @@ -167,7 +168,9 @@ const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) · 11 unmodified lines | |||
| 167 | 168 | ||
| 168 | const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) => | 169 | const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) => |
| 169 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 170 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 171 | const executor = yield* createExecutor( |
| 172 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 173 | ); | ||
| 171 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" }); | 174 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" }); |
| 172 | return { executor, address: conn.address(LIST_THINGS) }; | 175 | return { executor, address: conn.address(LIST_THINGS) }; |
| 173 | }); | 176 | }); |
| @@ -349,7 +352,9 @@ describe("OpenAPI upstream failure modes", () => { · 175 unmodified lines | |||
| 349 | headers: { "content-type": "application/json" }, | 352 | headers: { "content-type": "application/json" }, |
| 350 | body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}', | 353 | body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}', |
| 351 | })); | 354 | })); |
| 352 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | 355 | const executor = yield* createExecutor( |
| 356 | makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | ||
| 357 | ); | ||
| 353 | // Declare the operation's scope in the spec blob before registering it, | 358 | // Declare the operation's scope in the spec blob before registering it, |
| 354 | // so the extracted binding carries requiredScopes. | 359 | // so the extracted binding carries requiredScopes. |
| 355 | const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); | 360 | const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); |
| @@ -56,6 +56,7 @@ · 55 unmodified lines | |||
| 56 | "@effect/atom-react": "catalog:", | 56 | "@effect/atom-react": "catalog:", |
| 57 | "@effect/vitest": "catalog:", | 57 | "@effect/vitest": "catalog:", |
| 58 | "@executor-js/api": "workspace:*", | 58 | "@executor-js/api": "workspace:*", |
| 59 | "@executor-js/product-access": "workspace:*", | ||
| 59 | "@executor-js/react": "workspace:*", | 60 | "@executor-js/react": "workspace:*", |
| 60 | "@tanstack/react-router": "catalog:", | 61 | "@tanstack/react-router": "catalog:", |
| 61 | "@types/node": "catalog:", | 62 | "@types/node": "catalog:", |
| @@ -3,11 +3,13 @@ import { Effect, Predicate, Result } from "effect"; · 2 unmodified lines | |||
| 3 | import { makeTestExecutor } from "@executor-js/sdk/testing"; | 3 | import { makeTestExecutor } from "@executor-js/sdk/testing"; |
| 4 | 4 | ||
| 5 | import { toolkitsPlugin } from "./server"; | 5 | import { toolkitsPlugin } from "./server"; |
| 6 | import { testAccess } from "@executor-js/product-access/testing"; | ||
| 6 | 7 | ||
| 7 | describe("toolkitsPlugin", () => { | 8 | describe("toolkitsPlugin", () => { |
| 8 | it.effect("creates toolkits and manages ordered policy rules", () => | 9 | it.effect("creates toolkits and manages ordered policy rules", () => |
| 9 | Effect.gen(function* () { | 10 | Effect.gen(function* () { |
| 10 | const executor = yield* makeTestExecutor({ | 11 | const executor = yield* makeTestExecutor({ |
| 12 | access: testAccess.member(), | ||
| 11 | plugins: [toolkitsPlugin()] as const, | 13 | plugins: [toolkitsPlugin()] as const, |
| 12 | }); | 14 | }); |
| 13 | 15 | ||
| @@ -51,6 +53,7 @@ describe("toolkitsPlugin", () => { · 37 unmodified lines | |||
| 51 | it.effect("rejects duplicate visible slugs", () => | 53 | it.effect("rejects duplicate visible slugs", () => |
| 52 | Effect.gen(function* () { | 54 | Effect.gen(function* () { |
| 53 | const executor = yield* makeTestExecutor({ | 55 | const executor = yield* makeTestExecutor({ |
| 56 | access: testAccess.member(), | ||
| 54 | plugins: [toolkitsPlugin()] as const, | 57 | plugins: [toolkitsPlugin()] as const, |
| 55 | }); | 58 | }); |
| 56 | yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" }); | 59 | yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" }); |
| @@ -67,6 +70,7 @@ describe("toolkitsPlugin", () => { · 10 unmodified lines | |||
| 67 | it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => | 70 | it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => |
| 68 | Effect.gen(function* () { | 71 | Effect.gen(function* () { |
| 69 | const executor = yield* makeTestExecutor({ | 72 | const executor = yield* makeTestExecutor({ |
| 73 | access: testAccess.member(), | ||
| 70 | plugins: [toolkitsPlugin()] as const, | 74 | plugins: [toolkitsPlugin()] as const, |
| 71 | }); | 75 | }); |
| 72 | 76 | ||
| @@ -135,6 +139,7 @@ describe("toolkitsPlugin", () => { · 62 unmodified lines | |||
| 135 | it.effect("treats a persisted connection-root approve as an access policy", () => | 139 | it.effect("treats a persisted connection-root approve as an access policy", () => |
| 136 | Effect.gen(function* () { | 140 | Effect.gen(function* () { |
| 137 | const executor = yield* makeTestExecutor({ | 141 | const executor = yield* makeTestExecutor({ |
| 142 | access: testAccess.member(), | ||
| 138 | plugins: [toolkitsPlugin()] as const, | 143 | plugins: [toolkitsPlugin()] as const, |
| 139 | }); | 144 | }); |
| 140 | 145 | ||
| @@ -169,6 +174,7 @@ describe("toolkitsPlugin", () => { · 28 unmodified lines | |||
| 169 | it.effect("applies a broad approve policy over a narrower connection", () => | 174 | it.effect("applies a broad approve policy over a narrower connection", () => |
| 170 | Effect.gen(function* () { | 175 | Effect.gen(function* () { |
| 171 | const executor = yield* makeTestExecutor({ | 176 | const executor = yield* makeTestExecutor({ |
| 177 | access: testAccess.member(), | ||
| 172 | plugins: [toolkitsPlugin()] as const, | 178 | plugins: [toolkitsPlugin()] as const, |
| 173 | }); | 179 | }); |
| 174 | 180 | ||
| @@ -36,6 +36,7 @@ · 35 unmodified lines | |||
| 36 | "@base-ui/react": "^1.3.0", | 36 | "@base-ui/react": "^1.3.0", |
| 37 | "@effect/atom-react": "catalog:", | 37 | "@effect/atom-react": "catalog:", |
| 38 | "@executor-js/api": "workspace:*", | 38 | "@executor-js/api": "workspace:*", |
| 39 | "@executor-js/product-access": "workspace:*", | ||
| 39 | "@executor-js/sdk": "workspace:*", | 40 | "@executor-js/sdk": "workspace:*", |
| 40 | "@hookform/resolvers": "^5.2.2", | 41 | "@hookform/resolvers": "^5.2.2", |
| 41 | "@lobehub/icons": "^5.4.0", | 42 | "@lobehub/icons": "^5.4.0", |
| @@ -10,10 +10,10 @@ import { · 9 unmodified lines | |||
| 10 | AuthTemplateSlug, | 10 | AuthTemplateSlug, |
| 11 | IntegrationSlug, | 11 | IntegrationSlug, |
| 12 | ToolAddress, | 12 | ToolAddress, |
| 13 | effectivePolicyFromSorted, | ||
| 14 | type Connection, | 13 | type Connection, |
| 15 | type Owner, | 14 | type Owner, |
| 16 | } from "@executor-js/sdk/shared"; | 15 | } from "@executor-js/sdk/shared"; |
| 16 | import { effectivePolicyFromSorted } from "@executor-js/product-access/policy"; | ||
| 17 | import { | 17 | import { |
| 18 | checkConnectionHealth, | 18 | checkConnectionHealth, |
| 19 | connectionsAllAtom, | 19 | connectionsAllAtom, |
| @@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; · 1 unmodified line | |||
| 2 | import { Link } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared"; | 5 | import { ToolAddress } from "@executor-js/sdk/shared"; |
| 6 | import { effectivePolicyFromSorted } from "@executor-js/product-access/policy"; | ||
| 6 | 7 | ||
| 7 | import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; | 8 | import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; |
| 8 | import { usePolicyActions } from "../hooks/use-policy-actions"; | 9 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| @@ -31,6 +31,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 30 unmodified lines | |||
| 31 | "packages/kernel/core", | 31 | "packages/kernel/core", |
| 32 | "packages/kernel/runtime-quickjs", | 32 | "packages/kernel/runtime-quickjs", |
| 33 | "packages/core/sdk", | 33 | "packages/core/sdk", |
| 34 | "packages/core/product-access", | ||
| 34 | "packages/core/config", | 35 | "packages/core/config", |
| 35 | "packages/core/execution", | 36 | "packages/core/execution", |
| 36 | "packages/core/cli", | 37 | "packages/core/cli", |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | |||
| 7 | * name (`@executor-js/sdk`) instead of relying on workspace resolution. | 7 | * name (`@executor-js/sdk`) instead of relying on workspace resolution. |
| 8 | */ | 8 | */ |
| 9 | import { $ } from "bun"; | 9 | import { $ } from "bun"; |
| 10 | import { Schema } from "effect"; | ||
| 10 | import { existsSync, readdirSync } from "node:fs"; | 11 | import { existsSync, readdirSync } from "node:fs"; |
| 11 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | 12 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; |
| 12 | import { tmpdir } from "node:os"; | 13 | import { tmpdir } from "node:os"; |
| @@ -14,10 +15,15 @@ import { dirname, join, resolve } from "node:path"; · 1 unmodified line | |||
| 14 | import { fileURLToPath } from "node:url"; | 15 | import { fileURLToPath } from "node:url"; |
| 15 | 16 | ||
| 16 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | 17 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 17 | const documentedPackages = ["@executor-js/sdk", "@executor-js/plugin-openapi"] as const; | 18 | const documentedPackages = [ |
| 19 | "@executor-js/sdk", | ||
| 20 | "@executor-js/product-access", | ||
| 21 | "@executor-js/plugin-openapi", | ||
| 22 | ] as const; | ||
| 18 | const publicPackageDirs = [ | 23 | const publicPackageDirs = [ |
| 19 | "packages/core/fumadb", | 24 | "packages/core/fumadb", |
| 20 | "packages/core/sdk", | 25 | "packages/core/sdk", |
| 26 | "packages/core/product-access", | ||
| 21 | "packages/core/config", | 27 | "packages/core/config", |
| 22 | "packages/plugins/openapi", | 28 | "packages/plugins/openapi", |
| 23 | ] as const; | 29 | ] as const; |
| @@ -27,6 +33,21 @@ const readPackageName = async (pkgDir: string): Promise<string> => { · 3 unmodified lines | |||
| 27 | return (JSON.parse(raw) as { name: string }).name; | 33 | return (JSON.parse(raw) as { name: string }).name; |
| 28 | }; | 34 | }; |
| 29 | 35 | ||
| 36 | // `effect` is the SDK's (and product-access's) required peer, and the docs | ||
| 37 | // install command tells consumers to add it themselves. `npm install | ||
| 38 | // --legacy-peer-deps` below skips auto-installing peers, so the fixture must | ||
| 39 | // declare it like a real consumer would — pinned from the workspace catalog | ||
| 40 | // so it matches what the packed manifests resolved `catalog:` to. | ||
| 41 | const decodeCatalog = Schema.decodeUnknownSync( | ||
| 42 | Schema.Struct({ catalog: Schema.Struct({ effect: Schema.NonEmptyString }) }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | const readCatalogEffectVersion = async (): Promise<string> => { | ||
| 46 | const raw = await readFile(join(repoRoot, "package.json"), "utf8"); | ||
| 47 | const manifest = decodeCatalog(JSON.parse(raw)); | ||
| 48 | return manifest.catalog.effect; | ||
| 49 | }; | ||
| 50 | |||
| 30 | const findTarball = (pkgDir: string, packageName: string): string => { | 51 | const findTarball = (pkgDir: string, packageName: string): string => { |
| 31 | const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz")); | 52 | const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz")); |
| 32 | if (!tarball) { | 53 | if (!tarball) { |
| @@ -48,7 +69,7 @@ for (const relDir of publicPackageDirs) { · 15 unmodified lines | |||
| 48 | const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-")); | 69 | const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-")); |
| 49 | 70 | ||
| 50 | try { | 71 | try { |
| 51 | const dependencies: Record<string, string> = {}; | 72 | const dependencies: Record<string, string> = { effect: await readCatalogEffectVersion() }; |
| 52 | const overrides: Record<string, string> = {}; | 73 | const overrides: Record<string, string> = {}; |
| 53 | for (const [name, tarball] of tarballs) { | 74 | for (const [name, tarball] of tarballs) { |
| 54 | overrides[name] = `file:${tarball}`; | 75 | overrides[name] = `file:${tarball}`; |
| @@ -89,9 +110,23 @@ try { · 34 unmodified lines | |||
| 89 | } | 110 | } |
| 90 | 111 | ||
| 91 | console.log(`[docs-smoke] import documented SDK packages`); | 112 | console.log(`[docs-smoke] import documented SDK packages`); |
| 92 | await $`node --input-type=module --eval ${`const sdk = await import("@executor-js/sdk"); const openapi = await import("@executor-js/plugin-openapi"); if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor"); if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`}`.cwd( | 113 | // The documented minimal embed: the SDK root export is the Promise façade |
| 93 | tmp, | 114 | // and ships no access posture of its own, so the probe constructs a |
| 94 | ); | 115 | // subject-less workspace-service executor with the posture from |
| 116 | // `@executor-js/product-access` (ephemeral in-memory backend, nothing | ||
| 117 | // invoked over the network) and closes it. | ||
| 118 | const importProbe = [ | ||
| 119 | `const sdk = await import("@executor-js/sdk");`, | ||
| 120 | `const access = await import("@executor-js/product-access");`, | ||
| 121 | `const openapi = await import("@executor-js/plugin-openapi");`, | ||
| 122 | `if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor");`, | ||
| 123 | `if (typeof access.workspaceServiceAccess !== "function") throw new Error("missing workspaceServiceAccess");`, | ||
| 124 | `if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`, | ||
| 125 | `const executor = await sdk.createExecutor({ access: access.workspaceServiceAccess(), onElicitation: "accept-all" });`, | ||
| 126 | `if (typeof executor.close !== "function") throw new Error("missing executor.close");`, | ||
| 127 | `await executor.close();`, | ||
| 128 | ].join("\n"); | ||
| 129 | await $`node --input-type=module --eval ${importProbe}`.cwd(tmp); | ||
| 95 | } finally { | 130 | } finally { |
| 96 | await rm(tmp, { recursive: true, force: true }); | 131 | await rm(tmp, { recursive: true, force: true }); |
| 97 | } | 132 | } |
| @@ -37,6 +37,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 36 unmodified lines | |||
| 37 | "packages/kernel/core", | 37 | "packages/kernel/core", |
| 38 | "packages/kernel/runtime-quickjs", | 38 | "packages/kernel/runtime-quickjs", |
| 39 | "packages/core/sdk", | 39 | "packages/core/sdk", |
| 40 | "packages/core/product-access", | ||
| 40 | "packages/core/config", | 41 | "packages/core/config", |
| 41 | "packages/core/execution", | 42 | "packages/core/execution", |
| 42 | "packages/core/cli", | 43 | "packages/core/cli", |