From c676e56c4243377403ad414ffd8a22c797f7828c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:59:36 -0700 Subject: [PATCH] Move product access rules out of the SDK --- .changeset/config.json | 1 + .changeset/product-access-split.md | 21 + .github/workflows/pkg-pr-new.yml | 1 + README.md | 16 +- apps/cloud/package.json | 1 + apps/cloud/src/admin/admin-users-api.ts | 2 +- apps/cloud/src/auth/workos-auth-provider.ts | 2 +- apps/cloud/src/mcp-session.e2e.node.test.ts | 2 + apps/cloud/src/mcp/session-durable-object.ts | 3 +- .../oauth-callback-telemetry.test.ts | 3 +- apps/host-cloudflare/package.json | 1 + .../src/mcp/session-durable-object.ts | 3 +- apps/host-selfhost/package.json | 1 + .../src/integrations-mcp.test.ts | 7 +- apps/host-selfhost/src/integrations.test.ts | 7 +- .../src/secrets-integration.test.ts | 7 +- .../src/subject-sightings.test.ts | 7 +- apps/local/package.json | 1 + apps/local/src/auth-tool-failures.test.ts | 2 + apps/local/src/db/legacy-bigint-boot.test.ts | 16 +- apps/local/src/db/v1-v2-boot-drive.test.ts | 2 + apps/local/src/executor.ts | 4 + apps/local/src/mcp-browser-resume.test.ts | 2 + apps/local/src/mcp-oauth.test.ts | 2 + bun.lock | 43 ++ examples/all-plugins/package.json | 1 + examples/all-plugins/src/main.ts | 3 + examples/docs-sdk-quickstart/package.json | 1 + examples/docs-sdk-quickstart/src/main.ts | 4 + examples/promise-sdk/package.json | 1 + examples/promise-sdk/src/main.ts | 4 + package.json | 3 +- packages/core/api/package.json | 1 + .../core/api/src/admin/admin-users.test.ts | 6 +- packages/core/api/src/admin/api.ts | 2 +- packages/core/api/src/admin/reads.ts | 2 +- .../integrations-auth-methods.test.ts | 17 +- packages/core/api/src/scoped-targets.test.ts | 6 +- .../src/server/execution-stack-middleware.ts | 8 +- .../core/api/src/server/execution-stack.ts | 17 +- packages/core/api/src/server/mcp-build.ts | 6 +- .../core/api/src/server/scoped-executor.ts | 21 +- packages/core/execution/README.md | 2 + packages/core/execution/package.json | 1 + .../core/execution/src/description.test.ts | 24 +- packages/core/execution/src/engine.test.ts | 10 +- .../execution/src/tool-invoker.leak.test.ts | 25 +- .../core/execution/src/tool-invoker.test.ts | 6 +- packages/core/product-access/CHANGELOG.md | 1 + packages/core/product-access/README.md | 79 +++ packages/core/product-access/package.json | 67 +++ .../core/product-access/src/index.test.ts | 185 +++++++ packages/core/product-access/src/index.ts | 159 ++++++ packages/core/product-access/src/policy.ts | 263 ++++++++++ packages/core/product-access/src/testing.ts | 54 ++ packages/core/product-access/tsconfig.json | 27 + packages/core/product-access/tsup.config.ts | 14 + packages/core/product-access/vitest.config.ts | 7 + packages/core/sdk/README.md | 17 +- packages/core/sdk/src/access.test.ts | 370 +++++++++++++ packages/core/sdk/src/access.ts | 215 ++++++++ .../sdk/src/approval-access-recheck.test.ts | 138 +++++ .../core/sdk/src/approval-plugin-swap.test.ts | 90 ++++ packages/core/sdk/src/artifact-table.test.ts | 12 +- packages/core/sdk/src/artifacts.test.ts | 47 +- packages/core/sdk/src/blob.test.ts | 177 ++++++- packages/core/sdk/src/blob.ts | 104 +++- packages/core/sdk/src/connections.test.ts | 107 +++- packages/core/sdk/src/core-schema.ts | 18 +- packages/core/sdk/src/errors.ts | 7 +- packages/core/sdk/src/executor.test.ts | 51 +- packages/core/sdk/src/executor.ts | 496 ++++++++++-------- packages/core/sdk/src/index.ts | 15 +- .../src/integration-removal-cascade.test.ts | 12 +- .../core/sdk/src/oauth-ema-lifecycle.test.ts | 31 +- .../core/sdk/src/oauth-ema-rollout.test.ts | 2 + .../core/sdk/src/oauth-first-party.test.ts | 16 +- packages/core/sdk/src/oauth-flow.test.ts | 146 ++++-- .../core/sdk/src/oauth-list-clients.test.ts | 23 +- .../sdk/src/oauth-register-dynamic.test.ts | 78 ++- .../core/sdk/src/oauth-remove-client.test.ts | 27 +- .../core/sdk/src/oauth-scope-union.test.ts | 44 +- packages/core/sdk/src/oauth-service.ts | 6 +- .../sdk/src/oauth-session-cleanup.test.ts | 6 + packages/core/sdk/src/org-write-access.ts | 33 -- packages/core/sdk/src/org-writes.test.ts | 34 +- .../core/sdk/src/owner-policy-reach.test.ts | 27 +- packages/core/sdk/src/owner-policy.ts | 71 ++- packages/core/sdk/src/platform-view.test.ts | 8 +- .../core/sdk/src/plugin-after-commit.test.ts | 8 +- packages/core/sdk/src/plugin-storage.test.ts | 9 + packages/core/sdk/src/plugin.ts | 6 +- packages/core/sdk/src/policies.test.ts | 20 +- packages/core/sdk/src/policies.ts | 127 +---- packages/core/sdk/src/promise-executor.ts | 11 + packages/core/sdk/src/promise.test.ts | 5 + .../sdk/src/provider-call-timeout.test.ts | 13 +- .../core/sdk/src/settings-authority.test.ts | 118 +++++ packages/core/sdk/src/shared.ts | 3 +- ...ite-bigint-storage-class-migration.test.ts | 24 +- .../core/sdk/src/subject-registry.test.ts | 6 + packages/core/sdk/src/subject-registry.ts | 3 + packages/core/sdk/src/test-config.ts | 27 +- packages/core/sdk/src/testing.test.ts | 6 +- .../src/tool-not-found-empty-catalog.test.ts | 5 +- .../core/sdk/src/tools-list-merge.test.ts | 8 +- .../hosts/cloudflare/src/blob-store.test.ts | 5 +- packages/hosts/mcp-apps-shell/package.json | 1 + .../src/shell/mcp-app.browser.test.ts | 6 +- packages/hosts/mcp/package.json | 1 + .../mcp/src/in-memory-session-store.test.ts | 24 +- packages/hosts/mcp/src/seams.ts | 7 +- .../runtime-dynamic-worker/package.json | 1 + .../src/integration.test.ts | 2 + .../plugins/desktop-settings/package.json | 1 + .../desktop-settings/src/server.test.ts | 2 + packages/plugins/file-secrets/package.json | 1 + .../plugins/file-secrets/src/data-dir.test.ts | 13 +- .../plugins/file-secrets/src/index.test.ts | 9 +- packages/plugins/graphql/package.json | 1 + .../plugins/graphql/src/sdk/configure.test.ts | 2 + .../src/sdk/invocation-timeout.test.ts | 2 + .../src/sdk/multi-placement-auth.test.ts | 6 +- .../plugins/graphql/src/sdk/plugin.test.ts | 15 +- packages/plugins/keychain/package.json | 1 + packages/plugins/keychain/src/index.test.ts | 7 +- packages/plugins/mcp/package.json | 1 + .../plugins/mcp/src/sdk/catalog-sync.test.ts | 11 +- .../plugins/mcp/src/sdk/elicitation.test.ts | 12 +- .../plugins/mcp/src/sdk/image-content.test.ts | 6 +- .../mcp/src/sdk/multi-placement-auth.test.ts | 21 +- .../mcp/src/sdk/owner-isolation.test.ts | 6 +- packages/plugins/mcp/src/sdk/plugin.test.ts | 104 +++- .../sdk/probe-shape-real-servers.live.test.ts | 5 +- .../slack-connect-file.integration.test.ts | 2 + packages/plugins/onepassword/package.json | 1 + .../onepassword/src/sdk/plugin.test.ts | 14 +- packages/plugins/openapi/package.json | 1 + .../google/spec-format-adapter.test.ts | 4 + .../plugins/openapi/src/sdk/configure.test.ts | 33 +- .../src/sdk/form-urlencoded-body.test.ts | 45 +- .../src/sdk/health-check-redaction.test.ts | 2 + .../src/sdk/multi-scope-bearer.test.ts | 5 +- .../openapi/src/sdk/non-json-body.test.ts | 141 +++-- .../plugins/openapi/src/sdk/plugin.test.ts | 139 +++-- .../openapi/src/sdk/real-specs.test.ts | 2 + .../src/sdk/response-body-timeout.test.ts | 5 +- .../src/sdk/response-headers-timeout.test.ts | 5 +- .../plugins/openapi/src/sdk/spec-blob.test.ts | 21 +- .../src/sdk/spec-overrides-lifecycle.test.ts | 13 +- .../src/sdk/streaming-response.test.ts | 9 +- .../src/sdk/tool-row-projection.test.ts | 5 +- .../openapi/src/sdk/upstream-failures.test.ts | 11 +- packages/plugins/toolkits/package.json | 1 + packages/plugins/toolkits/src/server.test.ts | 6 + packages/react/package.json | 1 + .../react/src/pages/integration-detail.tsx | 2 +- packages/react/src/pages/tools.tsx | 3 +- scripts/publish-packages.ts | 1 + scripts/smoke-docs-install.ts | 45 +- scripts/smoke-test-packed.ts | 1 + 161 files changed, 3935 insertions(+), 867 deletions(-) create mode 100644 .changeset/product-access-split.md create mode 100644 packages/core/product-access/CHANGELOG.md create mode 100644 packages/core/product-access/README.md create mode 100644 packages/core/product-access/package.json create mode 100644 packages/core/product-access/src/index.test.ts create mode 100644 packages/core/product-access/src/index.ts create mode 100644 packages/core/product-access/src/policy.ts create mode 100644 packages/core/product-access/src/testing.ts create mode 100644 packages/core/product-access/tsconfig.json create mode 100644 packages/core/product-access/tsup.config.ts create mode 100644 packages/core/product-access/vitest.config.ts create mode 100644 packages/core/sdk/src/access.test.ts create mode 100644 packages/core/sdk/src/access.ts create mode 100644 packages/core/sdk/src/approval-access-recheck.test.ts create mode 100644 packages/core/sdk/src/approval-plugin-swap.test.ts delete mode 100644 packages/core/sdk/src/org-write-access.ts create mode 100644 packages/core/sdk/src/settings-authority.test.ts diff --git a/.changeset/config.json b/.changeset/config.json index a26580776e..245e4cebfd 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,6 +5,7 @@ "fixed": [ [ "@executor-js/sdk", + "@executor-js/product-access", "@executor-js/codemode-core", "@executor-js/runtime-quickjs", "@executor-js/execution", diff --git a/.changeset/product-access-split.md b/.changeset/product-access-split.md new file mode 100644 index 0000000000..5c99ebf286 --- /dev/null +++ b/.changeset/product-access-split.md @@ -0,0 +1,21 @@ +--- +"executor": minor +--- + +Extract personal/organization access rules from the core SDK into the new +`@executor-js/product-access` package. BREAKING for SDK embedders: +`createExecutor` (Effect and Promise APIs alike) now REQUIRES +`access: ExecutorAccess` — the product's decisions for row visibility and +write partitions (`owners`), user-intent settings authorization +(`settingsWrite`), view capabilities (`adminReads` / `storageWrites`), and +effective tool-policy evaluation (`toolPolicy`). The former `orgWrites` and +`platformView` options are removed; select a posture from +`@executor-js/product-access` (`singleUserAccess()`, +`workspaceServiceAccess()`, `memberAccessForRole(...)`, +`requestBoundMemberAccess()`, `platformObserverAccess()`) or supply your own. +Core keeps enforcing tenant isolation, the storage owner policy clamps, and +approval mechanics; it no longer decides any product rule. Test helpers +changed too: `makeTestConfig` and the workspace harness require `access` +(postures in `@executor-js/product-access/testing`). No stored schema or data +migration — persisted rows, tenants, owners, subjects and addresses are +unchanged. diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 1966249d59..aab4c800b5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -149,6 +149,7 @@ jobs: './apps/cli/dist/executor' './packages/core/storage-core' './packages/core/sdk' + './packages/core/product-access' './packages/core/config' './packages/core/execution' './packages/core/cli' diff --git a/README.md b/README.md index cbf860f1bd..a866909e50 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,18 @@ Embed Executor with the TypeScript SDK (a Promise API; an Effect-native API is also available): ```ts -import { createExecutor } from "@executor-js/sdk/promise"; -import { openApiPlugin } from "@executor-js/plugin-openapi/promise"; - -const executor = await createExecutor({ plugins: [openApiPlugin()] }); +import { workspaceServiceAccess } from "@executor-js/product-access"; +import { createExecutor } from "@executor-js/sdk"; +import { openApiPlugin } from "@executor-js/plugin-openapi"; + +const executor = await createExecutor({ + plugins: [openApiPlugin()], + onElicitation: "accept-all", + // Product access decisions are REQUIRED and stated explicitly — the SDK + // ships no default posture. `@executor-js/product-access` has the standard + // ones (this is the subject-less single-workspace posture). + access: workspaceServiceAccess(), +}); // add an integration, create a connection, then list and call tools const tools = await executor.tools.list({ integration: "inventory" }); diff --git a/apps/cloud/package.json b/apps/cloud/package.json index b7871ba869..180314672c 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -55,6 +55,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index 0c77c03a93..712d9f401c 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -17,7 +17,7 @@ // acting member, and this plane deliberately serves the whole tenant. // // The executor is built by `makePlatformExecutor` — `{ tenant, subject: -// undefined, platformView: true }` — so the reads are tenant-wide and read-only +// undefined, access: platformObserverAccess() }` — so the reads are tenant-wide and read-only // by storage policy, and no `subject` row is minted for the caller. // // Cross-tenant isolation is structural, not a check in this file: the tenant is diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 7165a93042..e5bcca0205 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -174,7 +174,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => * `subject: null` + the read-only tenant reach rather than inventing a subject. * * The `/admin/*` mount turns this into an executor with `{ tenant: - * organizationId, subject: undefined, platformView: true }`. + * organizationId, subject: undefined, access: platformObserverAccess() }`. */ export interface PlatformAuth { readonly kind: "platform"; diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index 37d140ab01..08ce37317b 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -39,6 +39,7 @@ import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/test import executorConfig from "../executor.config"; import { DbService } from "./db/db"; import { createDrizzleFumaDb } from "./db/fuma"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // Test-only plugin: exposes one in-memory tool that elicits once. Lets the @@ -124,6 +125,7 @@ const buildScopedExecutor = ( plugins, httpClientLayer: FetchHttpClient.layer, onElicitation: "accept-all", + access: testAccess.member(), }); }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 701166ece7..61daa79d7a 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -26,6 +26,7 @@ import { } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { requestBoundMemberAccess } from "@executor-js/product-access"; import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { @@ -349,7 +350,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { it.effect("exports no span attribute containing the authorization code or state", () => Effect.gen(function* () { const { exporter, provider, tracerLayer } = makeTracing(); - const executor = yield* createExecutor(makeTestConfig({})); + const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() })); const web = yield* Effect.acquireRelease( Effect.sync(() => diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index b693834278..6fd383211a 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -30,6 +30,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 62d8685747..d5d7d3a299 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -9,6 +9,7 @@ import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import type { ExecutorDbHandle } from "@executor-js/api/server"; +import { requestBoundMemberAccess } from "@executor-js/product-access"; import { McpAgentSessionDOBase, type BuiltMcpServer, @@ -151,7 +152,7 @@ export class McpSessionDO extends McpAgentSessionDOBase - makeScopedExecutor(accountId, organizationId, organizationName).pipe( - Effect.provide(SelfHostScopedExecutorSeams), - ); + makeScopedExecutor(accountId, organizationId, organizationName, { + access: memberAccess("allowed"), + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); // End-to-end: an org-owned connection's tools are reachable from a user's MCP // `execute` sandbox. diff --git a/apps/host-selfhost/src/integrations.test.ts b/apps/host-selfhost/src/integrations.test.ts index fd77778b19..8ab6d27af0 100644 --- a/apps/host-selfhost/src/integrations.test.ts +++ b/apps/host-selfhost/src/integrations.test.ts @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; import { SelfHostScopedExecutorSeams } from "./execution"; import type { SelfHostPlugins } from "./plugins"; +import { memberAccess } from "@executor-js/product-access"; // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, @@ -21,9 +22,9 @@ const createScopedExecutor = ( organizationId: string, organizationName: string, ) => - makeScopedExecutor(accountId, organizationId, organizationName).pipe( - Effect.provide(SelfHostScopedExecutorSeams), - ); + makeScopedExecutor(accountId, organizationId, organizationName, { + access: memberAccess("allowed"), + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); process.env.EXECUTOR_DATA_DIR = dataDir; diff --git a/apps/host-selfhost/src/secrets-integration.test.ts b/apps/host-selfhost/src/secrets-integration.test.ts index 29b3090539..23454b8dfe 100644 --- a/apps/host-selfhost/src/secrets-integration.test.ts +++ b/apps/host-selfhost/src/secrets-integration.test.ts @@ -12,6 +12,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; import { SelfHostScopedExecutorSeams } from "./execution"; import type { SelfHostPlugins } from "./plugins"; +import { memberAccess } from "@executor-js/product-access"; // In v2 a connection IS the credential: its inline `value` is written through the // default writable provider — here the encrypted-secrets provider, which stores @@ -28,9 +29,9 @@ const createScopedExecutor = ( organizationId: string, organizationName: string, ) => - makeScopedExecutor(accountId, organizationId, organizationName).pipe( - Effect.provide(SelfHostScopedExecutorSeams), - ); + makeScopedExecutor(accountId, organizationId, organizationName, { + access: memberAccess("allowed"), + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); let dbLayer!: Layer.Layer; let dbHandle: Awaited> | undefined; diff --git a/apps/host-selfhost/src/subject-sightings.test.ts b/apps/host-selfhost/src/subject-sightings.test.ts index 635ea5d472..4d18a54b4e 100644 --- a/apps/host-selfhost/src/subject-sightings.test.ts +++ b/apps/host-selfhost/src/subject-sightings.test.ts @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; import { SelfHostScopedExecutorSeams } from "./execution"; import type { SelfHostPlugins } from "./plugins"; +import { memberAccess } from "@executor-js/product-access"; // The `subject` table is populated at the request seam: `makeScopedExecutor` is // what every HTTP request and MCP session on every host passes through, so @@ -19,9 +20,9 @@ import type { SelfHostPlugins } from "./plugins"; // the point is that the production wiring reaches the writer at all. const createScopedExecutor = (accountId: string, organizationId: string) => - makeScopedExecutor(accountId, organizationId, "Default").pipe( - Effect.provide(SelfHostScopedExecutorSeams), - ); + makeScopedExecutor(accountId, organizationId, "Default", { + access: memberAccess("allowed"), + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); process.env.EXECUTOR_DATA_DIR = dataDir; diff --git a/apps/local/package.json b/apps/local/package.json index 79bebeef85..2042e83a9e 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -41,6 +41,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", diff --git a/apps/local/src/auth-tool-failures.test.ts b/apps/local/src/auth-tool-failures.test.ts index a7599cb185..f7b3200ac8 100644 --- a/apps/local/src/auth-tool-failures.test.ts +++ b/apps/local/src/auth-tool-failures.test.ts @@ -61,6 +61,7 @@ import { createExecutor, } from "@executor-js/sdk"; import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; +import { testAccess } from "@executor-js/product-access/testing"; import { ErrorCaptureLive } from "./observability"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; @@ -109,6 +110,7 @@ const startHarness = async (tmpDir: string): Promise => { db: sqlite.db, plugins, onElicitation: "accept-all", + access: testAccess.member(), }), ); diff --git a/apps/local/src/db/legacy-bigint-boot.test.ts b/apps/local/src/db/legacy-bigint-boot.test.ts index aaec50eb7b..ab25bd50ba 100644 --- a/apps/local/src/db/legacy-bigint-boot.test.ts +++ b/apps/local/src/db/legacy-bigint-boot.test.ts @@ -17,13 +17,21 @@ import { Effect } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; import { collectTables } from "@executor-js/api/server"; -import { runSqliteDataMigrations } from "@executor-js/sdk"; +import { runSqliteDataMigrations, type ExecutorOwnerPolicyContext } from "@executor-js/sdk"; import { localDataMigrations } from "./data-migrations"; import { createSqliteFumaDb } from "./sqlite-fumadb"; const TENANT = "executor-workspace-1771"; const SUBJECT = "local"; +// The fixture's inspection context: the local single-user binding's full +// identity view, with its owner partitions stated explicitly — the owner +// policy has no full-view default, and the seeded row is a `user` row. +const FIXTURE_CONTEXT: ExecutorOwnerPolicyContext = { + tenant: TENANT, + subject: SUBJECT, + owners: ["user", "org"], +}; // Epoch millis, the shape an OAuth token expiry takes. const LEGACY_EXPIRES_AT = 1787321623456; @@ -71,7 +79,7 @@ describe("local boot over a legacy bigint database", () => { await seedLegacyConnection(dbPath); const sqlite = await openDb(dbPath); - const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT); // The reported symptom: not a wrong value, a throw — so the gateway lost // every saved integration at once. await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); @@ -88,7 +96,7 @@ describe("local boot over a legacy bigint database", () => { ); expect(applied).toContain("2026-08-28-bigint-storage-class"); - const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT); const rows = await scoped.findMany("connection", {}); expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([ ["default", LEGACY_EXPIRES_AT], @@ -112,7 +120,7 @@ describe("local boot over a legacy bigint database", () => { ); expect(applied).not.toContain("2026-08-28-bigint-storage-class"); - const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(second.db, FIXTURE_CONTEXT); const rows = await scoped.findMany("connection", {}); expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); await second.close(); diff --git a/apps/local/src/db/v1-v2-boot-drive.test.ts b/apps/local/src/db/v1-v2-boot-drive.test.ts index d8f78a5612..a3d16d389f 100644 --- a/apps/local/src/db/v1-v2-boot-drive.test.ts +++ b/apps/local/src/db/v1-v2-boot-drive.test.ts @@ -36,6 +36,7 @@ import { runSqliteDataMigrations, } from "@executor-js/sdk"; import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; +import { testAccess } from "@executor-js/product-access/testing"; import { openApiPlugin } from "@executor-js/plugin-openapi"; import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; @@ -123,6 +124,7 @@ const bootRealStack = async (dbPath: string) => { memoryCredentialsPlugin(), ] as const, onElicitation: "accept-all", + access: testAccess.member(), }), ); return { diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 1ec7ebbf68..15b88a1feb 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -13,6 +13,7 @@ import { type Executor, } from "@executor-js/sdk"; import { collectTables } from "@executor-js/api/server"; +import { singleUserAccess } from "@executor-js/product-access"; import { loadPluginsFromJsonc } from "@executor-js/config"; import type { McpPluginExtension } from "@executor-js/plugin-mcp"; @@ -220,6 +221,9 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { { plugin_key: event.pluginKey }, ), onElicitation: "accept-all", + // Single-user product: one human owns the deployment and may + // configure everything (see @executor-js/product-access). + access: singleUserAccess(), oauthEndpointUrlPolicy: { allowHttp: true }, // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` // route on the same origin as the web UI. Derived from `webBaseUrl` diff --git a/apps/local/src/mcp-browser-resume.test.ts b/apps/local/src/mcp-browser-resume.test.ts index e9b51acd80..514c973eca 100644 --- a/apps/local/src/mcp-browser-resume.test.ts +++ b/apps/local/src/mcp-browser-resume.test.ts @@ -34,6 +34,7 @@ import { import { createMcpRequestHandler } from "./mcp"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; +import { testAccess } from "@executor-js/product-access/testing"; const TEST_BASE_URL = "http://local.test"; @@ -90,6 +91,7 @@ const makeExecutor = async (tmpDir: string): Promise => { db: sqlite.db, plugins, onElicitation: "accept-all", + access: testAccess.member(), }), ); diff --git a/apps/local/src/mcp-oauth.test.ts b/apps/local/src/mcp-oauth.test.ts index 405471486f..9955d0ac32 100644 --- a/apps/local/src/mcp-oauth.test.ts +++ b/apps/local/src/mcp-oauth.test.ts @@ -49,6 +49,7 @@ import { createExecutor, } from "@executor-js/sdk"; import { serveOAuthTestServer } from "@executor-js/sdk/testing"; +import { testAccess } from "@executor-js/product-access/testing"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; @@ -98,6 +99,7 @@ const startHarness = async (tmpDir: string): Promise => { db: sqlite.db, plugins, onElicitation: "accept-all", + access: testAccess.member(), oauthEndpointUrlPolicy: { allowHttp: true }, // EXPLICIT OAuth callback — required now that the localhost default is // gone; the local daemon serves `/api/oauth/callback` on the web origin. diff --git a/bun.lock b/bun.lock index 52aac3adf6..47918597e3 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@effect/tsgo": "^0.5.2", "@effect/vitest": "catalog:", "@executor-js/plugin-provider-service-split": "workspace:*", + "@executor-js/product-access": "workspace:*", "@typescript/native-preview": "^7.0.0-dev.20260410.1", "@vitest/expect": "catalog:", "@vitest/mocker": "catalog:", @@ -76,6 +77,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", @@ -186,6 +188,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", @@ -239,6 +242,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -296,6 +300,7 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -397,6 +402,7 @@ "@executor-js/plugin-onepassword": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", }, @@ -410,6 +416,7 @@ "version": "0.0.54", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", }, @@ -425,6 +432,7 @@ "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", }, "devDependencies": { @@ -484,6 +492,7 @@ "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", }, @@ -546,6 +555,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/product-access": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", @@ -603,6 +613,25 @@ "vitest": "catalog:", }, }, + "packages/core/product-access": { + "name": "@executor-js/product-access", + "version": "1.6.8", + "dependencies": { + "@executor-js/sdk": "workspace:*", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "effect": "catalog:", + }, + }, "packages/core/sdk": { "name": "@executor-js/sdk", "version": "1.6.8", @@ -704,6 +733,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", "@modelcontextprotocol/sdk": "^1.29.0", @@ -734,6 +764,7 @@ "@effect/vitest": "catalog:", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@tailwindcss/browser": "^4.2.2", "@tailwindcss/vite": "catalog:", @@ -820,6 +851,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@types/node": "catalog:", "drizzle-orm": "catalog:", @@ -895,6 +927,7 @@ "react": "catalog:", }, "devDependencies": { + "@executor-js/product-access": "workspace:*", "@types/react": "catalog:", "tsup": "catalog:", "typescript": "catalog:", @@ -945,6 +978,7 @@ "@executor-js/sdk": "workspace:*", }, "devDependencies": { + "@executor-js/product-access": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", "effect": "catalog:", @@ -969,6 +1003,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", @@ -1003,6 +1038,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/product-access": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", "effect": "catalog:", @@ -1031,6 +1067,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", @@ -1068,6 +1105,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", @@ -1105,6 +1143,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "catalog:", @@ -1158,6 +1197,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", "@types/node": "catalog:", @@ -1224,6 +1264,7 @@ "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@hookform/resolvers": "^5.2.2", "@lobehub/icons": "^5.4.0", @@ -1869,6 +1910,8 @@ "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], + "@executor-js/product-access": ["@executor-js/product-access@workspace:packages/core/product-access"], + "@executor-js/react": ["@executor-js/react@workspace:packages/react"], "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"], diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 5fbf3eda2b..b09108399c 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -16,6 +16,7 @@ "@executor-js/plugin-onepassword": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:" }, diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index 9b81cd644f..c1dc66ef76 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -31,6 +31,7 @@ import { ToolAddress, type CredentialProvider, } from "@executor-js/sdk"; +import { workspaceServiceAccess } from "@executor-js/product-access"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { @@ -192,6 +193,8 @@ const program = Effect.gen(function* () { const executor = yield* createExecutor({ tenant: Tenant.make("example-tenant"), + // Subject-less single-workspace example: org partition, writes allowed. + access: workspaceServiceAccess(), plugins, providers: [memoryProvider], onElicitation: "accept-all" as const, diff --git a/examples/docs-sdk-quickstart/package.json b/examples/docs-sdk-quickstart/package.json index 60147d100c..3c27e1ad88 100644 --- a/examples/docs-sdk-quickstart/package.json +++ b/examples/docs-sdk-quickstart/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:" }, diff --git a/examples/docs-sdk-quickstart/src/main.ts b/examples/docs-sdk-quickstart/src/main.ts index 77e60425ec..e0180082ec 100644 --- a/examples/docs-sdk-quickstart/src/main.ts +++ b/examples/docs-sdk-quickstart/src/main.ts @@ -8,6 +8,7 @@ import { type CredentialProvider, } from "@executor-js/sdk/promise"; import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; +import { workspaceServiceAccess } from "@executor-js/product-access"; const inventoryApi = { openapi: "3.0.0", @@ -92,6 +93,9 @@ const memoryProvider: CredentialProvider = { }; const executor = await createExecutor({ + // Subject-less single-workspace embedder: the product posture is stated + // explicitly — the SDK ships no default. + access: workspaceServiceAccess(), plugins: [openApiPlugin()], providers: [memoryProvider], onElicitation: "accept-all", diff --git a/examples/promise-sdk/package.json b/examples/promise-sdk/package.json index 01967a0693..70a50af530 100644 --- a/examples/promise-sdk/package.json +++ b/examples/promise-sdk/package.json @@ -12,6 +12,7 @@ "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*" }, "devDependencies": { diff --git a/examples/promise-sdk/src/main.ts b/examples/promise-sdk/src/main.ts index d310d55b0d..d4ba033f9a 100644 --- a/examples/promise-sdk/src/main.ts +++ b/examples/promise-sdk/src/main.ts @@ -20,6 +20,7 @@ import { Effect } from "effect"; import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; +import { workspaceServiceAccess } from "@executor-js/product-access"; // --------------------------------------------------------------------------- // 1. Create the executor with all plugins @@ -44,6 +45,9 @@ const memoryProvider: CredentialProvider = { }; const executor = await createExecutor({ + // Subject-less single-workspace embedder: the product posture is stated + // explicitly — the SDK ships no default. + access: workspaceServiceAccess(), plugins, providers: [memoryProvider], onElicitation: "accept-all", diff --git a/package.json b/package.json index c284f45623..2e9b35c537 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}", "test:e2e": "bun run --cwd e2e test", "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", - "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", + "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", "typecheck": "turbo run typecheck", "typecheck:slow": "turbo run typecheck:slow", "ci": "bun run lint && bun run typecheck && bun run test", @@ -78,6 +78,7 @@ "@effect/tsgo": "^0.5.2", "@effect/vitest": "catalog:", "@executor-js/plugin-provider-service-split": "workspace:*", + "@executor-js/product-access": "workspace:*", "@typescript/native-preview": "^7.0.0-dev.20260410.1", "@vitest/expect": "catalog:", "@vitest/mocker": "catalog:", diff --git a/packages/core/api/package.json b/packages/core/api/package.json index ae47232599..99d2597198 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -16,6 +16,7 @@ "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:" }, diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index b2c73c8e7f..fa9146f687 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -13,6 +13,7 @@ import { type ExecutorAdmin, } from "@executor-js/sdk"; import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; +import { testAccess } from "@executor-js/product-access/testing"; import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal"; import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api"; @@ -204,7 +205,7 @@ const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effec tenant: Tenant.make(tenant), db: db.db, onElicitation: "accept-all", - platformView: true, + access: testAccess.platform({ subject: false }), }).pipe(Effect.orDie); /** 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 subject: Subject.make(USER_A1), db: db.db, onElicitation: "accept-all", + access: testAccess.member(), }).pipe(Effect.orDie); /** @@ -639,7 +641,7 @@ describe("admin users API", () => { withDb((db) => Effect.gen(function* () { yield* seed(db); - // A host that forgot `platformView: true` must not silently report an + // A host that forgot the platform access posture must not silently report an // empty tenant — that would read as "this owner has no users". const web = yield* webHandlerFor( stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize), diff --git a/packages/core/api/src/admin/api.ts b/packages/core/api/src/admin/api.ts index acda949904..b7ca87cc96 100644 --- a/packages/core/api/src/admin/api.ts +++ b/packages/core/api/src/admin/api.ts @@ -4,7 +4,7 @@ // The product plane (`/api/*`) answers "what can I, this member, reach". This // one answers the OWNER's question: "who are my users, and what have they // connected". It reads the SDK's platform view (`executor.admin`, opt-in via -// `platformView: true`), which is read-only by construction — the owner policy +// `platformObserverAccess()`), which is read-only by construction — the owner policy // rejects writes at `reach: "tenant"` — so every endpoint here is a GET. // // VOCABULARY: this is the translation seam. Internal code says subject / tenant diff --git a/packages/core/api/src/admin/reads.ts b/packages/core/api/src/admin/reads.ts index 6110686268..32e619738f 100644 --- a/packages/core/api/src/admin/reads.ts +++ b/packages/core/api/src/admin/reads.ts @@ -35,7 +35,7 @@ import type { AdminUsersListOptions } from "./service"; /** * Narrow an executor to its platform view. `admin` is present only when the - * executor was built with `platformView: true`; a host that reaches these reads + * executor was built with the platform access posture; a host that reaches these reads * with a product-view executor is a wiring bug, so it fails loudly as a 500 * rather than silently returning an empty tenant (which would read as "this * owner has no users"). diff --git a/packages/core/api/src/integrations/integrations-auth-methods.test.ts b/packages/core/api/src/integrations/integrations-auth-methods.test.ts index 6f1551bf63..7051504a17 100644 --- a/packages/core/api/src/integrations/integrations-auth-methods.test.ts +++ b/packages/core/api/src/integrations/integrations-auth-methods.test.ts @@ -16,6 +16,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; import { ExecutorApi } from "../api"; import { observabilityMiddleware } from "../observability"; import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // The catalog response surfaces each plugin's DECLARED auth methods (projected @@ -109,7 +110,9 @@ interface IntegrationResponseBody { describe("catalog surfaces declared auth methods", () => { it.effect("an OAuth integration with zero connections advertises an oauth method", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), + ); const slug = IntegrationSlug.make("oauth-server"); yield* executor.declaring.seed(slug, [OAUTH_METHOD]); @@ -133,7 +136,9 @@ describe("catalog surfaces declared auth methods", () => { it.effect("an apikey integration advertises an apikey method", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), + ); const slug = IntegrationSlug.make("apikey-server"); yield* executor.declaring.seed(slug, [APIKEY_METHOD]); @@ -156,7 +161,9 @@ describe("catalog surfaces declared auth methods", () => { it.effect("list surfaces authMethods and a plugin with no projector yields []", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), + ); yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]); yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []); @@ -180,7 +187,9 @@ describe("catalog surfaces declared auth methods", () => { it.effect("surfaces plugin-derived display URLs without exposing config", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }), + ); const slug = IntegrationSlug.make("autumn"); yield* executor.declaring.seed(slug, [], "https://api.useautumn.com"); diff --git a/packages/core/api/src/scoped-targets.test.ts b/packages/core/api/src/scoped-targets.test.ts index 5c09679291..79e5fa6ca0 100644 --- a/packages/core/api/src/scoped-targets.test.ts +++ b/packages/core/api/src/scoped-targets.test.ts @@ -17,6 +17,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { ExecutorApi } from "./api"; import { observabilityMiddleware } from "./observability"; import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // v2 owner-scoped API behaviour. @@ -88,7 +89,7 @@ const vercelPlugin = definePlugin(() => ({ describe("core API owner-scoped writes (v2)", () => { it.effect("policy create + update target an explicit owner", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({})); + const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() })); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); @@ -135,6 +136,7 @@ describe("core API owner-scoped writes (v2)", () => { it.effect("connection remove deletes the named owner row, not the other owner", () => Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); @@ -179,6 +181,7 @@ describe("core API owner-scoped writes (v2)", () => { it.effect("connection create accepts pasted values payloads", () => Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); @@ -220,6 +223,7 @@ describe("core API owner-scoped writes (v2)", () => { it.effect("connection list returns both owners' rows under one integration", () => Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index df3b0d9341..314b16b329 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -38,6 +38,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import { Context, Data, Effect, Layer } from "effect"; import type * as Cause from "effect/Cause"; +import { memberAccessForRole } from "@executor-js/product-access"; import type { AnyPlugin } from "@executor-js/sdk"; import type { ExecutionEngine } from "@executor-js/execution"; @@ -247,10 +248,9 @@ export const makeExecutionStackMiddleware = < resolved.organizationId, resolved.organizationName, { - orgWrites: - resolved.orgRoleModel === "none" || resolved.orgRole === "admin" - ? "allowed" - : "denied", + // The product's role rule, decided from the freshly resolved + // principal for exactly this request's stack. + access: memberAccessForRole(resolved), }, ).pipe( Effect.provide(options.stackLayer, { local: true }), diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index 3aa0dd6b8d..7d2f2ce4b7 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -27,7 +27,7 @@ import { Context, Effect, Layer } from "effect"; import type * as Cause from "effect/Cause"; import type { McpResource } from "@executor-js/host-mcp"; -import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk"; +import type { AnyPlugin, Executor, ExecutorAccess, StorageFailure } from "@executor-js/sdk"; import { createExecutionEngine, type ExecutionEngine, @@ -112,11 +112,12 @@ export const makeExecutionStack = < accountId: string, organizationId: string, organizationName: string, - options?: { + options: { readonly mcpResource?: McpResource; - /** Workspace-settings permission for this binding (see - * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ - readonly orgWrites?: ExecutorConfig["orgWrites"]; + /** The product's access decisions for this binding (see + * `ExecutorConfig.access`), selected by the host — role-derived on the + * HTTP plane, request-bound on MCP session stacks. */ + readonly access: ExecutorAccess; }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, @@ -129,8 +130,8 @@ export const makeExecutionStack = < organizationId, organizationName, { - plugins: { mcpResource: options?.mcpResource }, - ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + plugins: { mcpResource: options.mcpResource }, + access: options.access, }, ).pipe(Effect.withSpan("executor.stack.scoped_executor")); const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( @@ -147,7 +148,7 @@ export const makeExecutionStack = < organizationId, organizationName, }, - { mcpResource: options?.mcpResource }, + { mcpResource: options.mcpResource }, ), ); return { executor, engine }; diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3891f6cae3..f4d4c6bd09 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -1,6 +1,7 @@ import { Effect, Layer } from "effect"; import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; +import { requestBoundMemberAccess } from "@executor-js/product-access"; import { McpEngineBuildError, type McpBuildServer, @@ -50,7 +51,10 @@ export const makeMcpBuildServer = principal.organizationName, { mcpResource: options?.resource, - orgWrites: "request", + // Session-lifetime stack: the workspace-settings decision is read + // from the request-stamped fiber-local at every guarded sink, so a + // session never caches a positive authorization. + access: requestBoundMemberAccess(), }, ).pipe(Effect.withSpan("mcp.execution_stack.build")); // Read inside the provided boundary: `webBaseUrl` is a host seam, and diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 749839be3e..25d03fca1c 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -34,12 +34,14 @@ import { Context, Effect, Option } from "effect"; import type { McpResource } from "@executor-js/host-mcp"; +import { platformObserverAccess } from "@executor-js/product-access"; import { createExecutor, Subject, Tenant, type AnyPlugin, type Executor, + type ExecutorAccess, type ExecutorConfig, type FirstPartyOAuthClientConfig, type StorageFailure, @@ -258,12 +260,13 @@ export const makeScopedExecutor = < // `EngineStackIdentity` (the engine decorator still wants it); not part of the // v2 executor binding, which is `{ tenant, subject }` only. _organizationName: string, - options?: { + options: { readonly plugins?: PluginsProviderContext; - /** Workspace-settings permission for this binding (see - * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's - * role; omitted -> allowed (hosts with no role model). */ - readonly orgWrites?: ExecutorConfig["orgWrites"]; + /** The product's access decisions for this binding (see + * `ExecutorConfig.access`) — REQUIRED, selected by the host from + * `@executor-js/product-access` (role-derived on the HTTP plane, + * request-bound on session stacks). */ + readonly access: ExecutorAccess; }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { @@ -336,7 +339,7 @@ export const makeScopedExecutor = < ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), ...(waitUntil !== undefined ? { waitUntil } : {}), onElicitation: "accept-all", - ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + access: options.access, redirectUri, oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, @@ -370,9 +373,9 @@ export const makeScopedExecutor = < // An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on // the whole workspace) has NO acting member, so there is no honest subject to // bind. This builds the executor that shape implies: `{ tenant, subject: -// undefined, platformView: true }`. +// undefined, access: platformObserverAccess() }`. // -// WHAT "READ-ONLY" MEANS HERE, PRECISELY. `platformView: true` puts +// WHAT "READ-ONLY" MEANS HERE, PRECISELY. The platform access posture puts // `writes: "denied"` on the executor's base owner-policy context and // `reach: "tenant"` on the `admin` handle's. So: // - EVERY surface on this executor — `admin`, and the ordinary `connections` @@ -429,6 +432,6 @@ export const makePlatformExecutor = ( httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions), fetch: makeHostedFetch(hostedHttpOptions), onElicitation: "accept-all", - platformView: true, + access: platformObserverAccess(), }).pipe(Effect.withSpan("executor.platform.create_executor")); }); diff --git a/packages/core/execution/README.md b/packages/core/execution/README.md index e3e9fc3c28..80e1b14b6f 100644 --- a/packages/core/execution/README.md +++ b/packages/core/execution/README.md @@ -17,12 +17,14 @@ npm install @executor-js/sdk @executor-js/execution @executor-js/runtime-quickjs ## Usage ```ts +import { workspaceServiceAccess } from "@executor-js/product-access"; import { createExecutor } from "@executor-js/sdk"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; const executor = await createExecutor({ onElicitation: "accept-all", + access: workspaceServiceAccess(), }); const engine = createExecutionEngine({ diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index c5c21f5e57..476a3ea665 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/product-access": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index 365f115585..193dddeff3 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -14,6 +14,7 @@ import { import { makeTestConfig } from "@executor-js/sdk/testing"; import { buildExecuteDescription, parseIntegrationInventory } from "./description"; +import { testAccess } from "@executor-js/product-access/testing"; const memoryProvider = (): CredentialProvider => { const store = new Map(); @@ -74,7 +75,10 @@ describe("buildExecuteDescription", () => { it.effect("lists the connected integrations, not the connection prefixes", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [slackPlugin, githubPlugin] as const, + }), ); yield* executor["slack-plugin"].seed(); yield* executor["github-plugin"].seed(); @@ -121,7 +125,10 @@ describe("buildExecuteDescription", () => { it.effect("lists integration names only, with no descriptions", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [slackPlugin, githubPlugin] as const, + }), ); yield* executor["slack-plugin"].seed(); yield* executor["github-plugin"].seed(); @@ -154,7 +161,9 @@ describe("buildExecuteDescription", () => { it.effect("dedupes many connections of one integration into a single line", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [githubPlugin] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [githubPlugin] as const }), + ); yield* executor["github-plugin"].seed(); yield* executor.connections.create({ owner: "org", @@ -181,7 +190,9 @@ describe("buildExecuteDescription", () => { it.effect("omits the Available integrations section when no connections exist", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [] as const }), + ); const description = yield* buildExecuteDescription(executor); @@ -195,7 +206,10 @@ describe("parseIntegrationInventory", () => { it.effect("round-trips the slugs a built description lists", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [slackPlugin, githubPlugin] as const, + }), ); yield* executor["slack-plugin"].seed(); yield* executor["github-plugin"].seed(); diff --git a/packages/core/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index 9cd3609072..c497e0bc61 100644 --- a/packages/core/execution/src/engine.test.ts +++ b/packages/core/execution/src/engine.test.ts @@ -14,6 +14,7 @@ import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine"; import { FormElicitation } from "@executor-js/sdk/core"; +import { testAccess } from "@executor-js/product-access/testing"; // Regression for the hang reported as the executor-MCP "180s timeout" against // Cowork (Claude web). Cowork goes down the `executeWithPause` branch because @@ -42,7 +43,10 @@ const emptyPlugin = definePlugin(() => ({ staticIntegrations: () => [], })); -const makeExecutor = () => createExecutor(makeTestConfig({ plugins: [emptyPlugin()] as const })); +const makeExecutor = () => + createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [emptyPlugin()] as const }), + ); describe("executeWithPause failure propagation", () => { it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () => @@ -98,7 +102,7 @@ describe("paused execution authorization", () => { it.effect("uses the resumer's current org-write access after approval", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ coreTools: {}, orgWrites: "request" }), + makeTestConfig({ coreTools: {}, access: testAccess.requestBound() }), ); const engine = createExecutionEngine({ executor, @@ -170,7 +174,7 @@ describe("paused execution authorization", () => { const executor = yield* createExecutor( makeTestConfig({ coreTools: {}, - orgWrites: "request", + access: testAccess.requestBound(), plugins: [joinPlugin()] as const, }), ); diff --git a/packages/core/execution/src/tool-invoker.leak.test.ts b/packages/core/execution/src/tool-invoker.leak.test.ts index ad766cf45a..e869a1622b 100644 --- a/packages/core/execution/src/tool-invoker.leak.test.ts +++ b/packages/core/execution/src/tool-invoker.leak.test.ts @@ -5,6 +5,7 @@ import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/ import { makeTestConfig } from "@executor-js/sdk/testing"; import { ExecutionToolError } from "./errors"; import { makeExecutorToolInvoker } from "./tool-invoker"; +import { testAccess } from "@executor-js/product-access/testing"; const EmptyInputSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(Schema.Struct({})), @@ -138,7 +139,9 @@ const leakyPlugin = definePlugin(() => ({ describe("internal-error leak audit (opaque defects)", () => { it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), + ); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); @@ -160,7 +163,9 @@ describe("internal-error leak audit (opaque defects)", () => { it.effect("plain Error with stack: stack and message do NOT escape", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), + ); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); @@ -179,7 +184,9 @@ describe("internal-error leak audit (opaque defects)", () => { describe("openapi pre-flight invocation errors (expected failures)", () => { it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), + ); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); @@ -197,7 +204,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), + ); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); @@ -215,7 +224,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { it.effect("transport failure (statusCode None but cause present) stays opaque", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), + ); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); @@ -235,7 +246,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { it.effect("post-response failure (statusCode Some) stays opaque", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }), + ); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index a2aa222d17..efd996c008 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -38,6 +38,7 @@ import { searchTools, type ToolDiscoveryProvider, } from "./tool-invoker"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // v2 port. The v1 suite modelled namespaces as `staticSources` whose tools @@ -477,7 +478,7 @@ const provision = ( }); const makeExecutorWith = (plugins: TPlugins) => - createExecutor(makeTestConfig({ plugins })); + createExecutor(makeTestConfig({ access: testAccess.member(), plugins })); const makeSearchExecutor = () => Effect.gen(function* () { @@ -1250,6 +1251,7 @@ describe("tool discovery", () => { }); const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin()] as const, coreTools: {}, }), @@ -1303,6 +1305,7 @@ describe("tool discovery", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, }); const executor = yield* createExecutor(config); @@ -1386,6 +1389,7 @@ describe("tool discovery", () => { invalidRefreshTokenDescription: "Refresh token expired", }); const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, }); const executor = yield* createExecutor(config); diff --git a/packages/core/product-access/CHANGELOG.md b/packages/core/product-access/CHANGELOG.md new file mode 100644 index 0000000000..d82bd4bedd --- /dev/null +++ b/packages/core/product-access/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/product-access diff --git a/packages/core/product-access/README.md b/packages/core/product-access/README.md new file mode 100644 index 0000000000..849c7a8ff8 --- /dev/null +++ b/packages/core/product-access/README.md @@ -0,0 +1,79 @@ +# @executor-js/product-access + +The product's personal/organization access rules for +[`@executor-js/sdk`](../sdk). The SDK executes tools and enforces tenant +isolation; it deliberately owns no product rule. Every `createExecutor` call +requires an `ExecutorAccess` — the product's decisions for: + +- **`owners`** — which owner partitions (`"user"`, `"org"`) the binding sees + and may write, in precedence order. Really filters storage CRUD, tool + list/schema/invoke, plugin storage and plugin blobs. +- **`settingsWrite(target)`** — whether a user-intent settings mutation + (connections, policies, OAuth clients, the integration catalog) is allowed, + asked live at every sink. +- **`capabilities`** — `adminReads` (the tenant-wide read-only admin surface) + and `storageWrites` (`"denied"` = whole-executor read-only). +- **`toolPolicy(sources)`** — how stored policy rows and toolkit capability + rules resolve into an effective allow/approve/block decision. + +This package ships the standard rules. Core never falls back to them — a +composition root states its posture explicitly. + +## Effect SDK + +```ts +import { Effect } from "effect"; +import { createExecutor } from "@executor-js/sdk/core"; +import { singleUserAccess } from "@executor-js/product-access"; + +const program = Effect.gen(function* () { + const executor = yield* createExecutor({ + tenant, + subject, + onElicitation: "accept-all", + access: singleUserAccess(), + }); + // Execution stays on the executor namespaces: executor.tools, executor.execute, … +}); +``` + +## Promise SDK + +```ts +import { createExecutor } from "@executor-js/sdk"; // published root = Promise API +import { workspaceServiceAccess } from "@executor-js/product-access"; + +const executor = await createExecutor({ + onElicitation: "accept-all", + // Subject-less single-workspace embedder posture. + access: workspaceServiceAccess(), +}); +``` + +## Postures + +| Constructor | Binding | Used by | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `singleUserAccess()` | bound subject, everything allowed | local daemon, CLI, desktop | +| `memberAccessForRole({ orgRoleModel, orgRole })` | bound member; admins configure, members use | HTTP API plane | +| `requestBoundMemberAccess()` | bound member; decision re-read per request via `CurrentOrgWriteAccess`, re-stamped on approval resume | MCP session stacks | +| `workspaceServiceAccess()` | subject-less org service; boot convergence | seeding/system executors, embedders | +| `platformObserverAccess()` | subject-less, storage read-only, tenant-wide `admin` reads | `/admin/*` org-credential plane | + +`orgWriteAccessForRole` is the single home of the admin/member rule. +`./policy` exports the resolution semantics (owner ranking, most-restrictive +merge, plugin-default fallback, toolkit allowlist) — browser-safe, built on +`@executor-js/sdk/shared`. `./testing` exports `testAccess` postures for +tests (`makeTestConfig({ access: testAccess.member() })`). + +## Breaking changes (from the pre-split SDK) + +- `ExecutorConfig.orgWrites` and `platformView` are gone; `access` is + required on both the Effect and Promise `createExecutor`. +- `makeTestConfig` / `makeTestWorkspaceHarness` / `makeTestExecutor` / + `makeTestWorkspaceLayer` require `access`. +- Policy resolution functions moved here from `@executor-js/sdk` + (`resolveEffectivePolicy`, `effectivePolicyFromSorted`, …); pattern + matching (`matchPattern`, `isValidPattern`) stays in the SDK. +- No stored schema or data migration: rows, tenants, owners, subjects and + tool addresses are unchanged. diff --git a/packages/core/product-access/package.json b/packages/core/product-access/package.json new file mode 100644 index 0000000000..c19faa507b --- /dev/null +++ b/packages/core/product-access/package.json @@ -0,0 +1,67 @@ +{ + "name": "@executor-js/product-access", + "version": "1.6.8", + "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/product-access", + "bugs": { + "url": "https://github.com/UsefulSoftwareCo/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", + "directory": "packages/core/product-access" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./policy": "./src/policy.ts", + "./testing": "./src/testing.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./policy": { + "import": { + "types": "./dist/policy.d.ts", + "default": "./dist/policy.js" + } + }, + "./testing": { + "import": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + } + } + } + }, + "scripts": { + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/sdk": "workspace:*" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" + } +} diff --git a/packages/core/product-access/src/index.test.ts b/packages/core/product-access/src/index.test.ts new file mode 100644 index 0000000000..eceed26924 --- /dev/null +++ b/packages/core/product-access/src/index.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Ref } from "effect"; + +import { + CurrentOrgWriteAccess, + makeOrgWriteAccessState, + type ToolPolicyRow, +} from "@executor-js/sdk/core"; + +import { + memberAccess, + memberAccessForRole, + orgWriteAccessForRole, + platformObserverAccess, + requestBoundMemberAccess, + singleUserAccess, + workspaceServiceAccess, +} from "./index"; +import { resolveProviderPolicyFromRules, standardToolPolicy } from "./policy"; + +describe("orgWriteAccessForRole", () => { + it("trusts the single user of a deployment without a role model", () => { + expect(orgWriteAccessForRole({ orgRoleModel: "none" })).toBe("allowed"); + }); + + it("lets admins configure the workspace under a role model", () => { + expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "admin" })).toBe( + "allowed", + ); + }); + + it("denies plain members", () => { + expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "member" })).toBe( + "denied", + ); + }); + + it("fails closed when a role model is present but the role is missing", () => { + expect(orgWriteAccessForRole({ orgRoleModel: "organization" })).toBe("denied"); + }); +}); + +describe("settings-write rules", () => { + it.effect("the role decision gates workspace targets; personal stays open", () => + Effect.gen(function* () { + const member = memberAccessForRole({ orgRoleModel: "organization", orgRole: "member" }); + const admin = memberAccessForRole({ orgRoleModel: "organization", orgRole: "admin" }); + expect(yield* member.settingsWrite({ kind: "workspace" })).toBe("denied"); + expect(yield* member.settingsWrite({ kind: "owner", owner: "org" })).toBe("denied"); + expect(yield* member.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed"); + expect(yield* admin.settingsWrite({ kind: "workspace" })).toBe("allowed"); + expect(admin.owners).toEqual(["user", "org"]); + expect(admin.capabilities).toEqual({ adminReads: false, storageWrites: "allowed" }); + }), + ); + + it.effect("requestBoundMemberAccess fails closed without a request binding", () => + Effect.gen(function* () { + const access = requestBoundMemberAccess(); + expect(yield* access.settingsWrite({ kind: "workspace" })).toBe("denied"); + // Personal targets never consult the request decision. + expect(yield* access.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed"); + }), + ); + + it.effect("requestBoundMemberAccess reads the live request binding at every ask", () => + Effect.gen(function* () { + const access = requestBoundMemberAccess(); + const state = makeOrgWriteAccessState("allowed"); + const ask = access + .settingsWrite({ kind: "owner", owner: "org" }) + .pipe(Effect.provideService(CurrentOrgWriteAccess, state)); + expect(yield* ask).toBe("allowed"); + // The engine re-stamps this Ref from the resuming principal; the SAME + // access value must observe the new decision, not a snapshot. + yield* Ref.set(state.current, "denied"); + expect(yield* ask).toBe("denied"); + }), + ); + + it.effect("single-user and workspace-service postures allow workspace writes", () => + Effect.gen(function* () { + const single = singleUserAccess(); + const service = workspaceServiceAccess(); + expect(yield* single.settingsWrite({ kind: "workspace" })).toBe("allowed"); + expect(single.owners).toEqual(["user", "org"]); + expect(yield* service.settingsWrite({ kind: "workspace" })).toBe("allowed"); + expect(service.owners).toEqual(["org"]); + expect(yield* memberAccess("denied").settingsWrite({ kind: "workspace" })).toBe("denied"); + }), + ); + + it("the platform observer is a subject-less read-only view with admin reads", () => { + const access = platformObserverAccess(); + expect(access.owners).toEqual(["org"]); + expect(access.capabilities).toEqual({ adminReads: true, storageWrites: "denied" }); + }); +}); + +describe("standardToolPolicy", () => { + const row = ( + id: string, + owner: "org" | "user", + pattern: string, + action: "approve" | "require_approval" | "block", + ): ToolPolicyRow => + ({ + id, + owner, + subject: owner === "org" ? "" : "u", + pattern, + action, + position: "a0", + created_at: new Date(0), + updated_at: new Date(0), + }) as ToolPolicyRow; + + const evaluate = ( + rows: readonly ReturnType[], + toolId: string, + defaultRequiresApproval?: boolean, + ) => + Effect.gen(function* () { + const evaluator = yield* standardToolPolicy(["user", "org"])({ + policyRows: Effect.succeed(rows), + provider: null, + }); + return yield* evaluator.resolve({ toolId, defaultRequiresApproval }); + }); + + it.effect("an org guardrail beats a user preference", () => + Effect.gen(function* () { + const effective = yield* evaluate( + [ + row("outer", "org", "vercel.*", "block"), + row("inner", "user", "vercel.dns.create", "approve"), + ], + "vercel.dns.create", + ); + expect(effective).toMatchObject({ action: "block", policyId: "outer" }); + }), + ); + + it.effect("no authored match falls back to the plugin default", () => + Effect.gen(function* () { + expect(yield* evaluate([], "vercel.dns.create", true)).toMatchObject({ + action: "require_approval", + source: "plugin-default", + }); + expect(yield* evaluate([], "vercel.dns.create")).toMatchObject({ + action: "approve", + source: "plugin-default", + }); + }), + ); + + it.effect("a list-only provider is an allowlist: unmatched tools block", () => + Effect.gen(function* () { + const evaluator = yield* standardToolPolicy(["user", "org"])({ + policyRows: Effect.succeed([]), + provider: { + list: () => + Effect.succeed([ + { id: "r1", pattern: "github.*", action: "approve" as const, position: "a0" }, + ]), + }, + }); + expect(yield* evaluator.resolve({ toolId: "github.org.acme.repos.list" })).toMatchObject({ + action: "approve", + }); + expect(yield* evaluator.resolve({ toolId: "vercel.org.acme.deploy" })).toMatchObject({ + action: "block", + pattern: "*", + }); + }), + ); + + it("resolveProviderPolicyFromRules picks the first match by position", () => { + const rules = [ + { id: "b", pattern: "github.*", action: "block" as const, position: "a1" }, + { id: "a", pattern: "github.*", action: "approve" as const, position: "a0" }, + ]; + expect(resolveProviderPolicyFromRules("github.x", rules)).toMatchObject({ policyId: "a" }); + }); +}); diff --git a/packages/core/product-access/src/index.ts b/packages/core/product-access/src/index.ts new file mode 100644 index 0000000000..acf2ff3a5e --- /dev/null +++ b/packages/core/product-access/src/index.ts @@ -0,0 +1,159 @@ +// --------------------------------------------------------------------------- +// @executor-js/product-access — the PRODUCT's personal/organization access +// rules, stated once and selected explicitly by every composition root. +// +// The SDK owns none of these. `createExecutor` requires an `ExecutorAccess` +// and enforces its decisions at core's sinks — storage row visibility and +// write partitions (`owners`), the user-intent settings gate +// (`settingsWrite`), view capabilities (`capabilities`), and effective +// tool-policy evaluation (`toolPolicy`) — while the storage owner policy +// keeps clamping everything to the binding's tenant/subject partition. +// This package is where "what may this principal touch" is decided: +// - the role rule (admins configure the workspace, members use it), +// - the personal-stays-open settings rule (a denied member still manages +// Personal-scope resources), +// - the request-bound session posture (MCP sessions re-decide per request +// and re-stamp on approval resume), +// - the single-user posture (local daemon / CLI / examples), +// - the subject-less workspace-service and platform-observer postures, +// - how authored policy rows and toolkit capability rules resolve +// (`./policy`). +// Non-HTTP hosts consume it without pulling `@executor-js/api`. +// +// Imports come from the PUBLISHED sdk entries: `@executor-js/sdk/core` (the +// Effect SDK; the sdk ROOT export is the Promise façade) and the +// browser-safe `@executor-js/sdk/shared` inside `./policy`. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { + currentOrgWriteAccess, + type AccessDecision, + type ExecutorAccess, + type OrgWriteAccess, + type Owner, + type SettingsWriteTarget, +} from "@executor-js/sdk/core"; + +import { standardToolPolicy } from "./policy"; + +export { + effectivePolicyFromSorted, + resolveEffectivePolicy, + resolveProviderPolicyFromRules, + resolveToolPolicy, + standardToolPolicy, + type ToolPolicyRuleLike, +} from "./policy"; + +/** The role shape the org-write rule reads — structural, so both the API's + * `Principal` and host-mcp's schema'd copy satisfy it without translation. */ +export interface OrgRoleInput { + /** `"organization"` when the deployment has an org role model, `"none"` + * when it does not (self-host single-org, local). */ + readonly orgRoleModel: "organization" | "none"; + /** The acting member's role under an `"organization"` model. Missing at a + * legacy boundary fails closed (treated as a plain member). */ + readonly orgRole?: "admin" | "member" | undefined; +} + +/** + * THE product rule for workspace-settings writes: a deployment without a + * role model trusts its user; under a role model only admins configure the + * workspace, and an absent role fails closed. Previously duplicated by the + * HTTP execution-stack middleware and the MCP serving seams — this is now + * the single home. + */ +export const orgWriteAccessForRole = (input: OrgRoleInput): OrgWriteAccess => + input.orgRoleModel === "none" || input.orgRole === "admin" ? "allowed" : "denied"; + +/** A bound member's partitions, personal shadowing shared. */ +const MEMBER_OWNERS: readonly Owner[] = ["user", "org"]; +/** A subject-less binding's single partition. */ +const ORG_OWNERS: readonly Owner[] = ["org"]; + +/** + * The settings rule every current product shares: Personal-scope targets + * are always the member's own to configure; workspace-level targets (org + * rows and tenant-shared surfaces) follow the workspace decision, evaluated + * LIVE at each sink so a request-bound decision is re-read after pauses. + */ +const personalOpenSettingsWrite = + (workspace: Effect.Effect) => + (target: SettingsWriteTarget): Effect.Effect => + target.kind === "owner" && target.owner === "user" + ? Effect.succeed("allowed" as const) + : workspace; + +const memberCapabilities = { adminReads: false, storageWrites: "allowed" } as const; + +const memberPosture = (workspace: Effect.Effect): ExecutorAccess => ({ + owners: MEMBER_OWNERS, + settingsWrite: personalOpenSettingsWrite(workspace), + capabilities: memberCapabilities, + toolPolicy: standardToolPolicy(MEMBER_OWNERS), +}); + +/** + * A bound member whose workspace-settings decision was made from the freshly + * authenticated request and holds for its lifetime — the HTTP API plane, + * where every request re-authenticates and builds a new stack. + */ +export const memberAccess = (workspaceWrites: OrgWriteAccess): ExecutorAccess => + memberPosture(Effect.succeed(workspaceWrites)); + +/** {@link memberAccess} with the decision derived by {@link orgWriteAccessForRole}. */ +export const memberAccessForRole = (input: OrgRoleInput): ExecutorAccess => + memberAccess(orgWriteAccessForRole(input)); + +/** + * A bound member on a session-lifetime stack (MCP sessions): the executor + * outlives any one request, so the workspace-settings decision is read from + * the fiber-local `CurrentOrgWriteAccess` at EVERY guarded sink. The session + * host stamps that reference from each freshly authenticated request, the + * engine re-stamps it from the resuming principal on approval resume, and a + * missing binding fails closed (denied) — a positive authorization is never + * cached for the session lifetime. + */ +export const requestBoundMemberAccess = (): ExecutorAccess => memberPosture(currentOrgWriteAccess); + +/** + * The single-user products (local daemon, CLI, desktop, examples): one human + * owns the whole deployment, there is no role model, and the bound subject + * may configure everything. + */ +export const singleUserAccess = (): ExecutorAccess => memberPosture(Effect.succeed("allowed")); + +/** + * A subject-less workspace-service binding — boot/system executors that + * converge the tenant's shared state (catalog registration, seeding) with no + * acting member. Sees and writes only the org partition; workspace writes + * are the whole point. + */ +export const workspaceServiceAccess = (): ExecutorAccess => ({ + owners: ORG_OWNERS, + settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")), + capabilities: memberCapabilities, + toolPolicy: standardToolPolicy(ORG_OWNERS), +}); + +/** + * The platform observer an org-level credential gets (the `/admin/*` plane): + * subject-less, the WHOLE executor read-only at the storage boundary + * (`storageWrites: "denied"`), and only the `admin` surface reading + * tenant-wide (`adminReads: true`). + * + * `settingsWrite` stays `"allowed"` ON PURPOSE: the platform posture's + * enforcement point is the storage owner policy, which refuses every + * mutation regardless of the surface gate. Deciding `"denied"` here would + * merely swap the failure the (already unreachable-over-HTTP) mutation + * surfaces raise from the storage denial to `OrgWriteDeniedError`; the + * observer's read-only-ness never depends on it. + */ +export const platformObserverAccess = (): ExecutorAccess => ({ + owners: ORG_OWNERS, + settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")), + capabilities: { adminReads: true, storageWrites: "denied" }, + toolPolicy: standardToolPolicy(ORG_OWNERS), +}); diff --git a/packages/core/product-access/src/policy.ts b/packages/core/product-access/src/policy.ts new file mode 100644 index 0000000000..08503e2123 --- /dev/null +++ b/packages/core/product-access/src/policy.ts @@ -0,0 +1,263 @@ +// --------------------------------------------------------------------------- +// The PRODUCT's tool-policy resolution rules — how authored rows and toolkit +// capability rules combine into one `EffectivePolicy`: +// +// - owner-ranked, per-owner first-match by local position; +// - the MOST RESTRICTIVE matched action across owners wins, so a user +// preference cannot weaken an org guardrail (org = outer, user = inner); +// - no authored match falls back to the tool's own `requiresApproval` +// annotation (the plugin default); +// - a list-only toolkit provider is a capability ALLOWLIST: no matching +// rule means the tool is outside the capability boundary and blocks. +// +// Core (`@executor-js/sdk`) never applies these rules itself: it hands the +// rule material to `ExecutorAccess.toolPolicy` and enforces whatever +// `EffectivePolicy` comes back. This module deliberately imports only the +// browser-safe `@executor-js/sdk/shared` utilities (pattern matching, row +// ordering) at runtime, so UI surfaces can preview the same resolution +// without pulling the server SDK. +// --------------------------------------------------------------------------- + +import { Effect, Match } from "effect"; + +import { + comparePolicyRow, + matchPattern, + type EffectivePolicy, + type PolicyMatch, + type ToolPolicy, +} from "@executor-js/sdk/shared"; +import type { + StorageFailure, + ToolPolicyEvaluationInput, + ToolPolicyEvaluator, + ToolPolicyProvider, + ToolPolicyProviderRule, + ToolPolicySources, +} from "@executor-js/sdk/core"; + +/** The stored-row shape resolution reads — structural, so both core's + * persisted `ToolPolicyRow` and UI projections satisfy it. */ +export interface ToolPolicyRuleLike { + readonly id: string; + readonly owner: string; + readonly pattern: string; + /** Persisted rows carry a plain string; resolution narrows it. */ + readonly action: string; + readonly position: string; +} + +const actionRestrictionRank = (action: ToolPolicy["action"]): number => + Match.value(action).pipe( + Match.when("block", () => 3), + Match.when("require_approval", () => 2), + Match.when("approve", () => 1), + Match.exhaustive, + ); + +const moreRestrictive = ( + current: T | undefined, + candidate: T, +): T => { + if (!current) return candidate; + return actionRestrictionRank(candidate.action) > actionRestrictionRank(current.action) + ? candidate + : current; +}; + +const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => + defaultRequiresApproval + ? { action: "require_approval", source: "plugin-default" } + : { action: "approve", source: "plugin-default" }; + +const liftUser = (match: PolicyMatch): EffectivePolicy => ({ + action: match.action, + source: "user", + pattern: match.pattern, + policyId: match.policyId, +}); + +/** + * Resolve the matching authored rule for one tool id: each owner contributes + * its first matching rule by local position, then the most restrictive + * matched action across owners wins. `ownerRank` orders owners for the + * per-owner scan (the product's precedence); it never weakens the merge. + * Returns undefined when no rule matches. + */ +export const resolveToolPolicy = ( + toolId: string, + policies: readonly ToolPolicyRuleLike[], + ownerRank: (row: { readonly owner: string }) => number, +): PolicyMatch | undefined => { + if (policies.length === 0) return undefined; + const sorted = [...policies].sort((a, b) => { + const sa = ownerRank(a); + const sb = ownerRank(b); + if (sa !== sb) return sa - sb; + return comparePolicyRow(a, b); + }); + const firstMatchByOwner = new Map(); + for (const row of sorted) { + if (firstMatchByOwner.has(row.owner)) continue; + if (matchPattern(row.pattern, toolId)) { + firstMatchByOwner.set(row.owner, { + action: row.action as ToolPolicy["action"], + pattern: row.pattern, + policyId: row.id, + }); + } + } + let selected: PolicyMatch | undefined; + for (const match of firstMatchByOwner.values()) { + selected = moreRestrictive(selected, match); + } + return selected; +}; + +/** + * {@link resolveToolPolicy} lifted to an {@link EffectivePolicy}: an authored + * match wins, else the plugin default (`requiresApproval` → + * `require_approval`, otherwise `approve`). + */ +export const resolveEffectivePolicy = ( + toolId: string, + policies: readonly ToolPolicyRuleLike[], + ownerRank: (row: { readonly owner: string }) => number, + defaultRequiresApproval?: boolean, +): EffectivePolicy => { + const match = resolveToolPolicy(toolId, policies, ownerRank); + return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); +}; + +/** + * UI-preview variant over an ALREADY-SORTED rule list (a settings page's + * loaded policy list): same per-owner first-match + most-restrictive merge, + * treating rows without an owner as one flat list. + */ +export const effectivePolicyFromSorted = ( + toolId: string, + sortedPolicies: readonly (Pick & + Partial>)[], + defaultRequiresApproval?: boolean, +): EffectivePolicy => { + const firstMatchByOwner = new Map(); + for (const p of sortedPolicies) { + const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__"; + if (firstMatchByOwner.has(ownerKey)) continue; + if (matchPattern(p.pattern, toolId)) { + firstMatchByOwner.set(ownerKey, { + action: p.action, + source: "user", + pattern: p.pattern, + policyId: p.id, + }); + } + } + let selected: EffectivePolicy | undefined; + for (const match of firstMatchByOwner.values()) { + selected = moreRestrictive(selected, match); + } + return selected ?? liftPlugin(defaultRequiresApproval); +}; + +const compareProviderPolicyRule = ( + a: ToolPolicyProviderRule, + b: ToolPolicyProviderRule, +): number => { + if (a.position < b.position) return -1; + if (a.position > b.position) return 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +}; + +/** + * Resolution for a list-only toolkit provider: first matching rule by + * position wins; NO matching rule blocks — the provider is a capability + * allowlist, and a tool it does not name is outside the boundary. + */ +export const resolveProviderPolicyFromRules = ( + toolId: string, + rules: readonly ToolPolicyProviderRule[], +): EffectivePolicy => { + for (const rule of [...rules].sort(compareProviderPolicyRule)) { + if (!matchPattern(rule.pattern, toolId)) continue; + return { + action: rule.action, + source: "user", + pattern: rule.pattern, + policyId: rule.id, + }; + } + return { + action: "block", + source: "user", + pattern: "*", + }; +}; + +/** + * The standard product implementation of `ExecutorAccess.toolPolicy`. + * + * With a plugin-registered provider (a toolkit session), the provider's own + * batched `prepare` / per-tool `resolve` / plain `list` allowlist governs. + * Otherwise the stored owner-scoped rows resolve under the binding's owner + * precedence with the most-restrictive merge and plugin-default fallback. + */ +export const standardToolPolicy = + (owners: readonly string[]) => + (sources: ToolPolicySources): Effect.Effect => { + const provider: ToolPolicyProvider | null = sources.provider; + if (provider) { + if (provider.prepare) { + // Batched per-operation resolver: fetch all policy + connection + // state once, resolve every tool in the operation from the snapshot. + return provider.prepare().pipe( + Effect.map( + (resolve): ToolPolicyEvaluator => ({ + resolve: (input: ToolPolicyEvaluationInput) => + Effect.succeed( + resolve({ + toolId: input.toolId, + ...(input.defaultRequiresApproval === undefined + ? {} + : { defaultRequiresApproval: input.defaultRequiresApproval }), + }), + ), + }), + ), + ); + } + const perTool = provider.resolve; + if (perTool) { + return Effect.succeed({ + resolve: (input: ToolPolicyEvaluationInput) => + perTool({ + toolId: input.toolId, + ...(input.defaultRequiresApproval === undefined + ? {} + : { defaultRequiresApproval: input.defaultRequiresApproval }), + }), + }); + } + return provider.list().pipe( + Effect.map( + (rules): ToolPolicyEvaluator => ({ + resolve: (input) => Effect.succeed(resolveProviderPolicyFromRules(input.toolId, rules)), + }), + ), + ); + } + const ownerRank = (row: { readonly owner: string }): number => { + const rank = owners.indexOf(row.owner); + return rank === -1 ? owners.length : rank; + }; + return sources.policyRows.pipe( + Effect.map( + (rows): ToolPolicyEvaluator => ({ + resolve: (input) => + Effect.succeed( + resolveEffectivePolicy(input.toolId, rows, ownerRank, input.defaultRequiresApproval), + ), + }), + ), + ); + }; diff --git a/packages/core/product-access/src/testing.ts b/packages/core/product-access/src/testing.ts new file mode 100644 index 0000000000..4c8b9120e3 --- /dev/null +++ b/packages/core/product-access/src/testing.ts @@ -0,0 +1,54 @@ +// --------------------------------------------------------------------------- +// Explicit product-access postures for tests. These are the PRODUCTION rules +// from ./index, packaged so a test states its posture at the call site — +// `makeTestConfig` (from `@executor-js/sdk/testing`) has no default posture. +// +// `@executor-js/sdk`'s OWN tests import this entry too — resolved through +// the workspace ROOT devDependency on `@executor-js/product-access`, not +// through the sdk package itself, which declares no dependency on this +// package. That keeps the package graph acyclic (turbo rejects even +// dev-dependency cycles) while every test states a real product posture. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import type { ExecutorAccess, OrgWriteAccess } from "@executor-js/sdk/core"; + +import { + memberAccess, + platformObserverAccess, + requestBoundMemberAccess, + workspaceServiceAccess, +} from "./index"; + +/** Explicit product postures for test bindings. */ +export const testAccess = { + /** A bound member with a static workspace-settings decision. */ + member: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => + memberAccess(workspaceWrites), + /** A bound member on a session-lifetime stack: the decision is read from + * the fiber-local `CurrentOrgWriteAccess` at every guarded sink. */ + requestBound: (): ExecutorAccess => requestBoundMemberAccess(), + /** A subject-less workspace-service binding (org partition only), with an + * optional denied workspace-settings decision for boot-convergence + * scenarios. */ + org: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => { + const base = workspaceServiceAccess(); + if (workspaceWrites === "allowed") return base; + return { + ...base, + settingsWrite: (target) => + Effect.succeed(target.kind === "owner" && target.owner === "user" ? "allowed" : "denied"), + }; + }, + /** The platform observer posture; `subject: false` is the production + * subject-less shape, the default keeps a bound subject's partitions for + * tests that combine a member binding with the platform capabilities. */ + platform: (options?: { readonly subject?: boolean }): ExecutorAccess => + options?.subject === false + ? platformObserverAccess() + : { + ...platformObserverAccess(), + owners: ["user", "org"], + }, +} as const; diff --git a/packages/core/product-access/tsconfig.json b/packages/core/product-access/tsconfig.json new file mode 100644 index 0000000000..2ea2e133c6 --- /dev/null +++ b/packages/core/product-access/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "lib": ["ES2022"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/product-access/tsup.config.ts b/packages/core/product-access/tsup.config.ts new file mode 100644 index 0000000000..1255f09c18 --- /dev/null +++ b/packages/core/product-access/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + policy: "src/policy.ts", + testing: "src/testing.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//], +}); diff --git a/packages/core/product-access/vitest.config.ts b/packages/core/product-access/vitest.config.ts new file mode 100644 index 0000000000..ae847ff6d9 --- /dev/null +++ b/packages/core/product-access/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/core/sdk/README.md b/packages/core/sdk/README.md index ac616178cc..011d721ba9 100644 --- a/packages/core/sdk/README.md +++ b/packages/core/sdk/README.md @@ -7,14 +7,17 @@ The default surface is `Promise`-based — plugins are built on [Effect](https:/ ## Install ```sh -bun add @executor-js/sdk +bun add @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59 # or -npm install @executor-js/sdk +npm install @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59 ``` +`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. + ## Quick start ```ts +import { workspaceServiceAccess } from "@executor-js/product-access"; import { createExecutor } from "@executor-js/sdk"; const executor = await createExecutor({ @@ -22,6 +25,9 @@ const executor = await createExecutor({ // `"accept-all"` auto-approves every prompt — fine for tests/automation. // For an interactive host, pass a handler `(ctx) => Promise`. onElicitation: "accept-all", + // Required: the product's access decisions for this binding. The SDK ships + // no default posture; `@executor-js/product-access` has the standard ones. + access: workspaceServiceAccess(), }); const tools = await executor.tools.list(); @@ -35,9 +41,13 @@ await executor.close(); To invoke a tool once one is registered: ```ts +import { workspaceServiceAccess } from "@executor-js/product-access"; import { createExecutor } from "@executor-js/sdk"; -const executor = await createExecutor({ onElicitation: "accept-all" }); +const executor = await createExecutor({ + onElicitation: "accept-all", + access: workspaceServiceAccess(), +}); const tools = await executor.tools.list(); const target = tools[0]; @@ -110,6 +120,7 @@ export const memorySecretsPlugin = definePlugin((options?: MemorySecretsConfig) const executor = await createExecutor({ plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const, onElicitation: "accept-all", + access: workspaceServiceAccess(), }); console.log(executor.memorySecrets.label); // "in-memory secrets" diff --git a/packages/core/sdk/src/access.test.ts b/packages/core/sdk/src/access.test.ts new file mode 100644 index 0000000000..c21b30c1f0 --- /dev/null +++ b/packages/core/sdk/src/access.test.ts @@ -0,0 +1,370 @@ +// --------------------------------------------------------------------------- +// ExecutorAccess — the product/core boundary itself. +// +// `createExecutor` requires an access value, validates it against the +// binding, and ENFORCES its decisions without owning any of them. These +// tests pin the contract with the REAL product implementations +// (@executor-js/product-access, resolved via the workspace root +// devDependency): +// - a malformed access fails the boot loudly; +// - the settings decision is consulted live, never snapshotted; +// - `owners` really filters storage CRUD and the tool surfaces (org-only +// and personal-only views included — core forces no partition); +// - the `toolPolicy` hook is the ONLY policy authority core consults; +// - the tenant clamp holds even under an over-permissive access. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Ref } from "effect"; + +import { standardToolPolicy } from "@executor-js/product-access/policy"; +import { testAccess } from "@executor-js/product-access/testing"; + +import { executorAccessViolation, type ExecutorAccess } from "./access"; +import { createExecutor } from "./executor"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + Tenant, + ToolAddress, + ToolName, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; + +const memberShaped: ExecutorAccess = testAccess.member(); + +/** A subject-bound binding whose product view is PERSONAL-ONLY — no + * production posture ships this shape yet, which is exactly the point: the + * contract must carry it without core forcing the org partition back in. */ +const personalOnlyAccess = (): ExecutorAccess => ({ + ...testAccess.member(), + owners: ["user"], + toolPolicy: standardToolPolicy(["user"]), +}); + +/** An org-only view for a subject-BOUND member (narrower than identity). */ +const orgOnlyMemberAccess = (): ExecutorAccess => ({ + ...testAccess.member(), + owners: ["org"], + toolPolicy: standardToolPolicy(["org"]), +}); + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + delete: (id) => Effect.sync(() => void store.delete(String(id))), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key })), + ), + }; +}; + +const INTEG = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), +}))(); + +/** Seed one org and one user connection (with their tools) plus one org and + * one user policy row, through a full-view member executor. */ +const seedWorkspace = Effect.fn("seedWorkspace")(function* ( + config: ReturnType>, +) { + const admin = yield* createExecutor(config); + yield* Effect.addFinalizer(() => admin.close().pipe(Effect.ignore)); + yield* admin.demo.seed(); + yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + yield* admin.connections.create({ + owner: "user", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + value: "user-token", + }); + yield* admin.policies.create({ owner: "org", pattern: "github.*", action: "block" }); + yield* admin.policies.create({ owner: "user", pattern: "linear.*", action: "approve" }); + return admin; +}); + +describe("executorAccessViolation", () => { + it("accepts the production postures against their bindings", () => { + expect(executorAccessViolation(memberShaped, "subject-1")).toBeNull(); + expect(executorAccessViolation(testAccess.org(), null)).toBeNull(); + expect(executorAccessViolation(testAccess.platform({ subject: false }), null)).toBeNull(); + // Personal-only is a VALID product shape: core mandates no partition. + expect(executorAccessViolation(personalOnlyAccess(), "subject-1")).toBeNull(); + }); + + it("rejects an empty owners list", () => { + expect(executorAccessViolation({ ...memberShaped, owners: [] }, "subject-1")).toContain( + "must not be empty", + ); + }); + + it("rejects a duplicated owner", () => { + expect(executorAccessViolation({ ...memberShaped, owners: ["user", "user"] }, "s")).toContain( + "repeat", + ); + }); + + it("rejects a user partition on a subject-less binding", () => { + expect(executorAccessViolation(memberShaped, null)).toContain("no subject"); + }); +}); + +describe("createExecutor access validation", () => { + it.effect("fails the boot when the access does not fit the binding", () => + Effect.gen(function* () { + const config = makeTestConfig({ access: testAccess.org(), subject: null }); + const error = yield* Effect.flip( + // member-shaped access (user partition) on a subject-less binding. + createExecutor({ ...config, access: testAccess.member() }), + ); + expect(error).toMatchObject({ + _tag: "StorageError", + message: expect.stringContaining("no subject"), + }); + }), + ); +}); + +describe("settingsWrite is consulted live", () => { + it.effect("a flipped decision applies to the next guarded call, not a snapshot", () => + Effect.gen(function* () { + const decision = yield* Ref.make<"allowed" | "denied">("allowed"); + const config = makeTestConfig({ + access: { + ...testAccess.member(), + settingsWrite: (target) => + target.kind === "owner" && target.owner === "user" + ? Effect.succeed("allowed" as const) + : Ref.get(decision), + }, + }); + const executor = yield* createExecutor(config); + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); + + const created = yield* executor.policies.create({ + owner: "org", + pattern: "vercel.*", + action: "block", + }); + expect(created.pattern).toBe("vercel.*"); + + yield* Ref.set(decision, "denied"); + const error = yield* Effect.flip( + executor.policies.create({ owner: "org", pattern: "github.*", action: "block" }), + ); + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); + + // Personal-scope writes stay open under the denied decision. + const personal = yield* executor.policies.create({ + owner: "user", + pattern: "linear.*", + action: "approve", + }); + expect(personal.owner).toBe("user"); + }).pipe(Effect.scoped), + ); +}); + +describe("owners really filters storage and tool surfaces", () => { + it.effect("an org-only member view sees no user rows anywhere", () => + Effect.gen(function* () { + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); + yield* seedWorkspace(config); + + const orgOnly = yield* createExecutor({ ...config, access: orgOnlyMemberAccess() }); + yield* Effect.addFinalizer(() => orgOnly.close().pipe(Effect.ignore)); + + const policies = yield* orgOnly.policies.list(); + expect(policies.map((p) => p.owner)).toEqual(["org"]); + + const connections = yield* orgOnly.connections.list(); + expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["org:shared"]); + + const tools = yield* orgOnly.tools.list(); + const dynamic = tools.filter((t) => !t.static); + expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["org:shared"]); + + // The excluded partition is not writable either: the settings gate + // (personal stays open) passes, and the STORAGE clamp refuses. + const error = yield* Effect.flip( + orgOnly.policies.create({ owner: "user", pattern: "x.*", action: "approve" }), + ); + expect(error).toMatchObject({ + _tag: "StorageError", + message: expect.stringContaining("does not include"), + }); + }).pipe(Effect.scoped), + ); + + it.effect("a personal-only view sees no org rows anywhere", () => + Effect.gen(function* () { + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); + yield* seedWorkspace(config); + + const personal = yield* createExecutor({ ...config, access: personalOnlyAccess() }); + yield* Effect.addFinalizer(() => personal.close().pipe(Effect.ignore)); + + const policies = yield* personal.policies.list(); + expect(policies.map((p) => p.owner)).toEqual(["user"]); + + const connections = yield* personal.connections.list(); + expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["user:mine"]); + + const tools = yield* personal.tools.list(); + const dynamic = tools.filter((t) => !t.static); + expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["user:mine"]); + + // Invoking a tool over the hidden org connection fails as not-found — + // the row is invisible, not merely blocked. + const invokeError = yield* Effect.flip( + personal.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), + ); + expect(invokeError).toMatchObject({ _tag: "ToolNotFoundError" }); + + // Shared-catalog (org-partition) writes refuse loudly at storage. + const writeError = yield* Effect.flip( + personal.policies.create({ owner: "org", pattern: "x.*", action: "block" }), + ); + expect(writeError).toMatchObject({ + _tag: "StorageError", + message: expect.stringContaining("does not include"), + }); + }).pipe(Effect.scoped), + ); +}); + +describe("the product toolPolicy hook is the only policy authority", () => { + it.effect("a hook that blocks everything gates execute and list, whatever rows say", () => + Effect.gen(function* () { + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); + yield* seedWorkspace(config); + + const blockAll: ExecutorAccess = { + ...testAccess.member(), + toolPolicy: () => + Effect.succeed({ + resolve: () => + Effect.succeed({ action: "block" as const, source: "user" as const, pattern: "*" }), + }), + }; + const executor = yield* createExecutor({ ...config, access: blockAll }); + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); + + const tools = yield* executor.tools.list(); + expect(tools.filter((t) => !t.static)).toHaveLength(0); + const error = yield* Effect.flip( + executor.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), + ); + expect(error).toMatchObject({ _tag: "ToolBlockedError" }); + }).pipe(Effect.scoped), + ); + + it.effect("a hook that approves everything overrides a stored org block", () => + Effect.gen(function* () { + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); + const admin = yield* seedWorkspace(config); + // A stored rule that would block the tool under the standard product + // resolution — proving core itself never consults the rows. + yield* admin.policies.create({ owner: "org", pattern: "*", action: "block" }); + + const approveAll: ExecutorAccess = { + ...testAccess.member(), + toolPolicy: () => + Effect.succeed({ + resolve: () => + Effect.succeed({ + action: "approve" as const, + source: "plugin-default" as const, + }), + }), + }; + const executor = yield* createExecutor({ ...config, access: approveAll }); + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); + + const result = yield* executor.execute( + ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), + {}, + ); + expect(result).toMatchObject({ ran: "deploy" }); + }).pipe(Effect.scoped), + ); +}); + +describe("tenant clamp is not product-configurable", () => { + it.effect("an over-permissive access never reads another tenant's rows", () => + Effect.gen(function* () { + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); + yield* seedWorkspace(config); + + // Same database, different tenant, maximally permissive product view. + const intruder = yield* createExecutor({ + ...config, + tenant: Tenant.make("other-tenant"), + access: { + ...testAccess.member(), + toolPolicy: () => + Effect.succeed({ + resolve: () => + Effect.succeed({ + action: "approve" as const, + source: "plugin-default" as const, + }), + }), + }, + }); + yield* Effect.addFinalizer(() => intruder.close().pipe(Effect.ignore)); + + expect(yield* intruder.policies.list()).toHaveLength(0); + expect(yield* intruder.connections.list()).toHaveLength(0); + expect((yield* intruder.tools.list()).filter((t) => !t.static)).toHaveLength(0); + const error = yield* Effect.flip( + intruder.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), + ); + expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/access.ts b/packages/core/sdk/src/access.ts new file mode 100644 index 0000000000..49cec455f5 --- /dev/null +++ b/packages/core/sdk/src/access.ts @@ -0,0 +1,215 @@ +import { Context, Effect, Ref } from "effect"; + +import type { ToolPolicyRow } from "./core-schema"; +import type { StorageFailure } from "./fuma-runtime"; +import type { Owner } from "./ids"; +import type { ToolPolicyProvider } from "./plugin"; +import type { EffectivePolicy } from "./policies"; + +// --------------------------------------------------------------------------- +// Product access — the defined interface through which the PRODUCT hands the +// executor its personal/organization access decisions. +// +// Core deliberately owns no product rule here: it does not know what a role +// is, which member is an admin, or why a deployment has no role model at all. +// Core defines the decision vocabulary (this module), consults the supplied +// decisions at its enforcement sinks, and keeps enforcing the security +// invariants that are NOT product-configurable: tenant isolation and the +// storage owner policy (owner-policy.ts), which clamp whatever the product +// supplies. `ExecutorConfig.access` is required — there is no default posture +// in core, so every composition root states its product's rules explicitly. +// --------------------------------------------------------------------------- + +/** Workspace-settings authorization bound to the currently executing request. */ +export type OrgWriteAccess = "allowed" | "denied"; + +/** A single product authorization answer core enforces verbatim. */ +export type AccessDecision = "allowed" | "denied"; + +/** + * The target of a user-intent settings mutation, as core describes it to the + * product's {@link ExecutorAccess.settingsWrite} rule: + * + * - `{ kind: "owner", owner }` — an owner-scoped settings row (a connection, + * tool policy, OAuth client, …) filed under that partition. + * - `{ kind: "workspace" }` — a tenant-shared surface (the integration + * catalog and other whole-workspace settings). + */ +export type SettingsWriteTarget = + | { readonly kind: "owner"; readonly owner: Owner } + | { readonly kind: "workspace" }; + +/** + * Structural view capabilities the product grants a binding. Core maps each + * to a non-overridable mechanism: + * + * - `adminReads` — expose `executor.admin`, the ONLY surface whose reads are + * tenant-wide. The widened context is read-only by construction + * (owner-policy reach mechanics), regardless of what the product says. + * - `storageWrites` — `"denied"` makes the WHOLE executor read-only at the + * storage boundary (every create/update/delete on every guarded table is + * refused, and catalog re-sync is skipped). The platform observer posture + * combines both. + */ +export interface ExecutorAccessCapabilities { + readonly adminReads: boolean; + readonly storageWrites: AccessDecision; +} + +/** One effective-policy question, as core asks the product's evaluator. */ +export interface ToolPolicyEvaluationInput { + /** Normalized policy id: a static tool's address, or the 4-segment + * `...` form. */ + readonly toolId: string; + /** The tool's own `requiresApproval` annotation — the fallback material a + * product resolution may lift when no authored rule matches. */ + readonly defaultRequiresApproval?: boolean | undefined; +} + +/** + * A per-operation effective-policy evaluator returned by + * {@link ExecutorAccess.toolPolicy}. Built once per surface operation (one + * tools list / schema read / invocation / post-approval recheck) so a product + * implementation can batch its underlying reads; `resolve` is then asked once + * per tool. Core ENFORCES whatever comes back — `block` refuses the call, + * `require_approval` gates it behind an elicitation — but never decides it. + */ +export interface ToolPolicyEvaluator { + readonly resolve: ( + input: ToolPolicyEvaluationInput, + ) => Effect.Effect; +} + +/** + * The rule material core hands the product's {@link ExecutorAccess.toolPolicy} + * hook for one operation. Both members are the product's to consult or + * ignore; core pre-selects nothing. + */ +export interface ToolPolicySources { + /** + * The stored, owner-scoped `tool_policy` rows visible to this binding — + * a LAZY effect, fetched only if the product's resolution reads it. + */ + readonly policyRows: Effect.Effect; + /** + * The plugin-registered policy provider, when a plugin opted this executor + * instance into a session rule source (a toolkit's capability set), else + * null. Toolkit sessions also hide catalog entries that grant no tools; + * that presentation follows the provider's presence (part of the + * `ToolPolicyProvider` plugin contract), while HOW its rules resolve — + * including the empty-match default — is decided here, by the product. + */ + readonly provider: ToolPolicyProvider | null; +} + +/** + * Product-owned access decisions for one executor binding. Supplied by the + * host's composition root (see `@executor-js/product-access` for the product + * rule implementations); enforced — never decided — by core. + * + * Shape invariants, validated at `createExecutor` (violations fail the boot): + * - `owners` is a non-empty, duplicate-free subset of `["user", "org"]`; + * - `"user"` may appear only when the executor binds a subject. + * + * Core's non-overridable clamps apply to every decision: the tenant clause + * is never relaxed, user rows only ever resolve to the bound subject, + * tenant-reach contexts are read-only (or delete-only, for the internal + * removal cascade), and a mid-run tool is never interrupted by a policy + * change — `block` means "don't start new runs". + * + * PAUSED EXECUTIONS: a pause/resume boundary re-evaluates the + * request-bound `settingsWrite` decision as the RESUMING principal (via + * {@link CurrentOrgWriteAccess}), and the post-approval recheck re-resolves + * the tool policy and re-reads rows — but storage reads and credential + * resolution still run under the STARTER's binding, whose executor instance + * owns the paused fiber. + */ +export interface ExecutorAccess { + /** + * ROW VISIBILITY AND WRITE AUTHORIZATION: the owner partitions this + * binding sees and may write, in precedence order (the first entry shadows + * later ones on plugin-storage reads, ranks first in policy listings, and + * is the partition static tools present under). Carried onto every storage + * context: reads, updates and deletes are FILTERED to these partitions and + * creates outside them are refused — an org-only or personal-only product + * view really is one, across storage CRUD and the tool surfaces built on + * it. Core contributes no partition of its own. + */ + readonly owners: readonly Owner[]; + /** + * USER-INTENT SETTINGS AUTHORIZATION, including the owner-target rule. + * Consulted live at every guarded sink (never cached by core, so a + * request-bound implementation is re-read after pauses and on resume); + * `"denied"` surfaces as `OrgWriteDeniedError`. Operational writes a + * denied member's usage implies (token refresh, catalog re-sync) do not + * pass through this gate — they are bounded by `owners` and the storage + * clamps instead. + */ + readonly settingsWrite: (target: SettingsWriteTarget) => Effect.Effect; + /** View capabilities — see {@link ExecutorAccessCapabilities}. */ + readonly capabilities: ExecutorAccessCapabilities; + /** + * EFFECTIVE TOOL-POLICY EVALUATION. Called once per surface operation with + * this binding's rule material; the returned evaluator answers every + * per-tool question in that operation. Owner ranking, cross-owner merging, + * plugin-default fallback and the capability-allowlist default all live in + * the product implementation. + */ + readonly toolPolicy: ( + sources: ToolPolicySources, + ) => Effect.Effect; +} + +/** + * Validate an {@link ExecutorAccess} against the executor's `{ tenant, + * subject }` binding. Returns null when well-formed; a violation is a + * programmer error at a composition root and fails fast via the returned + * message so `createExecutor` can surface it as a startup failure. + */ +export const executorAccessViolation = ( + access: ExecutorAccess, + subject: string | null, +): string | null => { + const owners = access.owners; + if (owners.length === 0) return "ExecutorAccess.owners must not be empty."; + if (new Set(owners).size !== owners.length) { + return "ExecutorAccess.owners must not repeat an owner."; + } + if (owners.includes("user") && subject == null) { + return 'ExecutorAccess.owners includes "user" but the executor has no subject.'; + } + return null; +}; + +/** + * Fiber-local workspace-settings authorization for request-bound executors. + * + * This is the decision CARRIER, not a decision rule: a session host stamps it + * from each freshly authenticated request, the execution engine snapshots the + * state onto a paused execution and re-stamps `current` from the resuming (or + * joining) principal's fiber, and a request-bound `ExecutorAccess` + * implementation reads it at every guarded sink. The denied default makes a + * missing request binding fail closed. Executors whose product supplies a + * static decision never consult this reference. + */ +export interface OrgWriteAccessState { + /** Mutable value inherited by a detached execution and refreshed on resume. */ + readonly current: Ref.Ref; +} + +/** Create an isolated request/execution authorization state. */ +export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ + current: Ref.makeUnsafe(access), +}); + +/** Request-local workspace-write authorization inherited by child fibers. */ +export const CurrentOrgWriteAccess = Context.Reference( + "@executor-js/sdk/CurrentOrgWriteAccess", + { defaultValue: () => makeOrgWriteAccessState("denied") }, +); + +/** Read the effective authorization at a workspace-write sink. */ +export const currentOrgWriteAccess: Effect.Effect = Effect.gen(function* () { + const state = yield* CurrentOrgWriteAccess; + return yield* Ref.get(state.current); +}); diff --git a/packages/core/sdk/src/approval-access-recheck.test.ts b/packages/core/sdk/src/approval-access-recheck.test.ts new file mode 100644 index 0000000000..b125e09c61 --- /dev/null +++ b/packages/core/sdk/src/approval-access-recheck.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Ref, Schema } from "effect"; + +import { ElicitationResponse } from "./elicitation"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; +import { definePlugin, tool } from "./plugin"; +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; +import { testAccess } from "@executor-js/product-access/testing"; + +const integration = IntegrationSlug.make("approvalcheck"); +const connection = ConnectionName.make("main"); +const staticAddress = ToolAddress.make("approvalcheck.control.run"); +const dynamicAddress = ToolAddress.make("tools.approvalcheck.org.main.run"); + +const setup = Effect.gen(function* () { + const invocations = yield* Ref.make(0); + const recordInvocation = Ref.update(invocations, (count) => count + 1).pipe( + Effect.as("executed"), + ); + const plugin = definePlugin(() => ({ + id: "approval-check" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + id: "approvalcheck.control", + kind: "control" as const, + name: "Approval check", + tools: [ + tool({ + name: "run", + description: "Record an invocation after approval.", + annotations: { requiresApproval: true }, + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => recordInvocation, + }), + ], + }, + ], + resolveTools: () => + Effect.succeed({ + tools: [ + { + name: ToolName.make("run"), + description: "Record an invocation after approval.", + annotations: { requiresApproval: true }, + }, + ], + }), + invokeTool: () => recordInvocation, + extension: (ctx) => ({ + register: () => + ctx.core.integrations.register({ + slug: integration, + description: "Approval check", + config: {}, + }), + }), + })); + const executor = yield* makeTestExecutor({ + access: testAccess.member(), + plugins: [plugin(), memoryCredentialsPlugin()] as const, + }); + yield* executor["approval-check"].register(); + yield* executor.connections.create({ + owner: "org", + integration, + name: connection, + template: AuthTemplateSlug.make("none"), + value: "synthetic-approval-test-key", + }); + return { executor, invocations }; +}); + +describe("access changes while approval is pending", () => { + for (const address of [staticAddress, dynamicAddress]) { + it.effect(`blocks ${address} if policy changes before acceptance`, () => + Effect.gen(function* () { + const { executor, invocations } = yield* setup; + const error = yield* Effect.flip( + executor.execute( + address, + {}, + { + onElicitation: () => + executor.policies + .create({ owner: "org", pattern: "approvalcheck.*", action: "block" }) + .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))), + }, + ), + ); + expect(error).toMatchObject({ _tag: "ToolBlockedError" }); + expect(yield* Ref.get(invocations)).toBe(0); + }), + ); + } + + it.effect("does not invoke a connection disconnected before acceptance", () => + Effect.gen(function* () { + const { executor, invocations } = yield* setup; + const error = yield* Effect.flip( + executor.execute( + dynamicAddress, + {}, + { + onElicitation: () => + executor.connections + .remove({ owner: "org", integration, name: connection }) + .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))), + }, + ), + ); + expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); + expect(yield* Ref.get(invocations)).toBe(0); + }), + ); + + it.effect("accepts once when access stays valid", () => + Effect.gen(function* () { + const { executor, invocations } = yield* setup; + const approvals = yield* Ref.make(0); + const result = yield* executor.execute( + dynamicAddress, + {}, + { + onElicitation: () => + Ref.update(approvals, (count) => count + 1).pipe( + Effect.as(ElicitationResponse.make({ action: "accept" })), + ), + }, + ); + expect(result).toBe("executed"); + expect(yield* Ref.get(invocations)).toBe(1); + expect(yield* Ref.get(approvals)).toBe(1); + }), + ); +}); diff --git a/packages/core/sdk/src/approval-plugin-swap.test.ts b/packages/core/sdk/src/approval-plugin-swap.test.ts new file mode 100644 index 0000000000..39b3a4de59 --- /dev/null +++ b/packages/core/sdk/src/approval-plugin-swap.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Ref } from "effect"; + +import { ElicitationResponse } from "./elicitation"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; +import { definePlugin } from "./plugin"; +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; +import { testAccess } from "@executor-js/product-access/testing"; + +// --------------------------------------------------------------------------- +// Regression: the plugin RUNTIME bound before an approval pause must never +// invoke a REPLACEMENT tool row. If the integration is torn down and +// re-registered under a different plugin while the call waits on a human, +// the tool the user approved no longer exists in that form — the resumed +// call fails as not-found instead of handing the new row (and the new +// connection's credential) to the old plugin's handler. +// --------------------------------------------------------------------------- + +const integration = IntegrationSlug.make("swapcheck"); +const connection = ConnectionName.make("main"); +const address = ToolAddress.make("tools.swapcheck.org.main.run"); + +describe("integration replaced under a different plugin during approval", () => { + it.effect("fails as not-found; neither plugin's handler runs", () => + Effect.gen(function* () { + const oldInvocations = yield* Ref.make(0); + const newInvocations = yield* Ref.make(0); + + const makeCatalogPlugin = (id: "swap-old" | "swap-new", invocations: Ref.Ref) => + definePlugin(() => ({ + id, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [ + { + name: ToolName.make("run"), + description: "Run after approval.", + annotations: { requiresApproval: true }, + }, + ], + }), + invokeTool: () => Ref.update(invocations, (count) => count + 1).pipe(Effect.as("ran")), + extension: (ctx) => ({ + register: () => + ctx.core.integrations.register({ + slug: integration, + description: `registered by ${id}`, + config: {}, + }), + }), + }))(); + + const executor = yield* makeTestExecutor({ + access: testAccess.member(), + plugins: [ + makeCatalogPlugin("swap-old", oldInvocations), + makeCatalogPlugin("swap-new", newInvocations), + memoryCredentialsPlugin(), + ] as const, + }); + yield* executor["swap-old"].register(); + const createConnection = executor.connections.create({ + owner: "org", + integration, + name: connection, + template: AuthTemplateSlug.make("none"), + value: "synthetic-swap-test-key", + }); + yield* createConnection; + + const swapPlugins = Effect.gen(function* () { + // Tear the integration down (cascading its connection and tool rows) + // and re-register the SAME slug under the other plugin, with a fresh + // connection producing a same-named tool row owned by `swap-new`. + yield* executor.integrations.remove(integration); + yield* executor["swap-new"].register(); + yield* createConnection; + return ElicitationResponse.make({ action: "accept" }); + }).pipe(Effect.orDie); + + const error = yield* Effect.flip( + executor.execute(address, {}, { onElicitation: () => swapPlugins }), + ); + expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); + expect(yield* Ref.get(oldInvocations)).toBe(0); + expect(yield* Ref.get(newInvocations)).toBe(0); + }), + ); +}); diff --git a/packages/core/sdk/src/artifact-table.test.ts b/packages/core/sdk/src/artifact-table.test.ts index 68f2c339db..1c6d5b80a9 100644 --- a/packages/core/sdk/src/artifact-table.test.ts +++ b/packages/core/sdk/src/artifact-table.test.ts @@ -119,11 +119,19 @@ describe("artifact table", () => { title: "Theirs", }); - const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT, + owners: ["user", "org"], + }); const visible = await scoped.findMany("artifact", {}); expect(visible.map((row) => row.id)).toEqual(["art_mine"]); - const otherScoped = withQueryContext(db.db, { tenant: TENANT, subject: OTHER_SUBJECT }); + const otherScoped = withQueryContext(db.db, { + tenant: TENANT, + subject: OTHER_SUBJECT, + owners: ["user", "org"], + }); const otherVisible = await otherScoped.findMany("artifact", {}); expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]); }), diff --git a/packages/core/sdk/src/artifacts.test.ts b/packages/core/sdk/src/artifacts.test.ts index d07a56fbc5..485ba840b8 100644 --- a/packages/core/sdk/src/artifacts.test.ts +++ b/packages/core/sdk/src/artifacts.test.ts @@ -4,6 +4,7 @@ import { Effect, Predicate, Result } from "effect"; import { makeTestExecutor } from "./testing"; import type { ArtifactBinding } from "./artifact"; import { ConnectionName, IntegrationSlug } from "./ids"; +import { testAccess } from "@executor-js/product-access/testing"; const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({ integration: IntegrationSlug.make(integration), @@ -20,14 +21,14 @@ const CODE = "export default function App() { return
hi
; }"; describe("executor.artifacts", () => { it.effect("list is empty when nothing is saved", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); expect(yield* executor.artifacts.list()).toEqual([]); }), ); it.effect("save mints an id and stores the source", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Active users", description: "Daily active users over time", @@ -46,7 +47,7 @@ describe("executor.artifacts", () => { it.effect("round-trips connection bindings through the json column", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const bindings = { vercel: binding("vercel", "user", "personalVercel"), prod: binding("linear", "org", "linearProd"), @@ -59,7 +60,7 @@ describe("executor.artifacts", () => { it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} }); // `{}` and `null` are different facts: `{}` is an artifact that calls no // integration, `null` is one written before bindings existed and whose @@ -71,7 +72,7 @@ describe("executor.artifacts", () => { it.effect("leaves bindings null when a save omits them", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE }); expect(saved.bindings).toBe(null); }), @@ -79,7 +80,7 @@ describe("executor.artifacts", () => { it.effect("overwrites bindings along with the source", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Domains", code: CODE, @@ -100,7 +101,7 @@ describe("executor.artifacts", () => { it.effect("save defaults a missing description to null", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE }); expect(saved.description).toBe(null); }), @@ -108,7 +109,7 @@ describe("executor.artifacts", () => { it.effect("list omits the source", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Active users", description: "described", @@ -141,7 +142,7 @@ describe("executor.artifacts", () => { // storage layer really has. it.live("list returns the most recently updated artifact first", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const first = yield* executor.artifacts.save({ title: "First", code: CODE }); yield* Effect.sleep("1100 millis"); const second = yield* executor.artifacts.save({ title: "Second", code: CODE }); @@ -159,7 +160,7 @@ describe("executor.artifacts", () => { it.effect("save with an id overwrites in place", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE }); const updated = yield* executor.artifacts.save({ id: saved.id, @@ -182,7 +183,7 @@ describe("executor.artifacts", () => { it.effect("save fails for an id that names no visible artifact", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const result = yield* Effect.result( executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }), ); @@ -194,7 +195,7 @@ describe("executor.artifacts", () => { it.effect("get fails for an unknown id", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const result = yield* Effect.result(executor.artifacts.get("art_missing")); expect(Result.isFailure(result)).toBe(true); if (!Result.isFailure(result)) return; @@ -204,7 +205,7 @@ describe("executor.artifacts", () => { it.effect("rename changes only the title", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Old name", description: "kept", @@ -219,7 +220,7 @@ describe("executor.artifacts", () => { it.effect("rename fails for an unknown id", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const result = yield* Effect.result( executor.artifacts.rename({ id: "art_missing", title: "Nope" }), ); @@ -231,7 +232,7 @@ describe("executor.artifacts", () => { it.effect("remove hard-deletes the artifact", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE }); yield* executor.artifacts.remove({ id: saved.id }); @@ -245,7 +246,7 @@ describe("executor.artifacts", () => { it.effect("remove is idempotent", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); yield* executor.artifacts.remove({ id: "art_missing" }); }), ); @@ -254,7 +255,7 @@ describe("executor.artifacts", () => { Effect.gen(function* () { // Pure-org executors (host request paths with no member bound) have no // user partition to file a personal artifact into. - const executor = yield* makeTestExecutor({ subject: null }); + const executor = yield* makeTestExecutor({ access: testAccess.org(), subject: null }); const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE })); expect(Result.isFailure(result)).toBe(true); }), @@ -266,7 +267,7 @@ describe("executor.artifacts", () => { it.effect("round-trips layout markup, and reads it back as a layout preview", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE, @@ -284,7 +285,7 @@ describe("executor.artifacts", () => { it.effect("leaves the preview null when a save omits one", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE }); expect(saved.preview).toBe(null); }), @@ -295,7 +296,7 @@ describe("executor.artifacts", () => { // The preview interprets `code`, so a save that replaces the source // must replace the picture — including back to null. Carrying it // forward would advertise a version of the artifact that is gone. - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE, @@ -313,7 +314,7 @@ describe("executor.artifacts", () => { it.effect("setPreview upgrades the preview to the settled render", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE, @@ -331,7 +332,7 @@ describe("executor.artifacts", () => { Effect.gen(function* () { // `updated_at` is the gallery's sort key. Opening an artifact must not // move it to the front — being looked at is not an edit. - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE }); yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED }); expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt); @@ -340,7 +341,7 @@ describe("executor.artifacts", () => { it.effect("setPreview fails for an artifact that is not visible", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor(); + const executor = yield* makeTestExecutor({ access: testAccess.member() }); const result = yield* Effect.result( executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }), ); diff --git a/packages/core/sdk/src/blob.test.ts b/packages/core/sdk/src/blob.test.ts index 59e5a75d12..95cb70a435 100644 --- a/packages/core/sdk/src/blob.test.ts +++ b/packages/core/sdk/src/blob.test.ts @@ -3,23 +3,35 @@ import { Effect } from "effect"; import { StorageError } from "./fuma-runtime"; -import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob"; +import { + makeInMemoryBlobStore, + pluginBlobStore, + type BlobAccess, + type OwnerPartitions, +} from "./blob"; -// v2: owner partitions instead of a scope stack. Reads fall through -// [user, org] (user = innermost); writes/deletes name an explicit owner. +// v2: owner partitions instead of a scope stack. Reads fall through the +// product view's owners in their supplied order (first listed wins); writes +// and deletes name an explicit owner and are refused outside the view. const partitions = (org: string, user: string | null): OwnerPartitions => ({ org, user, }); +// The full member view — the pre-BlobAccess behavior: user shadows org on +// read, both partitions writable. +const fullView: BlobAccess = { owners: ["user", "org"], storageWrites: "allowed" }; +const personalOnly: BlobAccess = { owners: ["user"], storageWrites: "allowed" }; +const orgOnly: BlobAccess = { owners: ["org"], storageWrites: "allowed" }; + describe("pluginBlobStore", () => { - it.effect("get returns user (innermost) value when both owners have one", () => + it.effect("get returns user (first-ranked) value when both owners have one", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); yield* store.put("u/my-plugin", "k", "user-value"); yield* store.put("o/my-plugin", "k", "org-value"); - const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); const value = yield* plugin.get("k"); expect(value).toBe("user-value"); }), @@ -30,7 +42,7 @@ describe("pluginBlobStore", () => { const store = makeInMemoryBlobStore(); yield* store.put("o/my-plugin", "k", "org-value"); - const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); const value = yield* plugin.get("k"); expect(value).toBe("org-value"); }), @@ -39,7 +51,7 @@ describe("pluginBlobStore", () => { it.effect("get returns null when no owner has the key", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); const value = yield* plugin.get("k"); expect(value).toBeNull(); }), @@ -50,7 +62,7 @@ describe("pluginBlobStore", () => { const store = makeInMemoryBlobStore(); yield* store.put("o/my-plugin", "k", "v"); - const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); const found = yield* plugin.has("k"); expect(found).toBe(true); }), @@ -59,7 +71,7 @@ describe("pluginBlobStore", () => { it.effect("has returns false when no owner has the key", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView); const found = yield* plugin.has("k"); expect(found).toBe(false); }), @@ -71,8 +83,8 @@ describe("pluginBlobStore", () => { yield* store.put("u/plugin-a", "k", "a-value"); yield* store.put("u/plugin-b", "k", "b-value"); - const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a"); - const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b"); + const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a", fullView); + const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b", fullView); expect(yield* pluginA.get("k")).toBe("a-value"); expect(yield* pluginB.get("k")).toBe("b-value"); }), @@ -81,10 +93,14 @@ describe("pluginBlobStore", () => { it.effect("put rejects owner:user when the executor has no subject", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - // No user partition → owner:"user" writes fail. - const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); + // No user partition → owner:"user" writes fail even when the product + // view lists "user"; the identity clamp is not the product's to relax. + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); expect(err).toBeInstanceOf(StorageError); + expect(err).toMatchObject({ + message: 'Blob write targets owner "user" but the executor has no subject.', + }); // Write must not have reached the store. expect(yield* store.get("o/my-plugin", "k")).toBeNull(); }), @@ -93,9 +109,142 @@ describe("pluginBlobStore", () => { it.effect("delete rejects owner:user when the executor has no subject", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip); expect(err).toBeInstanceOf(StorageError); + expect(err).toMatchObject({ + message: 'Blob write targets owner "user" but the executor has no subject.', + }); + }), + ); +}); + +describe("pluginBlobStore access clamp", () => { + it.effect("personal-only view cannot read an org blob", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + // e.g. an org-shared provider config holding a service-account token. + yield* store.put("o/my-plugin", "k", "org-secret"); + + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); + expect(yield* plugin.get("k")).toBeNull(); + expect(yield* plugin.has("k")).toBe(false); + }), + ); + + it.effect("personal-only view still reads its own user blob", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + yield* store.put("u/my-plugin", "k", "user-value"); + yield* store.put("o/my-plugin", "k", "org-value"); + + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); + expect(yield* plugin.get("k")).toBe("user-value"); + }), + ); + + it.effect("org-only view with a bound subject cannot read a user blob", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + yield* store.put("u/my-plugin", "k", "user-value"); + + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly); + expect(yield* plugin.get("k")).toBeNull(); + expect(yield* plugin.has("k")).toBe(false); + }), + ); + + it.effect("read precedence follows the supplied owner order", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + yield* store.put("u/my-plugin", "k", "user-value"); + yield* store.put("o/my-plugin", "k", "org-value"); + + const orgFirst = pluginBlobStore(store, partitions("o", "u"), "my-plugin", { + owners: ["org", "user"], + storageWrites: "allowed", + }); + expect(yield* orgFirst.get("k")).toBe("org-value"); + }), + ); + + it.effect("put outside the view is rejected and does not reach the store", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); + const err = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip); + expect(err).toBeInstanceOf(StorageError); + expect(err).toMatchObject({ + message: + 'Blob write targets the "org" partition, which this product view does not include.', + }); + expect(yield* store.get("o/my-plugin", "k")).toBeNull(); + }), + ); + + it.effect("delete outside the view is rejected and leaves the row in place", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + yield* store.put("o/my-plugin", "k", "org-value"); + + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); + const err = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip); + expect(err).toBeInstanceOf(StorageError); + expect(err).toMatchObject({ + message: + 'Blob write targets the "org" partition, which this product view does not include.', + }); + expect(yield* store.get("o/my-plugin", "k")).toBe("org-value"); + }), + ); + + it.effect("org-only view cannot write the user partition", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly); + const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); + expect(err).toBeInstanceOf(StorageError); + expect(err).toMatchObject({ + message: + 'Blob write targets the "user" partition, which this product view does not include.', + }); + expect(yield* store.get("u/my-plugin", "k")).toBeNull(); + }), + ); + + it.effect("read-only view refuses every mutation but keeps reading", () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + yield* store.put("o/my-plugin", "k", "org-value"); + + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", { + owners: ["user", "org"], + storageWrites: "denied", + }); + const putErr = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip); + expect(putErr).toBeInstanceOf(StorageError); + expect(putErr.message).toBe( + 'Blob write on plugin "my-plugin" is not allowed: this executor\'s storage is read-only.', + ); + const userPutErr = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); + expect(userPutErr).toBeInstanceOf(StorageError); + const deleteErr = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip); + expect(deleteErr).toBeInstanceOf(StorageError); + // Nothing was mutated; reads keep working. + expect(yield* store.get("o/my-plugin", "k")).toBe("org-value"); + expect(yield* plugin.get("k")).toBe("org-value"); + }), + ); + + it.effect('a view listing "user" on a subject-less identity reads org rows only', () => + Effect.gen(function* () { + const store = makeInMemoryBlobStore(); + yield* store.put("o/my-plugin", "k", "org-value"); + + // `createExecutor` validation rejects this shape, but the seam must not + // assume it: the null partition contributes no namespace, not a failure. + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); + expect(yield* plugin.get("k")).toBe("org-value"); }), ); }); diff --git a/packages/core/sdk/src/blob.ts b/packages/core/sdk/src/blob.ts index c98286611d..0ca6c7e805 100644 --- a/packages/core/sdk/src/blob.ts +++ b/packages/core/sdk/src/blob.ts @@ -4,18 +4,21 @@ // metadata and plugin-owned config rows. // // Plugins see a `PluginBlobStore` that's already namespaced to the -// plugin id and bound to the executor's scope stack. Reads fall through -// the stack in order (innermost first, first hit wins); writes and -// deletes require an explicit scope id naming where the operation -// should land. That mirrors the secrets API — shadowing by key on -// read, explicit target on write. +// plugin id and clamped to the product's `BlobAccess` view. Reads fall +// through the view's owners in order (first listed wins); writes and +// deletes require an explicit owner naming where the operation should +// land, and are refused outside the view. That mirrors the secrets API — +// shadowing by key on read, explicit target on write — and it is where +// the executor's access decisions apply to blobs at all: the blob table +// sits outside the storage owner policy (isolation is the namespace +// string), so nothing downstream re-checks them. // // Error channel is `StorageError` — blobs only do read/write/delete, so // they never produce `UniqueViolationError`. The HTTP edge translates // `StorageError` to the opaque public `InternalError({ traceId })`. // --------------------------------------------------------------------------- -import { Effect } from "effect"; +import { Effect, Predicate } from "effect"; import { StorageError, type IFumaClient } from "./fuma-runtime"; import type { Owner } from "./ids"; @@ -41,53 +44,101 @@ export interface BlobStore { } export interface PluginBlobStore { - /** Read precedence: this subject's own (`user`) value first, then the - * org-shared value. Returns the first non-null. */ + /** Read precedence: the product view's owner order (`BlobAccess.owners`, + * first entry wins). Returns the first non-null. */ readonly get: (key: string) => Effect.Effect; /** Write `value` under `key` for the named owner (`"org"` shared, `"user"` - * private). `"user"` requires the executor to be bound to a subject. */ + * private). The owner must be in the product view, and `"user"` requires + * the executor to be bound to a subject. */ readonly put: ( key: string, value: string, options: { readonly owner: Owner }, ) => Effect.Effect; - /** Delete `key` for the named owner. */ + /** Delete `key` for the named owner. Bounded like `put`. */ readonly delete: ( key: string, options: { readonly owner: Owner }, ) => Effect.Effect; - /** True if either the user or org partition has a value for `key`. */ + /** True if any partition in the product view has a value for `key`. */ readonly has: (key: string) => Effect.Effect; } -/** The owner partition strings an executor binding resolves to: the org +/** The owner partition strings an executor IDENTITY resolves to: the org * partition (always present) and this subject's user partition (null for a - * pure-org executor). Reads walk `[user, org]`; writes target one. */ + * pure-org executor). Identity-shaped on purpose — identity-bound consumers + * (the pending-approval store) keep using it directly; which partitions a + * plugin blob store actually reads or writes is the separate, product-owned + * `BlobAccess` decision. */ export interface OwnerPartitions { readonly org: string; readonly user: string | null; } +/** + * The slice of the product's access decisions the blob seam enforces: the + * blob table is exempt from the storage owner policy (isolation lives in the + * row namespace), so `ExecutorAccess.owners` and the read-only capability + * must be applied HERE, at namespace construction, or not at all. + * + * Structural and required — there is no default view: the caller states the + * product's decision explicitly, and core contributes only the clamps + * (tenant/subject are already baked into `OwnerPartitions`). + */ +export interface BlobAccess { + /** The owner partitions reads see and writes may target, in read-precedence + * order — the first listed owner shadows later ones on `get`. */ + readonly owners: readonly Owner[]; + /** `"denied"` refuses every `put`/`delete` through this store (the + * read-only posture, `ExecutorAccessCapabilities.storageWrites`). */ + readonly storageWrites: "allowed" | "denied"; +} + const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`; /** - * Bind a `BlobStore` to an owner partitioning + plugin id. Reads fall through - * `[user, org]` (user first); writes target an explicit owner. Used by the - * executor to build the `blobs` field handed to each plugin's `storage` factory. + * Bind a `BlobStore` to an owner partitioning + plugin id, clamped to the + * product's `BlobAccess`. Reads fall through the access owners in their + * supplied order (first hit wins); writes target an explicit owner and are + * refused outside the access view. Used by the executor to build the `blobs` + * field handed to each plugin's `storage` factory. */ export const pluginBlobStore = ( store: BlobStore, partitions: OwnerPartitions, pluginId: string, + access: BlobAccess, ): PluginBlobStore => { - const readNamespaces = (): readonly string[] => - (partitions.user == null ? [partitions.org] : [partitions.user, partitions.org]).map((p) => - nsFor(p, pluginId), - ); + const partitionOf = (owner: Owner): string | null => + owner === "org" ? partitions.org : partitions.user; + + // The product view's partitions, in ITS precedence order. An owner the + // identity cannot carry (`"user"` with no subject) maps to no namespace + // rather than failing a read — the partition simply has no rows to see. + const readNamespaces: readonly string[] = access.owners + .map(partitionOf) + .filter(Predicate.isNotNull) + .map((partition) => nsFor(partition, pluginId)); const partitionFor = (owner: Owner): Effect.Effect => { - if (owner === "org") return Effect.succeed(partitions.org); - if (partitions.user == null) { + if (access.storageWrites === "denied") { + return Effect.fail( + new StorageError({ + message: `Blob write on plugin "${pluginId}" is not allowed: this executor's storage is read-only.`, + cause: undefined, + }), + ); + } + if (!access.owners.includes(owner)) { + return Effect.fail( + new StorageError({ + message: `Blob write targets the "${owner}" partition, which this product view does not include.`, + cause: undefined, + }), + ); + } + const partition = partitionOf(owner); + if (partition == null) { return Effect.fail( new StorageError({ message: 'Blob write targets owner "user" but the executor has no subject.', @@ -95,16 +146,15 @@ export const pluginBlobStore = ( }), ); } - return Effect.succeed(partitions.user); + return Effect.succeed(partition); }; return { get: (key) => Effect.gen(function* () { - const namespaces = readNamespaces(); - const hits = yield* store.getMany(namespaces, key); + const hits = yield* store.getMany(readNamespaces, key); if (hits.size === 0) return null; - for (const ns of namespaces) { + for (const ns of readNamespaces) { const v = hits.get(ns); if (v !== undefined) return v; } @@ -118,7 +168,7 @@ export const pluginBlobStore = ( Effect.flatMap(partitionFor(options.owner), (partition) => store.delete(nsFor(partition, pluginId), key), ), - has: (key) => store.getMany(readNamespaces(), key).pipe(Effect.map((hits) => hits.size > 0)), + has: (key) => store.getMany(readNamespaces, key).pipe(Effect.map((hits) => hits.size > 0)), }; }; diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 51c7f8d110..775c74be45 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -33,6 +33,7 @@ import { definePlugin, type ResolveToolsResult } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestConfig, makeTestExecutor } from "./testing"; import { ToolResult } from "./tool-result"; +import { testAccess } from "@executor-js/product-access/testing"; // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2 @@ -163,7 +164,7 @@ const demoPlugin = definePlugin(() => ({ }))(); const setup = () => - makeTestExecutor({ plugins: [demoPlugin] as const }).pipe( + makeTestExecutor({ access: testAccess.member(), plugins: [demoPlugin] as const }).pipe( Effect.tap((executor) => executor.demo.seed()), ); @@ -338,7 +339,10 @@ describe("connections.create", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [gatedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [gatedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.gated.seed(); @@ -435,7 +439,7 @@ describe("connections.create", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [plugin] as const }); + const config = makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const }); const first = yield* createExecutor(config); yield* first.recoverable.seed(); @@ -545,7 +549,10 @@ describe("connections.create", () => { return wrap(db); }; - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor({ ...config, db: blindfoldConnectionReads(config.db), @@ -1079,6 +1086,7 @@ describe("connections.create credential-write compensation", () => { : Effect.sync(() => void store.set(String(id), value)), }); const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [durabilityPlugin(provider)] as const, }); yield* executor.durable.seed(); @@ -1116,7 +1124,10 @@ describe("connections.create credential-write compensation", () => { ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) : Effect.sync(() => void store.set(String(id), value)), }); - const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const }); + const executor = yield* makeTestExecutor({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); yield* executor.durable.seed(); const result = yield* Effect.result( @@ -1151,7 +1162,10 @@ describe("connections.create credential-write compensation", () => { : Effect.sync(() => void store.set(String(id), value)), delete: undefined, }); - const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const }); + const executor = yield* makeTestExecutor({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); yield* executor.durable.seed(); const warnings: string[] = []; @@ -1202,7 +1216,10 @@ describe("connections.create credential-write compensation", () => { ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) : Effect.sync(() => void store.set(String(id), value)), }); - const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); const executor = yield* createExecutor({ ...config, db: failableCompensationRowDelete(config.db, () => failRowDelete), @@ -1276,6 +1293,7 @@ describe("connections.create credential-write compensation", () => { }, }); const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [durabilityPlugin(provider)] as const, }); yield* executor.durable.seed(); @@ -1355,7 +1373,10 @@ describe("connections.create credential-write compensation", () => { }, }); const raceState = { armed: false }; - const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); const executor = yield* createExecutor({ ...config, db: staleCompensationRead(config.db, raceState), @@ -1435,7 +1456,10 @@ describe("connections.create credential-write compensation", () => { : Effect.sync(() => void store.set(String(id), value)), }); const readState = { armed: false }; - const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); const executor = yield* createExecutor({ ...config, db: failableConfirmationRead(config.db, readState), @@ -1510,7 +1534,10 @@ describe("connections.create credential-write compensation", () => { ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) : Effect.sync(() => void store.set(String(id), value)), }); - const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); const executor = yield* createExecutor({ ...config, db: failableConnectionDeletes(config.db, () => failRowDelete), @@ -1586,7 +1613,10 @@ describe("connections.create credential-write compensation", () => { ? Effect.die("provider crashed") : Effect.sync(() => void store.set(String(id), value)), }); - const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); const executor = yield* createExecutor({ ...config, db: failableCompensationRowDelete(config.db, () => failRowDelete), @@ -1646,7 +1676,10 @@ describe("connections.create credential-write compensation", () => { ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never)) : Effect.sync(() => void store.set(String(id), value)), }); - const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [durabilityPlugin(provider)] as const, + }); const executor = yield* createExecutor({ ...config, db: failableCompensationRowDelete(config.db, () => failRowDelete), @@ -1696,7 +1729,11 @@ describe("connections.create credential-write compensation", () => { describe("connections.list / get", () => { it.effect("only includes full health diagnostics in verbose core tool output", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [demoPlugin] as const, coreTools: {} }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + coreTools: {}, + }); const executor = yield* createExecutor(config); yield* executor.demo.seed(); yield* executor.connections.create({ @@ -1876,7 +1913,10 @@ describe("tool catalog sync safety", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.guarded.seed(); yield* executor.connections.create({ @@ -1942,7 +1982,10 @@ describe("tool catalog sync safety", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.guarded.seed(); yield* executor.connections.create({ @@ -2005,7 +2048,10 @@ describe("tool catalog sync safety", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [storedStatePlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [storedStatePlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor["stored-state"].seed(); yield* executor.connections.create({ @@ -2064,7 +2110,7 @@ describe("tool catalog sync safety", () => { }), }))(); const executor = yield* createExecutor( - makeTestConfig({ plugins: [guardedPlugin] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [guardedPlugin] as const }), ); yield* executor.guarded.seed(); yield* executor.connections.create({ @@ -2122,7 +2168,10 @@ describe("tool catalog sync safety", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.guarded.seed(); yield* executor.connections.create({ @@ -2201,7 +2250,10 @@ describe("tool catalog sync safety", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.guarded.seed(); yield* executor.connections.create({ @@ -2282,7 +2334,10 @@ describe("tool catalog sync safety", () => { }), }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.guarded.seed(); yield* executor.connections.create({ @@ -2368,7 +2423,10 @@ describe("tool catalog sync safety", () => { }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor({ ...config, db: instrumentTransactions(config.db, { @@ -2457,7 +2515,10 @@ describe("tool catalog sync safety", () => { }), }))(); - const config = makeTestConfig({ plugins: [guardedPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [guardedPlugin] as const, + }); const executor = yield* createExecutor(config); yield* executor.guarded.seed(); for (const name of ["broken", "healthy"]) { @@ -2676,6 +2737,7 @@ const makeHealthHarness = (options?: { return Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [plugin] as const, coreTools: { webBaseUrl: "http://localhost:3000" }, }); @@ -3421,6 +3483,7 @@ describe("health probe gate key integrity", () => { // Both executors share ONE root db handle — and therefore one gate map; // only the key separates their probes. const configA = makeTestConfig({ + access: testAccess.member(), plugins: [probingPlugin()] as const, tenant: "a", subject: "user:b", diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..d1868eb673 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -49,7 +49,23 @@ const unscopedExecutorTable = ( id: keyColumn("id"), }); out.unique(`${name}_id_uidx`, ["id"]); - return out.policy({ name: executorUnscopedPolicyName }); + // No visibility condition (isolation lives in the namespace string), but + // the read-only / delete-only postures still hold for the FumaDB-backed + // blob store. External blob backends never see table policies, so the + // executor ALSO wraps its base BlobStore when storage writes are denied — + // this is defense in depth for the table-backed path, not the sole guard. + return out.policy({ + name: executorUnscopedPolicyName, + onCreate: ({ context }) => assertReachReadOnly(name, "write", context), + onUpdate: ({ context }) => { + assertReachReadOnly(name, "write", context); + return true; + }, + onDelete: ({ context }) => { + assertReachReadOnly(name, "delete", context); + return true; + }, + }); }; /** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */ diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index d0f16e7d12..dd0c2780d1 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -149,10 +149,9 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass { it.effect("rolls back plugin and core writes from ctx.transaction failures", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, }); const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites()); @@ -237,6 +239,7 @@ describe("createExecutor", () => { close: () => Effect.sync(() => void (closed = true)), }))(); const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [closingPlugin] as const, }); yield* executor.close(); @@ -248,6 +251,7 @@ describe("createExecutor", () => { Effect.gen(function* () { const events: Array<{ kind: string; pluginKey: string; slug: string }> = []; const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, onIntegrationChange: (event) => Effect.sync(() => { @@ -281,6 +285,7 @@ describe("createExecutor", () => { it.effect("a failing onIntegrationChange observer never fails the operation", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, onIntegrationChange: () => Effect.die("observer exploded"), }); @@ -294,6 +299,7 @@ describe("createExecutor", () => { Effect.gen(function* () { const events: string[] = []; const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))), }); @@ -306,6 +312,7 @@ describe("createExecutor", () => { it.effect("projects core tools as the built-in Executor integration", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), coreTools: { webBaseUrl: "http://localhost:3000" }, }); const integrations = yield* executor.integrations.list(); @@ -351,6 +358,7 @@ describe("createExecutor", () => { it.effect("can omit provider tools from the built-in Executor integration", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), coreTools: { webBaseUrl: "http://localhost:3000", includeProviders: false, @@ -372,6 +380,7 @@ describe("createExecutor", () => { it.effect("creates provider-backed connections through the built-in Executor tools", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, coreTools: { webBaseUrl: "http://localhost:3000" }, }); @@ -414,6 +423,7 @@ describe("createExecutor", () => { it.effect("creates a provider-backed legacy slug-none connection through the core tool", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, coreTools: { webBaseUrl: "http://localhost:3000" }, }); @@ -466,6 +476,7 @@ describe("createExecutor", () => { it.effect("removes catalog integrations through the built-in Executor tools", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, coreTools: {}, }); @@ -526,6 +537,7 @@ describe("createExecutor", () => { it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, coreTools: {}, }); @@ -604,6 +616,7 @@ describe("createExecutor", () => { it.effect("preserves actionable health from an incomplete tool catalog", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, coreTools: {}, }); @@ -639,6 +652,7 @@ describe("createExecutor", () => { it.effect("hands pasted credential entry to the web UI", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), coreTools: { webBaseUrl: "http://localhost:3000" }, }); @@ -672,6 +686,7 @@ describe("createExecutor", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, coreTools: { webBaseUrl: "http://localhost:3000" }, redirectUri: null, @@ -739,7 +754,7 @@ describe("createExecutor", () => { detector("high-detector", "high"), detector("medium-detector", "medium"), ] as const; - const executor = yield* makeTestExecutor({ plugins }); + const executor = yield* makeTestExecutor({ access: testAccess.member(), plugins }); const results = yield* executor.integrations.detect("https://example.com/thing"); // Every detector recognizes the URL; the list contains all three. expect(results.map((r) => r.kind).sort()).toEqual([ @@ -753,6 +768,7 @@ describe("createExecutor", () => { it.effect("tools.schema returns roots with shared reachable definitions", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, }); yield* executor.demo.seed(); @@ -778,6 +794,7 @@ describe("createExecutor", () => { it.effect("execute dispatches a connection-produced tool to the owning plugin", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, }); yield* executor.demo.seed(); @@ -800,6 +817,7 @@ describe("createExecutor", () => { it.effect("execute on a missing address fails with ToolNotFoundError", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, }); yield* executor.demo.seed(); @@ -843,6 +861,7 @@ describe("createExecutor", () => { describe("muscle memory (observed output shapes)", () => { const provisioned = Effect.fn(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [demoPlugin] as const, coreTools: { webBaseUrl: "http://localhost:3000" }, }); @@ -1049,7 +1068,7 @@ const seedRunConnection = < const recordToolRowLaunch = (maxOps?: number) => Effect.gen(function* () { const recorder = makeReadOrderRecorder(); - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const }); const executor = yield* createExecutor({ ...config, db: withRecordedReads(config.db, recorder.record), @@ -1073,6 +1092,7 @@ const recordCredentialLaunch = (maxOps?: number) => const calls = { count: 0 }; const provider = countingProvider(calls, (run) => recorder.record("credential.get", run)); const config = makeTestConfig({ + access: testAccess.member(), plugins: [invokeConcurrencyPlugin(provider)] as const, }); const executor = yield* createExecutor({ @@ -1199,6 +1219,7 @@ describe("execute read concurrency", () => { Effect.gen(function* () { const calls = { count: 0 }; const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const, }); yield* seedRunConnection(executor); @@ -1227,7 +1248,10 @@ describe("execute read concurrency", () => { it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor(config); yield* seedRunConnection(executor); @@ -1367,7 +1391,10 @@ describe("speculative read abandonment", () => { it.effect("unknown-tool error surfaces while the speculative reads hang forever", () => Effect.gen(function* () { const faults = makeReadFaults(); - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor({ ...config, db: withFaultedReads(config.db, faults.fault), @@ -1393,7 +1420,10 @@ describe("speculative read abandonment", () => { it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () => Effect.gen(function* () { const faults = makeReadFaults(); - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor({ ...config, db: withFaultedReads(config.db, faults.fault), @@ -1419,7 +1449,10 @@ describe("speculative read abandonment", () => { it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () => Effect.gen(function* () { const faults = makeReadFaults(); - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor({ ...config, db: withFaultedReads(config.db, faults.fault), @@ -1452,7 +1485,10 @@ describe("speculative read abandonment", () => { () => Effect.gen(function* () { const faults = makeReadFaults(); - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor({ ...config, db: withFaultedReads(config.db, faults.fault), @@ -1479,6 +1515,7 @@ describe("speculative read abandonment", () => { const faults = makeReadFaults(); const armable = armableFailingProvider(); const config = makeTestConfig({ + access: testAccess.member(), plugins: [invokeConcurrencyPlugin(armable.provider)] as const, }); const executor = yield* createExecutor({ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index cd89ab9075..0d83aa1e99 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -62,7 +62,6 @@ import { type OAuthClientRow, type ToolInvocationRow, type ToolRow, - type ToolPolicyRow, } from "./core-schema"; import { ElicitationDeclinedError, @@ -73,7 +72,7 @@ import { type OnElicitation, type InvokeOptions, } from "./elicitation"; -import { currentOrgWriteAccess, type OrgWriteAccess } from "./org-write-access"; +import { executorAccessViolation, type ExecutorAccess, type ToolPolicyEvaluator } from "./access"; import { restoreCredentialSnapshotsWithRecheck, snapshotCredentialWrites, @@ -155,9 +154,7 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { comparePolicyRow, isValidPattern, - matchPattern, positionForNewPattern, - resolveEffectivePolicy, rowToToolPolicy, type CreateToolPolicyInput, type EffectivePolicy, @@ -181,7 +178,6 @@ import type { StaticToolDecl, StorageDeps, ToolPolicyProvider, - ToolPolicyProviderRule, ToolInvocationCredential, } from "./plugin"; import { @@ -493,10 +489,11 @@ export type Executor = { /** * The PLATFORM VIEW: read-only, tenant-wide reads across every subject. - * Present only when the executor was built with `platformView: true` - * (default off) — every other surface on this executor stays bound to the - * single `{ tenant, subject }` product view and is unaffected by this one. - * Internal admin surface; the public HTTP shape is a separate concern. + * Present only when the executor was built with a product access granting + * `capabilities.adminReads` — every other surface on this executor stays + * bound to the single `{ tenant, subject }` product view and is unaffected + * by this one. Internal admin surface; the public HTTP shape is a separate + * concern. */ readonly admin?: ExecutorAdmin; /** Saved generative-UI artifacts, visible to the bound owner scope. */ @@ -810,47 +807,27 @@ export interface ExecutorConfig Effect.Effect; /** - * Opt into the PLATFORM VIEW: a read-only, tenant-wide `executor.admin` - * surface that reads across every subject in the tenant (see - * {@link ExecutorAdmin}). Default OFF — `admin` is simply absent, so the - * escape hatch has to be asked for by a host that has authorized an - * org-level caller. + * The product's access decisions for this binding — REQUIRED, with no core + * default. The product (host composition root) states which owner + * partitions this binding reads and in what precedence, whether it may + * configure workspace-level state (consulted live at every guarded sink), + * and its view capabilities (`capabilities.adminReads` exposes the + * tenant-observing `admin` surface, whose reads span every subject; + * `capabilities.storageWrites` sets the storage posture). + * Core validates the shape against the binding, enforces the decisions at + * its sinks, and clamps everything with the storage owner policy — tenant + * isolation is never product-configurable. See {@link ExecutorAccess} and + * `@executor-js/product-access` for the product rule implementations. * - * Enabling it makes the WHOLE executor read-only, not just `admin`: the base - * owner context carries `writes: "denied"`, so `connections`, `policies`, - * `integrations` and `oauth` refuse every create/update/delete at the storage - * boundary. An executor built for an org-level caller is an observer, and - * `admin` being its only tenant-wide surface is not the same as it being its - * only guarded one. - * - * READS still differ by surface: only `admin` is tenant-wide. Every other - * surface stays bound to `{ tenant, subject }` — widening them would expose - * every subject's connection rows, credential item ids included. - */ - readonly platformView?: boolean; - /** - * Whether this binding may CONFIGURE workspace-level state: `owner: "org"` - * rows (shared connections, org tool policies, org OAuth clients) and the - * tenant-shared integration catalog. Hosts derive it from the acting - * member's role — admins bind `"allowed"`, plain members `"denied"`. - * `"request"` reads the fiber-local {@link CurrentOrgWriteAccess} at every - * guarded sink. Session hosts bind that reference from the freshly - * authenticated request without caching a positive authorization decision - * for the session lifetime. - * Defaults to `"allowed"` for non-session callers with no role model - * (local's single user, the CLI, tests). - * - * `"denied"` gates only the USER-INTENT settings surfaces (`policies`, - * `connections` create/update/remove in Workspace scope, `integrations` - * update/remove/healthCheck, OAuth client CRUD and connect flows in Workspace - * scope, new-integration registration). Members may still create and manage - * Personal connections and OAuth apps. They also USE workspace resources: - * reads, tool execution over org connections, and the operational writes - * those imply (token refresh, tool-catalog re-sync) are deliberately - * untouched — which is why this is a surface gate, not a storage-policy axis - * like `platformView`'s blanket `writes: "denied"`. + * `capabilities.storageWrites: "denied"` makes the WHOLE executor + * read-only, not just `admin`: the base owner context carries + * `writes: "denied"`, so `connections`, `policies`, `integrations` and + * `oauth` refuse every create/update/delete at the storage boundary. READS + * still differ by surface: only `admin` is tenant-wide; every other + * surface stays bound to `{ tenant, subject }` — widening them would + * expose every subject's connection rows, credential item ids included. */ - readonly orgWrites?: OrgWriteAccess | "request"; + readonly access: ExecutorAccess; } /** Default freshness window for remote-catalog connections (see @@ -1522,9 +1499,11 @@ const makePluginStorageFacade = (input: { readonly core: CoreDb; readonly pluginId: string; readonly owner: OwnerBinding; + /** Product-supplied owner precedence (`ExecutorAccess.owners`): + * which partitions reads see, first entry shadowing later ones. */ + readonly readOwners: readonly Owner[]; }): PluginStorageFacade => { - // Owner partitions: org always, plus this subject's user partition. - const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"]; + const readOwners = input.readOwners; const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => { if (owner === "org") return { owner: "org", subject: ORG_SUBJECT }; @@ -1555,7 +1534,12 @@ const makePluginStorageFacade = (input: { ); }; - const ownerRank = (owner: Owner): number => readOwners.indexOf(owner); + // A row whose owner is outside the view (only reachable if this facade is + // ever handed a wider db handle) sorts LAST, never shadowing view rows. + const ownerRank = (owner: Owner): number => { + const rank = readOwners.indexOf(owner); + return rank === -1 ? readOwners.length : rank; + }; const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => [...rows].sort((left, right) => { @@ -1919,6 +1903,19 @@ export const createExecutor = => Effect.gen(function* () { - const access = - config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites; - if (access === "denied" && (owner === undefined || owner === "org")) { + const decision = yield* access.settingsWrite( + owner === undefined ? { kind: "workspace" } : { kind: "owner", owner }, + ); + if (decision === "denied") { return yield* new OrgWriteDeniedError(); } }); @@ -2010,7 +2012,11 @@ export const createExecutor = { + const refuse = (operation: string) => + Effect.fail( + new StorageError({ + message: `Blob ${operation} is not allowed: this executor's storage is read-only.`, + cause: undefined, + }), + ); + return { + get: (namespace, key) => store.get(namespace, key), + getMany: (namespaces, key) => store.getMany(namespaces, key), + has: (namespace, key) => store.has(namespace, key), + put: () => refuse("write"), + delete: () => refuse("delete"), + }; + }; + const blobs = + access.capabilities.storageWrites === "denied" ? readOnlyBlobStore(rawBlobs) : rawBlobs; const transaction = (effect: Effect.Effect) => fuma.transaction(effect); // Runtime-observed output shapes ("muscle memory"): learned on the @@ -2046,6 +2076,7 @@ export const createExecutor = (subject == null ? "org" : "user"); + // The partition static tools present under: the product's first-ranked + // owner (a bound member's "user", a subject-less binding's "org"). + const staticToolOwner = (): Owner => accessOwners[0]!; const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName => ConnectionName.make(integration.id === EXECUTOR_INTEGRATION_ID ? "coreTools" : "static"); @@ -3276,11 +3309,12 @@ export const createExecutor = b("slug", "=", String(input.slug)), set: { @@ -5387,103 +5421,22 @@ export const createExecutor = EffectivePolicy; - }; - - const compareProviderPolicyRule = ( - a: ToolPolicyProviderRule, - b: ToolPolicyProviderRule, - ): number => { - if (a.position < b.position) return -1; - if (a.position > b.position) return 1; - return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; - }; - - const resolveProviderPolicyFromRules = ( - toolId: string, - rules: readonly ToolPolicyProviderRule[], - ): EffectivePolicy => { - for (const rule of [...rules].sort(compareProviderPolicyRule)) { - if (!matchPattern(rule.pattern, toolId)) continue; - return { - action: rule.action, - source: "user", - pattern: rule.pattern, - policyId: rule.id, - }; - } - // Toolkit-style providers are capability allowlists. No matching rule - // means the tool is outside the capability boundary. - return { - action: "block", - source: "user", - pattern: "*", - }; - }; - - const listActivePolicyRuleSet = (): Effect.Effect => - activeToolPolicyProvider - ? // Batched per-operation resolver: fetch all policy + connection state - // once, then resolve every tool in this operation against that - // snapshot. Avoids the per-tool resolve N+1 on the list surface. - activeToolPolicyProvider.prepare - ? activeToolPolicyProvider.prepare().pipe( - Effect.map((resolve) => ({ - kind: "prepared" as const, - resolve, - })), - ) - : activeToolPolicyProvider.resolve - ? Effect.succeed({ - kind: "provider" as const, - provider: activeToolPolicyProvider, - rules: null, - }) - : activeToolPolicyProvider.list().pipe( - Effect.map((rules) => ({ - kind: "provider" as const, - provider: activeToolPolicyProvider!, - rules, - })), - ) - : core - .findMany("tool_policy", {}) - .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); - - const resolvePolicyFromRuleSet = ( - toolId: string, - ruleSet: ActivePolicyRuleSet, - defaultRequiresApproval?: boolean, - ): Effect.Effect => - ruleSet.kind === "prepared" - ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval })) - : ruleSet.kind === "provider" - ? ruleSet.provider.resolve - ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) - : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) - : Effect.succeed( - resolveEffectivePolicy( - toolId, - ruleSet.rows, - ownerRankForRow, - defaultRequiresApproval, - ), - ); + const makeToolPolicyEvaluator = (): Effect.Effect => + access.toolPolicy({ + policyRows: core.findMany("tool_policy", {}), + provider: activeToolPolicyProvider, + }); // ------------------------------------------------------------------ // Tools (read surface) @@ -5528,7 +5481,7 @@ export const createExecutor = [row.slug, row] as const)); @@ -5692,7 +5645,7 @@ export const createExecutor = => Effect.gen(function* () { - const policyRules = yield* listActivePolicyRuleSet(); + const policyEvaluator = yield* makeToolPolicyEvaluator(); const staticEntry = staticTools.get(String(address)); if (staticEntry) { const tool = staticToolToTool(staticEntry); - const effective = yield* resolvePolicyFromRuleSet( - normalizedPolicyId(tool), - policyRules, - tool.annotations?.requiresApproval, - ); + const effective = yield* policyEvaluator.resolve({ + toolId: normalizedPolicyId(tool), + defaultRequiresApproval: tool.annotations?.requiresApproval, + }); if (effective.action === "block") return null; const preview = yield* Effect.tryPromise({ try: () => @@ -5779,11 +5729,10 @@ export const createExecutor = - row.owner === "user" ? 0 : 1; + const ownerListRank = (row: { readonly owner: string }): number => { + const rank = accessOwners.indexOf(row.owner as Owner); + return rank === -1 ? accessOwners.length : rank; + }; // Tool policies gate by tool identity (`.`), independent of // which connection serves it; the org/user split is handled by owner-scoped @@ -5961,7 +5914,7 @@ export const createExecutor = [...rows] - .sort((a, b) => ownerRankForRow(a) - ownerRankForRow(b) || comparePolicyRow(a, b)) + .sort((a, b) => ownerListRank(a) - ownerListRank(b) || comparePolicyRow(a, b)) .map(rowToToolPolicy), ), ); @@ -6089,7 +6042,14 @@ export const createExecutor = => + Effect.gen(function* () { + const policyEvaluator = yield* makeToolPolicyEvaluator(); + const fresh = yield* policyEvaluator.resolve({ toolId, defaultRequiresApproval }); + if (fresh.action === "block") { + return yield* new ToolBlockedError({ + address, + pattern: fresh.pattern ?? "*", + }); + } + }); + // ------------------------------------------------------------------ // execute — the invoke path. // ------------------------------------------------------------------ @@ -6388,12 +6373,11 @@ export const createExecutor = + b.and( + byOwner(parsed.owner)(b), + b("integration", "=", String(parsed.integration)), + b("connection", "=", String(parsed.connection)), + b("name", "=", String(parsed.tool)), + ), + select: TOOL_INVOCATION_COLUMNS, + }); + if (!freshToolRow) { + return yield* new ToolNotFoundError({ address }); + } + // The plugin runtime and handler were bound to the PRE-pause row. + // If the integration was torn down and re-registered under a + // different plugin while the call waited, the tool the user + // approved no longer exists in that form — fail as not-found + // rather than hand the replacement row to the old plugin. + if (freshToolRow.plugin_id !== row.plugin_id) { + return yield* new ToolNotFoundError({ address }); + } + approvedToolRow = freshToolRow; + const freshConnectionRow = yield* findConnectionRow({ + owner: parsed.owner, + integration: parsed.integration, + name: parsed.connection, + }); + if (!freshConnectionRow) { + return yield* new ConnectionNotFoundError({ + owner: parsed.owner, + integration: parsed.integration, + name: parsed.connection, + }); + } + approvedConnectionRow = freshConnectionRow; + } + // Resolve every named credential input (`variable → value`); `value` is // the primary `token` for single-input + OAuth callers. The // integration-row read is independent of credential resolution, so @@ -6594,9 +6634,12 @@ export const createExecutor = , @@ -6620,7 +6663,7 @@ export const createExecutor = ({ const setup = () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [demoPlugin] as const, subject: ALICE }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + subject: ALICE, + }); const alice = yield* createExecutor(config); const bob = yield* createExecutor({ ...config, @@ -119,7 +124,7 @@ describe("integrations.remove cascade", () => { it.effect("a member (org writes denied) still cannot remove", () => Effect.gen(function* () { const { config } = yield* setup(); - const member = yield* createExecutor({ ...config, orgWrites: "denied" }); + const member = yield* createExecutor({ ...config, access: testAccess.member("denied") }); yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); const error = yield* Effect.flip(member.integrations.remove(INTEG)); expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true); @@ -129,7 +134,7 @@ describe("integrations.remove cascade", () => { it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () => Effect.gen(function* () { const { config } = yield* setup(); - const platform = yield* createExecutor({ ...config, platformView: true }); + const platform = yield* createExecutor({ ...config, access: testAccess.platform() }); yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore)); yield* platform.integrations.remove(INTEG).pipe( Effect.flatMap(() => Effect.die("expected the platform view to refuse the removal")), @@ -170,6 +175,7 @@ describe("orphaned rows are not served", () => { withQueryContext(config.testDb.db, { tenant: String(config.tenant), subject: BOB, + owners: ["user", "org"], }).findMany("tool", {}), ); expect(stored.map((row) => String(row.integration)).sort()).toEqual([ diff --git a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts index 73d7dcefb1..a19591ce09 100644 --- a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts +++ b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts @@ -24,6 +24,7 @@ import type { OAuthService } from "./oauth-client"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; const INTEG = IntegrationSlug.make("acme"); const TEMPLATE = AuthTemplateSlug.make("oauth"); @@ -157,7 +158,10 @@ describe("enterprise-managed connections", () => { Effect.scoped( Effect.gen(function* () { const servers = yield* enterpriseServers({}); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* registerClients(executor.oauth.createClient, servers); @@ -182,7 +186,10 @@ describe("enterprise-managed connections", () => { Effect.scoped( Effect.gen(function* () { const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* registerClients(executor.oauth.createClient, servers); yield* executor.oauth.start(startEnterpriseConnect(servers)); @@ -207,7 +214,10 @@ describe("enterprise-managed connections", () => { Effect.scoped( Effect.gen(function* () { const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* registerClients(executor.oauth.createClient, servers); yield* executor.oauth.start(startEnterpriseConnect(servers)); @@ -241,7 +251,10 @@ describe("enterprise-managed connections", () => { Effect.scoped( Effect.gen(function* () { const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* registerClients(executor.oauth.createClient, servers); yield* executor.oauth.start(startEnterpriseConnect(servers)); @@ -287,7 +300,10 @@ describe("enterprise-managed connections", () => { errorDescription: "This client is not approved for the requested MCP server.", }, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* registerClients(executor.oauth.createClient, servers); @@ -316,7 +332,10 @@ describe("enterprise-managed connections", () => { Effect.scoped( Effect.gen(function* () { const servers = yield* enterpriseServers({ advertiseProfile: false }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* registerClients(executor.oauth.createClient, servers); diff --git a/packages/core/sdk/src/oauth-ema-rollout.test.ts b/packages/core/sdk/src/oauth-ema-rollout.test.ts index c867c6ddd4..6f2cfab809 100644 --- a/packages/core/sdk/src/oauth-ema-rollout.test.ts +++ b/packages/core/sdk/src/oauth-ema-rollout.test.ts @@ -34,6 +34,7 @@ import type { import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; const INTEG = IntegrationSlug.make("acme"); const TEMPLATE = AuthTemplateSlug.make("oauth"); @@ -233,6 +234,7 @@ const tokenExchangeCount = (servers: EnterpriseServers) => const harness = (rollout: EnterpriseManagedRollout | undefined) => makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant: TENANT, subject: SUBJECT, diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index 8a2be1cc3d..ddcf56538b 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -18,6 +18,7 @@ import { import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // First-party OAuth clients: host-operated apps declared in executor config // (`firstPartyOAuthClients`), addressed as `first-party:`. Resolved from @@ -119,6 +120,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [firstPartyClientFor(server)], }); @@ -170,6 +172,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["repo"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }], }); @@ -204,6 +207,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [ { @@ -240,6 +244,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const harness = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [firstPartyClientFor(server)], }); @@ -288,6 +293,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [ { @@ -330,6 +336,7 @@ describe("first-party oauth clients", () => { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); let enabled = true; const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, subject: "review-user", tenant: "review-org", @@ -368,6 +375,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const harness = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [ { @@ -439,6 +447,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }], }); @@ -467,6 +476,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [firstPartyClientFor(server)], }); @@ -496,7 +506,10 @@ describe("first-party oauth clients", () => { Effect.scoped( Effect.gen(function* () { yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const error = yield* executor.oauth @@ -523,6 +536,7 @@ describe("first-party oauth clients", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [firstPartyClientFor(server)], }); diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 7496ce1efd..d43086501e 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -24,6 +24,7 @@ import type { CredentialProvider } from "./provider"; import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { ToolResult } from "./tool-result"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow // and OAuth access-token refresh end to end against the test authorization @@ -197,7 +198,10 @@ describe("oauth.start / oauth.complete", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -302,6 +306,7 @@ describe("oauth.start / oauth.complete", () => { }))(); const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const, waitUntil: (promise) => keptAlive.push(promise), }); @@ -363,7 +368,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["read"], defaultTokenEndpointAuthMethod: "client_secret_basic", }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -428,7 +436,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["read"], defaultTokenEndpointAuthMethod: "client_secret_basic", }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -486,6 +497,7 @@ describe("oauth.start / oauth.complete", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, oauthCallbackStateOrgSlug: "acme", }); @@ -546,7 +558,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["offline_access", "read"], omitTokenResponseScopes: ["offline_access"], }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["offline_access", "read"]); yield* executor.oauth.createClient({ @@ -590,7 +605,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ @@ -634,7 +652,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ @@ -679,7 +700,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ @@ -739,7 +763,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ @@ -797,7 +824,10 @@ describe("oauth.start / oauth.complete", () => { scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ @@ -852,7 +882,7 @@ describe("oauth.start / oauth.complete", () => { idTokenClaims: { email: "alice@example.com", sub: "user-1" }, refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" }, }); - const harness = yield* makeTestWorkspaceHarness({ plugins }); + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); const { executor, config } = harness; yield* executor.acme.seed(["openid", "email", "profile", "read"]); @@ -910,6 +940,7 @@ describe("oauth.start / oauth.complete", () => { // is no silent localhost default. The redirect flow must fail loudly // rather than handing the provider a wrong `http://127.0.0.1/callback`. const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, redirectUri: null, }); @@ -951,6 +982,7 @@ describe("oauth.start / oauth.complete", () => { // No redirectUri configured, but client_credentials never redirects — // it must still mint the connection inline. const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, redirectUri: null, }); @@ -983,7 +1015,10 @@ describe("oauth.start / oauth.complete", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer(); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", @@ -1011,7 +1046,10 @@ describe("oauth.start / oauth.complete", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // The app is registered under the WORKSPACE (org) — "shared with @@ -1062,7 +1100,10 @@ describe("oauth.start / oauth.complete", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // A PRIVATE app owned by the member. @@ -1100,7 +1141,10 @@ describe("oauth.start / oauth.complete", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); // Deliberately NOT seeded: the slug names nothing in the catalog — the // shape of a reconnect against a connection whose integration was // removed, or an agent replaying a stale slug. @@ -1141,7 +1185,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const harness = yield* makeTestWorkspaceHarness({ plugins }); + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); const { executor, config } = harness; yield* executor.acme.seed(); @@ -1225,7 +1269,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { // One database handle and one credential store under two execution // stacks — what a self-host holds while two MCP sessions are open. - const config = { ...makeTestConfig({ plugins }), fetch: park.fetch }; + const config = { + ...makeTestConfig({ access: testAccess.member(), plugins }), + fetch: park.fetch, + }; const sessionA = yield* createExecutor(config); const sessionB = yield* createExecutor(config); yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); @@ -1322,7 +1369,12 @@ describe("oauth token refresh in resolveConnectionValue", () => { // the store layer and mask the gate-key collision this test is about. const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const; const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const; - const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" }); + const shared = makeTestConfig({ + access: testAccess.member(), + plugins: pluginsA, + tenant: "a", + subject: "user:b", + }); const configA = { ...shared, fetch: parkA.fetch }; const configB = { ...shared, @@ -1379,7 +1431,11 @@ describe("oauth token refresh in resolveConnectionValue", () => { // Expire BOTH rows so both tenants must refresh. `shared.db` is bound // to tenant A; tenant B's partition needs its own scoped handle. - const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" }); + const dbB = withQueryContext(shared.testDb.db, { + tenant: "a:user", + subject: "b", + owners: ["user", "org"], + }); yield* Effect.promise(() => shared.db.updateMany("connection", { where: (b) => b("name", "=", "mine"), @@ -1452,7 +1508,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const park = makeTokenRequestPark(); - const config = { ...makeTestConfig({ plugins }), fetch: park.fetch }; + const config = { + ...makeTestConfig({ access: testAccess.member(), plugins }), + fetch: park.fetch, + }; const sessionA = yield* createExecutor(config); const sessionB = yield* createExecutor(config); yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); @@ -1598,6 +1657,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { // handle, which is what the extra `withQueryContext` wrapper is. const config = { ...makeTestConfig({ + access: testAccess.member(), plugins: [oauthPlugin] as const, tenant: SHARED_STORE_TENANT, subject: SHARED_STORE_SUBJECT, @@ -1722,7 +1782,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const harness = yield* makeTestWorkspaceHarness({ plugins }); + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); const { executor, config } = harness; yield* executor.acme.seed(); @@ -1790,7 +1850,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { tokenExpiresInSeconds: 0, invalidRefreshTokenDescription: "Grant not found", }); - const harness = yield* makeTestWorkspaceHarness({ plugins }); + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); const { executor, config } = harness; yield* executor.acme.seed(); @@ -1876,7 +1936,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { tokenExpiresInSeconds: 0, invalidRefreshTokenDescription: "Grant revoked", }); - const harness = yield* makeTestWorkspaceHarness({ plugins }); + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); const { executor, config } = harness; yield* executor.acme.seed(); @@ -1983,7 +2043,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -2040,7 +2103,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { scopes: ["openid", "email", "profile", "offline_access", "read", "write"], omitTokenResponseScopes: ["email", "profile", "write"], }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed([ "openid", "email", @@ -2098,7 +2164,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(["read", "write"]); yield* executor.oauth.createClient({ @@ -2159,7 +2228,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // 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)", ( Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const tokenCalls: TokenEndpointCall[] = []; @@ -2423,6 +2498,7 @@ const connectRejecting = (options?: { readonly tokenExpiresInSeconds?: number }) return issued.length > 0 ? issued[issued.length - 1]! : null; }); const harness = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const, }); const { executor, config } = harness; @@ -2612,6 +2688,7 @@ describe("reactive OAuth refresh on upstream 401", () => { }))(); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const, }); yield* executor.acme.seed(); @@ -2692,7 +2769,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // `resource: null` — explicitly none, not merely unset. @@ -2761,7 +2841,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -2799,7 +2882,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ diff --git a/packages/core/sdk/src/oauth-list-clients.test.ts b/packages/core/sdk/src/oauth-list-clients.test.ts index 331c91d6f7..7df09c373f 100644 --- a/packages/core/sdk/src/oauth-list-clients.test.ts +++ b/packages/core/sdk/src/oauth-list-clients.test.ts @@ -7,6 +7,7 @@ import { Effect, Predicate } from "effect"; import { OAuthClientSlug } from "./ids"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { testAccess } from "@executor-js/product-access/testing"; // listClients returns metadata-only summaries of the clients visible to the // caller — the tenant's org clients plus the caller's own user clients — and @@ -21,7 +22,10 @@ describe("oauth.listClients", () => { it.effect("rejects metadata URLs persisted as authorization URLs", () => Effect.scoped( Effect.gen(function* () { - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); const error = yield* Effect.flip( executor.oauth.createClient({ @@ -50,7 +54,10 @@ describe("oauth.listClients", () => { it.effect("returns owner-visible clients as summaries without the secret", () => Effect.scoped( Effect.gen(function* () { - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.oauth.createClient({ owner: "org", @@ -113,7 +120,10 @@ describe("oauth.listClients", () => { it.effect("rejects HTTP Basic authentication without a client secret", () => Effect.scoped( Effect.gen(function* () { - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); const error = yield* Effect.flip( executor.oauth.createClient({ @@ -147,6 +157,7 @@ describe("oauth.listClients", () => { const tenant = "shared-tenant"; const a = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-a", @@ -172,6 +183,7 @@ describe("oauth.listClients", () => { }); const b = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-b", @@ -190,7 +202,10 @@ describe("oauth.listClients", () => { it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () => Effect.scoped( Effect.gen(function* () { - const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { config, executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.oauth.createClient({ owner: "org", diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index bc13050ded..ad153d92ce 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -12,6 +12,7 @@ import { OAuthRegisterDynamicError } from "./oauth-client"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // RFC 7591 Dynamic Client Registration, end to end: // probe → registerDynamicClient (no pasted client id/secret) → listClients @@ -64,7 +65,7 @@ describe("oauth.registerDynamicClient", () => { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ plugins, - orgWrites: "denied", + access: testAccess.member("denied"), }); const error = yield* executor.oauth @@ -94,7 +95,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // Probe surfaces the registration endpoint + advertised auth methods so @@ -197,7 +201,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -230,7 +237,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -276,7 +286,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -327,7 +340,10 @@ describe("oauth.registerDynamicClient", () => { // reused, so a fresh DCR registration happens. The GC migration then // backfills/GCs any duplicate this transient window mints. const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { config, executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); @@ -379,7 +395,10 @@ describe("oauth.registerDynamicClient", () => { const server = yield* serveOAuthTestServer({ scopes: ["openid", "offline_access"], }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.registerDynamicClient({ @@ -413,7 +432,10 @@ describe("oauth.registerDynamicClient", () => { // row's origin_issuer, the reuse lookup keys on it and mints no // duplicate. This is the steady state the migration establishes. const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { config, executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); @@ -467,7 +489,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); const resourceA = `${server.issuerUrl}/mcp/a`; @@ -533,7 +558,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); const resourceA = `${server.issuerUrl}/mcp/a`; @@ -638,7 +666,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -733,7 +764,10 @@ describe("oauth.registerDynamicClient", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -784,7 +818,10 @@ describe("oauth.registerDynamicClient", () => { // Mirror Mercury: reject any client_name containing the brand. approveClientName: (name) => !name.includes("Acme"), }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -821,7 +858,10 @@ describe("oauth.registerDynamicClient", () => { scopes: ["read"], approveClientName: () => false, }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -866,7 +906,10 @@ describe("oauth.registerDynamicClient", () => { approveRedirectUri: (uri) => uri.startsWith("http://localhost") || uri.startsWith("http://127."), }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); @@ -910,7 +953,10 @@ describe("oauth.registerDynamicClient", () => { scopes: ["read"], approveRedirectUri: () => false, // reject every redirect URI, even loopback }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); diff --git a/packages/core/sdk/src/oauth-remove-client.test.ts b/packages/core/sdk/src/oauth-remove-client.test.ts index 94580671f8..26ddd78b80 100644 --- a/packages/core/sdk/src/oauth-remove-client.test.ts +++ b/packages/core/sdk/src/oauth-remove-client.test.ts @@ -18,6 +18,7 @@ import { import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // removeClient permanently deletes an owner-scoped oauth_client row, keyed by // (owner, slug). The owner policy on `oauth_client` prevents removing another @@ -32,7 +33,10 @@ describe("oauth.removeClient", () => { it.effect("removes a client so it no longer lists", () => Effect.scoped( Effect.gen(function* () { - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.oauth.createClient({ owner: "user", @@ -58,7 +62,10 @@ describe("oauth.removeClient", () => { it.effect("is idempotent — removing a non-existent client succeeds", () => Effect.scoped( Effect.gen(function* () { - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); // No client was ever created; removing it must not error. yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed")); @@ -72,7 +79,10 @@ describe("oauth.removeClient", () => { it.effect("removing an org client leaves a user client intact (and vice versa)", () => Effect.scoped( Effect.gen(function* () { - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.oauth.createClient({ owner: "org", @@ -115,6 +125,7 @@ describe("oauth.removeClient", () => { const tenant = "shared-tenant"; const a = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-a", @@ -131,6 +142,7 @@ describe("oauth.removeClient", () => { }); const b = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-b", @@ -155,6 +167,7 @@ describe("oauth.removeClient", () => { const tenant = "shared-tenant"; const a = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-a", @@ -171,6 +184,7 @@ describe("oauth.removeClient", () => { }); const b = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-b", @@ -194,6 +208,7 @@ describe("oauth.removeClient", () => { Effect.scoped( Effect.gen(function* () { const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, coreTools: {}, }); @@ -306,6 +321,7 @@ describe("removing a client defers the secret deletion to the outermost commit", Effect.gen(function* () { const store = new Map(); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [txPlugin(store)] as const, }); yield* executor.oauth.createClient(userClient("user-secret")); @@ -339,6 +355,7 @@ describe("removing a client defers the secret deletion to the outermost commit", Effect.gen(function* () { const store = new Map(); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [txPlugin(store)] as const, }); yield* executor.oauth.createClient(userClient("user-secret")); @@ -360,6 +377,7 @@ describe("removing a client defers the secret deletion to the outermost commit", Effect.gen(function* () { const store = new Map(); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [txPlugin(store)] as const, }); yield* executor.oauth.createClient(userClient("user-secret")); @@ -385,6 +403,7 @@ describe("removing a client defers the secret deletion to the outermost commit", const tenant = "shared-tenant"; const a = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-a", @@ -394,6 +413,7 @@ describe("removing a client defers the secret deletion to the outermost commit", expect(secretValue(store)).toBe("a-secret"); const b = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-b", @@ -454,6 +474,7 @@ describe("removing a client does not delete a recreated client's secret", () => }); const store = new Map(); const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [txPlugin(store), integrationPlugin] as const, redirectUri: null, }); diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 47b92d16e0..ab5f812b9b 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -16,6 +16,7 @@ import { definePlugin, type IntegrationRecord } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveTestHttpApp } from "./testing"; import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // Integration-driven scopes: at connect, `oauth.start` requests the integration's // scopes — its DECLARED oauth scopes when it has any, otherwise the scopes @@ -187,7 +188,7 @@ const setupMcpScopeClient = ( ) => Effect.gen(function* () { const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); yield* executor.mcp.seed(); yield* executor.oauth.createClient({ owner: "org", @@ -213,7 +214,10 @@ describe("oauth.start integration-driven scopes", () => { memoryCredentialsPlugin(), makeScopePlugin({ scopes: DECLARED_SCOPES }), ] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // The app is pure identity — no scope set. @@ -265,7 +269,10 @@ describe("oauth.start integration-driven scopes", () => { authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, }), ] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", @@ -303,7 +310,10 @@ describe("oauth.start integration-driven scopes", () => { memoryCredentialsPlugin(), makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }), ] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -340,7 +350,10 @@ describe("oauth.start integration-driven scopes", () => { // `scopes: null` ⇒ the integration declares an oauth method with no // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested. const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -411,7 +424,10 @@ describe("oauth.start integration-driven scopes", () => { memoryCredentialsPlugin(), makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }), ] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ @@ -635,7 +651,10 @@ describe("oauth.start integration-driven scopes", () => { { discoversScopes: true, discoveryUrl: server.mcpResourceUrl }, ), ] as const; - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.mcp.seed(); // No `resource` on the client — the wire parameter is absent by @@ -681,6 +700,7 @@ describe("oauth.start integration-driven scopes", () => { makeMcpScopePlugin({ scopes: null }), ] as const; const { executor } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, firstPartyOAuthClients: [ { @@ -835,7 +855,10 @@ describe("oauth.start recorded scope fallback", () => { memoryCredentialsPlugin(), makeScopePlugin({ scopes: DECLARED_SCOPES }), ] as const; - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.acme.seed(); // A client_credentials client so `start` mints inline (no redirect), @@ -889,7 +912,10 @@ describe("oauth.start recorded scope fallback", () => { memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null }), ] as const; - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins, + }); yield* executor.mcp.seed(); yield* executor.oauth.createClient({ diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ba91260a51..c1a0b9815d 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -232,9 +232,9 @@ export interface OAuthServiceDeps { readonly owner: Owner; readonly subject: string; }; - /** Workspace-settings gate from the executor binding - * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the - * user-intent client/connect surfaces. */ + /** Settings gate from the executor binding (`ExecutorAccess.settingsWrite`): + * enforces the product's decision for the owner targeted by a user-intent + * client/connect operation. */ readonly guardOrgWrite: (owner: Owner) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ diff --git a/packages/core/sdk/src/oauth-session-cleanup.test.ts b/packages/core/sdk/src/oauth-session-cleanup.test.ts index 7517c274de..740f7ecd87 100644 --- a/packages/core/sdk/src/oauth-session-cleanup.test.ts +++ b/packages/core/sdk/src/oauth-session-cleanup.test.ts @@ -20,6 +20,7 @@ import { import { definePlugin } from "./plugin"; import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // An in-flight authorization flow parks its PKCE verifier in `oauth_session` in // 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", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({}); const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin, acmePlugin] as const, }); yield* executor.acme.seed(); @@ -205,6 +207,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({}); const { executor, config } = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin, acmePlugin] as const, }); yield* executor.acme.seed(); @@ -280,6 +283,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { const plugins = [memoryCredentialsPlugin, acmePlugin] as const; const a = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-a", @@ -298,6 +302,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { // A second member of the same tenant, on the same database. const b = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "subject-b", @@ -363,6 +368,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({}); const base = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin, acmePlugin] as const, }); const executor = yield* createExecutor({ diff --git a/packages/core/sdk/src/org-write-access.ts b/packages/core/sdk/src/org-write-access.ts deleted file mode 100644 index f3597f9d63..0000000000 --- a/packages/core/sdk/src/org-write-access.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Context, Effect, Ref } from "effect"; - -/** Workspace-settings authorization bound to the currently executing request. */ -export type OrgWriteAccess = "allowed" | "denied"; - -/** - * Fiber-local workspace-settings authorization for request-bound executors. - * - * The denied default makes a missing request binding fail closed. Non-session - * executors continue to use their explicit {@link ExecutorConfig.orgWrites} - * value (or the allowed default) and never consult this reference. - */ -export interface OrgWriteAccessState { - /** Mutable value inherited by a detached execution and refreshed on resume. */ - readonly current: Ref.Ref; -} - -/** Create an isolated request/execution authorization state. */ -export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ - current: Ref.makeUnsafe(access), -}); - -/** Request-local workspace-write authorization inherited by child fibers. */ -export const CurrentOrgWriteAccess = Context.Reference( - "@executor-js/sdk/CurrentOrgWriteAccess", - { defaultValue: () => makeOrgWriteAccessState("denied") }, -); - -/** Read the effective authorization at a workspace-write sink. */ -export const currentOrgWriteAccess: Effect.Effect = Effect.gen(function* () { - const state = yield* CurrentOrgWriteAccess; - return yield* Ref.get(state.current); -}); diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts index f30c5d14e7..ec2ca9cb10 100644 --- a/packages/core/sdk/src/org-writes.test.ts +++ b/packages/core/sdk/src/org-writes.test.ts @@ -12,14 +12,15 @@ import { ToolName, } from "./ids"; import { createExecutor } from "./executor"; -import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./org-write-access"; +import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./access"; import { definePlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestConfig } from "./testing"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- -// `ExecutorConfig.orgWrites` — the workspace-settings gate. +// `ExecutorAccess.workspaceWrites` — the workspace-settings gate. // // A `"denied"` binding (a plain member) may USE workspace resources — read // them, execute tools over org connections — but every user-intent @@ -29,8 +30,8 @@ import { serveOAuthTestServer } from "./testing/oauth-test-server"; // `"allowed"` (admins, and hosts with no role model) behaves exactly as before. // // The fixtures build TWO executors over ONE test database: an admin -// (default `orgWrites`) that seeds the workspace, and a member -// (`orgWrites: "denied"`) that the assertions run against. +// (workspace writes allowed) that seeds the workspace, and a member +// (workspace writes denied) that the assertions run against. // --------------------------------------------------------------------------- const memoryProvider = (): CredentialProvider => { @@ -83,9 +84,9 @@ const demoPlugin = definePlugin(() => ({ const setup = () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const }); const admin = yield* createExecutor(config); - const member = yield* createExecutor({ ...config, orgWrites: "denied" }); + const member = yield* createExecutor({ ...config, access: testAccess.member("denied") }); yield* Effect.addFinalizer(() => admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), ); @@ -101,13 +102,16 @@ const expectOrgWriteDenied = (effect: Effect.Effect) => }), ); -describe("orgWrites: denied", () => { +describe("workspace writes denied", () => { it.effect("reads a live session binding at every workspace-write sink", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const executor = yield* createExecutor({ ...config, - orgWrites: "request", + access: testAccess.requestBound(), }); yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); const policy = yield* executor.demo.seed().pipe( @@ -249,12 +253,18 @@ describe("orgWrites: denied", () => { it.effect("allows subjectless system re-registration during boot convergence", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [demoPlugin] as const, + }); const admin = yield* createExecutor(config); const { subject: _subject, ...systemConfig } = config; + // Boot convergence is the PRODUCT's rule now: the workspace-service + // posture allows it. A subject-less binding whose product DENIES + // settings writes is refused instead — see settings-authority.test.ts. const system = yield* createExecutor({ ...systemConfig, - orgWrites: "denied", + access: testAccess.org(), }); yield* Effect.addFinalizer(() => admin.close().pipe(Effect.andThen(system.close()), Effect.ignore), @@ -357,7 +367,7 @@ describe("orgWrites: denied", () => { ); }); -describe("orgWrites: default (allowed)", () => { +describe("workspace writes allowed", () => { it.effect("admin bindings mutate workspace-level state as before", () => Effect.gen(function* () { const { admin } = yield* setup(); diff --git a/packages/core/sdk/src/owner-policy-reach.test.ts b/packages/core/sdk/src/owner-policy-reach.test.ts index 3622687a34..85ce7eee45 100644 --- a/packages/core/sdk/src/owner-policy-reach.test.ts +++ b/packages/core/sdk/src/owner-policy-reach.test.ts @@ -95,7 +95,11 @@ describe("owner policy reach", () => { withDb(async (db) => { await seedTwoSubjects(db); - const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); + const bound = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT_A, + owners: ["user", "org"], + }); const rows = await bound.findMany("connection", {}); expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]); @@ -111,6 +115,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A, + owners: ["user", "org"], reach: "tenant", }); const rows = await platform.findMany("connection", {}); @@ -129,6 +134,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A, + owners: ["user", "org"], reach: "tenant", }); const rows = await platform.findMany("connection", {}); @@ -149,6 +155,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: null, + owners: ["org"], reach: "tenant", }); const rows = await platform.findMany("connection", {}); @@ -168,6 +175,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A, + owners: ["user", "org"], reach: "tenant", }); @@ -197,6 +205,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A, + owners: ["user", "org"], reach: "tenant", }); @@ -225,6 +234,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A, + owners: ["user", "org"], reach: "tenant", }); @@ -249,6 +259,7 @@ describe("owner policy reach", () => { const platform = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A, + owners: ["user", "org"], reach: "tenant", }); @@ -272,12 +283,17 @@ describe("owner policy reach", () => { withDb(async (db) => { // The guard must not disturb the product view: an ordinary bound // context writes exactly as before. - const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); + const bound = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT_A, + owners: ["user", "org"], + }); await bound.create("connection", { tenant: TENANT, owner: "user", subject: SUBJECT_A, + owners: ["user", "org"], integration: "github", name: "written-by-bound-view", template: "apiKey", @@ -296,13 +312,18 @@ describe("owner policy reach", () => { it.effect("a bound context still cannot write outside its own subject", () => Effect.promise(() => withDb(async (db) => { - const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); + const bound = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT_A, + owners: ["user", "org"], + }); await expect( bound.create("connection", { tenant: TENANT, owner: "user", subject: SUBJECT_B, + owners: ["user", "org"], integration: "github", name: "cross-subject", template: "apiKey", diff --git a/packages/core/sdk/src/owner-policy.ts b/packages/core/sdk/src/owner-policy.ts index 4c2e16afa9..777bbe775b 100644 --- a/packages/core/sdk/src/owner-policy.ts +++ b/packages/core/sdk/src/owner-policy.ts @@ -2,6 +2,7 @@ import { type Condition, type ConditionBuilder } from "@executor-js/fumadb/query import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema"; import { StorageError } from "./fuma-runtime"; +import type { Owner } from "./ids"; /* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the @@ -53,6 +54,19 @@ export interface ExecutorOwnerPolicyContext { /** The acting member, or null for a pure-org executor (no `owner:"user"` * reads/writes are allowed when null). */ readonly subject: string | null; + /** + * The row-visibility/write decision (`ExecutorAccess.owners` for + * principal contexts): which owner partitions this context sees, and may + * create/patch rows in. REQUIRED — there is no full-view default; a + * context that reaches the policy without it fails closed (reads match + * nothing, writes are refused). Host-internal contexts that only touch + * tenant-scoped tables state `[]` explicitly. + * + * This NARROWS, never widens: whatever it lists, the tenant clause and the + * bound-subject clause on user rows still apply, and `reach: "tenant"` + * contexts remain read-only (or delete-only) by construction. + */ + readonly owners: readonly Owner[]; /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the * platform view: it sees the whole tenant but writes exactly as a bound * context does, so it can never mutate another subject's rows. */ @@ -106,17 +120,29 @@ export const ownerVisibilityCondition = ( // The platform view: partition by tenant alone. Still never cross-tenant — // `tenant` is the one clause that is NEVER relaxed, at any reach. if (context.reach === "tenant") return builder("tenant", "=", context.tenant); - const orgClause = builder.and( - builder("tenant", "=", context.tenant), - builder("owner", "=", "org"), - ); - if (context.subject == null) return orgClause; - const userClause = builder.and( - builder("tenant", "=", context.tenant), - builder("owner", "=", "user"), - builder("subject", "=", context.subject), - ); - return builder.or(orgClause, userClause); + // The product's visible partitions. A malformed context that reaches the + // policy without them (only possible past the type system) fails CLOSED: + // no partitions means no rows. Core contributes only the clamps inside + // each clause — never a partition of its own. + const owners: readonly Owner[] = Array.isArray(context.owners) ? context.owners : []; + const clauses: (Condition | boolean)[] = []; + if (owners.includes("org")) { + clauses.push(builder.and(builder("tenant", "=", context.tenant), builder("owner", "=", "org"))); + } + if (owners.includes("user") && context.subject != null) { + clauses.push( + builder.and( + builder("tenant", "=", context.tenant), + builder("owner", "=", "user"), + builder("subject", "=", context.subject), + ), + ); + } + // A context whose product view lists no partition this binding can carry + // (personal-only with no subject) sees nothing — fail closed, not open. + if (clauses.length === 0) return false; + if (clauses.length === 1) return clauses[0]!; + return builder.or(...clauses); }; /** @@ -172,6 +198,21 @@ export const assertOwnerWritable = ( if (values.tenant !== ctx.tenant) { policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`); } + // The product's write partitions: a row may only be created in a partition + // the product view lists (update/delete are already filtered by + // `ownerVisibilityCondition`). A context missing the decision fails + // closed. Purely narrowing — the per-owner clamps below still apply to + // whatever the product allows. + if (!Array.isArray(ctx.owners)) { + policyViolation( + `Storage write on table "${tableName}" is missing the context's owner partitions.`, + ); + } + if ((values.owner === "org" || values.owner === "user") && !ctx.owners.includes(values.owner)) { + policyViolation( + `Storage write on table "${tableName}" targets the "${values.owner}" partition, which this product view does not include.`, + ); + } if (values.owner === "org") { if (values.subject !== ORG_SUBJECT) { policyViolation(`Storage write on table "${tableName}" set a subject on an org row.`); @@ -204,6 +245,14 @@ export const assertOwnerPatch = ( if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) { policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`); } + if ( + (patch.owner === "org" || patch.owner === "user") && + !(Array.isArray(ctx.owners) && ctx.owners.includes(patch.owner)) + ) { + policyViolation( + `Storage write on table "${tableName}" cannot move a row into the "${patch.owner}" partition, which this product view does not include.`, + ); + } if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) { policyViolation( `Storage write on table "${tableName}" cannot move a row outside the bound subject.`, diff --git a/packages/core/sdk/src/platform-view.test.ts b/packages/core/sdk/src/platform-view.test.ts index 9c5ef821f3..e7e8b974c6 100644 --- a/packages/core/sdk/src/platform-view.test.ts +++ b/packages/core/sdk/src/platform-view.test.ts @@ -22,6 +22,7 @@ import { } from "./ids"; import { definePlugin } from "./plugin"; import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; +import { testAccess } from "@executor-js/product-access/testing"; // The platform view: `executor.admin`, an OPT-IN read-only surface that reads // across every subject in the tenant. Written against the real SQLite bring-up @@ -254,7 +255,12 @@ const makePlatformExecutor = ( db: db.db, plugins: [providerPlugin], onElicitation: "accept-all", - ...(options?.platformView === false ? {} : { platformView: true }), + access: + options?.platformView === false + ? options?.subject === null + ? testAccess.org() + : testAccess.member() + : testAccess.platform({ subject: options?.subject !== null }), }).pipe(Effect.orDie); const requireAdmin = (executor: Executor) => { diff --git a/packages/core/sdk/src/plugin-after-commit.test.ts b/packages/core/sdk/src/plugin-after-commit.test.ts index 2cb466a67f..1bd9fb9c94 100644 --- a/packages/core/sdk/src/plugin-after-commit.test.ts +++ b/packages/core/sdk/src/plugin-after-commit.test.ts @@ -13,6 +13,7 @@ import { import { definePlugin } from "./plugin"; import { makeTestExecutor } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { testAccess } from "@executor-js/product-access/testing"; // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is // what makes its database work atomic with the row deletions. The same property @@ -98,9 +99,10 @@ const revokingPlugin = (revoked: string[]) => })(); const setup = (revoked: string[]) => - makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe( - Effect.tap((executor) => executor.demo.seed()), - ); + makeTestExecutor({ + access: testAccess.member(), + plugins: [revokingPlugin(revoked)] as const, + }).pipe(Effect.tap((executor) => executor.demo.seed())); const REF = { owner: "org", diff --git a/packages/core/sdk/src/plugin-storage.test.ts b/packages/core/sdk/src/plugin-storage.test.ts index 3cc1d0793e..39eb0d7e17 100644 --- a/packages/core/sdk/src/plugin-storage.test.ts +++ b/packages/core/sdk/src/plugin-storage.test.ts @@ -12,6 +12,7 @@ import { type PluginStorageCollectionWhere, } from "./plugin-storage"; import { makeTestConfig, makeTestExecutor } from "./testing"; +import { testAccess } from "@executor-js/product-access/testing"; const ToolCall = Schema.Struct({ runId: Schema.String, @@ -167,6 +168,7 @@ describe("plugin storage collections", () => { it.effect("queries declared indexes through the executor's SQLite FumaDB target", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -229,6 +231,7 @@ describe("plugin storage collections", () => { it.effect("bulk puts and removes plugin storage rows", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -275,6 +278,7 @@ describe("plugin storage collections", () => { it.effect("stores and overwrites every row when a bulk write spans multiple batches", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -312,6 +316,7 @@ describe("plugin storage collections", () => { it.effect("rolls back every plugin storage row when a bulk write fails", () => Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -369,6 +374,7 @@ describe("plugin storage collections", () => { it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () => Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -435,6 +441,7 @@ describe("plugin storage collections", () => { () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -463,6 +470,7 @@ describe("plugin storage collections", () => { // One executor bound to a subject sees both org and user owner rows; a // user-owned row shadows an org-owned row under the same key on read. const executor = yield* makeTestExecutor({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); @@ -508,6 +516,7 @@ describe("plugin storage collections", () => { it.effect("rejects runtime queries against undeclared index fields", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), backend: "sqlite", plugins: [executionHistoryPlugin] as const, }); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 2ace32f891..4dee951875 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -168,9 +168,9 @@ export interface PluginCtx { * external work or writes to storage outside the catalog transaction. */ readonly authorizeWrite: () => Effect.Effect; /** Register / replace this plugin's integration in the catalog. Both - * operations are workspace-level changes gated by the executor's - * `orgWrites` binding for end-user principals. Subjectless system - * executors may re-register an existing row during boot convergence. */ + * operations are workspace-level changes gated by the product's + * `ExecutorAccess.settingsWrite` decision, including replacement by + * subjectless system executors during boot convergence. */ readonly register: ( input: RegisterIntegrationInput, ) => Effect.Effect; diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index 18c5d29a72..038af40cff 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -15,15 +15,15 @@ import { import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; import { createExecutor } from "./executor"; import type { FumaDb } from "./fuma-runtime"; -import { - effectivePolicyFromSorted, - isValidPattern, - matchPattern, - resolveToolPolicy, -} from "./policies"; +// Resolution semantics are PRODUCT rules — these tests exercise the real +// implementations from @executor-js/product-access (via the workspace root +// devDependency), never a copy. +import { effectivePolicyFromSorted, resolveToolPolicy } from "@executor-js/product-access/policy"; +import { isValidPattern, matchPattern } from "./policies"; import { definePlugin, tool } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestConfig, makeTestExecutor } from "./testing"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // Pure unit tests — pattern matcher + resolution. No executor required. @@ -332,7 +332,7 @@ const addr = (integration: IntegrationSlug, tool: string): ToolAddress => ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`); const setupExecutor = () => - makeTestExecutor({ plugins: [policyTestPlugin()] as const }).pipe( + makeTestExecutor({ access: testAccess.member(), plugins: [policyTestPlugin()] as const }).pipe( Effect.tap((executor) => Effect.gen(function* () { yield* executor.ptest.seed(); @@ -533,7 +533,10 @@ describe("executor.policies", () => { it.effect("fails when the policy vanishes during update", () => Effect.gen(function* () { let armed = false; - const config = makeTestConfig({ plugins: [policyTestPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [policyTestPlugin()] as const, + }); const executor = yield* createExecutor({ ...config, db: removePolicyAfterUpdate(config.db, () => armed), @@ -685,6 +688,7 @@ describe("active tool-policy provider", () => { it.effect("uses provider rules as an allowlist for list, schema, and execute", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [staticPlugin, policyProviderPlugin] as const, }); diff --git a/packages/core/sdk/src/policies.ts b/packages/core/sdk/src/policies.ts index 8620d9c6d3..69229e37f2 100644 --- a/packages/core/sdk/src/policies.ts +++ b/packages/core/sdk/src/policies.ts @@ -1,15 +1,17 @@ // --------------------------------------------------------------------------- -// Tool policies — pattern matcher + policy resolution. Pure functions; the -// executor stitches them into `tools.list`, `execute`, and the public -// `executor.policies` CRUD surface. Plugins consume the same surface. +// Tool policies — the decision VOCABULARY (types, schemas, projections) plus +// the pure pattern-matching and rule-placement utilities the executor's CRUD +// surface needs. Policies are owner-scoped (org | user) rows. // -// v2: policies are owner-scoped (org | user) instead of scope-stacked. Each -// owner contributes its first matching rule by local position; the final answer -// is the most restrictive matched action across owners, so a user preference -// cannot weaken an org guardrail (org = outer, user = inner). +// HOW rules resolve into an effective decision — owner ranking, the +// most-restrictive merge across owners, the plugin-default fallback and the +// capability-allowlist default — is a PRODUCT rule: it lives in +// `@executor-js/product-access/policy` and reaches core only through the +// `ExecutorAccess.toolPolicy` hook, whose `EffectivePolicy` answers core +// enforces at list/schema/invoke/approval-recheck. // --------------------------------------------------------------------------- -import { Match, Schema } from "effect"; +import { Schema } from "effect"; import { generateKeyBetween } from "fractional-indexing"; import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; @@ -121,10 +123,11 @@ export const isValidPattern = (pattern: string): boolean => { }; // --------------------------------------------------------------------------- -// Resolution — each owner contributes its first matching rule by local -// position; the most restrictive matched action across owners wins. Caller -// passes an `ownerRank` so the resolver doesn't need to know which owner is -// the outer guardrail. +// Ordering / placement utilities. HOW matched rules combine into an +// effective decision (owner ranking, most-restrictive merge, fallbacks) is a +// PRODUCT rule and lives in `@executor-js/product-access/policy`; core only +// consumes the resulting `EffectivePolicy` through +// `ExecutorAccess.toolPolicy` and enforces it. // --------------------------------------------------------------------------- export const comparePolicyRow = ( @@ -180,106 +183,6 @@ export const positionForNewPattern = ( return generateKeyBetween(prev, next); }; -const actionRestrictionRank = (action: ToolPolicyAction): number => - Match.value(action).pipe( - Match.when("block", () => 3), - Match.when("require_approval", () => 2), - Match.when("approve", () => 1), - Match.exhaustive, - ); - -const moreRestrictive = ( - current: T | undefined, - candidate: T, -): T => { - if (!current) return candidate; - const currentRank = actionRestrictionRank(current.action); - const candidateRank = actionRestrictionRank(candidate.action); - return candidateRank > currentRank ? candidate : current; -}; - -export const resolveToolPolicy = ( - toolId: string, - policies: readonly ToolPolicyRow[], - ownerRank: (row: Pick) => number, -): PolicyMatch | undefined => { - if (policies.length === 0) return undefined; - const sorted = [...policies].sort((a, b) => { - const sa = ownerRank(a); - const sb = ownerRank(b); - if (sa !== sb) return sa - sb; - return comparePolicyRow(a, b); - }); - const firstMatchByOwner = new Map(); - for (const row of sorted) { - if (firstMatchByOwner.has(row.owner)) continue; - if (matchPattern(row.pattern, toolId)) { - firstMatchByOwner.set(row.owner, { - action: row.action as ToolPolicyAction, - pattern: row.pattern, - policyId: row.id, - }); - } - } - let selected: PolicyMatch | undefined; - for (const match of firstMatchByOwner.values()) { - selected = moreRestrictive(selected, match); - } - return selected; -}; - -// --------------------------------------------------------------------------- -// Layered resolution — user-authored rules + plugin default `requiresApproval`. -// --------------------------------------------------------------------------- - -const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => - defaultRequiresApproval - ? { action: "require_approval", source: "plugin-default" } - : { action: "approve", source: "plugin-default" }; - -const liftUser = (match: PolicyMatch): EffectivePolicy => ({ - action: match.action, - source: "user", - pattern: match.pattern, - policyId: match.policyId, -}); - -export const resolveEffectivePolicy = ( - toolId: string, - policies: readonly ToolPolicyRow[], - ownerRank: (row: Pick) => number, - defaultRequiresApproval?: boolean, -): EffectivePolicy => { - const match = resolveToolPolicy(toolId, policies, ownerRank); - return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); -}; - -export const effectivePolicyFromSorted = ( - toolId: string, - sortedPolicies: readonly (Pick & - Partial>)[], - defaultRequiresApproval?: boolean, -): EffectivePolicy => { - const firstMatchByOwner = new Map(); - for (const p of sortedPolicies) { - const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__"; - if (firstMatchByOwner.has(ownerKey)) continue; - if (matchPattern(p.pattern, toolId)) { - firstMatchByOwner.set(ownerKey, { - action: p.action, - source: "user", - pattern: p.pattern, - policyId: p.id, - }); - } - } - let selected: EffectivePolicy | undefined; - for (const match of firstMatchByOwner.values()) { - selected = moreRestrictive(selected, match); - } - return selected ?? liftPlugin(defaultRequiresApproval); -}; - // --------------------------------------------------------------------------- // Row → public projection. // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/promise-executor.ts b/packages/core/sdk/src/promise-executor.ts index 0c59874e9f..57844827e6 100644 --- a/packages/core/sdk/src/promise-executor.ts +++ b/packages/core/sdk/src/promise-executor.ts @@ -21,6 +21,7 @@ import { type InvokeOptions as EffectInvokeOptions, type OnElicitation, } from "./executor"; +import type { ExecutorAccess } from "./access"; import type { ElicitationContext, ElicitationResponse } from "./elicitation"; import type { FumaDb, FumaTables } from "./fuma-runtime"; import { Subject, Tenant } from "./ids"; @@ -127,6 +128,15 @@ export interface ExecutorConfig { it("returns Promise-shaped executor and invokes static tools", async () => { const plugins = [echoPlugin()] as const; const executor = await createExecutor({ + access: testAccess.org(), plugins, onElicitation: "accept-all", }); @@ -54,6 +56,7 @@ describe("promise/createExecutor", () => { it("promisifies plugin extension methods", async () => { const plugins = [echoPlugin()] as const; const executor = await createExecutor({ + access: testAccess.org(), plugins, onElicitation: "accept-all", }); @@ -94,6 +97,7 @@ describe("promise/createExecutor", () => { const plugins = [approvedPlugin()] as const; const executor = await createExecutor({ + access: testAccess.org(), plugins, onElicitation: "accept-all", // default → auto-approve }); @@ -157,6 +161,7 @@ describe("promise/createExecutor", () => { const plugins = [inventoryPlugin()] as const; const executor = await createExecutor({ + access: testAccess.org(), plugins, providers: [memoryProvider], onElicitation: "accept-all", diff --git a/packages/core/sdk/src/provider-call-timeout.test.ts b/packages/core/sdk/src/provider-call-timeout.test.ts index 81039d9fc6..f207644a32 100644 --- a/packages/core/sdk/src/provider-call-timeout.test.ts +++ b/packages/core/sdk/src/provider-call-timeout.test.ts @@ -29,6 +29,7 @@ import { import { definePlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestConfig } from "./test-config"; +import { testAccess } from "@executor-js/product-access/testing"; const STORE = ProviderKey.make("remote-store"); const INTEG = IntegrationSlug.make("acme"); @@ -55,7 +56,7 @@ const plugin = (provider: CredentialProvider) => const executorWithConnection = (provider: CredentialProvider) => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [plugin(provider)] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [plugin(provider)] as const }), ); yield* executor.acme.seed(); yield* executor.connections.create({ @@ -110,7 +111,9 @@ describe("a credential provider that stops answering", () => { get: (id) => Effect.sync(() => items.get(String(id)) ?? null), set: (id, value) => Effect.sync(() => void items.set(String(id), value)), }; - const executor = yield* createExecutor(makeTestConfig({ plugins: [plugin(lit)] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [plugin(lit)] as const }), + ); yield* executor.acme.seed(); yield* executor.connections.create({ owner: "org", @@ -144,7 +147,10 @@ describe("a credential provider that stops answering", () => { } const executor = yield* createExecutor( - makeTestConfig({ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [plugin(new ClassProvider() as CredentialProvider)] as const, + }), ); yield* executor.acme.seed(); yield* executor.connections.create({ @@ -184,6 +190,7 @@ describe("a credential provider that stops answering", () => { const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const, }), ); diff --git a/packages/core/sdk/src/settings-authority.test.ts b/packages/core/sdk/src/settings-authority.test.ts new file mode 100644 index 0000000000..242cfdaa9b --- /dev/null +++ b/packages/core/sdk/src/settings-authority.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Result } from "effect"; +import { testAccess } from "@executor-js/product-access/testing"; + +import type { ExecutorAccess } from "./access"; +import { createExecutor } from "./executor"; +import { IntegrationSlug, Subject } from "./ids"; +import { definePlugin } from "./plugin"; +import { makeTestConfig } from "./testing"; + +// --------------------------------------------------------------------------- +// `ExecutorAccess.settingsWrite` is the product's decision, and core enforces +// it VERBATIM at every user-intent settings sink — including catalog +// replacement from a SUBJECT-LESS executor. A host that hands a subject-less +// binding a denying `settingsWrite` has stated its rule; core must not keep a +// residual rule of its own ("subject-less replacement of an existing row is +// always boot convergence") that overrides the supplied denial. Products that +// WANT boot convergence say so through their access implementation +// (`workspaceServiceAccess`), which these tests also pin. +// +// The fixtures build executors over ONE test database: a workspace-service +// seeder that registers the catalog row, then differently-postured executors +// that attempt to replace it through the same plugin-extension path. +// --------------------------------------------------------------------------- + +const SLUG = IntegrationSlug.make("settings-authority"); +const SEEDED_DESCRIPTION = "seeded by the workspace service"; +const REPLACED_DESCRIPTION = "replaced despite a denied settings decision"; + +const catalogPlugin = definePlugin(() => ({ + id: "settings-authority-demo" as const, + storage: () => ({}), + extension: (ctx) => ({ + register: (description: string) => + ctx.core.integrations.register({ slug: SLUG, description, config: {} }), + }), +}))(); + +/** The workspace-service posture with the product's settings decision + * replaced by an unconditional denial — the host's explicit word. */ +const denyingServiceAccess = (): ExecutorAccess => ({ + ...testAccess.org(), + settingsWrite: () => Effect.succeed("denied" as const), +}); + +const setup = () => + Effect.gen(function* () { + const config = makeTestConfig({ + subject: null, + access: testAccess.org(), + plugins: [catalogPlugin] as const, + }); + const seeder = yield* createExecutor(config); + yield* Effect.addFinalizer(() => + seeder + .close() + .pipe( + Effect.ignore, + Effect.andThen(Effect.promise(() => config.testDb.close()).pipe(Effect.ignore)), + ), + ); + yield* seeder["settings-authority-demo"].register(SEEDED_DESCRIPTION); + return { config, seeder }; + }); + +describe("settingsWrite authority over catalog replacement", () => { + it.effect("a supplied denial refuses replacement from a subject-less executor", () => + Effect.gen(function* () { + const { config, seeder } = yield* setup(); + const denied = yield* createExecutor({ ...config, access: denyingServiceAccess() }); + yield* Effect.addFinalizer(() => denied.close().pipe(Effect.ignore)); + + const outcome = yield* Effect.result( + denied["settings-authority-demo"].register(REPLACED_DESCRIPTION), + ); + // The product said "denied"; core must enforce it even with no bound + // subject — there is no core-owned boot-convergence exemption. + expect(Result.isFailure(outcome)).toBe(true); + expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } }); + + const after = yield* seeder.integrations.get(SLUG); + expect(after?.description).toBe(SEEDED_DESCRIPTION); + }).pipe(Effect.scoped), + ); + + it.effect("boot convergence stays available when the product allows it", () => + Effect.gen(function* () { + const { seeder } = yield* setup(); + // The same subject-less posture with the product's own (allowing) + // decision converges the existing row — the rule lives in the product, + // not in core. + yield* seeder["settings-authority-demo"].register("converged at boot"); + const after = yield* seeder.integrations.get(SLUG); + expect(after?.description).toBe("converged at boot"); + }).pipe(Effect.scoped), + ); + + it.effect("baseline: a bound member's denial already refuses replacement", () => + Effect.gen(function* () { + const { config, seeder } = yield* setup(); + const member = yield* createExecutor({ + ...config, + subject: Subject.make("member-1"), + access: testAccess.member("denied"), + }); + yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); + + const outcome = yield* Effect.result( + member["settings-authority-demo"].register(REPLACED_DESCRIPTION), + ); + expect(Result.isFailure(outcome)).toBe(true); + expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } }); + + const after = yield* seeder.integrations.get(SLUG); + expect(after?.description).toBe(SEEDED_DESCRIPTION); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..056eec6054 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -88,10 +88,11 @@ export { } from "./elicitation"; // Tool-policy helpers + projections (pure functions / Schema). +// Resolution semantics (owner ranking, most-restrictive merge, fallbacks) +// live in `@executor-js/product-access/policy`, which builds on these. export { matchPattern, isValidPattern, - effectivePolicyFromSorted, comparePolicyRow, patternSpecificity, positionForNewPattern, diff --git a/packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts b/packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts index 9058756c19..15ceae7ce1 100644 --- a/packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts +++ b/packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts @@ -88,7 +88,11 @@ describe("legacy bigint storage class migration", () => { }); expect(await storageClassOf(db, "c_legacy")).toBe("integer"); - const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT, + owners: ["user", "org"], + }); // Not "returns a bad value" — the read THROWS, which is why the gateway // lost every saved integration rather than one field of one row. await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); @@ -118,7 +122,11 @@ describe("legacy bigint storage class migration", () => { expect(await storageClassOf(db, "c_healthy")).toBe("blob"); expect(await storageClassOf(db, "c_null")).toBe("null"); - const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT, + owners: ["user", "org"], + }); const rows = await scoped.findMany("connection", {}); expect( rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]), @@ -143,7 +151,11 @@ describe("legacy bigint storage class migration", () => { expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1); expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0); - const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT, + owners: ["user", "org"], + }); const rows = await scoped.findMany("connection", {}); expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); }), @@ -169,7 +181,11 @@ describe("legacy bigint storage class migration", () => { expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2); - const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const scoped = withQueryContext(db.db, { + tenant: TENANT, + subject: SUBJECT, + owners: ["user", "org"], + }); const integrations = await scoped.findMany("integration", {}); expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([ LEGACY_EXPIRES_AT, diff --git a/packages/core/sdk/src/subject-registry.test.ts b/packages/core/sdk/src/subject-registry.test.ts index c07ba0508a..efb1c6be73 100644 --- a/packages/core/sdk/src/subject-registry.test.ts +++ b/packages/core/sdk/src/subject-registry.test.ts @@ -9,6 +9,7 @@ import { definePlugin } from "./plugin"; import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { testAccess } from "@executor-js/product-access/testing"; // `touchSubject` is the only writer of the `subject` table. Written against the // real SQLite bring-up (the same statements local/self-host/D1 boot with) so @@ -32,6 +33,7 @@ const scopedTo = (db: SqliteTestFumaDb, tenant: string) => withQueryContext(db.db, { tenant, subject: null, + owners: [], } satisfies ExecutorOwnerPolicyContext); const storedSubjects = ( @@ -345,6 +347,7 @@ const subjectsOf = ( const rows = await withQueryContext(harness.config.db as never, { tenant, subject: null, + owners: [], } satisfies ExecutorOwnerPolicyContext).findMany("subject", {}); return rows.map((row) => String(row.external_id)); }); @@ -354,6 +357,7 @@ describe("connections.create subject sighting", () => { Effect.scoped( Effect.gen(function* () { const harness = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant: TENANT, }); @@ -377,6 +381,7 @@ describe("connections.create subject sighting", () => { // The row names the acting principal, not the connection's owner tier: // a member who only ever creates org connections still exists. const harness = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant: TENANT, }); @@ -398,6 +403,7 @@ describe("connections.create subject sighting", () => { Effect.scoped( Effect.gen(function* () { const harness = yield* makeTestWorkspaceHarness({ + access: testAccess.org(), plugins, tenant: TENANT, subject: null, diff --git a/packages/core/sdk/src/subject-registry.ts b/packages/core/sdk/src/subject-registry.ts index ca02cef65a..4a4d5018a1 100644 --- a/packages/core/sdk/src/subject-registry.ts +++ b/packages/core/sdk/src/subject-registry.ts @@ -133,6 +133,9 @@ export const touchSubject = (db: FumaDb, input: TouchSubjectInput): Effect. withQueryContext(db, { tenant: input.tenant, subject: externalId, + // This host-internal writer touches ONLY the tenant-scoped `subject` + // table; it holds no owner-scoped view at all, stated explicitly. + owners: [], } satisfies ExecutorOwnerPolicyContext), ); // No `tenant` clause: the tenant policy adds it to every read/update. diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index df32bcda0f..dc036d342e 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -1,5 +1,6 @@ import { Context, Effect, Layer } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; +import type { ExecutorAccess } from "./access"; import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor"; import type { FumaDb } from "./fuma-runtime"; import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids"; @@ -134,15 +135,18 @@ export type TestConfigOptions["onIntegrationChange"]; readonly firstPartyOAuthClients?: ExecutorConfig["firstPartyOAuthClients"]; readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; - /** Workspace-settings permission for the test binding (see - * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts - * with no role model. */ - readonly orgWrites?: ExecutorConfig["orgWrites"]; + /** Product access decisions for the test binding (see + * `ExecutorConfig.access`). REQUIRED — this fixture is generic and has no + * posture of its own. Tests state one explicitly, normally from + * `@executor-js/product-access/testing` (`testAccess.member()`, …; the + * sdk's own tests resolve that package through the workspace ROOT + * devDependency, keeping the package graph acyclic). */ + readonly access: ExecutorAccess; readonly waitUntil?: ExecutorConfig["waitUntil"]; }; export const makeTestConfig = ( - options?: TestConfigOptions, + options: TestConfigOptions, ): Omit, "db"> & { readonly db: FumaDb; readonly testDb: TestFumaDb; @@ -156,9 +160,14 @@ export const makeTestConfig = ( - options?: TestConfigOptions, + options: TestConfigOptions, ) => Effect.acquireRelease( Effect.gen(function* () { @@ -233,7 +242,7 @@ export const makeTestWorkspaceHarness = ( - options?: TestConfigOptions, + options: TestConfigOptions, ) => Layer.effect(TestWorkspace)( makeTestWorkspaceHarness(options).pipe( @@ -242,7 +251,7 @@ export const makeTestWorkspaceLayer = ( - options?: TestConfigOptions, + options: TestConfigOptions, ) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); /** Built-in in-memory writable credential provider, contributed as a plugin diff --git a/packages/core/sdk/src/testing.test.ts b/packages/core/sdk/src/testing.test.ts index eaded9b4b1..2918b476b9 100644 --- a/packages/core/sdk/src/testing.test.ts +++ b/packages/core/sdk/src/testing.test.ts @@ -7,10 +7,14 @@ import { OAuthTestServer, TestWorkspace, } from "./testing"; +import { testAccess } from "@executor-js/product-access/testing"; const plugins = [memoryCredentialsPlugin()] as const; -const TestLayer = Layer.mergeAll(makeTestWorkspaceLayer({ plugins }), OAuthTestServer.layer()); +const TestLayer = Layer.mergeAll( + makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), + OAuthTestServer.layer(), +); layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () => diff --git a/packages/core/sdk/src/tool-not-found-empty-catalog.test.ts b/packages/core/sdk/src/tool-not-found-empty-catalog.test.ts index d8c01a541e..d2283508d4 100644 --- a/packages/core/sdk/src/tool-not-found-empty-catalog.test.ts +++ b/packages/core/sdk/src/tool-not-found-empty-catalog.test.ts @@ -30,6 +30,7 @@ import { import { definePlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestConfig } from "./test-config"; +import { testAccess } from "@executor-js/product-access/testing"; const STORE = ProviderKey.make("memory"); const INTEG = IntegrationSlug.make("demo"); @@ -65,7 +66,9 @@ const POPULATED = pluginWith([ const failInvoking = (plugin: ReturnType, tool: string) => Effect.gen(function* () { - const executor = yield* createExecutor({ ...makeTestConfig({ plugins: [plugin] as const }) }); + const executor = yield* createExecutor({ + ...makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const }), + }); yield* executor.demo.seed(); yield* executor.connections.create({ owner: "org", diff --git a/packages/core/sdk/src/tools-list-merge.test.ts b/packages/core/sdk/src/tools-list-merge.test.ts index c15a3518cd..095b93c261 100644 --- a/packages/core/sdk/src/tools-list-merge.test.ts +++ b/packages/core/sdk/src/tools-list-merge.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; import { definePlugin } from "./plugin"; import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // Cross-owner merge regression (the "Axiom shows 0 tools" bug). @@ -55,9 +56,10 @@ const demoPlugin = definePlugin(() => ({ // Default test executor binds `subject: "test-subject"`, so user-owned // connections/tools can be created and merge with org rows. const setup = () => - makeTestExecutor({ plugins: [memoryCredentialsPlugin(), demoPlugin] as const }).pipe( - Effect.tap((executor) => executor.demo.seed()), - ); + makeTestExecutor({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), demoPlugin] as const, + }).pipe(Effect.tap((executor) => executor.demo.seed())); describe("tools.list cross-owner merge", () => { it.effect("lists user-owned tools when owner is omitted (the fix)", () => diff --git a/packages/hosts/cloudflare/src/blob-store.test.ts b/packages/hosts/cloudflare/src/blob-store.test.ts index b8d595f705..857e0e46b2 100644 --- a/packages/hosts/cloudflare/src/blob-store.test.ts +++ b/packages/hosts/cloudflare/src/blob-store.test.ts @@ -93,7 +93,10 @@ describe("makeR2BlobStore", () => { Effect.gen(function* () { const { bucket } = makeFakeBucket(); const store = makeR2BlobStore(bucket); - const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi"); + const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi", { + owners: ["user", "org"], + storageWrites: "allowed", + }); yield* plugin.put("spec/h1", "org-spec", { owner: "org" }); expect(yield* plugin.get("spec/h1")).toBe("org-spec"); diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index 8bc62bb65e..b60a994f2d 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -79,6 +79,7 @@ "@effect/vitest": "catalog:", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@tailwindcss/browser": "^4.2.2", "@tailwindcss/vite": "catalog:", diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts index 96adc1b083..8d0a08d049 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts @@ -41,6 +41,7 @@ import type * as Cause from "effect/Cause"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; import { loadMcpAppsShellHtml } from "../shell-html"; +import { testAccess } from "@executor-js/product-access/testing"; type ShellServer = { readonly url: string; @@ -1305,7 +1306,10 @@ const startMcpHarness = async (openApi: OpenApiServer): Promise => { const executor = await Effect.runPromise( Effect.gen(function* () { const built = yield* createExecutor( - makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }), + makeTestConfig({ + access: testAccess.member(), + plugins: [inventoryPlugin(openApi.postRequests)], + }), ); // Tools only exist per connection, so register the integration and open // one org `main` connection — that is what makes diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 1e8dd7c4fc..731a496b08 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -46,6 +46,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index bdc51db85d..1e1b8f782d 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -13,6 +13,7 @@ import { } from "./in-memory-session-store"; import { defaultMcpResource, type Principal } from "./seams"; import { createExecutorMcpServer } from "./tool-server"; +import { testAccess } from "@executor-js/product-access/testing"; const TEST_PRINCIPAL: Principal = { accountId: "acct_test", @@ -161,7 +162,10 @@ const openSession = async ( it("keeps overlapping warm-session workspace writes bound to their request roles", async () => { const executor = await Effect.runPromise( - createExecutor({ ...makeTestConfig(), orgWrites: "request" }), + createExecutor({ + ...makeTestConfig({ access: testAccess.member() }), + access: testAccess.requestBound(), + }), ); const started = new Map void>(); const startedPromises = ["member", "admin"].map( @@ -240,7 +244,10 @@ it("keeps overlapping warm-session workspace writes bound to their request roles it("binds a paused workspace write to the resuming principal after demotion", async () => { const executor = await Effect.runPromise( - createExecutor({ ...makeTestConfig(), orgWrites: "request" }), + createExecutor({ + ...makeTestConfig({ access: testAccess.member() }), + access: testAccess.requestBound(), + }), ); const executionId = "exec_resume_demotion"; const pattern = "paused-resume-demotion.*"; @@ -313,7 +320,10 @@ it("binds a paused workspace write to the resuming principal after demotion", as it("uses the browser approver's demoted role after an admin starts waiting", async () => { const executor = await Effect.runPromise( - createExecutor({ ...makeTestConfig({ coreTools: {} }), orgWrites: "request" }), + createExecutor({ + ...makeTestConfig({ access: testAccess.member(), coreTools: {} }), + access: testAccess.requestBound(), + }), ); const executionId = "exec_browser_resume_demotion"; const pattern = "browser-resume-demotion.*"; @@ -695,7 +705,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => { let executorClosed = 0; let customClosed = 0; - const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig())); + const realExecutor = await Effect.runPromise( + createExecutor(makeTestConfig({ access: testAccess.member() })), + ); const testExecutor = { ...realExecutor, close: () => @@ -743,7 +755,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { it("shuts down the scoped executor when sessions.close() is called", async () => { let executorClosed = 0; - const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig())); + const realExecutor = await Effect.runPromise( + createExecutor(makeTestConfig({ access: testAccess.member() })), + ); const testExecutor = { ...realExecutor, close: () => diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 01d0994db0..35602f845e 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -1,6 +1,7 @@ import { Context, Effect, Layer, Schema } from "effect"; import type { Cause } from "effect"; +import { orgWriteAccessForRole } from "@executor-js/product-access"; import type { OrgWriteAccess } from "@executor-js/sdk"; // --------------------------------------------------------------------------- @@ -72,9 +73,11 @@ export type Principal = Schema.Schema.Type; /** Internal header overwritten by a trusted session store before MCP dispatch. */ export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access"; -/** Derive the effective workspace-write access for one authenticated request. */ +/** Derive the effective workspace-write access for one authenticated request + * — the product's role rule (`@executor-js/product-access`), applied here at + * the serving boundary that stamps the request-bound carrier. */ export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess => - principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied"; + orgWriteAccessForRole(principal); /** * Stamp request-bound workspace-write access for the MCP SDK request handler. diff --git a/packages/kernel/runtime-dynamic-worker/package.json b/packages/kernel/runtime-dynamic-worker/package.json index 3670e9b3dc..baaaff68d2 100644 --- a/packages/kernel/runtime-dynamic-worker/package.json +++ b/packages/kernel/runtime-dynamic-worker/package.json @@ -25,6 +25,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@types/node": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/kernel/runtime-dynamic-worker/src/integration.test.ts b/packages/kernel/runtime-dynamic-worker/src/integration.test.ts index a08f94b51a..b7b9b12126 100644 --- a/packages/kernel/runtime-dynamic-worker/src/integration.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/integration.test.ts @@ -45,6 +45,7 @@ import { type FumaDb, type FumaTables, } from "@executor-js/sdk"; +import { testAccess } from "@executor-js/product-access/testing"; import { makeExecutorToolInvoker } from "@executor-js/execution"; import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi"; @@ -231,6 +232,7 @@ const buildSandboxBridge = (spec: string, slug: string, baseUrl = "https://upstr providers: [memoryProvider()], plugins, onElicitation: "accept-all", + access: testAccess.member(), }); // v2: addSpec registers the integration; tools are produced per-connection, // so an org `main` connection is required for the operation to be callable. diff --git a/packages/plugins/desktop-settings/package.json b/packages/plugins/desktop-settings/package.json index cb8af09f23..e6cd9f72d3 100644 --- a/packages/plugins/desktop-settings/package.json +++ b/packages/plugins/desktop-settings/package.json @@ -47,6 +47,7 @@ "react": "catalog:" }, "devDependencies": { + "@executor-js/product-access": "workspace:*", "@types/react": "catalog:", "tsup": "catalog:", "typescript": "catalog:" diff --git a/packages/plugins/desktop-settings/src/server.test.ts b/packages/plugins/desktop-settings/src/server.test.ts index cafe661359..b553dd96ba 100644 --- a/packages/plugins/desktop-settings/src/server.test.ts +++ b/packages/plugins/desktop-settings/src/server.test.ts @@ -5,11 +5,13 @@ import { ToolAddress } from "@executor-js/sdk"; import { makeTestExecutor } from "@executor-js/sdk/testing"; import { desktopSettingsPlugin } from "./server"; +import { testAccess } from "@executor-js/product-access/testing"; describe("desktopSettingsPlugin", () => { it.effect("returns a browser handoff URL for Desktop-only settings", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const, }); diff --git a/packages/plugins/file-secrets/package.json b/packages/plugins/file-secrets/package.json index b12a393017..78d7ad4a84 100644 --- a/packages/plugins/file-secrets/package.json +++ b/packages/plugins/file-secrets/package.json @@ -47,6 +47,7 @@ "@executor-js/sdk": "workspace:*" }, "devDependencies": { + "@executor-js/product-access": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", "effect": "catalog:", diff --git a/packages/plugins/file-secrets/src/data-dir.test.ts b/packages/plugins/file-secrets/src/data-dir.test.ts index 303983a2d2..a308541e30 100644 --- a/packages/plugins/file-secrets/src/data-dir.test.ts +++ b/packages/plugins/file-secrets/src/data-dir.test.ts @@ -14,6 +14,7 @@ import { import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; import { fileSecretsPlugin } from "./index"; +import { testAccess } from "@executor-js/product-access/testing"; const INTEGRATION = IntegrationSlug.make("durable-secrets"); const CONNECTION = ConnectionName.make("main"); @@ -69,7 +70,11 @@ describe("file secrets data directory", () => { vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome); const firstAuthPath = yield* Effect.scoped( Effect.gen(function* () { - const first = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() }); + const first = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + dataDir, + plugins: plugins(), + }); yield* first.executor.connectionFixture.registerIntegration(); const connection = yield* first.executor.connections.create({ owner: "org", @@ -96,7 +101,11 @@ describe("file secrets data directory", () => { vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome); yield* Effect.scoped( Effect.gen(function* () { - const recreated = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() }); + const recreated = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + dataDir, + plugins: plugins(), + }); const connections = yield* recreated.executor.connections.list({ integration: INTEGRATION, }); diff --git a/packages/plugins/file-secrets/src/index.test.ts b/packages/plugins/file-secrets/src/index.test.ts index d33f78431e..deb7f2e939 100644 --- a/packages/plugins/file-secrets/src/index.test.ts +++ b/packages/plugins/file-secrets/src/index.test.ts @@ -17,13 +17,17 @@ import { ProviderKey } from "@executor-js/sdk"; import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; import { fileSecretsPlugin } from "./index"; +import { testAccess } from "@executor-js/product-access/testing"; const FILE_PROVIDER = ProviderKey.make("file"); const inspectPlugin = (plugin: ReturnType) => Effect.scoped( Effect.gen(function* () { - const workspace = yield* makeTestWorkspaceHarness({ plugins: [plugin] as const }); + const workspace = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), + plugins: [plugin] as const, + }); const items = yield* workspace.executor.providers.items(FILE_PROVIDER); return { filePath: workspace.executor.fileSecrets.filePath, @@ -136,6 +140,7 @@ describe("file secrets auth location", () => { vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); writeAuthFile(legacyFilePath, "not-json"); const workspace = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [fileSecretsPlugin()] as const, }); @@ -158,6 +163,7 @@ describe("file secrets auth location", () => { vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); mkdirSync(legacyFilePath, { recursive: true }); const workspace = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [fileSecretsPlugin()] as const, }); @@ -185,6 +191,7 @@ describe("file secrets auth location", () => { const legacyContents = '{"legacy-token":"legacy-secret"}'; writeAuthFile(legacyFilePath, legacyContents); const workspace = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins: [fileSecretsPlugin()] as const, }); diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index e08e541116..3433835f95 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -71,6 +71,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", diff --git a/packages/plugins/graphql/src/sdk/configure.test.ts b/packages/plugins/graphql/src/sdk/configure.test.ts index 7fca668875..769916390d 100644 --- a/packages/plugins/graphql/src/sdk/configure.test.ts +++ b/packages/plugins/graphql/src/sdk/configure.test.ts @@ -21,10 +21,12 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { graphqlPlugin } from "./plugin"; import type { GraphqlAuthMethod } from "./types"; +import { testAccess } from "@executor-js/product-access/testing"; const makeExecutor = () => createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, }), ); diff --git a/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts b/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts index 1db71b948e..2e398fbca8 100644 --- a/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts +++ b/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts @@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { makeGreetingGraphqlSchema } from "../testing"; import { graphqlPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; const INVOCATION_TIMEOUT_MS = 100; @@ -61,6 +62,7 @@ describe("GraphQL invocation timeout", () => { const server = yield* startHangingResponseServer(closed); const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [ memoryCredentialsPlugin(), graphqlPlugin({ diff --git a/packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts b/packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts index d4ec659122..fa6f7a7f63 100644 --- a/packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts +++ b/packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts @@ -23,12 +23,16 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { graphqlPlugin } from "./plugin"; import { variable } from "@executor-js/sdk/http-auth"; import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); const makeExecutor = () => createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, + }), ); const toolAddr = (integration: string, connection: string, tool: string): ToolAddress => diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 45290fbfec..7648f9cd70 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -35,6 +35,7 @@ import { serveGraphqlTestServer, waitForRecordedRequests, } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/ // usagesForConnection, multi-scope shadowing, and `executor.sources.*` / @@ -118,7 +119,10 @@ const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphql const makeExecutor = () => createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, + }), ); const recordingBlobStore = () => { @@ -160,11 +164,14 @@ describe("graphqlPlugin real protocol server", () => { it.effect("denies member schema persistence before writing an org blob", () => Effect.gen(function* () { const blobs = recordingBlobStore(); - const config = makeTestConfig({ plugins: [graphqlPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [graphqlPlugin()] as const, + }); const member = yield* createExecutor({ ...config, blobs: blobs.store, - orgWrites: "denied", + access: testAccess.member("denied"), }); const error = yield* member.graphql @@ -420,6 +427,7 @@ describe("graphqlPlugin real protocol server", () => { }), ); const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, }); const executor = yield* createExecutor({ ...config, httpClientLayer }); @@ -1236,6 +1244,7 @@ describe("graphqlPlugin", () => { it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () => Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, }); const executor = yield* createExecutor(config); diff --git a/packages/plugins/keychain/package.json b/packages/plugins/keychain/package.json index 3b59251b97..0fc6cf68b8 100644 --- a/packages/plugins/keychain/package.json +++ b/packages/plugins/keychain/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/product-access": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", "effect": "catalog:", diff --git a/packages/plugins/keychain/src/index.test.ts b/packages/plugins/keychain/src/index.test.ts index 07a24c5d2b..7362f3c5c8 100644 --- a/packages/plugins/keychain/src/index.test.ts +++ b/packages/plugins/keychain/src/index.test.ts @@ -5,6 +5,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; import { keychainPlugin } from "./index"; import { makeKeychainProvider } from "./provider"; import { setPassword, deletePassword } from "./keyring"; +import { testAccess } from "@executor-js/product-access/testing"; // removed: v1 tests routed through `executor.secrets.set/get/remove` with // `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived @@ -33,9 +34,7 @@ describe("keychain plugin", () => { it.effect("exposes keychain metadata and registers a provider when reachable", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ - plugins: [keychainPlugin()] as const, - }), + makeTestConfig({ access: testAccess.member(), plugins: [keychainPlugin()] as const }), ); expect(executor.keychain.displayName).toBeTypeOf("string"); @@ -60,6 +59,7 @@ describe("keychain plugin", () => { const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [keychainPlugin({ serviceName })] as const, }), ); @@ -86,6 +86,7 @@ describe("keychain plugin", () => { Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [keychainPlugin({ serviceName: "executor-test" })] as const, }), ); diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 6671783b79..fee0e8a204 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -76,6 +76,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", diff --git a/packages/plugins/mcp/src/sdk/catalog-sync.test.ts b/packages/plugins/mcp/src/sdk/catalog-sync.test.ts index f3328876b3..11c4ebd779 100644 --- a/packages/plugins/mcp/src/sdk/catalog-sync.test.ts +++ b/packages/plugins/mcp/src/sdk/catalog-sync.test.ts @@ -35,6 +35,7 @@ import { mcpPlugin } from "./plugin"; import { createMcpConnector } from "./connection"; import { discoverTools } from "./discover"; import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const INTEG = IntegrationSlug.make("catalog_mcp"); const CONNECTION = ConnectionName.make("main"); @@ -48,7 +49,10 @@ const makeCatalogTestExecutor = ( }, ) => createExecutor({ - ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + ...makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }), ...(options?.toolsSyncGraceMs === undefined ? {} @@ -357,7 +361,10 @@ describe("MCP stale-catalog refresh", () => { Effect.gen(function* () { const fixture = yield* serveLatchedListServer(); const executor = yield* createExecutor({ - ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + ...makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), // Everything is expired on every read, so a single tools read has the // whole set to rebuild. toolsSyncTtlMs: 0, diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 69f108d008..97233b25ee 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -23,6 +23,7 @@ import { import { mcpPlugin } from "./plugin"; import { makeElicitationMcpServer, serveMcpServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const isFormElicitation = Schema.is(FormElicitation); @@ -60,7 +61,12 @@ const TEMPLATE = AuthTemplateSlug.make("none"); const makeTestExecutor = (serverUrl: string) => Effect.acquireRelease( - createExecutor(makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const })), + createExecutor( + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), + ), (executor) => executor.close().pipe(Effect.ignore), ).pipe( Effect.tap((executor) => @@ -373,7 +379,9 @@ describe("MCP elicitation (end-to-end)", () => { it.effect("addServer preserves the configured display name as the integration description", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); yield* executor.mcp.addServer({ name: "Gmail", diff --git a/packages/plugins/mcp/src/sdk/image-content.test.ts b/packages/plugins/mcp/src/sdk/image-content.test.ts index 2aba5ae4a2..001fcbfc3f 100644 --- a/packages/plugins/mcp/src/sdk/image-content.test.ts +++ b/packages/plugins/mcp/src/sdk/image-content.test.ts @@ -17,6 +17,7 @@ import { makeImageMcpServer, serveMcpServer, } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const INTEGRATION = IntegrationSlug.make("image_mcp"); const TEMPLATE = AuthTemplateSlug.make("none"); @@ -32,7 +33,10 @@ describe("MCP image content", () => { Effect.gen(function* () { const server = yield* serveMcpServer(makeImageMcpServer); const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ diff --git a/packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts b/packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts index 590c05273c..3b673f7f1f 100644 --- a/packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts +++ b/packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts @@ -24,6 +24,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { mcpPlugin } from "./plugin"; import { variable } from "@executor-js/sdk/http-auth"; import { makeEchoMcpServer, serveMcpServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const serveRecordingServer = serveMcpServer(() => makeEchoMcpServer({ @@ -40,7 +41,10 @@ describe("MCP multi-placement auth", () => { Effect.gen(function* () { const server = yield* serveRecordingServer; const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ @@ -88,7 +92,10 @@ describe("MCP multi-placement auth", () => { Effect.gen(function* () { const server = yield* serveRecordingServer; const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ @@ -126,7 +133,10 @@ describe("MCP multi-placement auth", () => { Effect.gen(function* () { const server = yield* serveRecordingServer; const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); // Two declared methods; each connection picks one by template slug. @@ -193,7 +203,10 @@ describe("MCP multi-placement auth", () => { Effect.gen(function* () { const server = yield* serveRecordingServer; const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ diff --git a/packages/plugins/mcp/src/sdk/owner-isolation.test.ts b/packages/plugins/mcp/src/sdk/owner-isolation.test.ts index 7502bce0cf..094d6ad954 100644 --- a/packages/plugins/mcp/src/sdk/owner-isolation.test.ts +++ b/packages/plugins/mcp/src/sdk/owner-isolation.test.ts @@ -23,6 +23,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { mcpPlugin } from "./plugin"; import { makeEchoMcpServer, serveMcpServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const INTEG = IntegrationSlug.make("iso_mcp"); const TEMPLATE = AuthTemplateSlug.make("bearer"); @@ -43,7 +44,10 @@ describe("MCP owner isolation", () => { Effect.gen(function* () { const server = yield* serveAuthRecordingMcpServer; const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); // One integration with a Bearer header auth template; the connection's diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 487874677b..9dd6825b77 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -29,6 +29,7 @@ import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin import { McpInvocationError } from "./errors"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; // removed: the v1 addSource / scopes / secrets / credential-binding / usages / // sources.configure / multi-scope shadowing suites. v2 has no scope stack, no @@ -261,6 +262,7 @@ const seedCallToolExecutor = (input: { Effect.gen(function* () { const server = yield* serveCallToolServer(input.callTool); const config = makeTestConfig({ + access: testAccess.member(), plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, }); const executor = yield* createExecutor(config); @@ -590,9 +592,7 @@ describe("mcpPlugin", () => { it.effect("creates executor with mcp plugin", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ - plugins: [mcpPlugin()] as const, - }), + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), ); expect(executor.mcp).toBeDefined(); @@ -631,7 +631,9 @@ describe("mcpPlugin", () => { it.effect("integration catalog has no configured MCP integrations initially", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); const integrations = yield* executor.integrations.list(); expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0); }), @@ -639,7 +641,9 @@ describe("mcpPlugin", () => { it.effect("projects an MCP server family into the integration catalog", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); yield* executor.mcp.addServer({ name: "Cloudflare Docs", family: "cloudflare", @@ -656,7 +660,9 @@ describe("mcpPlugin", () => { it.effect("connection tools list is empty until a connection is created", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); const tools = yield* executor.tools.list(); expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0); }), @@ -667,7 +673,10 @@ describe("mcpPlugin", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: [] }); const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); const attachedClient = OAuthClientSlug.make("axiom-mcp"); const unrelatedClient = OAuthClientSlug.make("manual-app"); @@ -726,7 +735,10 @@ describe("mcpPlugin", () => { Effect.scoped( Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ @@ -769,7 +781,9 @@ describe("mcpPlugin", () => { // adding an API key to an OAuth server must NOT displace the OAuth method. it.effect("configureAuth merge-appends a custom method without clobbering oauth", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); yield* executor.mcp.addServer({ name: "OAuth MCP", @@ -796,7 +810,9 @@ describe("mcpPlugin", () => { it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); yield* executor.mcp.addServer({ name: "Open MCP", @@ -826,7 +842,10 @@ describe("mcpPlugin", () => { scopes: ["channels:history", "users:read"], }); const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ @@ -870,7 +889,10 @@ describe("mcpPlugin", () => { Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["mcp"] }); const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); yield* executor.mcp.addServer({ @@ -911,7 +933,10 @@ describe("mcpPlugin", () => { it.effect("registers integration + connection with 0 tools when discovery fails", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ); const slugStr = "broken_source"; @@ -941,7 +966,10 @@ describe("mcpPlugin", () => { it.effect("static probeEndpoint returns actionable tool failures", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [mcpPlugin()] as const, + }); const executor = yield* createExecutor(config); const result = yield* executor.execute(ToolAddress.make("executor.mcp.probeEndpoint"), { @@ -1294,7 +1322,10 @@ describe("mcpPlugin", () => { ), ), ); - const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [mcpPlugin()] as const, + }); const executor = yield* createExecutor(config); const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); @@ -1330,7 +1361,10 @@ describe("mcpPlugin", () => { : HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }), ), ); - const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [mcpPlugin()] as const, + }); const executor = yield* createExecutor(config); const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); @@ -1372,7 +1406,10 @@ describe("mcpPlugin", () => { return HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }); }), ); - const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [mcpPlugin()] as const, + }); const executor = yield* createExecutor(config); const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); @@ -1400,7 +1437,10 @@ const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); const seedAnnotationsExecutor = (serverUrl: string) => createExecutor( - makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + makeTestConfig({ + access: testAccess.member(), + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), ).pipe( Effect.tap((executor) => Effect.gen(function* () { @@ -1508,7 +1548,9 @@ describe("mcpPlugin detect URL-token fallback", () => { // a candidate. it.effect("returns low-confidence candidate when path has /mcp segment", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp"); const mcp = results.find((r) => r.kind === "mcp"); expect(mcp).toBeDefined(); @@ -1518,7 +1560,9 @@ describe("mcpPlugin detect URL-token fallback", () => { it.effect("matches mcp on hostname label", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/"); const mcp = results.find((r) => r.kind === "mcp"); expect(mcp?.confidence).toBe("low"); @@ -1527,7 +1571,9 @@ describe("mcpPlugin detect URL-token fallback", () => { it.effect("does not match mcp as a substring", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so // the URL-token fallback must not fire. const results = yield* executor.integrations.detect("http://127.0.0.1:1/mcpstore"); @@ -1537,7 +1583,9 @@ describe("mcpPlugin detect URL-token fallback", () => { it.effect("returns null when no token match and no wire-shape match", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1"); expect(results.find((r) => r.kind === "mcp")).toBeUndefined(); }), @@ -1590,7 +1638,9 @@ describe("mcpPlugin endpoint telemetry", () => { it.effect("stamps a sanitized endpoint on the detect span", () => Effect.gen(function* () { const spans: Array = []; - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); yield* executor.integrations .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`) @@ -1620,7 +1670,9 @@ describe("mcpPlugin endpoint telemetry", () => { it.effect("stamps a sanitized endpoint on the probe_endpoint span", () => Effect.gen(function* () { const spans: Array = []; - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); yield* executor.mcp .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`) @@ -1649,6 +1701,7 @@ describe("stdio static env", () => { Effect.scoped( Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [ memoryCredentialsPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: true }), @@ -1725,6 +1778,7 @@ describe("stdio static env", () => { Effect.scoped( Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [ memoryCredentialsPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: true }), @@ -1777,6 +1831,7 @@ describe("stdio static env", () => { Effect.scoped( Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [ memoryCredentialsPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: true }), @@ -1832,6 +1887,7 @@ describe("stdio static env", () => { Effect.scoped( Effect.gen(function* () { const config = makeTestConfig({ + access: testAccess.member(), plugins: [ memoryCredentialsPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: true }), diff --git a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts index 7cac5ef699..ded5601610 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts @@ -29,6 +29,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; import { probeMcpEndpointShape } from "./probe-shape"; +import { testAccess } from "@executor-js/product-access/testing"; const MCP_INITIALIZE_BODY = JSON.stringify({ jsonrpc: "2.0", @@ -176,7 +177,9 @@ const messageFromUnknown = (cause: unknown): string => const runEndpointProbe = (url: string): Effect.Effect => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), + ); return yield* executor.mcp.probeEndpoint(url).pipe( Effect.map( (r) => diff --git a/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts b/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts index 696fb12f5c..9f13fd8be1 100644 --- a/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts +++ b/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts @@ -12,6 +12,7 @@ import { import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; const FILE_ID = "F012ABC3456"; const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); @@ -128,6 +129,7 @@ describe("Slack Connect file fallback", () => { Effect.gen(function* () { const config = { ...makeTestConfig({ + access: testAccess.member(), plugins: [ memoryCredentialsPlugin(), mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }), diff --git a/packages/plugins/onepassword/package.json b/packages/plugins/onepassword/package.json index 5cfeb0e0fa..4e2a5edc0f 100644 --- a/packages/plugins/onepassword/package.json +++ b/packages/plugins/onepassword/package.json @@ -60,6 +60,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/onepassword/src/sdk/plugin.test.ts b/packages/plugins/onepassword/src/sdk/plugin.test.ts index 7cec7de173..5806fbbc53 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.test.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.test.ts @@ -15,6 +15,7 @@ import { import type { OnePasswordService } from "./service"; import { OnePasswordError } from "./errors"; import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types"; +import { testAccess } from "@executor-js/product-access/testing"; // removed: v1 routed configure/removeConfig through an explicit `ScopeId` // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and @@ -63,7 +64,7 @@ describe("onepassword plugin", () => { it.effect("registers onepassword as a credential provider", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), ); const providers = yield* executor.providers.list(); expect(providers).toContain(ONEPASSWORD); @@ -73,7 +74,7 @@ describe("onepassword plugin", () => { it.effect("configure upserts accounts by id and removeConfig removes them one by one", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), ); const initial = yield* executor.onepassword.getConfig(); @@ -124,7 +125,7 @@ describe("onepassword plugin", () => { it.effect("removeConfig without an id removes everything", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), ); yield* executor.onepassword.configure({ name: "Work", @@ -144,7 +145,7 @@ describe("onepassword plugin", () => { it.effect("getConfig redacts every account's service-account token", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), ); yield* executor.onepassword.configure({ @@ -171,7 +172,7 @@ describe("onepassword plugin", () => { it.effect("exposes provider configuration as agent-callable static tools", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), ); const configured = yield* executor.execute( @@ -225,7 +226,7 @@ describe("onepassword plugin", () => { it.effect("status reports not-configured before configure", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [onepasswordPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), ); const status = yield* executor.onepassword.status(); expect(status.connected).toBe(false); @@ -248,6 +249,7 @@ describe("onepassword store", () => { makeInMemoryBlobStore(), { org: "org_test", user: null }, "onepassword", + { owners: ["org"], storageWrites: "allowed" }, ); return { blobs, store: makeOnePasswordStore(blobs) }; }; diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index cfc9c0cd24..ba9299a5c5 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -86,6 +86,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "catalog:", diff --git a/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts b/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts index a5db2edf13..ef7c07df1e 100644 --- a/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +++ b/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts @@ -9,6 +9,7 @@ import type { AuthenticationInput } from "@executor-js/plugin-openapi"; import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; import { googleCatalog } from "./presets"; +import { testAccess } from "@executor-js/product-access/testing"; const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; 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 Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [ openApiPlugin({ httpClientLayer: discoveryHttpClientLayer, @@ -211,6 +213,7 @@ it.effect( ); const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [ openApiPlugin({ httpClientLayer: discoveryHttpClientLayer, @@ -257,6 +260,7 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre ); const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [ openApiPlugin({ httpClientLayer: gmailDiscoveryHttpClientLayer, diff --git a/packages/plugins/openapi/src/sdk/configure.test.ts b/packages/plugins/openapi/src/sdk/configure.test.ts index d8b73f2ac8..f4d360d385 100644 --- a/packages/plugins/openapi/src/sdk/configure.test.ts +++ b/packages/plugins/openapi/src/sdk/configure.test.ts @@ -35,6 +35,7 @@ import { serveOpenApiHttpApiTestServer, unwrapInvocation, } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const testPlugins = (httpClientLayer = FetchHttpClient.layer) => [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; @@ -90,7 +91,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { it.effect("adds a custom apiKey method and getConfig reflects it", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: specText() }, @@ -119,7 +122,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { it.effect("appends to an existing spec-derived template without dropping entries", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const seedTemplate: AuthenticationInput = { slug: "seed", @@ -145,7 +150,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { it.effect("generates a custom_ slug for a method submitted without one", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: specText() }, @@ -167,7 +174,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: specText() }, @@ -208,7 +217,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { it.effect("replace mode overwrites the auth template array", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const seedTemplate: AuthenticationInput = { slug: "seed", @@ -236,7 +247,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: server.specJson }, @@ -272,7 +285,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: server.specJson }, @@ -320,7 +335,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { it.effect("getConfig returns null for an unknown integration", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); expect(yield* executor.openapi.getConfig("nope")).toBeNull(); }), ), diff --git a/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts b/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts index 964e088c11..a2e2f7403e 100644 --- a/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts +++ b/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts @@ -30,6 +30,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; type Captured = { contentType: string; @@ -76,30 +77,28 @@ const plugins = [ memoryCredentialsPlugin(), ] as const; -layer( - makeTestWorkspaceLayer({ - plugins, - }), - { timeout: "15 seconds" }, -)("OpenAPI non-JSON request body serialization", (it) => { - it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => - Effect.gen(function* () { - const { server, captured } = yield* startEchoServer(); - const { config } = yield* TestWorkspace; - const executor = yield* createExecutor({ ...config, plugins }); +layer(makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), { timeout: "15 seconds" })( + "OpenAPI non-JSON request body serialization", + (it) => { + it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer(); + const { config } = yield* TestWorkspace; + const executor = yield* createExecutor({ ...config, plugins }); - const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" }); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" }); - yield* executor.execute(conn.address("forms.submit"), { - body: { name: "Acme", email: "a@b.com" }, - }); + yield* executor.execute(conn.address("forms.submit"), { + body: { name: "Acme", email: "a@b.com" }, + }); - expect(captured.contentType).toBe("application/x-www-form-urlencoded"); - expect(captured.body).not.toBe("[object Object]"); + expect(captured.contentType).toBe("application/x-www-form-urlencoded"); + expect(captured.body).not.toBe("[object Object]"); - const parsed = new URLSearchParams(captured.body); - expect(parsed.get("name")).toBe("Acme"); - expect(parsed.get("email")).toBe("a@b.com"); - }), - ); -}); + const parsed = new URLSearchParams(captured.body); + expect(parsed.get("name")).toBe("Acme"); + expect(parsed.get("email")).toBe("a@b.com"); + }), + ); + }, +); diff --git a/packages/plugins/openapi/src/sdk/health-check-redaction.test.ts b/packages/plugins/openapi/src/sdk/health-check-redaction.test.ts index 6f56a2d211..d300d0f27f 100644 --- a/packages/plugins/openapi/src/sdk/health-check-redaction.test.ts +++ b/packages/plugins/openapi/src/sdk/health-check-redaction.test.ts @@ -34,6 +34,7 @@ import { variable } from "@executor-js/sdk/http-auth"; import { serveOpenApiHttpApiTestServer } from "../testing"; import { openApiPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; /** The credential the connection authenticates with. Longer than the sample's * 120-char value cap on purpose: truncating before scrubbing would leave a @@ -84,6 +85,7 @@ const withProbe = ( }); const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [ openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin(), diff --git a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts index 81b21f2799..29d2f92e5b 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts @@ -37,6 +37,7 @@ import { import { openApiPlugin } from "./plugin"; import { type AuthenticationInput } from "./types"; +import { testAccess } from "@executor-js/product-access/testing"; // --------------------------------------------------------------------------- // Test API — a single endpoint that echoes the Authorization header so the @@ -86,7 +87,9 @@ describe("OpenAPI owner-isolated bearer", () => { Effect.scoped( Effect.gen(function* () { const server = yield* serveVercel(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // Admin uploads the spec once; the integration declares a bearer // template but no token value. diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index d8619e13fe..25b9c7fb82 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -32,6 +32,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; const JsonNameBody = Schema.fromJsonString( Schema.Struct({ @@ -172,7 +173,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" }); @@ -222,7 +225,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" }); const schema = yield* executor.tools.schema(conn.address("body.upload")); @@ -298,7 +303,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" }); const schema = yield* executor.tools.schema(conn.address("body.uploadPages")); @@ -378,7 +385,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" }); const exit = yield* executor @@ -424,7 +433,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" }); // The rewrite is scoped to multipart bodies: a JSON body keeps its @@ -465,7 +476,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" }); yield* executor.execute(conn.address("body.createNote"), { @@ -511,7 +524,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" }); const schema = yield* executor.tools.schema(conn.address("body.uploadMixed")); @@ -564,7 +579,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" }); // Documented limitation: component schemas are carried through @@ -584,7 +601,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "xml" }); @@ -603,7 +622,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tx" }); @@ -622,7 +643,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.String.pipe(HttpApiSchema.asText()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tp" }); @@ -639,7 +662,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin" }); @@ -658,7 +683,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" }); @@ -677,7 +704,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" }); @@ -699,7 +728,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_nested_b64_bad", @@ -723,7 +754,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_missing", @@ -743,7 +776,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_object_body", @@ -767,7 +802,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_string_body", @@ -837,7 +874,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" }); const schema = yield* executor.tools.schema(conn.address("gmail.getAttachment")); @@ -921,7 +960,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" }); const result = yield* executor.execute(conn.address("gmailText.getAttachment"), { @@ -989,7 +1030,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" }); const result = yield* executor.execute(conn.address("zipAttachment.getAttachment"), { @@ -1107,7 +1150,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" }); const result = yield* executor.execute(conn.address("gmailMeta.getAttachment"), { @@ -1156,7 +1201,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); const schema = yield* executor.tools.schema(conn.address("files.download")); @@ -1210,7 +1257,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); const result = yield* executor.execute(conn.address("files.download"), { @@ -1247,7 +1296,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: multiContentPayload, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc" }); @@ -1265,7 +1316,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { payload: multiContentPayload, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc2" }); @@ -1295,7 +1348,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" }); @@ -1322,7 +1377,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" }); @@ -1352,7 +1409,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }, }), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" }); @@ -1375,7 +1434,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { const { server } = yield* startEchoServer({ payload: multiContentPayload, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc3", @@ -1404,7 +1465,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { const { server } = yield* startEchoServer({ payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" }); @@ -1450,7 +1513,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mpe" }); @@ -1492,7 +1557,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fe" }); @@ -1523,7 +1590,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fd" }); @@ -1550,7 +1619,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { ), }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // No encoding → OAS3 defaults: style=form, explode=true. const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" }); diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 1e125e3b98..e33b950eda 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -49,6 +49,7 @@ import { serveOpenApiHttpApiTestServer, unwrapInvocation, } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const TOOL_ERROR_TYPESCRIPT = "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; @@ -393,7 +394,9 @@ describe("OpenAPI Plugin", () => { Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const preview = yield* executor.openapi.previewSpec(server.specJson); @@ -408,7 +411,10 @@ describe("OpenAPI Plugin", () => { Effect.gen(function* () { const server = yield* serveOAuthDiscoverableOpenApiSpec(); const executor = yield* createExecutor( - makeTestConfig({ plugins: testPlugins(server.httpClientLayer) }), + makeTestConfig({ + access: testAccess.member(), + plugins: testPlugins(server.httpClientLayer), + }), ); const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/")); @@ -456,7 +462,9 @@ describe("OpenAPI Plugin", () => { it.effect("exposes static openapi executor control tools via execute", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // v2: static control tools are NOT part of `tools.list()` (that's the // persisted per-connection catalog) and aren't `tools.schema()`-resolvable; @@ -473,7 +481,9 @@ describe("OpenAPI Plugin", () => { it.effect("invokes static previewSpec through executor.execute", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const preview = unwrapInvocation( yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { @@ -495,7 +505,9 @@ describe("OpenAPI Plugin", () => { it.effect("invokes static addSpec through executor.execute", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const result = unwrapInvocation( yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), { @@ -517,7 +529,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "url", url: specServer.specUrl }, @@ -565,7 +579,10 @@ describe("OpenAPI Plugin", () => { it.effect("static previewSpec returns actionable tool failures", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [openApiPlugin()] as const }); + const config = makeTestConfig({ + access: testAccess.member(), + plugins: [openApiPlugin()] as const, + }); const executor = yield* createExecutor(config); const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { @@ -587,7 +604,7 @@ describe("OpenAPI Plugin", () => { it.effect("requires approval before adding an integration through the runtime tool", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: [openApiPlugin()] as const }), + makeTestConfig({ access: testAccess.member(), plugins: [openApiPlugin()] as const }), ); const declined = yield* executor @@ -607,7 +624,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); @@ -625,7 +644,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); @@ -642,7 +663,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); const calls = { count: 0 }; @@ -671,7 +694,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); const calls = { count: 0 }; @@ -705,7 +730,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); const calls = { count: 0 }; @@ -738,7 +765,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); const calls = { count: 0 }; @@ -771,7 +800,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); @@ -812,7 +843,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); @@ -829,7 +862,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); const failure = yield* executor @@ -853,7 +888,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const multiServer = { ...server, specJson: makeOpenApiTestSpecJson(TestApi, { @@ -889,7 +926,9 @@ describe("OpenAPI Plugin", () => { const server = yield* serveOpenApiEchoTestServer({ transformSpec: withEchoCookieParameter, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "cookie_params", headers: { Cookie: "configured=yes" }, @@ -923,7 +962,9 @@ describe("OpenAPI Plugin", () => { const server = yield* serveOpenApiEchoTestServer({ transformSpec: withEchoCookieParameter, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "cookie_unknown", }); @@ -951,7 +992,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" }); @@ -980,7 +1023,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: server.specJson }, @@ -1012,7 +1057,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: server.specJson }, @@ -1044,7 +1091,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // The spec declares bearer auth; the caller passes NO template — the // agentic add path (MCP/API) does exactly this. Without server-side @@ -1097,7 +1146,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // No baseUrl override: the spec declares `servers`, so the host is // resolved per call from the operation's servers rather than baked into @@ -1127,7 +1178,9 @@ describe("OpenAPI Plugin", () => { Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // The add page sends [] when the user deletes every detected method. // That intent must survive — deriving methods back from the spec here @@ -1158,7 +1211,9 @@ describe("OpenAPI Plugin", () => { it.effect("addSpec accepts Graph-sized OpenAPI blobs", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const largeDescription = "x".repeat(36 * 1024 * 1024); const added = yield* executor.openapi.addSpec({ @@ -1191,7 +1246,9 @@ paths: "addSpec accepts Microsoft Graph-scale operation catalogs from one spec", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const added = yield* executor.openapi.addSpec({ spec: { kind: "blob", value: microsoftGraphScaleSpecText() }, @@ -1208,7 +1265,9 @@ paths: Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* addOpenApiTestConnection(executor, server, { slug: "removable" }); expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain( @@ -1232,7 +1291,9 @@ paths: Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // First add carries an apiKey auth template + a distinctive description. // A silent upsert on re-add would clobber both. @@ -1294,7 +1355,9 @@ paths: // The mutable spec server is a real 127.0.0.1 listener — reach it over // the default fetch-based client, like production would. const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const added = yield* executor.openapi.addSpec({ spec: { kind: "url", url: specServer.specUrl }, @@ -1370,7 +1433,7 @@ paths: it.effect("denies member updateSpec before writing an org blob", () => Effect.gen(function* () { const blobs = recordingBlobStore(); - const config = makeTestConfig({ plugins: testPlugins() }); + const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); const admin = yield* createExecutor({ ...config, blobs: blobs.store }); yield* admin.openapi.addSpec({ spec: { kind: "blob", value: testApiSpecText() }, @@ -1380,7 +1443,7 @@ paths: const member = yield* createExecutor({ ...config, blobs: blobs.store, - orgWrites: "denied", + access: testAccess.member("denied"), }); const error = yield* member.openapi @@ -1398,7 +1461,9 @@ paths: Effect.scoped( Effect.gen(function* () { const server = yield* servePluginTestApi(); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* addOpenApiTestConnection(executor, server, { slug: "pasted" }); // A pasted-blob integration has no source URL — re-fetch must say so. @@ -1450,6 +1515,7 @@ paths: const plugins = testPlugins(); const alice = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "alice", @@ -1470,6 +1536,7 @@ paths: }); const bob = yield* makeTestWorkspaceHarness({ + access: testAccess.member(), plugins, tenant, subject: "bob", @@ -1513,7 +1580,9 @@ paths: it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip); expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true); }), diff --git a/packages/plugins/openapi/src/sdk/real-specs.test.ts b/packages/plugins/openapi/src/sdk/real-specs.test.ts index eb0ffa89fc..5eca6100ce 100644 --- a/packages/plugins/openapi/src/sdk/real-specs.test.ts +++ b/packages/plugins/openapi/src/sdk/real-specs.test.ts @@ -28,6 +28,7 @@ import { openApiPlugin } from "./plugin"; import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; import { previewSpec as previewSpecRaw } from "./preview"; import type { ExtractionResult } from "./types"; +import { testAccess } from "@executor-js/product-access/testing"; const previewSpec = (input: string) => previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer)); @@ -91,6 +92,7 @@ const getRegisteredToolSchema = (slug: string, specText: string, toolName: strin const executor = yield* createExecutor( makeTestConfig({ + access: testAccess.member(), plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const, }), ); diff --git a/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts b/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts index 08441afd29..6df795a1f2 100644 --- a/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts +++ b/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts @@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing"; import { openApiPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; const BODY_READ_DEADLINE_MS = 15_000; const RESPONSE_BODY_TIMEOUT_MS = 100; @@ -42,7 +43,9 @@ const testPlugins = () => const buildExecutor = (baseUrl: string) => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec( makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "body_timeout", diff --git a/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts b/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts index b23148ff05..af8c9e57d5 100644 --- a/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts +++ b/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts @@ -18,6 +18,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te import { invokeWithLayer } from "./invoke"; import { openApiPlugin } from "./plugin"; import type { OperationBinding } from "./types"; +import { testAccess } from "@executor-js/product-access/testing"; const RESPONSE_HEADERS_TIMEOUT_MS = 100; const STREAM_TOOL = "logs.getLogs"; @@ -44,7 +45,9 @@ const testPlugins = () => const buildExecutor = (baseUrl: string) => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec( makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }), ); diff --git a/packages/plugins/openapi/src/sdk/spec-blob.test.ts b/packages/plugins/openapi/src/sdk/spec-blob.test.ts index bb1e7e4a1d..be334c5529 100644 --- a/packages/plugins/openapi/src/sdk/spec-blob.test.ts +++ b/packages/plugins/openapi/src/sdk/spec-blob.test.ts @@ -38,6 +38,7 @@ import { serveOpenApiHttpApiTestServer, unwrapInvocation, } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const testPlugins = (httpClientLayer = FetchHttpClient.layer) => [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; @@ -113,7 +114,9 @@ describe("OpenAPI plugin — spec blob storage", () => { it.effect("addSpec stores a content pointer, not the inline spec text", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const text = specText(); yield* executor.openapi.addSpec({ @@ -135,7 +138,9 @@ describe("OpenAPI plugin — spec blob storage", () => { api: TestApi, handlersLayer: EchoGroupLive, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: server.specJson }, @@ -168,7 +173,7 @@ describe("OpenAPI plugin — spec blob storage", () => { () => Effect.scoped( Effect.gen(function* () { - const config = makeTestConfig({ plugins: testPlugins() }); + const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); const executor = yield* createExecutor(config); const text = specTextWithDefinition(); const hash = yield* sha256Hex(text); @@ -245,7 +250,7 @@ describe("OpenAPI plugin — spec blob storage", () => { it.effect("stale sync preserves tools and definitions when the spec blob is missing", () => Effect.scoped( Effect.gen(function* () { - const config = makeTestConfig({ plugins: testPlugins() }); + const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); const executor = yield* createExecutor(config); const text = specTextWithDefinition(); const hash = yield* sha256Hex(text); @@ -318,7 +323,9 @@ describe("OpenAPI plugin — spec blob storage", () => { it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const emptySpec = JSON.stringify({ openapi: "3.1.0", info: { title: "Empty", version: "1.0.0" }, @@ -409,7 +416,9 @@ describe("OpenAPI plugin — spec blob storage", () => { it.effect("remove + re-add of the same spec is idempotent over the shared blob", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const text = specText(); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" }); diff --git a/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts b/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts index 7a0d285755..f0f0f84231 100644 --- a/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts +++ b/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts @@ -9,6 +9,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { openApiPlugin } from "./plugin"; import { applySpecOverrides, type SpecOverrides } from "./spec-overrides"; import { serveMutableOpenApiSpecTestServer } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const testPlugins = () => [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; @@ -60,7 +61,9 @@ describe("OpenAPI spec override lifecycle", () => { it.effect("applies scope overrides to preview and persists raw and patched hashes", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const sourceText = encodeJsonText(oauthSpec); const preview = yield* executor.openapi.previewSpec({ @@ -93,7 +96,9 @@ describe("OpenAPI spec override lifecycle", () => { it.effect("changes and clears overrides for an inline spec without repasting the source", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const sourceText = encodeJsonText(oauthSpec); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: sourceText }, @@ -130,7 +135,9 @@ describe("OpenAPI spec override lifecycle", () => { .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String })); const InitialApi = HttpApi.make("initial").add(Items); const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }]; yield* executor.openapi.addSpec({ diff --git a/packages/plugins/openapi/src/sdk/streaming-response.test.ts b/packages/plugins/openapi/src/sdk/streaming-response.test.ts index 54a2c5667a..8fc36d492d 100644 --- a/packages/plugins/openapi/src/sdk/streaming-response.test.ts +++ b/packages/plugins/openapi/src/sdk/streaming-response.test.ts @@ -17,6 +17,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke"; import { openApiPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; const testPlugins = () => [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; @@ -35,7 +36,9 @@ const StreamingApi = HttpApi.make("streamingResponseTest") const buildExecutor = (baseUrl: string) => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec( makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }), ); @@ -293,7 +296,9 @@ describe("OpenAPI streaming responses", () => { ); }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", diff --git a/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts b/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts index aeb9398746..1f55b52eb2 100644 --- a/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts +++ b/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts @@ -25,6 +25,7 @@ import { variable } from "@executor-js/sdk/http-auth"; import { openApiPlugin } from "./plugin"; import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing"; +import { testAccess } from "@executor-js/product-access/testing"; const testPlugins = (httpClientLayer = FetchHttpClient.layer) => [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; @@ -58,7 +59,9 @@ const setup = Effect.gen(function* () { api: TestApi, handlersLayer: EchoGroupLive, }); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: server.specJson }, slug: "proj_api", diff --git a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts index 1f52b30c50..25aedba989 100644 --- a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts +++ b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts @@ -48,6 +48,7 @@ import { } from "../testing"; import { openApiPlugin } from "./plugin"; +import { testAccess } from "@executor-js/product-access/testing"; class AdapterDefect extends Data.TaggedError("AdapterDefect") {} @@ -149,7 +150,7 @@ const FailureApi = HttpApi.make("failuresTest") const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ plugins: testPlugins(httpClientLayer) }), + makeTestConfig({ access: testAccess.member(), plugins: testPlugins(httpClientLayer) }), ); yield* executor.openapi.addSpec( makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }), @@ -167,7 +168,9 @@ const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" }); return { executor, address: conn.address(LIST_THINGS) }; }); @@ -349,7 +352,9 @@ describe("OpenAPI upstream failure modes", () => { headers: { "content-type": "application/json" }, body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}', })); - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), + ); // Declare the operation's scope in the spec blob before registering it, // so the extracted binding carries requiredScopes. const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); diff --git a/packages/plugins/toolkits/package.json b/packages/plugins/toolkits/package.json index 72a85a9c75..98d27fe086 100644 --- a/packages/plugins/toolkits/package.json +++ b/packages/plugins/toolkits/package.json @@ -56,6 +56,7 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", "@types/node": "catalog:", diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e9..722b539b4f 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -3,11 +3,13 @@ import { Effect, Predicate, Result } from "effect"; import { makeTestExecutor } from "@executor-js/sdk/testing"; import { toolkitsPlugin } from "./server"; +import { testAccess } from "@executor-js/product-access/testing"; describe("toolkitsPlugin", () => { it.effect("creates toolkits and manages ordered policy rules", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [toolkitsPlugin()] as const, }); @@ -51,6 +53,7 @@ describe("toolkitsPlugin", () => { it.effect("rejects duplicate visible slugs", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [toolkitsPlugin()] as const, }); yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" }); @@ -67,6 +70,7 @@ describe("toolkitsPlugin", () => { it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [toolkitsPlugin()] as const, }); @@ -135,6 +139,7 @@ describe("toolkitsPlugin", () => { it.effect("treats a persisted connection-root approve as an access policy", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [toolkitsPlugin()] as const, }); @@ -169,6 +174,7 @@ describe("toolkitsPlugin", () => { it.effect("applies a broad approve policy over a narrower connection", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ + access: testAccess.member(), plugins: [toolkitsPlugin()] as const, }); diff --git a/packages/react/package.json b/packages/react/package.json index 916b0cf410..e8868a6b57 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -36,6 +36,7 @@ "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/product-access": "workspace:*", "@executor-js/sdk": "workspace:*", "@hookform/resolvers": "^5.2.2", "@lobehub/icons": "^5.4.0", diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index 5f90c2efb6..7621161159 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -10,10 +10,10 @@ import { AuthTemplateSlug, IntegrationSlug, ToolAddress, - effectivePolicyFromSorted, type Connection, type Owner, } from "@executor-js/sdk/shared"; +import { effectivePolicyFromSorted } from "@executor-js/product-access/policy"; import { checkConnectionHealth, connectionsAllAtom, diff --git a/packages/react/src/pages/tools.tsx b/packages/react/src/pages/tools.tsx index 60e6e0fa9a..0162fd0698 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; import { Link } from "@tanstack/react-router"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared"; +import { ToolAddress } from "@executor-js/sdk/shared"; +import { effectivePolicyFromSorted } from "@executor-js/product-access/policy"; import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; import { usePolicyActions } from "../hooks/use-policy-actions"; diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index 62034b988c..5eb8227470 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -31,6 +31,7 @@ const PUBLIC_PACKAGE_DIRS = [ "packages/kernel/core", "packages/kernel/runtime-quickjs", "packages/core/sdk", + "packages/core/product-access", "packages/core/config", "packages/core/execution", "packages/core/cli", diff --git a/scripts/smoke-docs-install.ts b/scripts/smoke-docs-install.ts index 0a74fcd9a2..38a451feb1 100644 --- a/scripts/smoke-docs-install.ts +++ b/scripts/smoke-docs-install.ts @@ -7,6 +7,7 @@ * name (`@executor-js/sdk`) instead of relying on workspace resolution. */ import { $ } from "bun"; +import { Schema } from "effect"; import { existsSync, readdirSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -14,10 +15,15 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const documentedPackages = ["@executor-js/sdk", "@executor-js/plugin-openapi"] as const; +const documentedPackages = [ + "@executor-js/sdk", + "@executor-js/product-access", + "@executor-js/plugin-openapi", +] as const; const publicPackageDirs = [ "packages/core/fumadb", "packages/core/sdk", + "packages/core/product-access", "packages/core/config", "packages/plugins/openapi", ] as const; @@ -27,6 +33,21 @@ const readPackageName = async (pkgDir: string): Promise => { return (JSON.parse(raw) as { name: string }).name; }; +// `effect` is the SDK's (and product-access's) required peer, and the docs +// install command tells consumers to add it themselves. `npm install +// --legacy-peer-deps` below skips auto-installing peers, so the fixture must +// declare it like a real consumer would — pinned from the workspace catalog +// so it matches what the packed manifests resolved `catalog:` to. +const decodeCatalog = Schema.decodeUnknownSync( + Schema.Struct({ catalog: Schema.Struct({ effect: Schema.NonEmptyString }) }), +); + +const readCatalogEffectVersion = async (): Promise => { + const raw = await readFile(join(repoRoot, "package.json"), "utf8"); + const manifest = decodeCatalog(JSON.parse(raw)); + return manifest.catalog.effect; +}; + const findTarball = (pkgDir: string, packageName: string): string => { const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz")); if (!tarball) { @@ -48,7 +69,7 @@ for (const relDir of publicPackageDirs) { const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-")); try { - const dependencies: Record = {}; + const dependencies: Record = { effect: await readCatalogEffectVersion() }; const overrides: Record = {}; for (const [name, tarball] of tarballs) { overrides[name] = `file:${tarball}`; @@ -89,9 +110,23 @@ try { } console.log(`[docs-smoke] import documented SDK packages`); - 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( - tmp, - ); + // The documented minimal embed: the SDK root export is the Promise façade + // and ships no access posture of its own, so the probe constructs a + // subject-less workspace-service executor with the posture from + // `@executor-js/product-access` (ephemeral in-memory backend, nothing + // invoked over the network) and closes it. + const importProbe = [ + `const sdk = await import("@executor-js/sdk");`, + `const access = await import("@executor-js/product-access");`, + `const openapi = await import("@executor-js/plugin-openapi");`, + `if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor");`, + `if (typeof access.workspaceServiceAccess !== "function") throw new Error("missing workspaceServiceAccess");`, + `if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`, + `const executor = await sdk.createExecutor({ access: access.workspaceServiceAccess(), onElicitation: "accept-all" });`, + `if (typeof executor.close !== "function") throw new Error("missing executor.close");`, + `await executor.close();`, + ].join("\n"); + await $`node --input-type=module --eval ${importProbe}`.cwd(tmp); } finally { await rm(tmp, { recursive: true, force: true }); } diff --git a/scripts/smoke-test-packed.ts b/scripts/smoke-test-packed.ts index 4273e2c4e3..483f72be41 100644 --- a/scripts/smoke-test-packed.ts +++ b/scripts/smoke-test-packed.ts @@ -37,6 +37,7 @@ const PUBLIC_PACKAGE_DIRS = [ "packages/kernel/core", "packages/kernel/runtime-quickjs", "packages/core/sdk", + "packages/core/product-access", "packages/core/config", "packages/core/execution", "packages/core/cli",