History

Move product access rules out of the SDK

Rhys Sullivan committed 1 month ago

Commit c676e561 parent a72e51d161 files · +3935 −867Browse files

Unified · Split

161 files
  1. .changeset/config.json +1 −0
  2. .changeset/product-access-split.md +21 −0
  3. .github/workflows/pkg-pr-new.yml +1 −0
  4. README.md +12 −4
  5. apps/cloud/package.json +1 −0
  6. apps/cloud/src/admin/admin-users-api.ts +1 −1
  7. apps/cloud/src/auth/workos-auth-provider.ts +1 −1
  8. apps/cloud/src/mcp-session.e2e.node.test.ts +2 −0
  9. apps/cloud/src/mcp/session-durable-object.ts +2 −1
  10. apps/cloud/src/observability/oauth-callback-telemetry.test.ts +2 −1
  11. apps/host-cloudflare/package.json +1 −0
  12. apps/host-cloudflare/src/mcp/session-durable-object.ts +2 −1
  13. apps/host-selfhost/package.json +1 −0
  14. apps/host-selfhost/src/integrations-mcp.test.ts +4 −3
  15. apps/host-selfhost/src/integrations.test.ts +4 −3
  16. apps/host-selfhost/src/secrets-integration.test.ts +4 −3
  17. apps/host-selfhost/src/subject-sightings.test.ts +4 −3
  18. apps/local/package.json +1 −0
  19. apps/local/src/auth-tool-failures.test.ts +2 −0
  20. apps/local/src/db/legacy-bigint-boot.test.ts +12 −4
  21. apps/local/src/db/v1-v2-boot-drive.test.ts +2 −0
  22. apps/local/src/executor.ts +4 −0
  23. apps/local/src/mcp-browser-resume.test.ts +2 −0
  24. apps/local/src/mcp-oauth.test.ts +2 −0
  25. bun.lock +43 −0
  26. examples/all-plugins/package.json +1 −0
  27. examples/all-plugins/src/main.ts +3 −0
  28. examples/docs-sdk-quickstart/package.json +1 −0
  29. examples/docs-sdk-quickstart/src/main.ts +4 −0
  30. examples/promise-sdk/package.json +1 −0
  31. examples/promise-sdk/src/main.ts +4 −0
  32. package.json +2 −1
  33. packages/core/api/package.json +1 −0
  34. packages/core/api/src/admin/admin-users.test.ts +4 −2
  35. packages/core/api/src/admin/api.ts +1 −1
  36. packages/core/api/src/admin/reads.ts +1 −1
  37. packages/core/api/src/integrations/integrations-auth-methods.test.ts +13 −4
  38. packages/core/api/src/scoped-targets.test.ts +5 −1
  39. packages/core/api/src/server/execution-stack-middleware.ts +4 −4
  40. packages/core/api/src/server/execution-stack.ts +9 −8
  41. packages/core/api/src/server/mcp-build.ts +5 −1
  42. packages/core/api/src/server/scoped-executor.ts +12 −9
  43. packages/core/execution/README.md +2 −0
  44. packages/core/execution/package.json +1 −0
  45. packages/core/execution/src/description.test.ts +19 −5
  46. packages/core/execution/src/engine.test.ts +7 −3
  47. packages/core/execution/src/tool-invoker.leak.test.ts +19 −6
  48. packages/core/execution/src/tool-invoker.test.ts +5 −1
  49. packages/core/product-access/CHANGELOG.md +1 −0
  50. packages/core/product-access/README.md +79 −0
  51. packages/core/product-access/package.json +67 −0
  52. packages/core/product-access/src/index.test.ts +185 −0
  53. packages/core/product-access/src/index.ts +159 −0
  54. packages/core/product-access/src/policy.ts +263 −0
  55. packages/core/product-access/src/testing.ts +54 −0
  56. packages/core/product-access/tsconfig.json +27 −0
  57. packages/core/product-access/tsup.config.ts +14 −0
  58. packages/core/product-access/vitest.config.ts +7 −0
  59. packages/core/sdk/README.md +14 −3
  60. packages/core/sdk/src/access.test.ts +370 −0
  61. packages/core/sdk/src/access.ts +215 −0
  62. packages/core/sdk/src/approval-access-recheck.test.ts +138 −0
  63. packages/core/sdk/src/approval-plugin-swap.test.ts +90 −0
  64. packages/core/sdk/src/artifact-table.test.ts +10 −2
  65. packages/core/sdk/src/artifacts.test.ts +24 −23
  66. packages/core/sdk/src/blob.test.ts +163 −14
  67. packages/core/sdk/src/blob.ts +77 −27
  68. packages/core/sdk/src/connections.test.ts +85 −22
  69. packages/core/sdk/src/core-schema.ts +17 −1
  70. packages/core/sdk/src/errors.ts +3 −4
  71. packages/core/sdk/src/executor.test.ts +44 −7
  72. packages/core/sdk/src/executor.ts +276 −220
  73. packages/core/sdk/src/index.ts +12 −3
  74. packages/core/sdk/src/integration-removal-cascade.test.ts +9 −3
  75. packages/core/sdk/src/oauth-ema-lifecycle.test.ts +25 −6
  76. packages/core/sdk/src/oauth-ema-rollout.test.ts +2 −0
  77. packages/core/sdk/src/oauth-first-party.test.ts +15 −1
  78. packages/core/sdk/src/oauth-flow.test.ts +116 −30
  79. packages/core/sdk/src/oauth-list-clients.test.ts +19 −4
  80. packages/core/sdk/src/oauth-register-dynamic.test.ts +62 −16
  81. packages/core/sdk/src/oauth-remove-client.test.ts +24 −3
  82. packages/core/sdk/src/oauth-scope-union.test.ts +35 −9
  83. packages/core/sdk/src/oauth-service.ts +3 −3
  84. packages/core/sdk/src/oauth-session-cleanup.test.ts +6 −0
  85. packages/core/sdk/src/org-write-access.ts +0 −33
  86. packages/core/sdk/src/org-writes.test.ts +22 −12
  87. packages/core/sdk/src/owner-policy-reach.test.ts +24 −3
  88. packages/core/sdk/src/owner-policy.ts +60 −11
  89. packages/core/sdk/src/platform-view.test.ts +7 −1
  90. packages/core/sdk/src/plugin-after-commit.test.ts +5 −3
  91. packages/core/sdk/src/plugin-storage.test.ts +9 −0
  92. packages/core/sdk/src/plugin.ts +3 −3
  93. packages/core/sdk/src/policies.test.ts +12 −8
  94. packages/core/sdk/src/policies.ts +15 −112
  95. packages/core/sdk/src/promise-executor.ts +11 −0
  96. packages/core/sdk/src/promise.test.ts +5 −0
  97. packages/core/sdk/src/provider-call-timeout.test.ts +10 −3
  98. packages/core/sdk/src/settings-authority.test.ts +118 −0
  99. packages/core/sdk/src/shared.ts +2 −1
  100. packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts +20 −4
  101. packages/core/sdk/src/subject-registry.test.ts +6 −0
  102. packages/core/sdk/src/subject-registry.ts +3 −0
  103. packages/core/sdk/src/test-config.ts +18 −9
  104. packages/core/sdk/src/testing.test.ts +5 −1
  105. packages/core/sdk/src/tool-not-found-empty-catalog.test.ts +4 −1
  106. packages/core/sdk/src/tools-list-merge.test.ts +5 −3
  107. packages/hosts/cloudflare/src/blob-store.test.ts +4 −1
  108. packages/hosts/mcp-apps-shell/package.json +1 −0
  109. packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +5 −1
  110. packages/hosts/mcp/package.json +1 −0
  111. packages/hosts/mcp/src/in-memory-session-store.test.ts +19 −5
  112. packages/hosts/mcp/src/seams.ts +5 −2
  113. packages/kernel/runtime-dynamic-worker/package.json +1 −0
  114. packages/kernel/runtime-dynamic-worker/src/integration.test.ts +2 −0
  115. packages/plugins/desktop-settings/package.json +1 −0
  116. packages/plugins/desktop-settings/src/server.test.ts +2 −0
  117. packages/plugins/file-secrets/package.json +1 −0
  118. packages/plugins/file-secrets/src/data-dir.test.ts +11 −2
  119. packages/plugins/file-secrets/src/index.test.ts +8 −1
  120. packages/plugins/graphql/package.json +1 −0
  121. packages/plugins/graphql/src/sdk/configure.test.ts +2 −0
  122. packages/plugins/graphql/src/sdk/invocation-timeout.test.ts +2 −0
  123. packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts +5 −1
  124. packages/plugins/graphql/src/sdk/plugin.test.ts +12 −3
  125. packages/plugins/keychain/package.json +1 −0
  126. packages/plugins/keychain/src/index.test.ts +4 −3
  127. packages/plugins/mcp/package.json +1 −0
  128. packages/plugins/mcp/src/sdk/catalog-sync.test.ts +9 −2
  129. packages/plugins/mcp/src/sdk/elicitation.test.ts +10 −2
  130. packages/plugins/mcp/src/sdk/image-content.test.ts +5 −1
  131. packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts +17 −4
  132. packages/plugins/mcp/src/sdk/owner-isolation.test.ts +5 −1
  133. packages/plugins/mcp/src/sdk/plugin.test.ts +80 −24
  134. packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts +4 −1
  135. packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts +2 −0
  136. packages/plugins/onepassword/package.json +1 −0
  137. packages/plugins/onepassword/src/sdk/plugin.test.ts +8 −6
  138. packages/plugins/openapi/package.json +1 −0
  139. packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +4 −0
  140. packages/plugins/openapi/src/sdk/configure.test.ts +25 −8
  141. packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts +22 −23
  142. packages/plugins/openapi/src/sdk/health-check-redaction.test.ts +2 −0
  143. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +4 −1
  144. packages/plugins/openapi/src/sdk/non-json-body.test.ts +106 −35
  145. packages/plugins/openapi/src/sdk/plugin.test.ts +104 −35
  146. packages/plugins/openapi/src/sdk/real-specs.test.ts +2 −0
  147. packages/plugins/openapi/src/sdk/response-body-timeout.test.ts +4 −1
  148. packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts +4 −1
  149. packages/plugins/openapi/src/sdk/spec-blob.test.ts +15 −6
  150. packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts +10 −3
  151. packages/plugins/openapi/src/sdk/streaming-response.test.ts +7 −2
  152. packages/plugins/openapi/src/sdk/tool-row-projection.test.ts +4 −1
  153. packages/plugins/openapi/src/sdk/upstream-failures.test.ts +8 −3
  154. packages/plugins/toolkits/package.json +1 −0
  155. packages/plugins/toolkits/src/server.test.ts +6 −0
  156. packages/react/package.json +1 −0
  157. packages/react/src/pages/integration-detail.tsx +1 −1
  158. packages/react/src/pages/tools.tsx +2 −1
  159. scripts/publish-packages.ts +1 −0
  160. scripts/smoke-docs-install.ts +40 −5
  161. scripts/smoke-test-packed.ts +1 −0
.changeset/config.json+1−0
@@ -5,6 +5,7 @@ · 4 unmodified lines
55 "fixed": [
66 [
77 "@executor-js/sdk",
8+ "@executor-js/product-access",
89 "@executor-js/codemode-core",
910 "@executor-js/runtime-quickjs",
1011 "@executor-js/execution",
.changeset/product-access-split.md+21−0
@@ -0,0 +1,21 @@
1+---
2+"executor": minor
3+---
4+
5+Extract personal/organization access rules from the core SDK into the new
6+`@executor-js/product-access` package. BREAKING for SDK embedders:
7+`createExecutor` (Effect and Promise APIs alike) now REQUIRES
8+`access: ExecutorAccess` — the product's decisions for row visibility and
9+write partitions (`owners`), user-intent settings authorization
10+(`settingsWrite`), view capabilities (`adminReads` / `storageWrites`), and
11+effective tool-policy evaluation (`toolPolicy`). The former `orgWrites` and
12+`platformView` options are removed; select a posture from
13+`@executor-js/product-access` (`singleUserAccess()`,
14+`workspaceServiceAccess()`, `memberAccessForRole(...)`,
15+`requestBoundMemberAccess()`, `platformObserverAccess()`) or supply your own.
16+Core keeps enforcing tenant isolation, the storage owner policy clamps, and
17+approval mechanics; it no longer decides any product rule. Test helpers
18+changed too: `makeTestConfig` and the workspace harness require `access`
19+(postures in `@executor-js/product-access/testing`). No stored schema or data
20+migration — persisted rows, tenants, owners, subjects and addresses are
21+unchanged.
.github/workflows/pkg-pr-new.yml+1−0
@@ -149,6 +149,7 @@ jobs: · 148 unmodified lines
149149 './apps/cli/dist/executor'
150150 './packages/core/storage-core'
151151 './packages/core/sdk'
152+ './packages/core/product-access'
152153 './packages/core/config'
153154 './packages/core/execution'
154155 './packages/core/cli'
README.md+12−4
@@ -139,10 +139,18 @@ Embed Executor with the TypeScript SDK (a Promise API; an Effect-native API is · 138 unmodified lines
139139 also available):
140140
141141 ```ts
142−import { createExecutor } from "@executor-js/sdk/promise";
143−import { openApiPlugin } from "@executor-js/plugin-openapi/promise";
144−
145−const executor = await createExecutor({ plugins: [openApiPlugin()] });
142+import { workspaceServiceAccess } from "@executor-js/product-access";
143+import { createExecutor } from "@executor-js/sdk";
144+import { openApiPlugin } from "@executor-js/plugin-openapi";
145+
146+const executor = await createExecutor({
147+ plugins: [openApiPlugin()],
148+ onElicitation: "accept-all",
149+ // Product access decisions are REQUIRED and stated explicitly — the SDK
150+ // ships no default posture. `@executor-js/product-access` has the standard
151+ // ones (this is the subject-less single-workspace posture).
152+ access: workspaceServiceAccess(),
153+});
146154
147155 // add an integration, create a connection, then list and call tools
148156 const tools = await executor.tools.list({ integration: "inventory" });
apps/cloud/package.json+1−0
@@ -55,6 +55,7 @@ · 54 unmodified lines
5555 "@executor-js/plugin-openapi": "workspace:*",
5656 "@executor-js/plugin-toolkits": "workspace:*",
5757 "@executor-js/plugin-workos-vault": "workspace:*",
58+ "@executor-js/product-access": "workspace:*",
5859 "@executor-js/react": "workspace:*",
5960 "@executor-js/runtime-dynamic-worker": "workspace:*",
6061 "@executor-js/runtime-quickjs": "workspace:*",
apps/cloud/src/admin/admin-users-api.ts+1−1
@@ -17,7 +17,7 @@ · 16 unmodified lines
1717 // acting member, and this plane deliberately serves the whole tenant.
1818 //
1919 // The executor is built by `makePlatformExecutor` — `{ tenant, subject:
20−// undefined, platformView: true }` — so the reads are tenant-wide and read-only
20+// undefined, access: platformObserverAccess() }` — so the reads are tenant-wide and read-only
2121 // by storage policy, and no `subject` row is minted for the caller.
2222 //
2323 // Cross-tenant isolation is structural, not a check in this file: the tenant is
apps/cloud/src/auth/workos-auth-provider.ts+1−1
@@ -174,7 +174,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 173 unmodified lines
174174 * `subject: null` + the read-only tenant reach rather than inventing a subject.
175175 *
176176 * The `/admin/*` mount turns this into an executor with `{ tenant:
177− * organizationId, subject: undefined, platformView: true }`.
177+ * organizationId, subject: undefined, access: platformObserverAccess() }`.
178178 */
179179 export interface PlatformAuth {
180180 readonly kind: "platform";
apps/cloud/src/mcp-session.e2e.node.test.ts+2−0
@@ -39,6 +39,7 @@ import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/test · 38 unmodified lines
3939 import executorConfig from "../executor.config";
4040 import { DbService } from "./db/db";
4141 import { createDrizzleFumaDb } from "./db/fuma";
42+import { testAccess } from "@executor-js/product-access/testing";
4243
4344 // ---------------------------------------------------------------------------
4445 // Test-only plugin: exposes one in-memory tool that elicits once. Lets the
@@ -124,6 +125,7 @@ const buildScopedExecutor = ( · 79 unmodified lines
124125 plugins,
125126 httpClientLayer: FetchHttpClient.layer,
126127 onElicitation: "accept-all",
128+ access: testAccess.member(),
127129 });
128130 });
129131
apps/cloud/src/mcp/session-durable-object.ts+2−1
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
2626 } from "@executor-js/host-mcp/tool-server";
2727 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
2828 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
29+import { requestBoundMemberAccess } from "@executor-js/product-access";
2930 import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
3031 import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
3132 import {
@@ -349,7 +350,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 317 unmodified lines
349350 sessionMeta.organizationName,
350351 {
351352 mcpResource: sessionMeta.resource,
352− orgWrites: "request",
353+ access: requestBoundMemberAccess(),
353354 },
354355 ).pipe(
355356 // The metered stack tracks each execution to Autumn. It requires
apps/cloud/src/observability/oauth-callback-telemetry.test.ts+2−1
@@ -32,6 +32,7 @@ import { createExecutor } from "@executor-js/sdk"; · 31 unmodified lines
3232 import { makeTestConfig } from "@executor-js/sdk/testing";
3333
3434 import { UrlRedactingSpanProcessor } from "./redact-span-urls";
35+import { testAccess } from "@executor-js/product-access/testing";
3536
3637 // Synthetic placeholders — never a real authorization code or CSRF state.
3738 const CODE = "synthetic-authorization-code-9f2c";
@@ -55,7 +56,7 @@ describe("oauth callback telemetry", () => { · 17 unmodified lines
5556 it.effect("exports no span attribute containing the authorization code or state", () =>
5657 Effect.gen(function* () {
5758 const { exporter, provider, tracerLayer } = makeTracing();
58− const executor = yield* createExecutor(makeTestConfig({}));
59+ const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() }));
5960
6061 const web = yield* Effect.acquireRelease(
6162 Effect.sync(() =>
apps/host-cloudflare/package.json+1−0
@@ -30,6 +30,7 @@ · 29 unmodified lines
3030 "@executor-js/plugin-openapi": "workspace:*",
3131 "@executor-js/plugin-provider-service-split": "workspace:*",
3232 "@executor-js/plugin-toolkits": "workspace:*",
33+ "@executor-js/product-access": "workspace:*",
3334 "@executor-js/react": "workspace:*",
3435 "@executor-js/runtime-dynamic-worker": "workspace:*",
3536 "@executor-js/runtime-quickjs": "workspace:*",
apps/host-cloudflare/src/mcp/session-durable-object.ts+2−1
@@ -9,6 +9,7 @@ import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; · 8 unmodified lines
99 import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
1010 import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
1111 import type { ExecutorDbHandle } from "@executor-js/api/server";
12+import { requestBoundMemberAccess } from "@executor-js/product-access";
1213 import {
1314 McpAgentSessionDOBase,
1415 type BuiltMcpServer,
@@ -151,7 +152,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 136 unmodified lines
151152 sessionMeta.userId,
152153 sessionMeta.organizationId,
153154 sessionMeta.organizationName,
154− { mcpResource: sessionMeta.resource, orgWrites: "request" },
155+ { mcpResource: sessionMeta.resource, access: requestBoundMemberAccess() },
155156 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
156157 // Browser elicitation mode (the base owns the approval store + the HTTP
157158 // approval RPCs): a gated execution pauses and returns an approvalUrl into
apps/host-selfhost/package.json+1−0
@@ -35,6 +35,7 @@ · 34 unmodified lines
3535 "@executor-js/plugin-openapi": "workspace:*",
3636 "@executor-js/plugin-provider-service-split": "workspace:*",
3737 "@executor-js/plugin-toolkits": "workspace:*",
38+ "@executor-js/product-access": "workspace:*",
3839 "@executor-js/react": "workspace:*",
3940 "@executor-js/runtime-quickjs": "workspace:*",
4041 "@executor-js/sdk": "workspace:*",
apps/host-selfhost/src/integrations-mcp.test.ts+4−3
@@ -12,6 +12,7 @@ import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; · 11 unmodified lines
1212 import { mintInviteCode } from "./testing/mint-invite";
1313 import { SelfHostScopedExecutorSeams } from "./execution";
1414 import type { SelfHostPlugins } from "./plugins";
15+import { memberAccess } from "@executor-js/product-access";
1516
1617 // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,
1718 // fresh per-request plugins, host config) over the shared `makeScopedExecutor`,
@@ -22,9 +23,9 @@ const createScopedExecutor = ( · 4 unmodified lines
2223 organizationId: string,
2324 organizationName: string,
2425 ) =>
25− makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(
26− Effect.provide(SelfHostScopedExecutorSeams),
27− );
26+ makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
27+ access: memberAccess("allowed"),
28+ }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
2829
2930 // End-to-end: an org-owned connection's tools are reachable from a user's MCP
3031 // `execute` sandbox.
apps/host-selfhost/src/integrations.test.ts+4−3
@@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 10 unmodified lines
1111 import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
1212 import { SelfHostScopedExecutorSeams } from "./execution";
1313 import type { SelfHostPlugins } from "./plugins";
14+import { memberAccess } from "@executor-js/product-access";
1415
1516 // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,
1617 // fresh per-request plugins, host config) over the shared `makeScopedExecutor`,
@@ -21,9 +22,9 @@ const createScopedExecutor = ( · 4 unmodified lines
2122 organizationId: string,
2223 organizationName: string,
2324 ) =>
24− makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(
25− Effect.provide(SelfHostScopedExecutorSeams),
26− );
25+ makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
26+ access: memberAccess("allowed"),
27+ }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
2728
2829 const dataDir = mkdtempSync(join(tmpdir(), "eh-src-"));
2930 process.env.EXECUTOR_DATA_DIR = dataDir;
apps/host-selfhost/src/secrets-integration.test.ts+4−3
@@ -12,6 +12,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 11 unmodified lines
1212 import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
1313 import { SelfHostScopedExecutorSeams } from "./execution";
1414 import type { SelfHostPlugins } from "./plugins";
15+import { memberAccess } from "@executor-js/product-access";
1516
1617 // In v2 a connection IS the credential: its inline `value` is written through the
1718 // default writable provider — here the encrypted-secrets provider, which stores
@@ -28,9 +29,9 @@ const createScopedExecutor = ( · 10 unmodified lines
2829 organizationId: string,
2930 organizationName: string,
3031 ) =>
31− makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(
32− Effect.provide(SelfHostScopedExecutorSeams),
33− );
32+ makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
33+ access: memberAccess("allowed"),
34+ }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
3435
3536 let dbLayer!: Layer.Layer<SelfHostDb>;
3637 let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;
apps/host-selfhost/src/subject-sightings.test.ts+4−3
@@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 10 unmodified lines
1111 import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
1212 import { SelfHostScopedExecutorSeams } from "./execution";
1313 import type { SelfHostPlugins } from "./plugins";
14+import { memberAccess } from "@executor-js/product-access";
1415
1516 // The `subject` table is populated at the request seam: `makeScopedExecutor` is
1617 // what every HTTP request and MCP session on every host passes through, so
@@ -19,9 +20,9 @@ import type { SelfHostPlugins } from "./plugins"; · 2 unmodified lines
1920 // the point is that the production wiring reaches the writer at all.
2021
2122 const createScopedExecutor = (accountId: string, organizationId: string) =>
22− makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe(
23− Effect.provide(SelfHostScopedExecutorSeams),
24− );
23+ makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default", {
24+ access: memberAccess("allowed"),
25+ }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
2526
2627 const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-"));
2728 process.env.EXECUTOR_DATA_DIR = dataDir;
apps/local/package.json+1−0
@@ -41,6 +41,7 @@ · 40 unmodified lines
4141 "@executor-js/plugin-openapi": "workspace:*",
4242 "@executor-js/plugin-provider-service-split": "workspace:*",
4343 "@executor-js/plugin-toolkits": "workspace:*",
44+ "@executor-js/product-access": "workspace:*",
4445 "@executor-js/react": "workspace:*",
4546 "@executor-js/runtime-quickjs": "workspace:*",
4647 "@executor-js/sdk": "workspace:*",
apps/local/src/auth-tool-failures.test.ts+2−0
@@ -61,6 +61,7 @@ import { · 60 unmodified lines
6161 createExecutor,
6262 } from "@executor-js/sdk";
6363 import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";
64+import { testAccess } from "@executor-js/product-access/testing";
6465
6566 import { ErrorCaptureLive } from "./observability";
6667 import { createSqliteFumaDb } from "./db/sqlite-fumadb";
@@ -109,6 +110,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 42 unmodified lines
109110 db: sqlite.db,
110111 plugins,
111112 onElicitation: "accept-all",
113+ access: testAccess.member(),
112114 }),
113115 );
114116
apps/local/src/db/legacy-bigint-boot.test.ts+12−4
@@ -17,13 +17,21 @@ import { Effect } from "effect"; · 16 unmodified lines
1717 import { withQueryContext } from "@executor-js/fumadb/query";
1818
1919 import { collectTables } from "@executor-js/api/server";
20−import { runSqliteDataMigrations } from "@executor-js/sdk";
20+import { runSqliteDataMigrations, type ExecutorOwnerPolicyContext } from "@executor-js/sdk";
2121
2222 import { localDataMigrations } from "./data-migrations";
2323 import { createSqliteFumaDb } from "./sqlite-fumadb";
2424
2525 const TENANT = "executor-workspace-1771";
2626 const SUBJECT = "local";
27+// The fixture's inspection context: the local single-user binding's full
28+// identity view, with its owner partitions stated explicitly — the owner
29+// policy has no full-view default, and the seeded row is a `user` row.
30+const FIXTURE_CONTEXT: ExecutorOwnerPolicyContext = {
31+ tenant: TENANT,
32+ subject: SUBJECT,
33+ owners: ["user", "org"],
34+};
2735 // Epoch millis, the shape an OAuth token expiry takes.
2836 const LEGACY_EXPIRES_AT = 1787321623456;
2937
@@ -71,7 +79,7 @@ describe("local boot over a legacy bigint database", () => { · 41 unmodified lines
7179 await seedLegacyConnection(dbPath);
7280
7381 const sqlite = await openDb(dbPath);
74− const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
82+ const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT);
7583 // The reported symptom: not a wrong value, a throw — so the gateway lost
7684 // every saved integration at once.
7785 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
@@ -88,7 +96,7 @@ describe("local boot over a legacy bigint database", () => { · 10 unmodified lines
8896 );
8997 expect(applied).toContain("2026-08-28-bigint-storage-class");
9098
91− const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
99+ const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT);
92100 const rows = await scoped.findMany("connection", {});
93101 expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([
94102 ["default", LEGACY_EXPIRES_AT],
@@ -112,7 +120,7 @@ describe("local boot over a legacy bigint database", () => { · 17 unmodified lines
112120 );
113121 expect(applied).not.toContain("2026-08-28-bigint-storage-class");
114122
115− const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT });
123+ const scoped = withQueryContext(second.db, FIXTURE_CONTEXT);
116124 const rows = await scoped.findMany("connection", {});
117125 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
118126 await second.close();
apps/local/src/db/v1-v2-boot-drive.test.ts+2−0
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 runSqliteDataMigrations,
3737 } from "@executor-js/sdk";
3838 import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";
39+import { testAccess } from "@executor-js/product-access/testing";
3940 import { openApiPlugin } from "@executor-js/plugin-openapi";
4041 import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing";
4142 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
@@ -123,6 +124,7 @@ const bootRealStack = async (dbPath: string) => { · 81 unmodified lines
123124 memoryCredentialsPlugin(),
124125 ] as const,
125126 onElicitation: "accept-all",
127+ access: testAccess.member(),
126128 }),
127129 );
128130 return {
apps/local/src/executor.ts+4−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 type Executor,
1414 } from "@executor-js/sdk";
1515 import { collectTables } from "@executor-js/api/server";
16+import { singleUserAccess } from "@executor-js/product-access";
1617 import { loadPluginsFromJsonc } from "@executor-js/config";
1718 import type { McpPluginExtension } from "@executor-js/plugin-mcp";
1819
@@ -220,6 +221,9 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 201 unmodified lines
220221 { plugin_key: event.pluginKey },
221222 ),
222223 onElicitation: "accept-all",
224+ // Single-user product: one human owns the deployment and may
225+ // configure everything (see @executor-js/product-access).
226+ access: singleUserAccess(),
223227 oauthEndpointUrlPolicy: { allowHttp: true },
224228 // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback`
225229 // route on the same origin as the web UI. Derived from `webBaseUrl`
apps/local/src/mcp-browser-resume.test.ts+2−0
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
3434
3535 import { createMcpRequestHandler } from "./mcp";
3636 import { createSqliteFumaDb } from "./db/sqlite-fumadb";
37+import { testAccess } from "@executor-js/product-access/testing";
3738
3839 const TEST_BASE_URL = "http://local.test";
3940
@@ -90,6 +91,7 @@ const makeExecutor = async (tmpDir: string): Promise<Executor> => { · 50 unmodified lines
9091 db: sqlite.db,
9192 plugins,
9293 onElicitation: "accept-all",
94+ access: testAccess.member(),
9395 }),
9496 );
9597
apps/local/src/mcp-oauth.test.ts+2−0
@@ -49,6 +49,7 @@ import { · 48 unmodified lines
4949 createExecutor,
5050 } from "@executor-js/sdk";
5151 import { serveOAuthTestServer } from "@executor-js/sdk/testing";
52+import { testAccess } from "@executor-js/product-access/testing";
5253 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
5354 import { mcpPlugin } from "@executor-js/plugin-mcp";
5455 import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
@@ -98,6 +99,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 43 unmodified lines
9899 db: sqlite.db,
99100 plugins,
100101 onElicitation: "accept-all",
102+ access: testAccess.member(),
101103 oauthEndpointUrlPolicy: { allowHttp: true },
102104 // EXPLICIT OAuth callback — required now that the localhost default is
103105 // gone; the local daemon serves `/api/oauth/callback` on the web origin.
bun.lock+43−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 "@effect/tsgo": "^0.5.2",
1212 "@effect/vitest": "catalog:",
1313 "@executor-js/plugin-provider-service-split": "workspace:*",
14+ "@executor-js/product-access": "workspace:*",
1415 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
1516 "@vitest/expect": "catalog:",
1617 "@vitest/mocker": "catalog:",
@@ -76,6 +77,7 @@ · 59 unmodified lines
7677 "@executor-js/plugin-openapi": "workspace:*",
7778 "@executor-js/plugin-toolkits": "workspace:*",
7879 "@executor-js/plugin-workos-vault": "workspace:*",
80+ "@executor-js/product-access": "workspace:*",
7981 "@executor-js/react": "workspace:*",
8082 "@executor-js/runtime-dynamic-worker": "workspace:*",
8183 "@executor-js/runtime-quickjs": "workspace:*",
@@ -186,6 +188,7 @@ · 104 unmodified lines
186188 "@executor-js/plugin-openapi": "workspace:*",
187189 "@executor-js/plugin-provider-service-split": "workspace:*",
188190 "@executor-js/plugin-toolkits": "workspace:*",
191+ "@executor-js/product-access": "workspace:*",
189192 "@executor-js/react": "workspace:*",
190193 "@executor-js/runtime-dynamic-worker": "workspace:*",
191194 "@executor-js/runtime-quickjs": "workspace:*",
@@ -239,6 +242,7 @@ · 47 unmodified lines
239242 "@executor-js/plugin-openapi": "workspace:*",
240243 "@executor-js/plugin-provider-service-split": "workspace:*",
241244 "@executor-js/plugin-toolkits": "workspace:*",
245+ "@executor-js/product-access": "workspace:*",
242246 "@executor-js/react": "workspace:*",
243247 "@executor-js/runtime-quickjs": "workspace:*",
244248 "@executor-js/sdk": "workspace:*",
@@ -296,6 +300,7 @@ · 51 unmodified lines
296300 "@executor-js/plugin-openapi": "workspace:*",
297301 "@executor-js/plugin-provider-service-split": "workspace:*",
298302 "@executor-js/plugin-toolkits": "workspace:*",
303+ "@executor-js/product-access": "workspace:*",
299304 "@executor-js/react": "workspace:*",
300305 "@executor-js/runtime-quickjs": "workspace:*",
301306 "@executor-js/sdk": "workspace:*",
@@ -397,6 +402,7 @@ · 95 unmodified lines
397402 "@executor-js/plugin-onepassword": "workspace:*",
398403 "@executor-js/plugin-openapi": "workspace:*",
399404 "@executor-js/plugin-workos-vault": "workspace:*",
405+ "@executor-js/product-access": "workspace:*",
400406 "@executor-js/sdk": "workspace:*",
401407 "effect": "catalog:",
402408 },
@@ -410,6 +416,7 @@ · 7 unmodified lines
410416 "version": "0.0.54",
411417 "dependencies": {
412418 "@executor-js/plugin-openapi": "workspace:*",
419+ "@executor-js/product-access": "workspace:*",
413420 "@executor-js/sdk": "workspace:*",
414421 "effect": "catalog:",
415422 },
@@ -425,6 +432,7 @@ · 9 unmodified lines
425432 "@executor-js/plugin-graphql": "workspace:*",
426433 "@executor-js/plugin-mcp": "workspace:*",
427434 "@executor-js/plugin-openapi": "workspace:*",
435+ "@executor-js/product-access": "workspace:*",
428436 "@executor-js/sdk": "workspace:*",
429437 },
430438 "devDependencies": {
@@ -484,6 +492,7 @@ · 53 unmodified lines
484492 "dependencies": {
485493 "@executor-js/execution": "workspace:*",
486494 "@executor-js/host-mcp": "workspace:*",
495+ "@executor-js/product-access": "workspace:*",
487496 "@executor-js/sdk": "workspace:*",
488497 "effect": "catalog:",
489498 },
@@ -546,6 +555,7 @@ · 56 unmodified lines
546555 },
547556 "devDependencies": {
548557 "@effect/vitest": "catalog:",
558+ "@executor-js/product-access": "workspace:*",
549559 "@executor-js/runtime-quickjs": "workspace:*",
550560 "@types/node": "catalog:",
551561 "bun-types": "catalog:",
@@ -603,6 +613,25 @@ · 51 unmodified lines
603613 "vitest": "catalog:",
604614 },
605615 },
616+ "packages/core/product-access": {
617+ "name": "@executor-js/product-access",
618+ "version": "1.6.8",
619+ "dependencies": {
620+ "@executor-js/sdk": "workspace:*",
621+ },
622+ "devDependencies": {
623+ "@effect/vitest": "catalog:",
624+ "@types/node": "catalog:",
625+ "bun-types": "catalog:",
626+ "effect": "catalog:",
627+ "tsup": "catalog:",
628+ "typescript": "catalog:",
629+ "vitest": "catalog:",
630+ },
631+ "peerDependencies": {
632+ "effect": "catalog:",
633+ },
634+ },
606635 "packages/core/sdk": {
607636 "name": "@executor-js/sdk",
608637 "version": "1.6.8",
@@ -704,6 +733,7 @@ · 95 unmodified lines
704733 "dependencies": {
705734 "@cfworker/json-schema": "^4.1.1",
706735 "@executor-js/execution": "workspace:*",
736+ "@executor-js/product-access": "workspace:*",
707737 "@executor-js/sdk": "workspace:*",
708738 "@modelcontextprotocol/ext-apps": "^1.7.4",
709739 "@modelcontextprotocol/sdk": "^1.29.0",
@@ -734,6 +764,7 @@ · 24 unmodified lines
734764 "@effect/vitest": "catalog:",
735765 "@executor-js/execution": "workspace:*",
736766 "@executor-js/host-mcp": "workspace:*",
767+ "@executor-js/product-access": "workspace:*",
737768 "@executor-js/sdk": "workspace:*",
738769 "@tailwindcss/browser": "^4.2.2",
739770 "@tailwindcss/vite": "catalog:",
@@ -820,6 +851,7 @@ · 80 unmodified lines
820851 "@executor-js/execution": "workspace:*",
821852 "@executor-js/fumadb": "workspace:*",
822853 "@executor-js/plugin-openapi": "workspace:*",
854+ "@executor-js/product-access": "workspace:*",
823855 "@executor-js/sdk": "workspace:*",
824856 "@types/node": "catalog:",
825857 "drizzle-orm": "catalog:",
@@ -895,6 +927,7 @@ · 69 unmodified lines
895927 "react": "catalog:",
896928 },
897929 "devDependencies": {
930+ "@executor-js/product-access": "workspace:*",
898931 "@types/react": "catalog:",
899932 "tsup": "catalog:",
900933 "typescript": "catalog:",
@@ -945,6 +978,7 @@ · 44 unmodified lines
945978 "@executor-js/sdk": "workspace:*",
946979 },
947980 "devDependencies": {
981+ "@executor-js/product-access": "workspace:*",
948982 "@types/node": "catalog:",
949983 "bun-types": "catalog:",
950984 "effect": "catalog:",
@@ -969,6 +1003,7 @@ · 18 unmodified lines
9691003 "@effect/atom-react": "catalog:",
9701004 "@effect/vitest": "catalog:",
9711005 "@executor-js/api": "workspace:*",
1006+ "@executor-js/product-access": "workspace:*",
9721007 "@executor-js/react": "workspace:*",
9731008 "@types/node": "catalog:",
9741009 "@types/react": "catalog:",
@@ -1003,6 +1038,7 @@ · 28 unmodified lines
10031038 },
10041039 "devDependencies": {
10051040 "@effect/vitest": "catalog:",
1041+ "@executor-js/product-access": "workspace:*",
10061042 "@types/node": "catalog:",
10071043 "bun-types": "catalog:",
10081044 "effect": "catalog:",
@@ -1031,6 +1067,7 @@ · 22 unmodified lines
10311067 "@effect/atom-react": "catalog:",
10321068 "@effect/vitest": "catalog:",
10331069 "@executor-js/api": "workspace:*",
1070+ "@executor-js/product-access": "workspace:*",
10341071 "@executor-js/react": "workspace:*",
10351072 "@modelcontextprotocol/server": "2.0.0",
10361073 "@types/node": "catalog:",
@@ -1068,6 +1105,7 @@ · 31 unmodified lines
10681105 "devDependencies": {
10691106 "@effect/vitest": "catalog:",
10701107 "@executor-js/api": "workspace:*",
1108+ "@executor-js/product-access": "workspace:*",
10711109 "@types/node": "catalog:",
10721110 "@types/react": "catalog:",
10731111 "bun-types": "catalog:",
@@ -1105,6 +1143,7 @@ · 31 unmodified lines
11051143 "@effect/atom-react": "catalog:",
11061144 "@effect/vitest": "catalog:",
11071145 "@executor-js/api": "workspace:*",
1146+ "@executor-js/product-access": "workspace:*",
11081147 "@executor-js/react": "workspace:*",
11091148 "@types/js-yaml": "4.0.9",
11101149 "@types/node": "catalog:",
@@ -1158,6 +1197,7 @@ · 47 unmodified lines
11581197 "@effect/atom-react": "catalog:",
11591198 "@effect/vitest": "catalog:",
11601199 "@executor-js/api": "workspace:*",
1200+ "@executor-js/product-access": "workspace:*",
11611201 "@executor-js/react": "workspace:*",
11621202 "@tanstack/react-router": "catalog:",
11631203 "@types/node": "catalog:",
@@ -1224,6 +1264,7 @@ · 60 unmodified lines
12241264 "@base-ui/react": "^1.3.0",
12251265 "@effect/atom-react": "catalog:",
12261266 "@executor-js/api": "workspace:*",
1267+ "@executor-js/product-access": "workspace:*",
12271268 "@executor-js/sdk": "workspace:*",
12281269 "@hookform/resolvers": "^5.2.2",
12291270 "@lobehub/icons": "^5.4.0",
@@ -1869,6 +1910,8 @@ · 639 unmodified lines
18691910
18701911 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
18711912
1913+ "@executor-js/product-access": ["@executor-js/product-access@workspace:packages/core/product-access"],
1914+
18721915 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
18731916
18741917 "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],
examples/all-plugins/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 "@executor-js/plugin-onepassword": "workspace:*",
1717 "@executor-js/plugin-openapi": "workspace:*",
1818 "@executor-js/plugin-workos-vault": "workspace:*",
19+ "@executor-js/product-access": "workspace:*",
1920 "@executor-js/sdk": "workspace:*",
2021 "effect": "catalog:"
2122 },
examples/all-plugins/src/main.ts+3−0
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
3131 ToolAddress,
3232 type CredentialProvider,
3333 } from "@executor-js/sdk";
34+import { workspaceServiceAccess } from "@executor-js/product-access";
3435
3536 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
3637 import {
@@ -192,6 +193,8 @@ const program = Effect.gen(function* () { · 155 unmodified lines
192193
193194 const executor = yield* createExecutor({
194195 tenant: Tenant.make("example-tenant"),
196+ // Subject-less single-workspace example: org partition, writes allowed.
197+ access: workspaceServiceAccess(),
195198 plugins,
196199 providers: [memoryProvider],
197200 onElicitation: "accept-all" as const,
examples/docs-sdk-quickstart/package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 },
1111 "dependencies": {
1212 "@executor-js/plugin-openapi": "workspace:*",
13+ "@executor-js/product-access": "workspace:*",
1314 "@executor-js/sdk": "workspace:*",
1415 "effect": "catalog:"
1516 },
examples/docs-sdk-quickstart/src/main.ts+4−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 type CredentialProvider,
99 } from "@executor-js/sdk/promise";
1010 import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise";
11+import { workspaceServiceAccess } from "@executor-js/product-access";
1112
1213 const inventoryApi = {
1314 openapi: "3.0.0",
@@ -92,6 +93,9 @@ const memoryProvider: CredentialProvider = { · 78 unmodified lines
9293 };
9394
9495 const executor = await createExecutor({
96+ // Subject-less single-workspace embedder: the product posture is stated
97+ // explicitly — the SDK ships no default.
98+ access: workspaceServiceAccess(),
9599 plugins: [openApiPlugin()],
96100 providers: [memoryProvider],
97101 onElicitation: "accept-all",
examples/promise-sdk/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 "@executor-js/plugin-graphql": "workspace:*",
1313 "@executor-js/plugin-mcp": "workspace:*",
1414 "@executor-js/plugin-openapi": "workspace:*",
15+ "@executor-js/product-access": "workspace:*",
1516 "@executor-js/sdk": "workspace:*"
1617 },
1718 "devDependencies": {
examples/promise-sdk/src/main.ts+4−0
@@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines
2020 import { mcpPlugin } from "@executor-js/plugin-mcp/promise";
2121 import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise";
2222 import { graphqlPlugin } from "@executor-js/plugin-graphql/promise";
23+import { workspaceServiceAccess } from "@executor-js/product-access";
2324
2425 // ---------------------------------------------------------------------------
2526 // 1. Create the executor with all plugins
@@ -44,6 +45,9 @@ const memoryProvider: CredentialProvider = { · 18 unmodified lines
4445 };
4546
4647 const executor = await createExecutor({
48+ // Subject-less single-workspace embedder: the product posture is stated
49+ // explicitly — the SDK ships no default.
50+ access: workspaceServiceAccess(),
4751 plugins,
4852 providers: [memoryProvider],
4953 onElicitation: "accept-all",
package.json+2−1
@@ -39,7 +39,7 @@ · 38 unmodified lines
3939 "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}",
4040 "test:e2e": "bun run --cwd e2e test",
4141 "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",
42− "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
42+ "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/product-access' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
4343 "typecheck": "turbo run typecheck",
4444 "typecheck:slow": "turbo run typecheck:slow",
4545 "ci": "bun run lint && bun run typecheck && bun run test",
@@ -78,6 +78,7 @@ · 32 unmodified lines
7878 "@effect/tsgo": "^0.5.2",
7979 "@effect/vitest": "catalog:",
8080 "@executor-js/plugin-provider-service-split": "workspace:*",
81+ "@executor-js/product-access": "workspace:*",
8182 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
8283 "@vitest/expect": "catalog:",
8384 "@vitest/mocker": "catalog:",
packages/core/api/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 "dependencies": {
1717 "@executor-js/execution": "workspace:*",
1818 "@executor-js/host-mcp": "workspace:*",
19+ "@executor-js/product-access": "workspace:*",
1920 "@executor-js/sdk": "workspace:*",
2021 "effect": "catalog:"
2122 },
packages/core/api/src/admin/admin-users.test.ts+4−2
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 type ExecutorAdmin,
1414 } from "@executor-js/sdk";
1515 import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";
16+import { testAccess } from "@executor-js/product-access/testing";
1617 import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal";
1718
1819 import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api";
@@ -204,7 +205,7 @@ const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effec · 185 unmodified lines
204205 tenant: Tenant.make(tenant),
205206 db: db.db,
206207 onElicitation: "accept-all",
207− platformView: true,
208+ access: testAccess.platform({ subject: false }),
208209 }).pipe(Effect.orDie);
209210
210211 /** A product-view executor: bound to one subject, no platform view at all. */
@@ -214,6 +215,7 @@ const productExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect · 3 unmodified lines
214215 subject: Subject.make(USER_A1),
215216 db: db.db,
216217 onElicitation: "accept-all",
218+ access: testAccess.member(),
217219 }).pipe(Effect.orDie);
218220
219221 /**
@@ -639,7 +641,7 @@ describe("admin users API", () => { · 419 unmodified lines
639641 withDb((db) =>
640642 Effect.gen(function* () {
641643 yield* seed(db);
642− // A host that forgot `platformView: true` must not silently report an
644+ // A host that forgot the platform access posture must not silently report an
643645 // empty tenant — that would read as "this owner has no users".
644646 const web = yield* webHandlerFor(
645647 stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize),
packages/core/api/src/admin/api.ts+1−1
@@ -4,7 +4,7 @@ · 3 unmodified lines
44 // The product plane (`/api/*`) answers "what can I, this member, reach". This
55 // one answers the OWNER's question: "who are my users, and what have they
66 // connected". It reads the SDK's platform view (`executor.admin`, opt-in via
7−// `platformView: true`), which is read-only by construction — the owner policy
7+// `platformObserverAccess()`), which is read-only by construction — the owner policy
88 // rejects writes at `reach: "tenant"` — so every endpoint here is a GET.
99 //
1010 // VOCABULARY: this is the translation seam. Internal code says subject / tenant
packages/core/api/src/admin/reads.ts+1−1
@@ -35,7 +35,7 @@ import type { AdminUsersListOptions } from "./service"; · 34 unmodified lines
3535
3636 /**
3737 * Narrow an executor to its platform view. `admin` is present only when the
38− * executor was built with `platformView: true`; a host that reaches these reads
38+ * executor was built with the platform access posture; a host that reaches these reads
3939 * with a product-view executor is a wiring bug, so it fails loudly as a 500
4040 * rather than silently returning an empty tenant (which would read as "this
4141 * owner has no users").
packages/core/api/src/integrations/integrations-auth-methods.test.ts+13−4
@@ -16,6 +16,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 15 unmodified lines
1616 import { ExecutorApi } from "../api";
1717 import { observabilityMiddleware } from "../observability";
1818 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server";
19+import { testAccess } from "@executor-js/product-access/testing";
1920
2021 // ---------------------------------------------------------------------------
2122 // The catalog response surfaces each plugin's DECLARED auth methods (projected
@@ -109,7 +110,9 @@ interface IntegrationResponseBody { · 87 unmodified lines
109110 describe("catalog surfaces declared auth methods", () => {
110111 it.effect("an OAuth integration with zero connections advertises an oauth method", () =>
111112 Effect.gen(function* () {
112− const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));
113+ const executor = yield* createExecutor(
114+ makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
115+ );
113116 const slug = IntegrationSlug.make("oauth-server");
114117 yield* executor.declaring.seed(slug, [OAUTH_METHOD]);
115118
@@ -133,7 +136,9 @@ describe("catalog surfaces declared auth methods", () => { · 17 unmodified lines
133136
134137 it.effect("an apikey integration advertises an apikey method", () =>
135138 Effect.gen(function* () {
136− const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));
139+ const executor = yield* createExecutor(
140+ makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
141+ );
137142 const slug = IntegrationSlug.make("apikey-server");
138143 yield* executor.declaring.seed(slug, [APIKEY_METHOD]);
139144
@@ -156,7 +161,9 @@ describe("catalog surfaces declared auth methods", () => { · 16 unmodified lines
156161
157162 it.effect("list surfaces authMethods and a plugin with no projector yields []", () =>
158163 Effect.gen(function* () {
159− const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));
164+ const executor = yield* createExecutor(
165+ makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
166+ );
160167 yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]);
161168 yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []);
162169
@@ -180,7 +187,9 @@ describe("catalog surfaces declared auth methods", () => { · 17 unmodified lines
180187
181188 it.effect("surfaces plugin-derived display URLs without exposing config", () =>
182189 Effect.gen(function* () {
183− const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));
190+ const executor = yield* createExecutor(
191+ makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
192+ );
184193 const slug = IntegrationSlug.make("autumn");
185194 yield* executor.declaring.seed(slug, [], "https://api.useautumn.com");
186195
packages/core/api/src/scoped-targets.test.ts+5−1
@@ -17,6 +17,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 16 unmodified lines
1717 import { ExecutorApi } from "./api";
1818 import { observabilityMiddleware } from "./observability";
1919 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server";
20+import { testAccess } from "@executor-js/product-access/testing";
2021
2122 // ---------------------------------------------------------------------------
2223 // v2 owner-scoped API behaviour.
@@ -88,7 +89,7 @@ const vercelPlugin = definePlugin(() => ({ · 65 unmodified lines
8889 describe("core API owner-scoped writes (v2)", () => {
8990 it.effect("policy create + update target an explicit owner", () =>
9091 Effect.gen(function* () {
91− const executor = yield* createExecutor(makeTestConfig({}));
92+ const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() }));
9293 const web = yield* webHandlerFor(executor);
9394 const context = handlerContextFor(executor);
9495
@@ -135,6 +136,7 @@ describe("core API owner-scoped writes (v2)", () => { · 40 unmodified lines
135136 it.effect("connection remove deletes the named owner row, not the other owner", () =>
136137 Effect.gen(function* () {
137138 const config = makeTestConfig({
139+ access: testAccess.member(),
138140 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
139141 });
140142 const executor = yield* createExecutor(config);
@@ -179,6 +181,7 @@ describe("core API owner-scoped writes (v2)", () => { · 38 unmodified lines
179181 it.effect("connection create accepts pasted values payloads", () =>
180182 Effect.gen(function* () {
181183 const config = makeTestConfig({
184+ access: testAccess.member(),
182185 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
183186 });
184187 const executor = yield* createExecutor(config);
@@ -220,6 +223,7 @@ describe("core API owner-scoped writes (v2)", () => { · 35 unmodified lines
220223 it.effect("connection list returns both owners' rows under one integration", () =>
221224 Effect.gen(function* () {
222225 const config = makeTestConfig({
226+ access: testAccess.member(),
223227 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
224228 });
225229 const executor = yield* createExecutor(config);
packages/core/api/src/server/execution-stack-middleware.ts+4−4
@@ -38,6 +38,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab · 37 unmodified lines
3838 import { Context, Data, Effect, Layer } from "effect";
3939 import type * as Cause from "effect/Cause";
4040
41+import { memberAccessForRole } from "@executor-js/product-access";
4142 import type { AnyPlugin } from "@executor-js/sdk";
4243 import type { ExecutionEngine } from "@executor-js/execution";
4344
@@ -247,10 +248,9 @@ export const makeExecutionStackMiddleware = < · 203 unmodified lines
247248 resolved.organizationId,
248249 resolved.organizationName,
249250 {
250− orgWrites:
251− resolved.orgRoleModel === "none" || resolved.orgRole === "admin"
252− ? "allowed"
253− : "denied",
251+ // The product's role rule, decided from the freshly resolved
252+ // principal for exactly this request's stack.
253+ access: memberAccessForRole(resolved),
254254 },
255255 ).pipe(
256256 Effect.provide(options.stackLayer, { local: true }),
packages/core/api/src/server/execution-stack.ts+9−8
@@ -27,7 +27,7 @@ import { Context, Effect, Layer } from "effect"; · 26 unmodified lines
2727 import type * as Cause from "effect/Cause";
2828
2929 import type { McpResource } from "@executor-js/host-mcp";
30−import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk";
30+import type { AnyPlugin, Executor, ExecutorAccess, StorageFailure } from "@executor-js/sdk";
3131 import {
3232 createExecutionEngine,
3333 type ExecutionEngine,
@@ -112,11 +112,12 @@ export const makeExecutionStack = < · 78 unmodified lines
112112 accountId: string,
113113 organizationId: string,
114114 organizationName: string,
115− options?: {
115+ options: {
116116 readonly mcpResource?: McpResource;
117− /** Workspace-settings permission for this binding (see
118− * `ExecutorConfig.orgWrites`), derived from the acting member's role. */
119− readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];
117+ /** The product's access decisions for this binding (see
118+ * `ExecutorConfig.access`), selected by the host — role-derived on the
119+ * HTTP plane, request-bound on MCP session stacks. */
120+ readonly access: ExecutorAccess;
120121 },
121122 ): Effect.Effect<
122123 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
@@ -129,8 +130,8 @@ export const makeExecutionStack = < · 6 unmodified lines
129130 organizationId,
130131 organizationName,
131132 {
132− plugins: { mcpResource: options?.mcpResource },
133− ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),
133+ plugins: { mcpResource: options.mcpResource },
134+ access: options.access,
134135 },
135136 ).pipe(Effect.withSpan("executor.stack.scoped_executor"));
136137 const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe(
@@ -147,7 +148,7 @@ export const makeExecutionStack = < · 10 unmodified lines
147148 organizationId,
148149 organizationName,
149150 },
150− { mcpResource: options?.mcpResource },
151+ { mcpResource: options.mcpResource },
151152 ),
152153 );
153154 return { executor, engine };
packages/core/api/src/server/mcp-build.ts+5−1
@@ -1,6 +1,7 @@
11 import { Effect, Layer } from "effect";
22
33 import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";
4+import { requestBoundMemberAccess } from "@executor-js/product-access";
45 import {
56 McpEngineBuildError,
67 type McpBuildServer,
@@ -50,7 +51,10 @@ export const makeMcpBuildServer = · 43 unmodified lines
5051 principal.organizationName,
5152 {
5253 mcpResource: options?.resource,
53− orgWrites: "request",
54+ // Session-lifetime stack: the workspace-settings decision is read
55+ // from the request-stamped fiber-local at every guarded sink, so a
56+ // session never caches a positive authorization.
57+ access: requestBoundMemberAccess(),
5458 },
5559 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
5660 // Read inside the provided boundary: `webBaseUrl` is a host seam, and
packages/core/api/src/server/scoped-executor.ts+12−9
@@ -34,12 +34,14 @@ · 33 unmodified lines
3434 import { Context, Effect, Option } from "effect";
3535
3636 import type { McpResource } from "@executor-js/host-mcp";
37+import { platformObserverAccess } from "@executor-js/product-access";
3738 import {
3839 createExecutor,
3940 Subject,
4041 Tenant,
4142 type AnyPlugin,
4243 type Executor,
44+ type ExecutorAccess,
4345 type ExecutorConfig,
4446 type FirstPartyOAuthClientConfig,
4547 type StorageFailure,
@@ -258,12 +260,13 @@ export const makeScopedExecutor = < · 212 unmodified lines
258260 // `EngineStackIdentity` (the engine decorator still wants it); not part of the
259261 // v2 executor binding, which is `{ tenant, subject }` only.
260262 _organizationName: string,
261− options?: {
263+ options: {
262264 readonly plugins?: PluginsProviderContext;
263− /** Workspace-settings permission for this binding (see
264− * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's
265− * role; omitted -> allowed (hosts with no role model). */
266− readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];
265+ /** The product's access decisions for this binding (see
266+ * `ExecutorConfig.access`) — REQUIRED, selected by the host from
267+ * `@executor-js/product-access` (role-derived on the HTTP plane,
268+ * request-bound on session stacks). */
269+ readonly access: ExecutorAccess;
267270 },
268271 ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
269272 Effect.gen(function* () {
@@ -336,7 +339,7 @@ export const makeScopedExecutor = < · 66 unmodified lines
336339 ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),
337340 ...(waitUntil !== undefined ? { waitUntil } : {}),
338341 onElicitation: "accept-all",
339− ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),
342+ access: options.access,
340343 redirectUri,
341344 oauthCallbackStateOrgSlug: orgSlug,
342345 firstPartyOAuthClients: config.firstPartyOAuthClients,
@@ -370,9 +373,9 @@ export const makeScopedExecutor = < · 27 unmodified lines
370373 // An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on
371374 // the whole workspace) has NO acting member, so there is no honest subject to
372375 // bind. This builds the executor that shape implies: `{ tenant, subject:
373−// undefined, platformView: true }`.
376+// undefined, access: platformObserverAccess() }`.
374377 //
375−// WHAT "READ-ONLY" MEANS HERE, PRECISELY. `platformView: true` puts
378+// WHAT "READ-ONLY" MEANS HERE, PRECISELY. The platform access posture puts
376379 // `writes: "denied"` on the executor's base owner-policy context and
377380 // `reach: "tenant"` on the `admin` handle's. So:
378381 // - EVERY surface on this executor — `admin`, and the ordinary `connections`
@@ -429,6 +432,6 @@ export const makePlatformExecutor = ( · 50 unmodified lines
429432 httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions),
430433 fetch: makeHostedFetch(hostedHttpOptions),
431434 onElicitation: "accept-all",
432− platformView: true,
435+ access: platformObserverAccess(),
433436 }).pipe(Effect.withSpan("executor.platform.create_executor"));
434437 });
packages/core/execution/README.md+2−0
@@ -17,12 +17,14 @@ npm install @executor-js/sdk @executor-js/execution @executor-js/runtime-quickjs · 16 unmodified lines
1717 ## Usage
1818
1919 ```ts
20+import { workspaceServiceAccess } from "@executor-js/product-access";
2021 import { createExecutor } from "@executor-js/sdk";
2122 import { createExecutionEngine } from "@executor-js/execution";
2223 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
2324
2425 const executor = await createExecutor({
2526 onElicitation: "accept-all",
27+ access: workspaceServiceAccess(),
2628 });
2729
2830 const engine = createExecutionEngine({
packages/core/execution/package.json+1−0
@@ -48,6 +48,7 @@ · 47 unmodified lines
4848 },
4949 "devDependencies": {
5050 "@effect/vitest": "catalog:",
51+ "@executor-js/product-access": "workspace:*",
5152 "@executor-js/runtime-quickjs": "workspace:*",
5253 "@types/node": "catalog:",
5354 "bun-types": "catalog:",
packages/core/execution/src/description.test.ts+19−5
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 import { makeTestConfig } from "@executor-js/sdk/testing";
1515
1616 import { buildExecuteDescription, parseIntegrationInventory } from "./description";
17+import { testAccess } from "@executor-js/product-access/testing";
1718
1819 const memoryProvider = (): CredentialProvider => {
1920 const store = new Map<string, string>();
@@ -74,7 +75,10 @@ describe("buildExecuteDescription", () => { · 54 unmodified lines
7475 it.effect("lists the connected integrations, not the connection prefixes", () =>
7576 Effect.gen(function* () {
7677 const executor = yield* createExecutor(
77− makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),
78+ makeTestConfig({
79+ access: testAccess.member(),
80+ plugins: [slackPlugin, githubPlugin] as const,
81+ }),
7882 );
7983 yield* executor["slack-plugin"].seed();
8084 yield* executor["github-plugin"].seed();
@@ -121,7 +125,10 @@ describe("buildExecuteDescription", () => { · 40 unmodified lines
121125 it.effect("lists integration names only, with no descriptions", () =>
122126 Effect.gen(function* () {
123127 const executor = yield* createExecutor(
124− makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),
128+ makeTestConfig({
129+ access: testAccess.member(),
130+ plugins: [slackPlugin, githubPlugin] as const,
131+ }),
125132 );
126133 yield* executor["slack-plugin"].seed();
127134 yield* executor["github-plugin"].seed();
@@ -154,7 +161,9 @@ describe("buildExecuteDescription", () => { · 26 unmodified lines
154161
155162 it.effect("dedupes many connections of one integration into a single line", () =>
156163 Effect.gen(function* () {
157− const executor = yield* createExecutor(makeTestConfig({ plugins: [githubPlugin] as const }));
164+ const executor = yield* createExecutor(
165+ makeTestConfig({ access: testAccess.member(), plugins: [githubPlugin] as const }),
166+ );
158167 yield* executor["github-plugin"].seed();
159168 yield* executor.connections.create({
160169 owner: "org",
@@ -181,7 +190,9 @@ describe("buildExecuteDescription", () => { · 20 unmodified lines
181190
182191 it.effect("omits the Available integrations section when no connections exist", () =>
183192 Effect.gen(function* () {
184− const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const }));
193+ const executor = yield* createExecutor(
194+ makeTestConfig({ access: testAccess.member(), plugins: [] as const }),
195+ );
185196
186197 const description = yield* buildExecuteDescription(executor);
187198
@@ -195,7 +206,10 @@ describe("parseIntegrationInventory", () => { · 7 unmodified lines
195206 it.effect("round-trips the slugs a built description lists", () =>
196207 Effect.gen(function* () {
197208 const executor = yield* createExecutor(
198− makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),
209+ makeTestConfig({
210+ access: testAccess.member(),
211+ plugins: [slackPlugin, githubPlugin] as const,
212+ }),
199213 );
200214 yield* executor["slack-plugin"].seed();
201215 yield* executor["github-plugin"].seed();
packages/core/execution/src/engine.test.ts+7−3
@@ -14,6 +14,7 @@ import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; · 13 unmodified lines
1414
1515 import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine";
1616 import { FormElicitation } from "@executor-js/sdk/core";
17+import { testAccess } from "@executor-js/product-access/testing";
1718
1819 // Regression for the hang reported as the executor-MCP "180s timeout" against
1920 // Cowork (Claude web). Cowork goes down the `executeWithPause` branch because
@@ -42,7 +43,10 @@ const emptyPlugin = definePlugin(() => ({ · 22 unmodified lines
4243 staticIntegrations: () => [],
4344 }));
4445
45−const makeExecutor = () => createExecutor(makeTestConfig({ plugins: [emptyPlugin()] as const }));
46+const makeExecutor = () =>
47+ createExecutor(
48+ makeTestConfig({ access: testAccess.member(), plugins: [emptyPlugin()] as const }),
49+ );
4650
4751 describe("executeWithPause failure propagation", () => {
4852 it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () =>
@@ -98,7 +102,7 @@ describe("paused execution authorization", () => { · 49 unmodified lines
98102 it.effect("uses the resumer's current org-write access after approval", () =>
99103 Effect.gen(function* () {
100104 const executor = yield* createExecutor(
101− makeTestConfig({ coreTools: {}, orgWrites: "request" }),
105+ makeTestConfig({ coreTools: {}, access: testAccess.requestBound() }),
102106 );
103107 const engine = createExecutionEngine({
104108 executor,
@@ -170,7 +174,7 @@ describe("paused execution authorization", () => { · 65 unmodified lines
170174 const executor = yield* createExecutor(
171175 makeTestConfig({
172176 coreTools: {},
173− orgWrites: "request",
177+ access: testAccess.requestBound(),
174178 plugins: [joinPlugin()] as const,
175179 }),
176180 );
packages/core/execution/src/tool-invoker.leak.test.ts+19−6
@@ -5,6 +5,7 @@ import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/ · 4 unmodified lines
55 import { makeTestConfig } from "@executor-js/sdk/testing";
66 import { ExecutionToolError } from "./errors";
77 import { makeExecutorToolInvoker } from "./tool-invoker";
8+import { testAccess } from "@executor-js/product-access/testing";
89
910 const EmptyInputSchema = Schema.toStandardSchemaV1(
1011 Schema.toStandardJSONSchemaV1(Schema.Struct({})),
@@ -138,7 +139,9 @@ const leakyPlugin = definePlugin(() => ({ · 127 unmodified lines
138139 describe("internal-error leak audit (opaque defects)", () => {
139140 it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () =>
140141 Effect.gen(function* () {
141− const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
142+ const executor = yield* createExecutor(
143+ makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
144+ );
142145 const invoker = makeExecutorToolInvoker(executor, {
143146 invokeOptions: { onElicitation: acceptAll },
144147 });
@@ -160,7 +163,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 15 unmodified lines
160163
161164 it.effect("plain Error with stack: stack and message do NOT escape", () =>
162165 Effect.gen(function* () {
163− const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
166+ const executor = yield* createExecutor(
167+ makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
168+ );
164169 const invoker = makeExecutorToolInvoker(executor, {
165170 invokeOptions: { onElicitation: acceptAll },
166171 });
@@ -179,7 +184,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 12 unmodified lines
179184 describe("openapi pre-flight invocation errors (expected failures)", () => {
180185 it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () =>
181186 Effect.gen(function* () {
182− const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
187+ const executor = yield* createExecutor(
188+ makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
189+ );
183190 const invoker = makeExecutorToolInvoker(executor, {
184191 invokeOptions: { onElicitation: acceptAll },
185192 });
@@ -197,7 +204,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines
197204
198205 it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () =>
199206 Effect.gen(function* () {
200− const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
207+ const executor = yield* createExecutor(
208+ makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
209+ );
201210 const invoker = makeExecutorToolInvoker(executor, {
202211 invokeOptions: { onElicitation: acceptAll },
203212 });
@@ -215,7 +224,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines
215224
216225 it.effect("transport failure (statusCode None but cause present) stays opaque", () =>
217226 Effect.gen(function* () {
218− const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
227+ const executor = yield* createExecutor(
228+ makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
229+ );
219230 const invoker = makeExecutorToolInvoker(executor, {
220231 invokeOptions: { onElicitation: acceptAll },
221232 });
@@ -235,7 +246,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 13 unmodified lines
235246
236247 it.effect("post-response failure (statusCode Some) stays opaque", () =>
237248 Effect.gen(function* () {
238− const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
249+ const executor = yield* createExecutor(
250+ makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
251+ );
239252 const invoker = makeExecutorToolInvoker(executor, {
240253 invokeOptions: { onElicitation: acceptAll },
241254 });
packages/core/execution/src/tool-invoker.test.ts+5−1
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
3838 searchTools,
3939 type ToolDiscoveryProvider,
4040 } from "./tool-invoker";
41+import { testAccess } from "@executor-js/product-access/testing";
4142
4243 // ---------------------------------------------------------------------------
4344 // v2 port. The v1 suite modelled namespaces as `staticSources` whose tools
@@ -477,7 +478,7 @@ const provision = ( · 433 unmodified lines
477478 });
478479
479480 const makeExecutorWith = <const TPlugins extends readonly AnyPlugin[]>(plugins: TPlugins) =>
480− createExecutor(makeTestConfig({ plugins }));
481+ createExecutor(makeTestConfig({ access: testAccess.member(), plugins }));
481482
482483 const makeSearchExecutor = () =>
483484 Effect.gen(function* () {
@@ -1250,6 +1251,7 @@ describe("tool discovery", () => { · 766 unmodified lines
12501251 });
12511252 const executor = yield* createExecutor(
12521253 makeTestConfig({
1254+ access: testAccess.member(),
12531255 plugins: [memoryCredentialsPlugin()] as const,
12541256 coreTools: {},
12551257 }),
@@ -1303,6 +1305,7 @@ describe("tool discovery", () => { · 47 unmodified lines
13031305 Effect.gen(function* () {
13041306 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
13051307 const config = makeTestConfig({
1308+ access: testAccess.member(),
13061309 plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const,
13071310 });
13081311 const executor = yield* createExecutor(config);
@@ -1386,6 +1389,7 @@ describe("tool discovery", () => { · 77 unmodified lines
13861389 invalidRefreshTokenDescription: "Refresh token expired",
13871390 });
13881391 const config = makeTestConfig({
1392+ access: testAccess.member(),
13891393 plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const,
13901394 });
13911395 const executor = yield* createExecutor(config);
packages/core/product-access/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1+# @executor-js/product-access
packages/core/product-access/README.md+79−0
@@ -0,0 +1,79 @@
1+# @executor-js/product-access
2+
3+The product's personal/organization access rules for
4+[`@executor-js/sdk`](../sdk). The SDK executes tools and enforces tenant
5+isolation; it deliberately owns no product rule. Every `createExecutor` call
6+requires an `ExecutorAccess` — the product's decisions for:
7+
8+- **`owners`** — which owner partitions (`"user"`, `"org"`) the binding sees
9+ and may write, in precedence order. Really filters storage CRUD, tool
10+ list/schema/invoke, plugin storage and plugin blobs.
11+- **`settingsWrite(target)`** — whether a user-intent settings mutation
12+ (connections, policies, OAuth clients, the integration catalog) is allowed,
13+ asked live at every sink.
14+- **`capabilities`** — `adminReads` (the tenant-wide read-only admin surface)
15+ and `storageWrites` (`"denied"` = whole-executor read-only).
16+- **`toolPolicy(sources)`** — how stored policy rows and toolkit capability
17+ rules resolve into an effective allow/approve/block decision.
18+
19+This package ships the standard rules. Core never falls back to them — a
20+composition root states its posture explicitly.
21+
22+## Effect SDK
23+
24+```ts
25+import { Effect } from "effect";
26+import { createExecutor } from "@executor-js/sdk/core";
27+import { singleUserAccess } from "@executor-js/product-access";
28+
29+const program = Effect.gen(function* () {
30+ const executor = yield* createExecutor({
31+ tenant,
32+ subject,
33+ onElicitation: "accept-all",
34+ access: singleUserAccess(),
35+ });
36+ // Execution stays on the executor namespaces: executor.tools, executor.execute, …
37+});
38+```
39+
40+## Promise SDK
41+
42+```ts
43+import { createExecutor } from "@executor-js/sdk"; // published root = Promise API
44+import { workspaceServiceAccess } from "@executor-js/product-access";
45+
46+const executor = await createExecutor({
47+ onElicitation: "accept-all",
48+ // Subject-less single-workspace embedder posture.
49+ access: workspaceServiceAccess(),
50+});
51+```
52+
53+## Postures
54+
55+| Constructor | Binding | Used by |
56+| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------- |
57+| `singleUserAccess()` | bound subject, everything allowed | local daemon, CLI, desktop |
58+| `memberAccessForRole({ orgRoleModel, orgRole })` | bound member; admins configure, members use | HTTP API plane |
59+| `requestBoundMemberAccess()` | bound member; decision re-read per request via `CurrentOrgWriteAccess`, re-stamped on approval resume | MCP session stacks |
60+| `workspaceServiceAccess()` | subject-less org service; boot convergence | seeding/system executors, embedders |
61+| `platformObserverAccess()` | subject-less, storage read-only, tenant-wide `admin` reads | `/admin/*` org-credential plane |
62+
63+`orgWriteAccessForRole` is the single home of the admin/member rule.
64+`./policy` exports the resolution semantics (owner ranking, most-restrictive
65+merge, plugin-default fallback, toolkit allowlist) — browser-safe, built on
66+`@executor-js/sdk/shared`. `./testing` exports `testAccess` postures for
67+tests (`makeTestConfig({ access: testAccess.member() })`).
68+
69+## Breaking changes (from the pre-split SDK)
70+
71+- `ExecutorConfig.orgWrites` and `platformView` are gone; `access` is
72+ required on both the Effect and Promise `createExecutor`.
73+- `makeTestConfig` / `makeTestWorkspaceHarness` / `makeTestExecutor` /
74+ `makeTestWorkspaceLayer` require `access`.
75+- Policy resolution functions moved here from `@executor-js/sdk`
76+ (`resolveEffectivePolicy`, `effectivePolicyFromSorted`, …); pattern
77+ matching (`matchPattern`, `isValidPattern`) stays in the SDK.
78+- No stored schema or data migration: rows, tenants, owners, subjects and
79+ tool addresses are unchanged.
packages/core/product-access/package.json+67−0
@@ -0,0 +1,67 @@
1+{
2+ "name": "@executor-js/product-access",
3+ "version": "1.6.8",
4+ "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/product-access",
5+ "bugs": {
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7+ },
8+ "license": "MIT",
9+ "repository": {
10+ "type": "git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12+ "directory": "packages/core/product-access"
13+ },
14+ "files": [
15+ "dist"
16+ ],
17+ "type": "module",
18+ "exports": {
19+ ".": "./src/index.ts",
20+ "./policy": "./src/policy.ts",
21+ "./testing": "./src/testing.ts"
22+ },
23+ "publishConfig": {
24+ "access": "public",
25+ "exports": {
26+ ".": {
27+ "import": {
28+ "types": "./dist/index.d.ts",
29+ "default": "./dist/index.js"
30+ }
31+ },
32+ "./policy": {
33+ "import": {
34+ "types": "./dist/policy.d.ts",
35+ "default": "./dist/policy.js"
36+ }
37+ },
38+ "./testing": {
39+ "import": {
40+ "types": "./dist/testing.d.ts",
41+ "default": "./dist/testing.js"
42+ }
43+ }
44+ }
45+ },
46+ "scripts": {
47+ "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src",
48+ "typecheck": "tsgo --noEmit",
49+ "test": "vitest run",
50+ "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
51+ },
52+ "dependencies": {
53+ "@executor-js/sdk": "workspace:*"
54+ },
55+ "devDependencies": {
56+ "@effect/vitest": "catalog:",
57+ "@types/node": "catalog:",
58+ "bun-types": "catalog:",
59+ "effect": "catalog:",
60+ "tsup": "catalog:",
61+ "typescript": "catalog:",
62+ "vitest": "catalog:"
63+ },
64+ "peerDependencies": {
65+ "effect": "catalog:"
66+ }
67+}
packages/core/product-access/src/index.test.ts+185−0
@@ -0,0 +1,185 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Ref } from "effect";
3+
4+import {
5+ CurrentOrgWriteAccess,
6+ makeOrgWriteAccessState,
7+ type ToolPolicyRow,
8+} from "@executor-js/sdk/core";
9+
10+import {
11+ memberAccess,
12+ memberAccessForRole,
13+ orgWriteAccessForRole,
14+ platformObserverAccess,
15+ requestBoundMemberAccess,
16+ singleUserAccess,
17+ workspaceServiceAccess,
18+} from "./index";
19+import { resolveProviderPolicyFromRules, standardToolPolicy } from "./policy";
20+
21+describe("orgWriteAccessForRole", () => {
22+ it("trusts the single user of a deployment without a role model", () => {
23+ expect(orgWriteAccessForRole({ orgRoleModel: "none" })).toBe("allowed");
24+ });
25+
26+ it("lets admins configure the workspace under a role model", () => {
27+ expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "admin" })).toBe(
28+ "allowed",
29+ );
30+ });
31+
32+ it("denies plain members", () => {
33+ expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "member" })).toBe(
34+ "denied",
35+ );
36+ });
37+
38+ it("fails closed when a role model is present but the role is missing", () => {
39+ expect(orgWriteAccessForRole({ orgRoleModel: "organization" })).toBe("denied");
40+ });
41+});
42+
43+describe("settings-write rules", () => {
44+ it.effect("the role decision gates workspace targets; personal stays open", () =>
45+ Effect.gen(function* () {
46+ const member = memberAccessForRole({ orgRoleModel: "organization", orgRole: "member" });
47+ const admin = memberAccessForRole({ orgRoleModel: "organization", orgRole: "admin" });
48+ expect(yield* member.settingsWrite({ kind: "workspace" })).toBe("denied");
49+ expect(yield* member.settingsWrite({ kind: "owner", owner: "org" })).toBe("denied");
50+ expect(yield* member.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed");
51+ expect(yield* admin.settingsWrite({ kind: "workspace" })).toBe("allowed");
52+ expect(admin.owners).toEqual(["user", "org"]);
53+ expect(admin.capabilities).toEqual({ adminReads: false, storageWrites: "allowed" });
54+ }),
55+ );
56+
57+ it.effect("requestBoundMemberAccess fails closed without a request binding", () =>
58+ Effect.gen(function* () {
59+ const access = requestBoundMemberAccess();
60+ expect(yield* access.settingsWrite({ kind: "workspace" })).toBe("denied");
61+ // Personal targets never consult the request decision.
62+ expect(yield* access.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed");
63+ }),
64+ );
65+
66+ it.effect("requestBoundMemberAccess reads the live request binding at every ask", () =>
67+ Effect.gen(function* () {
68+ const access = requestBoundMemberAccess();
69+ const state = makeOrgWriteAccessState("allowed");
70+ const ask = access
71+ .settingsWrite({ kind: "owner", owner: "org" })
72+ .pipe(Effect.provideService(CurrentOrgWriteAccess, state));
73+ expect(yield* ask).toBe("allowed");
74+ // The engine re-stamps this Ref from the resuming principal; the SAME
75+ // access value must observe the new decision, not a snapshot.
76+ yield* Ref.set(state.current, "denied");
77+ expect(yield* ask).toBe("denied");
78+ }),
79+ );
80+
81+ it.effect("single-user and workspace-service postures allow workspace writes", () =>
82+ Effect.gen(function* () {
83+ const single = singleUserAccess();
84+ const service = workspaceServiceAccess();
85+ expect(yield* single.settingsWrite({ kind: "workspace" })).toBe("allowed");
86+ expect(single.owners).toEqual(["user", "org"]);
87+ expect(yield* service.settingsWrite({ kind: "workspace" })).toBe("allowed");
88+ expect(service.owners).toEqual(["org"]);
89+ expect(yield* memberAccess("denied").settingsWrite({ kind: "workspace" })).toBe("denied");
90+ }),
91+ );
92+
93+ it("the platform observer is a subject-less read-only view with admin reads", () => {
94+ const access = platformObserverAccess();
95+ expect(access.owners).toEqual(["org"]);
96+ expect(access.capabilities).toEqual({ adminReads: true, storageWrites: "denied" });
97+ });
98+});
99+
100+describe("standardToolPolicy", () => {
101+ const row = (
102+ id: string,
103+ owner: "org" | "user",
104+ pattern: string,
105+ action: "approve" | "require_approval" | "block",
106+ ): ToolPolicyRow =>
107+ ({
108+ id,
109+ owner,
110+ subject: owner === "org" ? "" : "u",
111+ pattern,
112+ action,
113+ position: "a0",
114+ created_at: new Date(0),
115+ updated_at: new Date(0),
116+ }) as ToolPolicyRow;
117+
118+ const evaluate = (
119+ rows: readonly ReturnType<typeof row>[],
120+ toolId: string,
121+ defaultRequiresApproval?: boolean,
122+ ) =>
123+ Effect.gen(function* () {
124+ const evaluator = yield* standardToolPolicy(["user", "org"])({
125+ policyRows: Effect.succeed(rows),
126+ provider: null,
127+ });
128+ return yield* evaluator.resolve({ toolId, defaultRequiresApproval });
129+ });
130+
131+ it.effect("an org guardrail beats a user preference", () =>
132+ Effect.gen(function* () {
133+ const effective = yield* evaluate(
134+ [
135+ row("outer", "org", "vercel.*", "block"),
136+ row("inner", "user", "vercel.dns.create", "approve"),
137+ ],
138+ "vercel.dns.create",
139+ );
140+ expect(effective).toMatchObject({ action: "block", policyId: "outer" });
141+ }),
142+ );
143+
144+ it.effect("no authored match falls back to the plugin default", () =>
145+ Effect.gen(function* () {
146+ expect(yield* evaluate([], "vercel.dns.create", true)).toMatchObject({
147+ action: "require_approval",
148+ source: "plugin-default",
149+ });
150+ expect(yield* evaluate([], "vercel.dns.create")).toMatchObject({
151+ action: "approve",
152+ source: "plugin-default",
153+ });
154+ }),
155+ );
156+
157+ it.effect("a list-only provider is an allowlist: unmatched tools block", () =>
158+ Effect.gen(function* () {
159+ const evaluator = yield* standardToolPolicy(["user", "org"])({
160+ policyRows: Effect.succeed([]),
161+ provider: {
162+ list: () =>
163+ Effect.succeed([
164+ { id: "r1", pattern: "github.*", action: "approve" as const, position: "a0" },
165+ ]),
166+ },
167+ });
168+ expect(yield* evaluator.resolve({ toolId: "github.org.acme.repos.list" })).toMatchObject({
169+ action: "approve",
170+ });
171+ expect(yield* evaluator.resolve({ toolId: "vercel.org.acme.deploy" })).toMatchObject({
172+ action: "block",
173+ pattern: "*",
174+ });
175+ }),
176+ );
177+
178+ it("resolveProviderPolicyFromRules picks the first match by position", () => {
179+ const rules = [
180+ { id: "b", pattern: "github.*", action: "block" as const, position: "a1" },
181+ { id: "a", pattern: "github.*", action: "approve" as const, position: "a0" },
182+ ];
183+ expect(resolveProviderPolicyFromRules("github.x", rules)).toMatchObject({ policyId: "a" });
184+ });
185+});
packages/core/product-access/src/index.ts+159−0
@@ -0,0 +1,159 @@
1+// ---------------------------------------------------------------------------
2+// @executor-js/product-access — the PRODUCT's personal/organization access
3+// rules, stated once and selected explicitly by every composition root.
4+//
5+// The SDK owns none of these. `createExecutor` requires an `ExecutorAccess`
6+// and enforces its decisions at core's sinks — storage row visibility and
7+// write partitions (`owners`), the user-intent settings gate
8+// (`settingsWrite`), view capabilities (`capabilities`), and effective
9+// tool-policy evaluation (`toolPolicy`) — while the storage owner policy
10+// keeps clamping everything to the binding's tenant/subject partition.
11+// This package is where "what may this principal touch" is decided:
12+// - the role rule (admins configure the workspace, members use it),
13+// - the personal-stays-open settings rule (a denied member still manages
14+// Personal-scope resources),
15+// - the request-bound session posture (MCP sessions re-decide per request
16+// and re-stamp on approval resume),
17+// - the single-user posture (local daemon / CLI / examples),
18+// - the subject-less workspace-service and platform-observer postures,
19+// - how authored policy rows and toolkit capability rules resolve
20+// (`./policy`).
21+// Non-HTTP hosts consume it without pulling `@executor-js/api`.
22+//
23+// Imports come from the PUBLISHED sdk entries: `@executor-js/sdk/core` (the
24+// Effect SDK; the sdk ROOT export is the Promise façade) and the
25+// browser-safe `@executor-js/sdk/shared` inside `./policy`.
26+// ---------------------------------------------------------------------------
27+
28+import { Effect } from "effect";
29+
30+import {
31+ currentOrgWriteAccess,
32+ type AccessDecision,
33+ type ExecutorAccess,
34+ type OrgWriteAccess,
35+ type Owner,
36+ type SettingsWriteTarget,
37+} from "@executor-js/sdk/core";
38+
39+import { standardToolPolicy } from "./policy";
40+
41+export {
42+ effectivePolicyFromSorted,
43+ resolveEffectivePolicy,
44+ resolveProviderPolicyFromRules,
45+ resolveToolPolicy,
46+ standardToolPolicy,
47+ type ToolPolicyRuleLike,
48+} from "./policy";
49+
50+/** The role shape the org-write rule reads — structural, so both the API's
51+ * `Principal` and host-mcp's schema'd copy satisfy it without translation. */
52+export interface OrgRoleInput {
53+ /** `"organization"` when the deployment has an org role model, `"none"`
54+ * when it does not (self-host single-org, local). */
55+ readonly orgRoleModel: "organization" | "none";
56+ /** The acting member's role under an `"organization"` model. Missing at a
57+ * legacy boundary fails closed (treated as a plain member). */
58+ readonly orgRole?: "admin" | "member" | undefined;
59+}
60+
61+/**
62+ * THE product rule for workspace-settings writes: a deployment without a
63+ * role model trusts its user; under a role model only admins configure the
64+ * workspace, and an absent role fails closed. Previously duplicated by the
65+ * HTTP execution-stack middleware and the MCP serving seams — this is now
66+ * the single home.
67+ */
68+export const orgWriteAccessForRole = (input: OrgRoleInput): OrgWriteAccess =>
69+ input.orgRoleModel === "none" || input.orgRole === "admin" ? "allowed" : "denied";
70+
71+/** A bound member's partitions, personal shadowing shared. */
72+const MEMBER_OWNERS: readonly Owner[] = ["user", "org"];
73+/** A subject-less binding's single partition. */
74+const ORG_OWNERS: readonly Owner[] = ["org"];
75+
76+/**
77+ * The settings rule every current product shares: Personal-scope targets
78+ * are always the member's own to configure; workspace-level targets (org
79+ * rows and tenant-shared surfaces) follow the workspace decision, evaluated
80+ * LIVE at each sink so a request-bound decision is re-read after pauses.
81+ */
82+const personalOpenSettingsWrite =
83+ (workspace: Effect.Effect<AccessDecision>) =>
84+ (target: SettingsWriteTarget): Effect.Effect<AccessDecision> =>
85+ target.kind === "owner" && target.owner === "user"
86+ ? Effect.succeed("allowed" as const)
87+ : workspace;
88+
89+const memberCapabilities = { adminReads: false, storageWrites: "allowed" } as const;
90+
91+const memberPosture = (workspace: Effect.Effect<AccessDecision>): ExecutorAccess => ({
92+ owners: MEMBER_OWNERS,
93+ settingsWrite: personalOpenSettingsWrite(workspace),
94+ capabilities: memberCapabilities,
95+ toolPolicy: standardToolPolicy(MEMBER_OWNERS),
96+});
97+
98+/**
99+ * A bound member whose workspace-settings decision was made from the freshly
100+ * authenticated request and holds for its lifetime — the HTTP API plane,
101+ * where every request re-authenticates and builds a new stack.
102+ */
103+export const memberAccess = (workspaceWrites: OrgWriteAccess): ExecutorAccess =>
104+ memberPosture(Effect.succeed(workspaceWrites));
105+
106+/** {@link memberAccess} with the decision derived by {@link orgWriteAccessForRole}. */
107+export const memberAccessForRole = (input: OrgRoleInput): ExecutorAccess =>
108+ memberAccess(orgWriteAccessForRole(input));
109+
110+/**
111+ * A bound member on a session-lifetime stack (MCP sessions): the executor
112+ * outlives any one request, so the workspace-settings decision is read from
113+ * the fiber-local `CurrentOrgWriteAccess` at EVERY guarded sink. The session
114+ * host stamps that reference from each freshly authenticated request, the
115+ * engine re-stamps it from the resuming principal on approval resume, and a
116+ * missing binding fails closed (denied) — a positive authorization is never
117+ * cached for the session lifetime.
118+ */
119+export const requestBoundMemberAccess = (): ExecutorAccess => memberPosture(currentOrgWriteAccess);
120+
121+/**
122+ * The single-user products (local daemon, CLI, desktop, examples): one human
123+ * owns the whole deployment, there is no role model, and the bound subject
124+ * may configure everything.
125+ */
126+export const singleUserAccess = (): ExecutorAccess => memberPosture(Effect.succeed("allowed"));
127+
128+/**
129+ * A subject-less workspace-service binding — boot/system executors that
130+ * converge the tenant's shared state (catalog registration, seeding) with no
131+ * acting member. Sees and writes only the org partition; workspace writes
132+ * are the whole point.
133+ */
134+export const workspaceServiceAccess = (): ExecutorAccess => ({
135+ owners: ORG_OWNERS,
136+ settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")),
137+ capabilities: memberCapabilities,
138+ toolPolicy: standardToolPolicy(ORG_OWNERS),
139+});
140+
141+/**
142+ * The platform observer an org-level credential gets (the `/admin/*` plane):
143+ * subject-less, the WHOLE executor read-only at the storage boundary
144+ * (`storageWrites: "denied"`), and only the `admin` surface reading
145+ * tenant-wide (`adminReads: true`).
146+ *
147+ * `settingsWrite` stays `"allowed"` ON PURPOSE: the platform posture's
148+ * enforcement point is the storage owner policy, which refuses every
149+ * mutation regardless of the surface gate. Deciding `"denied"` here would
150+ * merely swap the failure the (already unreachable-over-HTTP) mutation
151+ * surfaces raise from the storage denial to `OrgWriteDeniedError`; the
152+ * observer's read-only-ness never depends on it.
153+ */
154+export const platformObserverAccess = (): ExecutorAccess => ({
155+ owners: ORG_OWNERS,
156+ settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")),
157+ capabilities: { adminReads: true, storageWrites: "denied" },
158+ toolPolicy: standardToolPolicy(ORG_OWNERS),
159+});
packages/core/product-access/src/policy.ts+263−0
@@ -0,0 +1,263 @@
1+// ---------------------------------------------------------------------------
2+// The PRODUCT's tool-policy resolution rules — how authored rows and toolkit
3+// capability rules combine into one `EffectivePolicy`:
4+//
5+// - owner-ranked, per-owner first-match by local position;
6+// - the MOST RESTRICTIVE matched action across owners wins, so a user
7+// preference cannot weaken an org guardrail (org = outer, user = inner);
8+// - no authored match falls back to the tool's own `requiresApproval`
9+// annotation (the plugin default);
10+// - a list-only toolkit provider is a capability ALLOWLIST: no matching
11+// rule means the tool is outside the capability boundary and blocks.
12+//
13+// Core (`@executor-js/sdk`) never applies these rules itself: it hands the
14+// rule material to `ExecutorAccess.toolPolicy` and enforces whatever
15+// `EffectivePolicy` comes back. This module deliberately imports only the
16+// browser-safe `@executor-js/sdk/shared` utilities (pattern matching, row
17+// ordering) at runtime, so UI surfaces can preview the same resolution
18+// without pulling the server SDK.
19+// ---------------------------------------------------------------------------
20+
21+import { Effect, Match } from "effect";
22+
23+import {
24+ comparePolicyRow,
25+ matchPattern,
26+ type EffectivePolicy,
27+ type PolicyMatch,
28+ type ToolPolicy,
29+} from "@executor-js/sdk/shared";
30+import type {
31+ StorageFailure,
32+ ToolPolicyEvaluationInput,
33+ ToolPolicyEvaluator,
34+ ToolPolicyProvider,
35+ ToolPolicyProviderRule,
36+ ToolPolicySources,
37+} from "@executor-js/sdk/core";
38+
39+/** The stored-row shape resolution reads — structural, so both core's
40+ * persisted `ToolPolicyRow` and UI projections satisfy it. */
41+export interface ToolPolicyRuleLike {
42+ readonly id: string;
43+ readonly owner: string;
44+ readonly pattern: string;
45+ /** Persisted rows carry a plain string; resolution narrows it. */
46+ readonly action: string;
47+ readonly position: string;
48+}
49+
50+const actionRestrictionRank = (action: ToolPolicy["action"]): number =>
51+ Match.value(action).pipe(
52+ Match.when("block", () => 3),
53+ Match.when("require_approval", () => 2),
54+ Match.when("approve", () => 1),
55+ Match.exhaustive,
56+ );
57+
58+const moreRestrictive = <T extends { readonly action: ToolPolicy["action"] }>(
59+ current: T | undefined,
60+ candidate: T,
61+): T => {
62+ if (!current) return candidate;
63+ return actionRestrictionRank(candidate.action) > actionRestrictionRank(current.action)
64+ ? candidate
65+ : current;
66+};
67+
68+const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
69+ defaultRequiresApproval
70+ ? { action: "require_approval", source: "plugin-default" }
71+ : { action: "approve", source: "plugin-default" };
72+
73+const liftUser = (match: PolicyMatch): EffectivePolicy => ({
74+ action: match.action,
75+ source: "user",
76+ pattern: match.pattern,
77+ policyId: match.policyId,
78+});
79+
80+/**
81+ * Resolve the matching authored rule for one tool id: each owner contributes
82+ * its first matching rule by local position, then the most restrictive
83+ * matched action across owners wins. `ownerRank` orders owners for the
84+ * per-owner scan (the product's precedence); it never weakens the merge.
85+ * Returns undefined when no rule matches.
86+ */
87+export const resolveToolPolicy = (
88+ toolId: string,
89+ policies: readonly ToolPolicyRuleLike[],
90+ ownerRank: (row: { readonly owner: string }) => number,
91+): PolicyMatch | undefined => {
92+ if (policies.length === 0) return undefined;
93+ const sorted = [...policies].sort((a, b) => {
94+ const sa = ownerRank(a);
95+ const sb = ownerRank(b);
96+ if (sa !== sb) return sa - sb;
97+ return comparePolicyRow(a, b);
98+ });
99+ const firstMatchByOwner = new Map<string, PolicyMatch>();
100+ for (const row of sorted) {
101+ if (firstMatchByOwner.has(row.owner)) continue;
102+ if (matchPattern(row.pattern, toolId)) {
103+ firstMatchByOwner.set(row.owner, {
104+ action: row.action as ToolPolicy["action"],
105+ pattern: row.pattern,
106+ policyId: row.id,
107+ });
108+ }
109+ }
110+ let selected: PolicyMatch | undefined;
111+ for (const match of firstMatchByOwner.values()) {
112+ selected = moreRestrictive(selected, match);
113+ }
114+ return selected;
115+};
116+
117+/**
118+ * {@link resolveToolPolicy} lifted to an {@link EffectivePolicy}: an authored
119+ * match wins, else the plugin default (`requiresApproval` →
120+ * `require_approval`, otherwise `approve`).
121+ */
122+export const resolveEffectivePolicy = (
123+ toolId: string,
124+ policies: readonly ToolPolicyRuleLike[],
125+ ownerRank: (row: { readonly owner: string }) => number,
126+ defaultRequiresApproval?: boolean,
127+): EffectivePolicy => {
128+ const match = resolveToolPolicy(toolId, policies, ownerRank);
129+ return match ? liftUser(match) : liftPlugin(defaultRequiresApproval);
130+};
131+
132+/**
133+ * UI-preview variant over an ALREADY-SORTED rule list (a settings page's
134+ * loaded policy list): same per-owner first-match + most-restrictive merge,
135+ * treating rows without an owner as one flat list.
136+ */
137+export const effectivePolicyFromSorted = (
138+ toolId: string,
139+ sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> &
140+ Partial<Pick<ToolPolicy, "owner">>)[],
141+ defaultRequiresApproval?: boolean,
142+): EffectivePolicy => {
143+ const firstMatchByOwner = new Map<string, EffectivePolicy>();
144+ for (const p of sortedPolicies) {
145+ const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__";
146+ if (firstMatchByOwner.has(ownerKey)) continue;
147+ if (matchPattern(p.pattern, toolId)) {
148+ firstMatchByOwner.set(ownerKey, {
149+ action: p.action,
150+ source: "user",
151+ pattern: p.pattern,
152+ policyId: p.id,
153+ });
154+ }
155+ }
156+ let selected: EffectivePolicy | undefined;
157+ for (const match of firstMatchByOwner.values()) {
158+ selected = moreRestrictive(selected, match);
159+ }
160+ return selected ?? liftPlugin(defaultRequiresApproval);
161+};
162+
163+const compareProviderPolicyRule = (
164+ a: ToolPolicyProviderRule,
165+ b: ToolPolicyProviderRule,
166+): number => {
167+ if (a.position < b.position) return -1;
168+ if (a.position > b.position) return 1;
169+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
170+};
171+
172+/**
173+ * Resolution for a list-only toolkit provider: first matching rule by
174+ * position wins; NO matching rule blocks — the provider is a capability
175+ * allowlist, and a tool it does not name is outside the boundary.
176+ */
177+export const resolveProviderPolicyFromRules = (
178+ toolId: string,
179+ rules: readonly ToolPolicyProviderRule[],
180+): EffectivePolicy => {
181+ for (const rule of [...rules].sort(compareProviderPolicyRule)) {
182+ if (!matchPattern(rule.pattern, toolId)) continue;
183+ return {
184+ action: rule.action,
185+ source: "user",
186+ pattern: rule.pattern,
187+ policyId: rule.id,
188+ };
189+ }
190+ return {
191+ action: "block",
192+ source: "user",
193+ pattern: "*",
194+ };
195+};
196+
197+/**
198+ * The standard product implementation of `ExecutorAccess.toolPolicy`.
199+ *
200+ * With a plugin-registered provider (a toolkit session), the provider's own
201+ * batched `prepare` / per-tool `resolve` / plain `list` allowlist governs.
202+ * Otherwise the stored owner-scoped rows resolve under the binding's owner
203+ * precedence with the most-restrictive merge and plugin-default fallback.
204+ */
205+export const standardToolPolicy =
206+ (owners: readonly string[]) =>
207+ (sources: ToolPolicySources): Effect.Effect<ToolPolicyEvaluator, StorageFailure> => {
208+ const provider: ToolPolicyProvider | null = sources.provider;
209+ if (provider) {
210+ if (provider.prepare) {
211+ // Batched per-operation resolver: fetch all policy + connection
212+ // state once, resolve every tool in the operation from the snapshot.
213+ return provider.prepare().pipe(
214+ Effect.map(
215+ (resolve): ToolPolicyEvaluator => ({
216+ resolve: (input: ToolPolicyEvaluationInput) =>
217+ Effect.succeed(
218+ resolve({
219+ toolId: input.toolId,
220+ ...(input.defaultRequiresApproval === undefined
221+ ? {}
222+ : { defaultRequiresApproval: input.defaultRequiresApproval }),
223+ }),
224+ ),
225+ }),
226+ ),
227+ );
228+ }
229+ const perTool = provider.resolve;
230+ if (perTool) {
231+ return Effect.succeed({
232+ resolve: (input: ToolPolicyEvaluationInput) =>
233+ perTool({
234+ toolId: input.toolId,
235+ ...(input.defaultRequiresApproval === undefined
236+ ? {}
237+ : { defaultRequiresApproval: input.defaultRequiresApproval }),
238+ }),
239+ });
240+ }
241+ return provider.list().pipe(
242+ Effect.map(
243+ (rules): ToolPolicyEvaluator => ({
244+ resolve: (input) => Effect.succeed(resolveProviderPolicyFromRules(input.toolId, rules)),
245+ }),
246+ ),
247+ );
248+ }
249+ const ownerRank = (row: { readonly owner: string }): number => {
250+ const rank = owners.indexOf(row.owner);
251+ return rank === -1 ? owners.length : rank;
252+ };
253+ return sources.policyRows.pipe(
254+ Effect.map(
255+ (rows): ToolPolicyEvaluator => ({
256+ resolve: (input) =>
257+ Effect.succeed(
258+ resolveEffectivePolicy(input.toolId, rows, ownerRank, input.defaultRequiresApproval),
259+ ),
260+ }),
261+ ),
262+ );
263+ };
packages/core/product-access/src/testing.ts+54−0
@@ -0,0 +1,54 @@
1+// ---------------------------------------------------------------------------
2+// Explicit product-access postures for tests. These are the PRODUCTION rules
3+// from ./index, packaged so a test states its posture at the call site —
4+// `makeTestConfig` (from `@executor-js/sdk/testing`) has no default posture.
5+//
6+// `@executor-js/sdk`'s OWN tests import this entry too — resolved through
7+// the workspace ROOT devDependency on `@executor-js/product-access`, not
8+// through the sdk package itself, which declares no dependency on this
9+// package. That keeps the package graph acyclic (turbo rejects even
10+// dev-dependency cycles) while every test states a real product posture.
11+// ---------------------------------------------------------------------------
12+
13+import { Effect } from "effect";
14+
15+import type { ExecutorAccess, OrgWriteAccess } from "@executor-js/sdk/core";
16+
17+import {
18+ memberAccess,
19+ platformObserverAccess,
20+ requestBoundMemberAccess,
21+ workspaceServiceAccess,
22+} from "./index";
23+
24+/** Explicit product postures for test bindings. */
25+export const testAccess = {
26+ /** A bound member with a static workspace-settings decision. */
27+ member: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess =>
28+ memberAccess(workspaceWrites),
29+ /** A bound member on a session-lifetime stack: the decision is read from
30+ * the fiber-local `CurrentOrgWriteAccess` at every guarded sink. */
31+ requestBound: (): ExecutorAccess => requestBoundMemberAccess(),
32+ /** A subject-less workspace-service binding (org partition only), with an
33+ * optional denied workspace-settings decision for boot-convergence
34+ * scenarios. */
35+ org: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => {
36+ const base = workspaceServiceAccess();
37+ if (workspaceWrites === "allowed") return base;
38+ return {
39+ ...base,
40+ settingsWrite: (target) =>
41+ Effect.succeed(target.kind === "owner" && target.owner === "user" ? "allowed" : "denied"),
42+ };
43+ },
44+ /** The platform observer posture; `subject: false` is the production
45+ * subject-less shape, the default keeps a bound subject's partitions for
46+ * tests that combine a member binding with the platform capabilities. */
47+ platform: (options?: { readonly subject?: boolean }): ExecutorAccess =>
48+ options?.subject === false
49+ ? platformObserverAccess()
50+ : {
51+ ...platformObserverAccess(),
52+ owners: ["user", "org"],
53+ },
54+} as const;
packages/core/product-access/tsconfig.json+27−0
@@ -0,0 +1,27 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "outDir": "dist",
9+ "rootDir": "src",
10+ "declaration": true,
11+ "declarationMap": true,
12+ "sourceMap": true,
13+ "lib": ["ES2022"],
14+ "types": ["bun-types", "node"],
15+ "noUnusedLocals": true,
16+ "noImplicitOverride": true,
17+ "plugins": [
18+ {
19+ "name": "@effect/language-service",
20+ "ignoreEffectSuggestionsInTscExitCode": true,
21+ "ignoreEffectWarningsInTscExitCode": true,
22+ "diagnosticSeverity": {}
23+ }
24+ ]
25+ },
26+ "include": ["src/**/*.ts"]
27+}
packages/core/product-access/tsup.config.ts+14−0
@@ -0,0 +1,14 @@
1+import { defineConfig } from "tsup";
2+
3+export default defineConfig({
4+ entry: {
5+ index: "src/index.ts",
6+ policy: "src/policy.ts",
7+ testing: "src/testing.ts",
8+ },
9+ format: ["esm"],
10+ dts: false,
11+ sourcemap: true,
12+ clean: true,
13+ external: [/^@executor-js\//, /^effect/, /^@effect\//],
14+});
packages/core/product-access/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ },
7+});
packages/core/sdk/README.md+14−3
@@ -7,14 +7,17 @@ The default surface is `Promise`-based — plugins are built on [Effect](https:/ · 6 unmodified lines
77 ## Install
88
99 ```sh
10−bun add @executor-js/sdk
10+bun add @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59
1111 # or
12−npm install @executor-js/sdk
12+npm install @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59
1313 ```
1414
15+`effect` is a required peer dependency (some package managers don't install peers automatically). `@executor-js/product-access` supplies the standard `access` postures `createExecutor` requires — every snippet below uses it.
16+
1517 ## Quick start
1618
1719 ```ts
20+import { workspaceServiceAccess } from "@executor-js/product-access";
1821 import { createExecutor } from "@executor-js/sdk";
1922
2023 const executor = await createExecutor({
@@ -22,6 +25,9 @@ const executor = await createExecutor({ · 1 unmodified line
2225 // `"accept-all"` auto-approves every prompt — fine for tests/automation.
2326 // For an interactive host, pass a handler `(ctx) => Promise<ElicitationResponse>`.
2427 onElicitation: "accept-all",
28+ // Required: the product's access decisions for this binding. The SDK ships
29+ // no default posture; `@executor-js/product-access` has the standard ones.
30+ access: workspaceServiceAccess(),
2531 });
2632
2733 const tools = await executor.tools.list();
@@ -35,9 +41,13 @@ await executor.close(); · 7 unmodified lines
3541 To invoke a tool once one is registered:
3642
3743 ```ts
44+import { workspaceServiceAccess } from "@executor-js/product-access";
3845 import { createExecutor } from "@executor-js/sdk";
3946
40−const executor = await createExecutor({ onElicitation: "accept-all" });
47+const executor = await createExecutor({
48+ onElicitation: "accept-all",
49+ access: workspaceServiceAccess(),
50+});
4151
4252 const tools = await executor.tools.list();
4353 const target = tools[0];
@@ -110,6 +120,7 @@ export const memorySecretsPlugin = definePlugin((options?: MemorySecretsConfig) · 66 unmodified lines
110120 const executor = await createExecutor({
111121 plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const,
112122 onElicitation: "accept-all",
123+ access: workspaceServiceAccess(),
113124 });
114125
115126 console.log(executor.memorySecrets.label); // "in-memory secrets"
packages/core/sdk/src/access.test.ts+370−0
@@ -0,0 +1,370 @@
1+// ---------------------------------------------------------------------------
2+// ExecutorAccess — the product/core boundary itself.
3+//
4+// `createExecutor` requires an access value, validates it against the
5+// binding, and ENFORCES its decisions without owning any of them. These
6+// tests pin the contract with the REAL product implementations
7+// (@executor-js/product-access, resolved via the workspace root
8+// devDependency):
9+// - a malformed access fails the boot loudly;
10+// - the settings decision is consulted live, never snapshotted;
11+// - `owners` really filters storage CRUD and the tool surfaces (org-only
12+// and personal-only views included — core forces no partition);
13+// - the `toolPolicy` hook is the ONLY policy authority core consults;
14+// - the tenant clamp holds even under an over-permissive access.
15+// ---------------------------------------------------------------------------
16+
17+import { describe, expect, it } from "@effect/vitest";
18+import { Effect, Ref } from "effect";
19+
20+import { standardToolPolicy } from "@executor-js/product-access/policy";
21+import { testAccess } from "@executor-js/product-access/testing";
22+
23+import { executorAccessViolation, type ExecutorAccess } from "./access";
24+import { createExecutor } from "./executor";
25+import {
26+ AuthTemplateSlug,
27+ ConnectionName,
28+ IntegrationSlug,
29+ ProviderItemId,
30+ ProviderKey,
31+ Tenant,
32+ ToolAddress,
33+ ToolName,
34+} from "./ids";
35+import { definePlugin } from "./plugin";
36+import type { CredentialProvider } from "./provider";
37+import { makeTestConfig } from "./testing";
38+
39+const memberShaped: ExecutorAccess = testAccess.member();
40+
41+/** A subject-bound binding whose product view is PERSONAL-ONLY — no
42+ * production posture ships this shape yet, which is exactly the point: the
43+ * contract must carry it without core forcing the org partition back in. */
44+const personalOnlyAccess = (): ExecutorAccess => ({
45+ ...testAccess.member(),
46+ owners: ["user"],
47+ toolPolicy: standardToolPolicy(["user"]),
48+});
49+
50+/** An org-only view for a subject-BOUND member (narrower than identity). */
51+const orgOnlyMemberAccess = (): ExecutorAccess => ({
52+ ...testAccess.member(),
53+ owners: ["org"],
54+ toolPolicy: standardToolPolicy(["org"]),
55+});
56+
57+const memoryProvider = (): CredentialProvider => {
58+ const store = new Map<string, string>();
59+ return {
60+ key: ProviderKey.make("memory"),
61+ writable: true,
62+ get: (id) => Effect.sync(() => store.get(String(id)) ?? null),
63+ set: (id, value) => Effect.sync(() => void store.set(String(id), value)),
64+ delete: (id) => Effect.sync(() => void store.delete(String(id))),
65+ has: (id) => Effect.sync(() => store.has(String(id))),
66+ list: () =>
67+ Effect.sync(() =>
68+ Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key })),
69+ ),
70+ };
71+};
72+
73+const INTEG = IntegrationSlug.make("vercel");
74+const TEMPLATE = AuthTemplateSlug.make("apiKey");
75+
76+const demoPlugin = definePlugin(() => ({
77+ id: "demo" as const,
78+ credentialProviders: [memoryProvider()],
79+ storage: () => ({}),
80+ resolveTools: () =>
81+ Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }),
82+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
83+ extension: (ctx) => ({
84+ seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }),
85+ }),
86+}))();
87+
88+/** Seed one org and one user connection (with their tools) plus one org and
89+ * one user policy row, through a full-view member executor. */
90+const seedWorkspace = Effect.fn("seedWorkspace")(function* (
91+ config: ReturnType<typeof makeTestConfig<readonly [typeof demoPlugin]>>,
92+) {
93+ const admin = yield* createExecutor(config);
94+ yield* Effect.addFinalizer(() => admin.close().pipe(Effect.ignore));
95+ yield* admin.demo.seed();
96+ yield* admin.connections.create({
97+ owner: "org",
98+ name: ConnectionName.make("shared"),
99+ integration: INTEG,
100+ template: TEMPLATE,
101+ value: "org-token",
102+ });
103+ yield* admin.connections.create({
104+ owner: "user",
105+ name: ConnectionName.make("mine"),
106+ integration: INTEG,
107+ template: TEMPLATE,
108+ value: "user-token",
109+ });
110+ yield* admin.policies.create({ owner: "org", pattern: "github.*", action: "block" });
111+ yield* admin.policies.create({ owner: "user", pattern: "linear.*", action: "approve" });
112+ return admin;
113+});
114+
115+describe("executorAccessViolation", () => {
116+ it("accepts the production postures against their bindings", () => {
117+ expect(executorAccessViolation(memberShaped, "subject-1")).toBeNull();
118+ expect(executorAccessViolation(testAccess.org(), null)).toBeNull();
119+ expect(executorAccessViolation(testAccess.platform({ subject: false }), null)).toBeNull();
120+ // Personal-only is a VALID product shape: core mandates no partition.
121+ expect(executorAccessViolation(personalOnlyAccess(), "subject-1")).toBeNull();
122+ });
123+
124+ it("rejects an empty owners list", () => {
125+ expect(executorAccessViolation({ ...memberShaped, owners: [] }, "subject-1")).toContain(
126+ "must not be empty",
127+ );
128+ });
129+
130+ it("rejects a duplicated owner", () => {
131+ expect(executorAccessViolation({ ...memberShaped, owners: ["user", "user"] }, "s")).toContain(
132+ "repeat",
133+ );
134+ });
135+
136+ it("rejects a user partition on a subject-less binding", () => {
137+ expect(executorAccessViolation(memberShaped, null)).toContain("no subject");
138+ });
139+});
140+
141+describe("createExecutor access validation", () => {
142+ it.effect("fails the boot when the access does not fit the binding", () =>
143+ Effect.gen(function* () {
144+ const config = makeTestConfig({ access: testAccess.org(), subject: null });
145+ const error = yield* Effect.flip(
146+ // member-shaped access (user partition) on a subject-less binding.
147+ createExecutor({ ...config, access: testAccess.member() }),
148+ );
149+ expect(error).toMatchObject({
150+ _tag: "StorageError",
151+ message: expect.stringContaining("no subject"),
152+ });
153+ }),
154+ );
155+});
156+
157+describe("settingsWrite is consulted live", () => {
158+ it.effect("a flipped decision applies to the next guarded call, not a snapshot", () =>
159+ Effect.gen(function* () {
160+ const decision = yield* Ref.make<"allowed" | "denied">("allowed");
161+ const config = makeTestConfig({
162+ access: {
163+ ...testAccess.member(),
164+ settingsWrite: (target) =>
165+ target.kind === "owner" && target.owner === "user"
166+ ? Effect.succeed("allowed" as const)
167+ : Ref.get(decision),
168+ },
169+ });
170+ const executor = yield* createExecutor(config);
171+ yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore));
172+
173+ const created = yield* executor.policies.create({
174+ owner: "org",
175+ pattern: "vercel.*",
176+ action: "block",
177+ });
178+ expect(created.pattern).toBe("vercel.*");
179+
180+ yield* Ref.set(decision, "denied");
181+ const error = yield* Effect.flip(
182+ executor.policies.create({ owner: "org", pattern: "github.*", action: "block" }),
183+ );
184+ expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" });
185+
186+ // Personal-scope writes stay open under the denied decision.
187+ const personal = yield* executor.policies.create({
188+ owner: "user",
189+ pattern: "linear.*",
190+ action: "approve",
191+ });
192+ expect(personal.owner).toBe("user");
193+ }).pipe(Effect.scoped),
194+ );
195+});
196+
197+describe("owners really filters storage and tool surfaces", () => {
198+ it.effect("an org-only member view sees no user rows anywhere", () =>
199+ Effect.gen(function* () {
200+ const config = makeTestConfig({
201+ access: testAccess.member(),
202+ plugins: [demoPlugin] as const,
203+ });
204+ yield* seedWorkspace(config);
205+
206+ const orgOnly = yield* createExecutor({ ...config, access: orgOnlyMemberAccess() });
207+ yield* Effect.addFinalizer(() => orgOnly.close().pipe(Effect.ignore));
208+
209+ const policies = yield* orgOnly.policies.list();
210+ expect(policies.map((p) => p.owner)).toEqual(["org"]);
211+
212+ const connections = yield* orgOnly.connections.list();
213+ expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["org:shared"]);
214+
215+ const tools = yield* orgOnly.tools.list();
216+ const dynamic = tools.filter((t) => !t.static);
217+ expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["org:shared"]);
218+
219+ // The excluded partition is not writable either: the settings gate
220+ // (personal stays open) passes, and the STORAGE clamp refuses.
221+ const error = yield* Effect.flip(
222+ orgOnly.policies.create({ owner: "user", pattern: "x.*", action: "approve" }),
223+ );
224+ expect(error).toMatchObject({
225+ _tag: "StorageError",
226+ message: expect.stringContaining("does not include"),
227+ });
228+ }).pipe(Effect.scoped),
229+ );
230+
231+ it.effect("a personal-only view sees no org rows anywhere", () =>
232+ Effect.gen(function* () {
233+ const config = makeTestConfig({
234+ access: testAccess.member(),
235+ plugins: [demoPlugin] as const,
236+ });
237+ yield* seedWorkspace(config);
238+
239+ const personal = yield* createExecutor({ ...config, access: personalOnlyAccess() });
240+ yield* Effect.addFinalizer(() => personal.close().pipe(Effect.ignore));
241+
242+ const policies = yield* personal.policies.list();
243+ expect(policies.map((p) => p.owner)).toEqual(["user"]);
244+
245+ const connections = yield* personal.connections.list();
246+ expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["user:mine"]);
247+
248+ const tools = yield* personal.tools.list();
249+ const dynamic = tools.filter((t) => !t.static);
250+ expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["user:mine"]);
251+
252+ // Invoking a tool over the hidden org connection fails as not-found —
253+ // the row is invisible, not merely blocked.
254+ const invokeError = yield* Effect.flip(
255+ personal.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}),
256+ );
257+ expect(invokeError).toMatchObject({ _tag: "ToolNotFoundError" });
258+
259+ // Shared-catalog (org-partition) writes refuse loudly at storage.
260+ const writeError = yield* Effect.flip(
261+ personal.policies.create({ owner: "org", pattern: "x.*", action: "block" }),
262+ );
263+ expect(writeError).toMatchObject({
264+ _tag: "StorageError",
265+ message: expect.stringContaining("does not include"),
266+ });
267+ }).pipe(Effect.scoped),
268+ );
269+});
270+
271+describe("the product toolPolicy hook is the only policy authority", () => {
272+ it.effect("a hook that blocks everything gates execute and list, whatever rows say", () =>
273+ Effect.gen(function* () {
274+ const config = makeTestConfig({
275+ access: testAccess.member(),
276+ plugins: [demoPlugin] as const,
277+ });
278+ yield* seedWorkspace(config);
279+
280+ const blockAll: ExecutorAccess = {
281+ ...testAccess.member(),
282+ toolPolicy: () =>
283+ Effect.succeed({
284+ resolve: () =>
285+ Effect.succeed({ action: "block" as const, source: "user" as const, pattern: "*" }),
286+ }),
287+ };
288+ const executor = yield* createExecutor({ ...config, access: blockAll });
289+ yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore));
290+
291+ const tools = yield* executor.tools.list();
292+ expect(tools.filter((t) => !t.static)).toHaveLength(0);
293+ const error = yield* Effect.flip(
294+ executor.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}),
295+ );
296+ expect(error).toMatchObject({ _tag: "ToolBlockedError" });
297+ }).pipe(Effect.scoped),
298+ );
299+
300+ it.effect("a hook that approves everything overrides a stored org block", () =>
301+ Effect.gen(function* () {
302+ const config = makeTestConfig({
303+ access: testAccess.member(),
304+ plugins: [demoPlugin] as const,
305+ });
306+ const admin = yield* seedWorkspace(config);
307+ // A stored rule that would block the tool under the standard product
308+ // resolution — proving core itself never consults the rows.
309+ yield* admin.policies.create({ owner: "org", pattern: "*", action: "block" });
310+
311+ const approveAll: ExecutorAccess = {
312+ ...testAccess.member(),
313+ toolPolicy: () =>
314+ Effect.succeed({
315+ resolve: () =>
316+ Effect.succeed({
317+ action: "approve" as const,
318+ source: "plugin-default" as const,
319+ }),
320+ }),
321+ };
322+ const executor = yield* createExecutor({ ...config, access: approveAll });
323+ yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore));
324+
325+ const result = yield* executor.execute(
326+ ToolAddress.make(`tools.${INTEG}.org.shared.deploy`),
327+ {},
328+ );
329+ expect(result).toMatchObject({ ran: "deploy" });
330+ }).pipe(Effect.scoped),
331+ );
332+});
333+
334+describe("tenant clamp is not product-configurable", () => {
335+ it.effect("an over-permissive access never reads another tenant's rows", () =>
336+ Effect.gen(function* () {
337+ const config = makeTestConfig({
338+ access: testAccess.member(),
339+ plugins: [demoPlugin] as const,
340+ });
341+ yield* seedWorkspace(config);
342+
343+ // Same database, different tenant, maximally permissive product view.
344+ const intruder = yield* createExecutor({
345+ ...config,
346+ tenant: Tenant.make("other-tenant"),
347+ access: {
348+ ...testAccess.member(),
349+ toolPolicy: () =>
350+ Effect.succeed({
351+ resolve: () =>
352+ Effect.succeed({
353+ action: "approve" as const,
354+ source: "plugin-default" as const,
355+ }),
356+ }),
357+ },
358+ });
359+ yield* Effect.addFinalizer(() => intruder.close().pipe(Effect.ignore));
360+
361+ expect(yield* intruder.policies.list()).toHaveLength(0);
362+ expect(yield* intruder.connections.list()).toHaveLength(0);
363+ expect((yield* intruder.tools.list()).filter((t) => !t.static)).toHaveLength(0);
364+ const error = yield* Effect.flip(
365+ intruder.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}),
366+ );
367+ expect(error).toMatchObject({ _tag: "ToolNotFoundError" });
368+ }).pipe(Effect.scoped),
369+ );
370+});
packages/core/sdk/src/access.ts+215−0
@@ -0,0 +1,215 @@
1+import { Context, Effect, Ref } from "effect";
2+
3+import type { ToolPolicyRow } from "./core-schema";
4+import type { StorageFailure } from "./fuma-runtime";
5+import type { Owner } from "./ids";
6+import type { ToolPolicyProvider } from "./plugin";
7+import type { EffectivePolicy } from "./policies";
8+
9+// ---------------------------------------------------------------------------
10+// Product access — the defined interface through which the PRODUCT hands the
11+// executor its personal/organization access decisions.
12+//
13+// Core deliberately owns no product rule here: it does not know what a role
14+// is, which member is an admin, or why a deployment has no role model at all.
15+// Core defines the decision vocabulary (this module), consults the supplied
16+// decisions at its enforcement sinks, and keeps enforcing the security
17+// invariants that are NOT product-configurable: tenant isolation and the
18+// storage owner policy (owner-policy.ts), which clamp whatever the product
19+// supplies. `ExecutorConfig.access` is required — there is no default posture
20+// in core, so every composition root states its product's rules explicitly.
21+// ---------------------------------------------------------------------------
22+
23+/** Workspace-settings authorization bound to the currently executing request. */
24+export type OrgWriteAccess = "allowed" | "denied";
25+
26+/** A single product authorization answer core enforces verbatim. */
27+export type AccessDecision = "allowed" | "denied";
28+
29+/**
30+ * The target of a user-intent settings mutation, as core describes it to the
31+ * product's {@link ExecutorAccess.settingsWrite} rule:
32+ *
33+ * - `{ kind: "owner", owner }` — an owner-scoped settings row (a connection,
34+ * tool policy, OAuth client, …) filed under that partition.
35+ * - `{ kind: "workspace" }` — a tenant-shared surface (the integration
36+ * catalog and other whole-workspace settings).
37+ */
38+export type SettingsWriteTarget =
39+ | { readonly kind: "owner"; readonly owner: Owner }
40+ | { readonly kind: "workspace" };
41+
42+/**
43+ * Structural view capabilities the product grants a binding. Core maps each
44+ * to a non-overridable mechanism:
45+ *
46+ * - `adminReads` — expose `executor.admin`, the ONLY surface whose reads are
47+ * tenant-wide. The widened context is read-only by construction
48+ * (owner-policy reach mechanics), regardless of what the product says.
49+ * - `storageWrites` — `"denied"` makes the WHOLE executor read-only at the
50+ * storage boundary (every create/update/delete on every guarded table is
51+ * refused, and catalog re-sync is skipped). The platform observer posture
52+ * combines both.
53+ */
54+export interface ExecutorAccessCapabilities {
55+ readonly adminReads: boolean;
56+ readonly storageWrites: AccessDecision;
57+}
58+
59+/** One effective-policy question, as core asks the product's evaluator. */
60+export interface ToolPolicyEvaluationInput {
61+ /** Normalized policy id: a static tool's address, or the 4-segment
62+ * `<integration>.<owner>.<connection>.<tool>` form. */
63+ readonly toolId: string;
64+ /** The tool's own `requiresApproval` annotation — the fallback material a
65+ * product resolution may lift when no authored rule matches. */
66+ readonly defaultRequiresApproval?: boolean | undefined;
67+}
68+
69+/**
70+ * A per-operation effective-policy evaluator returned by
71+ * {@link ExecutorAccess.toolPolicy}. Built once per surface operation (one
72+ * tools list / schema read / invocation / post-approval recheck) so a product
73+ * implementation can batch its underlying reads; `resolve` is then asked once
74+ * per tool. Core ENFORCES whatever comes back — `block` refuses the call,
75+ * `require_approval` gates it behind an elicitation — but never decides it.
76+ */
77+export interface ToolPolicyEvaluator {
78+ readonly resolve: (
79+ input: ToolPolicyEvaluationInput,
80+ ) => Effect.Effect<EffectivePolicy, StorageFailure>;
81+}
82+
83+/**
84+ * The rule material core hands the product's {@link ExecutorAccess.toolPolicy}
85+ * hook for one operation. Both members are the product's to consult or
86+ * ignore; core pre-selects nothing.
87+ */
88+export interface ToolPolicySources {
89+ /**
90+ * The stored, owner-scoped `tool_policy` rows visible to this binding —
91+ * a LAZY effect, fetched only if the product's resolution reads it.
92+ */
93+ readonly policyRows: Effect.Effect<readonly ToolPolicyRow[], StorageFailure>;
94+ /**
95+ * The plugin-registered policy provider, when a plugin opted this executor
96+ * instance into a session rule source (a toolkit's capability set), else
97+ * null. Toolkit sessions also hide catalog entries that grant no tools;
98+ * that presentation follows the provider's presence (part of the
99+ * `ToolPolicyProvider` plugin contract), while HOW its rules resolve —
100+ * including the empty-match default — is decided here, by the product.
101+ */
102+ readonly provider: ToolPolicyProvider | null;
103+}
104+
105+/**
106+ * Product-owned access decisions for one executor binding. Supplied by the
107+ * host's composition root (see `@executor-js/product-access` for the product
108+ * rule implementations); enforced — never decided — by core.
109+ *
110+ * Shape invariants, validated at `createExecutor` (violations fail the boot):
111+ * - `owners` is a non-empty, duplicate-free subset of `["user", "org"]`;
112+ * - `"user"` may appear only when the executor binds a subject.
113+ *
114+ * Core's non-overridable clamps apply to every decision: the tenant clause
115+ * is never relaxed, user rows only ever resolve to the bound subject,
116+ * tenant-reach contexts are read-only (or delete-only, for the internal
117+ * removal cascade), and a mid-run tool is never interrupted by a policy
118+ * change — `block` means "don't start new runs".
119+ *
120+ * PAUSED EXECUTIONS: a pause/resume boundary re-evaluates the
121+ * request-bound `settingsWrite` decision as the RESUMING principal (via
122+ * {@link CurrentOrgWriteAccess}), and the post-approval recheck re-resolves
123+ * the tool policy and re-reads rows — but storage reads and credential
124+ * resolution still run under the STARTER's binding, whose executor instance
125+ * owns the paused fiber.
126+ */
127+export interface ExecutorAccess {
128+ /**
129+ * ROW VISIBILITY AND WRITE AUTHORIZATION: the owner partitions this
130+ * binding sees and may write, in precedence order (the first entry shadows
131+ * later ones on plugin-storage reads, ranks first in policy listings, and
132+ * is the partition static tools present under). Carried onto every storage
133+ * context: reads, updates and deletes are FILTERED to these partitions and
134+ * creates outside them are refused — an org-only or personal-only product
135+ * view really is one, across storage CRUD and the tool surfaces built on
136+ * it. Core contributes no partition of its own.
137+ */
138+ readonly owners: readonly Owner[];
139+ /**
140+ * USER-INTENT SETTINGS AUTHORIZATION, including the owner-target rule.
141+ * Consulted live at every guarded sink (never cached by core, so a
142+ * request-bound implementation is re-read after pauses and on resume);
143+ * `"denied"` surfaces as `OrgWriteDeniedError`. Operational writes a
144+ * denied member's usage implies (token refresh, catalog re-sync) do not
145+ * pass through this gate — they are bounded by `owners` and the storage
146+ * clamps instead.
147+ */
148+ readonly settingsWrite: (target: SettingsWriteTarget) => Effect.Effect<AccessDecision>;
149+ /** View capabilities — see {@link ExecutorAccessCapabilities}. */
150+ readonly capabilities: ExecutorAccessCapabilities;
151+ /**
152+ * EFFECTIVE TOOL-POLICY EVALUATION. Called once per surface operation with
153+ * this binding's rule material; the returned evaluator answers every
154+ * per-tool question in that operation. Owner ranking, cross-owner merging,
155+ * plugin-default fallback and the capability-allowlist default all live in
156+ * the product implementation.
157+ */
158+ readonly toolPolicy: (
159+ sources: ToolPolicySources,
160+ ) => Effect.Effect<ToolPolicyEvaluator, StorageFailure>;
161+}
162+
163+/**
164+ * Validate an {@link ExecutorAccess} against the executor's `{ tenant,
165+ * subject }` binding. Returns null when well-formed; a violation is a
166+ * programmer error at a composition root and fails fast via the returned
167+ * message so `createExecutor` can surface it as a startup failure.
168+ */
169+export const executorAccessViolation = (
170+ access: ExecutorAccess,
171+ subject: string | null,
172+): string | null => {
173+ const owners = access.owners;
174+ if (owners.length === 0) return "ExecutorAccess.owners must not be empty.";
175+ if (new Set(owners).size !== owners.length) {
176+ return "ExecutorAccess.owners must not repeat an owner.";
177+ }
178+ if (owners.includes("user") && subject == null) {
179+ return 'ExecutorAccess.owners includes "user" but the executor has no subject.';
180+ }
181+ return null;
182+};
183+
184+/**
185+ * Fiber-local workspace-settings authorization for request-bound executors.
186+ *
187+ * This is the decision CARRIER, not a decision rule: a session host stamps it
188+ * from each freshly authenticated request, the execution engine snapshots the
189+ * state onto a paused execution and re-stamps `current` from the resuming (or
190+ * joining) principal's fiber, and a request-bound `ExecutorAccess`
191+ * implementation reads it at every guarded sink. The denied default makes a
192+ * missing request binding fail closed. Executors whose product supplies a
193+ * static decision never consult this reference.
194+ */
195+export interface OrgWriteAccessState {
196+ /** Mutable value inherited by a detached execution and refreshed on resume. */
197+ readonly current: Ref.Ref<OrgWriteAccess>;
198+}
199+
200+/** Create an isolated request/execution authorization state. */
201+export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({
202+ current: Ref.makeUnsafe(access),
203+});
204+
205+/** Request-local workspace-write authorization inherited by child fibers. */
206+export const CurrentOrgWriteAccess = Context.Reference<OrgWriteAccessState>(
207+ "@executor-js/sdk/CurrentOrgWriteAccess",
208+ { defaultValue: () => makeOrgWriteAccessState("denied") },
209+);
210+
211+/** Read the effective authorization at a workspace-write sink. */
212+export const currentOrgWriteAccess: Effect.Effect<OrgWriteAccess> = Effect.gen(function* () {
213+ const state = yield* CurrentOrgWriteAccess;
214+ return yield* Ref.get(state.current);
215+});
packages/core/sdk/src/approval-access-recheck.test.ts+138−0
@@ -0,0 +1,138 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Ref, Schema } from "effect";
3+
4+import { ElicitationResponse } from "./elicitation";
5+import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids";
6+import { definePlugin, tool } from "./plugin";
7+import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
8+import { testAccess } from "@executor-js/product-access/testing";
9+
10+const integration = IntegrationSlug.make("approvalcheck");
11+const connection = ConnectionName.make("main");
12+const staticAddress = ToolAddress.make("approvalcheck.control.run");
13+const dynamicAddress = ToolAddress.make("tools.approvalcheck.org.main.run");
14+
15+const setup = Effect.gen(function* () {
16+ const invocations = yield* Ref.make(0);
17+ const recordInvocation = Ref.update(invocations, (count) => count + 1).pipe(
18+ Effect.as("executed"),
19+ );
20+ const plugin = definePlugin(() => ({
21+ id: "approval-check" as const,
22+ storage: () => ({}),
23+ staticIntegrations: () => [
24+ {
25+ id: "approvalcheck.control",
26+ kind: "control" as const,
27+ name: "Approval check",
28+ tools: [
29+ tool({
30+ name: "run",
31+ description: "Record an invocation after approval.",
32+ annotations: { requiresApproval: true },
33+ inputSchema: Schema.toStandardSchemaV1(
34+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
35+ ),
36+ execute: () => recordInvocation,
37+ }),
38+ ],
39+ },
40+ ],
41+ resolveTools: () =>
42+ Effect.succeed({
43+ tools: [
44+ {
45+ name: ToolName.make("run"),
46+ description: "Record an invocation after approval.",
47+ annotations: { requiresApproval: true },
48+ },
49+ ],
50+ }),
51+ invokeTool: () => recordInvocation,
52+ extension: (ctx) => ({
53+ register: () =>
54+ ctx.core.integrations.register({
55+ slug: integration,
56+ description: "Approval check",
57+ config: {},
58+ }),
59+ }),
60+ }));
61+ const executor = yield* makeTestExecutor({
62+ access: testAccess.member(),
63+ plugins: [plugin(), memoryCredentialsPlugin()] as const,
64+ });
65+ yield* executor["approval-check"].register();
66+ yield* executor.connections.create({
67+ owner: "org",
68+ integration,
69+ name: connection,
70+ template: AuthTemplateSlug.make("none"),
71+ value: "synthetic-approval-test-key",
72+ });
73+ return { executor, invocations };
74+});
75+
76+describe("access changes while approval is pending", () => {
77+ for (const address of [staticAddress, dynamicAddress]) {
78+ it.effect(`blocks ${address} if policy changes before acceptance`, () =>
79+ Effect.gen(function* () {
80+ const { executor, invocations } = yield* setup;
81+ const error = yield* Effect.flip(
82+ executor.execute(
83+ address,
84+ {},
85+ {
86+ onElicitation: () =>
87+ executor.policies
88+ .create({ owner: "org", pattern: "approvalcheck.*", action: "block" })
89+ .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))),
90+ },
91+ ),
92+ );
93+ expect(error).toMatchObject({ _tag: "ToolBlockedError" });
94+ expect(yield* Ref.get(invocations)).toBe(0);
95+ }),
96+ );
97+ }
98+
99+ it.effect("does not invoke a connection disconnected before acceptance", () =>
100+ Effect.gen(function* () {
101+ const { executor, invocations } = yield* setup;
102+ const error = yield* Effect.flip(
103+ executor.execute(
104+ dynamicAddress,
105+ {},
106+ {
107+ onElicitation: () =>
108+ executor.connections
109+ .remove({ owner: "org", integration, name: connection })
110+ .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))),
111+ },
112+ ),
113+ );
114+ expect(error).toMatchObject({ _tag: "ToolNotFoundError" });
115+ expect(yield* Ref.get(invocations)).toBe(0);
116+ }),
117+ );
118+
119+ it.effect("accepts once when access stays valid", () =>
120+ Effect.gen(function* () {
121+ const { executor, invocations } = yield* setup;
122+ const approvals = yield* Ref.make(0);
123+ const result = yield* executor.execute(
124+ dynamicAddress,
125+ {},
126+ {
127+ onElicitation: () =>
128+ Ref.update(approvals, (count) => count + 1).pipe(
129+ Effect.as(ElicitationResponse.make({ action: "accept" })),
130+ ),
131+ },
132+ );
133+ expect(result).toBe("executed");
134+ expect(yield* Ref.get(invocations)).toBe(1);
135+ expect(yield* Ref.get(approvals)).toBe(1);
136+ }),
137+ );
138+});
packages/core/sdk/src/approval-plugin-swap.test.ts+90−0
@@ -0,0 +1,90 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Ref } from "effect";
3+
4+import { ElicitationResponse } from "./elicitation";
5+import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids";
6+import { definePlugin } from "./plugin";
7+import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
8+import { testAccess } from "@executor-js/product-access/testing";
9+
10+// ---------------------------------------------------------------------------
11+// Regression: the plugin RUNTIME bound before an approval pause must never
12+// invoke a REPLACEMENT tool row. If the integration is torn down and
13+// re-registered under a different plugin while the call waits on a human,
14+// the tool the user approved no longer exists in that form — the resumed
15+// call fails as not-found instead of handing the new row (and the new
16+// connection's credential) to the old plugin's handler.
17+// ---------------------------------------------------------------------------
18+
19+const integration = IntegrationSlug.make("swapcheck");
20+const connection = ConnectionName.make("main");
21+const address = ToolAddress.make("tools.swapcheck.org.main.run");
22+
23+describe("integration replaced under a different plugin during approval", () => {
24+ it.effect("fails as not-found; neither plugin's handler runs", () =>
25+ Effect.gen(function* () {
26+ const oldInvocations = yield* Ref.make(0);
27+ const newInvocations = yield* Ref.make(0);
28+
29+ const makeCatalogPlugin = (id: "swap-old" | "swap-new", invocations: Ref.Ref<number>) =>
30+ definePlugin(() => ({
31+ id,
32+ storage: () => ({}),
33+ resolveTools: () =>
34+ Effect.succeed({
35+ tools: [
36+ {
37+ name: ToolName.make("run"),
38+ description: "Run after approval.",
39+ annotations: { requiresApproval: true },
40+ },
41+ ],
42+ }),
43+ invokeTool: () => Ref.update(invocations, (count) => count + 1).pipe(Effect.as("ran")),
44+ extension: (ctx) => ({
45+ register: () =>
46+ ctx.core.integrations.register({
47+ slug: integration,
48+ description: `registered by ${id}`,
49+ config: {},
50+ }),
51+ }),
52+ }))();
53+
54+ const executor = yield* makeTestExecutor({
55+ access: testAccess.member(),
56+ plugins: [
57+ makeCatalogPlugin("swap-old", oldInvocations),
58+ makeCatalogPlugin("swap-new", newInvocations),
59+ memoryCredentialsPlugin(),
60+ ] as const,
61+ });
62+ yield* executor["swap-old"].register();
63+ const createConnection = executor.connections.create({
64+ owner: "org",
65+ integration,
66+ name: connection,
67+ template: AuthTemplateSlug.make("none"),
68+ value: "synthetic-swap-test-key",
69+ });
70+ yield* createConnection;
71+
72+ const swapPlugins = Effect.gen(function* () {
73+ // Tear the integration down (cascading its connection and tool rows)
74+ // and re-register the SAME slug under the other plugin, with a fresh
75+ // connection producing a same-named tool row owned by `swap-new`.
76+ yield* executor.integrations.remove(integration);
77+ yield* executor["swap-new"].register();
78+ yield* createConnection;
79+ return ElicitationResponse.make({ action: "accept" });
80+ }).pipe(Effect.orDie);
81+
82+ const error = yield* Effect.flip(
83+ executor.execute(address, {}, { onElicitation: () => swapPlugins }),
84+ );
85+ expect(error).toMatchObject({ _tag: "ToolNotFoundError" });
86+ expect(yield* Ref.get(oldInvocations)).toBe(0);
87+ expect(yield* Ref.get(newInvocations)).toBe(0);
88+ }),
89+ );
90+});
packages/core/sdk/src/artifact-table.test.ts+10−2
@@ -119,11 +119,19 @@ describe("artifact table", () => { · 118 unmodified lines
119119 title: "Theirs",
120120 });
121121
122− const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
122+ const scoped = withQueryContext(db.db, {
123+ tenant: TENANT,
124+ subject: SUBJECT,
125+ owners: ["user", "org"],
126+ });
123127 const visible = await scoped.findMany("artifact", {});
124128 expect(visible.map((row) => row.id)).toEqual(["art_mine"]);
125129
126− const otherScoped = withQueryContext(db.db, { tenant: TENANT, subject: OTHER_SUBJECT });
130+ const otherScoped = withQueryContext(db.db, {
131+ tenant: TENANT,
132+ subject: OTHER_SUBJECT,
133+ owners: ["user", "org"],
134+ });
127135 const otherVisible = await otherScoped.findMany("artifact", {});
128136 expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]);
129137 }),
packages/core/sdk/src/artifacts.test.ts+24−23
@@ -4,6 +4,7 @@ import { Effect, Predicate, Result } from "effect"; · 3 unmodified lines
44 import { makeTestExecutor } from "./testing";
55 import type { ArtifactBinding } from "./artifact";
66 import { ConnectionName, IntegrationSlug } from "./ids";
7+import { testAccess } from "@executor-js/product-access/testing";
78
89 const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({
910 integration: IntegrationSlug.make(integration),
@@ -20,14 +21,14 @@ const CODE = "export default function App() { return <div>hi</div>; }"; · 10 unmodified lines
2021 describe("executor.artifacts", () => {
2122 it.effect("list is empty when nothing is saved", () =>
2223 Effect.gen(function* () {
23− const executor = yield* makeTestExecutor();
24+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
2425 expect(yield* executor.artifacts.list()).toEqual([]);
2526 }),
2627 );
2728
2829 it.effect("save mints an id and stores the source", () =>
2930 Effect.gen(function* () {
30− const executor = yield* makeTestExecutor();
31+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
3132 const saved = yield* executor.artifacts.save({
3233 title: "Active users",
3334 description: "Daily active users over time",
@@ -46,7 +47,7 @@ describe("executor.artifacts", () => { · 12 unmodified lines
4647
4748 it.effect("round-trips connection bindings through the json column", () =>
4849 Effect.gen(function* () {
49− const executor = yield* makeTestExecutor();
50+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
5051 const bindings = {
5152 vercel: binding("vercel", "user", "personalVercel"),
5253 prod: binding("linear", "org", "linearProd"),
@@ -59,7 +60,7 @@ describe("executor.artifacts", () => { · 6 unmodified lines
5960
6061 it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () =>
6162 Effect.gen(function* () {
62− const executor = yield* makeTestExecutor();
63+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
6364 const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} });
6465 // `{}` and `null` are different facts: `{}` is an artifact that calls no
6566 // integration, `null` is one written before bindings existed and whose
@@ -71,7 +72,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
7172
7273 it.effect("leaves bindings null when a save omits them", () =>
7374 Effect.gen(function* () {
74− const executor = yield* makeTestExecutor();
75+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
7576 const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE });
7677 expect(saved.bindings).toBe(null);
7778 }),
@@ -79,7 +80,7 @@ describe("executor.artifacts", () => { · 1 unmodified line
7980
8081 it.effect("overwrites bindings along with the source", () =>
8182 Effect.gen(function* () {
82− const executor = yield* makeTestExecutor();
83+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
8384 const saved = yield* executor.artifacts.save({
8485 title: "Domains",
8586 code: CODE,
@@ -100,7 +101,7 @@ describe("executor.artifacts", () => { · 14 unmodified lines
100101
101102 it.effect("save defaults a missing description to null", () =>
102103 Effect.gen(function* () {
103− const executor = yield* makeTestExecutor();
104+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
104105 const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE });
105106 expect(saved.description).toBe(null);
106107 }),
@@ -108,7 +109,7 @@ describe("executor.artifacts", () => { · 1 unmodified line
108109
109110 it.effect("list omits the source", () =>
110111 Effect.gen(function* () {
111− const executor = yield* makeTestExecutor();
112+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
112113 const saved = yield* executor.artifacts.save({
113114 title: "Active users",
114115 description: "described",
@@ -141,7 +142,7 @@ describe("executor.artifacts", () => { · 26 unmodified lines
141142 // storage layer really has.
142143 it.live("list returns the most recently updated artifact first", () =>
143144 Effect.gen(function* () {
144− const executor = yield* makeTestExecutor();
145+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
145146 const first = yield* executor.artifacts.save({ title: "First", code: CODE });
146147 yield* Effect.sleep("1100 millis");
147148 const second = yield* executor.artifacts.save({ title: "Second", code: CODE });
@@ -159,7 +160,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
159160
160161 it.effect("save with an id overwrites in place", () =>
161162 Effect.gen(function* () {
162− const executor = yield* makeTestExecutor();
163+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
163164 const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE });
164165 const updated = yield* executor.artifacts.save({
165166 id: saved.id,
@@ -182,7 +183,7 @@ describe("executor.artifacts", () => { · 16 unmodified lines
182183
183184 it.effect("save fails for an id that names no visible artifact", () =>
184185 Effect.gen(function* () {
185− const executor = yield* makeTestExecutor();
186+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
186187 const result = yield* Effect.result(
187188 executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }),
188189 );
@@ -194,7 +195,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
194195
195196 it.effect("get fails for an unknown id", () =>
196197 Effect.gen(function* () {
197− const executor = yield* makeTestExecutor();
198+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
198199 const result = yield* Effect.result(executor.artifacts.get("art_missing"));
199200 expect(Result.isFailure(result)).toBe(true);
200201 if (!Result.isFailure(result)) return;
@@ -204,7 +205,7 @@ describe("executor.artifacts", () => { · 3 unmodified lines
204205
205206 it.effect("rename changes only the title", () =>
206207 Effect.gen(function* () {
207− const executor = yield* makeTestExecutor();
208+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
208209 const saved = yield* executor.artifacts.save({
209210 title: "Old name",
210211 description: "kept",
@@ -219,7 +220,7 @@ describe("executor.artifacts", () => { · 8 unmodified lines
219220
220221 it.effect("rename fails for an unknown id", () =>
221222 Effect.gen(function* () {
222− const executor = yield* makeTestExecutor();
223+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
223224 const result = yield* Effect.result(
224225 executor.artifacts.rename({ id: "art_missing", title: "Nope" }),
225226 );
@@ -231,7 +232,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
231232
232233 it.effect("remove hard-deletes the artifact", () =>
233234 Effect.gen(function* () {
234− const executor = yield* makeTestExecutor();
235+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
235236 const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE });
236237 yield* executor.artifacts.remove({ id: saved.id });
237238
@@ -245,7 +246,7 @@ describe("executor.artifacts", () => { · 7 unmodified lines
245246
246247 it.effect("remove is idempotent", () =>
247248 Effect.gen(function* () {
248− const executor = yield* makeTestExecutor();
249+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
249250 yield* executor.artifacts.remove({ id: "art_missing" });
250251 }),
251252 );
@@ -254,7 +255,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines
254255 Effect.gen(function* () {
255256 // Pure-org executors (host request paths with no member bound) have no
256257 // user partition to file a personal artifact into.
257− const executor = yield* makeTestExecutor({ subject: null });
258+ const executor = yield* makeTestExecutor({ access: testAccess.org(), subject: null });
258259 const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE }));
259260 expect(Result.isFailure(result)).toBe(true);
260261 }),
@@ -266,7 +267,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
266267
267268 it.effect("round-trips layout markup, and reads it back as a layout preview", () =>
268269 Effect.gen(function* () {
269− const executor = yield* makeTestExecutor();
270+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
270271 const saved = yield* executor.artifacts.save({
271272 title: "Revenue",
272273 code: CODE,
@@ -284,7 +285,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
284285
285286 it.effect("leaves the preview null when a save omits one", () =>
286287 Effect.gen(function* () {
287− const executor = yield* makeTestExecutor();
288+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
288289 const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE });
289290 expect(saved.preview).toBe(null);
290291 }),
@@ -295,7 +296,7 @@ describe("executor.artifacts", () => { · 4 unmodified lines
295296 // The preview interprets `code`, so a save that replaces the source
296297 // must replace the picture — including back to null. Carrying it
297298 // forward would advertise a version of the artifact that is gone.
298− const executor = yield* makeTestExecutor();
299+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
299300 const saved = yield* executor.artifacts.save({
300301 title: "Revenue",
301302 code: CODE,
@@ -313,7 +314,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
313314
314315 it.effect("setPreview upgrades the preview to the settled render", () =>
315316 Effect.gen(function* () {
316− const executor = yield* makeTestExecutor();
317+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
317318 const saved = yield* executor.artifacts.save({
318319 title: "Revenue",
319320 code: CODE,
@@ -331,7 +332,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
331332 Effect.gen(function* () {
332333 // `updated_at` is the gallery's sort key. Opening an artifact must not
333334 // move it to the front — being looked at is not an edit.
334− const executor = yield* makeTestExecutor();
335+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
335336 const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE });
336337 yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED });
337338 expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt);
@@ -340,7 +341,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines
340341
341342 it.effect("setPreview fails for an artifact that is not visible", () =>
342343 Effect.gen(function* () {
343− const executor = yield* makeTestExecutor();
344+ const executor = yield* makeTestExecutor({ access: testAccess.member() });
344345 const result = yield* Effect.result(
345346 executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }),
346347 );
packages/core/sdk/src/blob.test.ts+163−14
@@ -3,23 +3,35 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { StorageError } from "./fuma-runtime";
55
6−import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob";
6+import {
7+ makeInMemoryBlobStore,
8+ pluginBlobStore,
9+ type BlobAccess,
10+ type OwnerPartitions,
11+} from "./blob";
712
8−// v2: owner partitions instead of a scope stack. Reads fall through
9−// [user, org] (user = innermost); writes/deletes name an explicit owner.
13+// v2: owner partitions instead of a scope stack. Reads fall through the
14+// product view's owners in their supplied order (first listed wins); writes
15+// and deletes name an explicit owner and are refused outside the view.
1016 const partitions = (org: string, user: string | null): OwnerPartitions => ({
1117 org,
1218 user,
1319 });
1420
21+// The full member view — the pre-BlobAccess behavior: user shadows org on
22+// read, both partitions writable.
23+const fullView: BlobAccess = { owners: ["user", "org"], storageWrites: "allowed" };
24+const personalOnly: BlobAccess = { owners: ["user"], storageWrites: "allowed" };
25+const orgOnly: BlobAccess = { owners: ["org"], storageWrites: "allowed" };
26+
1527 describe("pluginBlobStore", () => {
16− it.effect("get returns user (innermost) value when both owners have one", () =>
28+ it.effect("get returns user (first-ranked) value when both owners have one", () =>
1729 Effect.gen(function* () {
1830 const store = makeInMemoryBlobStore();
1931 yield* store.put("u/my-plugin", "k", "user-value");
2032 yield* store.put("o/my-plugin", "k", "org-value");
2133
22− const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");
34+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
2335 const value = yield* plugin.get("k");
2436 expect(value).toBe("user-value");
2537 }),
@@ -30,7 +42,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines
3042 const store = makeInMemoryBlobStore();
3143 yield* store.put("o/my-plugin", "k", "org-value");
3244
33− const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");
45+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
3446 const value = yield* plugin.get("k");
3547 expect(value).toBe("org-value");
3648 }),
@@ -39,7 +51,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
3951 it.effect("get returns null when no owner has the key", () =>
4052 Effect.gen(function* () {
4153 const store = makeInMemoryBlobStore();
42− const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");
54+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
4355 const value = yield* plugin.get("k");
4456 expect(value).toBeNull();
4557 }),
@@ -50,7 +62,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines
5062 const store = makeInMemoryBlobStore();
5163 yield* store.put("o/my-plugin", "k", "v");
5264
53− const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");
65+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
5466 const found = yield* plugin.has("k");
5567 expect(found).toBe(true);
5668 }),
@@ -59,7 +71,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
5971 it.effect("has returns false when no owner has the key", () =>
6072 Effect.gen(function* () {
6173 const store = makeInMemoryBlobStore();
62− const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");
74+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
6375 const found = yield* plugin.has("k");
6476 expect(found).toBe(false);
6577 }),
@@ -71,8 +83,8 @@ describe("pluginBlobStore", () => { · 5 unmodified lines
7183 yield* store.put("u/plugin-a", "k", "a-value");
7284 yield* store.put("u/plugin-b", "k", "b-value");
7385
74− const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a");
75− const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b");
86+ const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a", fullView);
87+ const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b", fullView);
7688 expect(yield* pluginA.get("k")).toBe("a-value");
7789 expect(yield* pluginB.get("k")).toBe("b-value");
7890 }),
@@ -81,10 +93,14 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
8193 it.effect("put rejects owner:user when the executor has no subject", () =>
8294 Effect.gen(function* () {
8395 const store = makeInMemoryBlobStore();
84− // No user partition → owner:"user" writes fail.
85− const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin");
96+ // No user partition → owner:"user" writes fail even when the product
97+ // view lists "user"; the identity clamp is not the product's to relax.
98+ const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView);
8699 const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);
87100 expect(err).toBeInstanceOf(StorageError);
101+ expect(err).toMatchObject({
102+ message: 'Blob write targets owner "user" but the executor has no subject.',
103+ });
88104 // Write must not have reached the store.
89105 expect(yield* store.get("o/my-plugin", "k")).toBeNull();
90106 }),
@@ -93,9 +109,142 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
93109 it.effect("delete rejects owner:user when the executor has no subject", () =>
94110 Effect.gen(function* () {
95111 const store = makeInMemoryBlobStore();
96− const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin");
112+ const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView);
97113 const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip);
98114 expect(err).toBeInstanceOf(StorageError);
115+ expect(err).toMatchObject({
116+ message: 'Blob write targets owner "user" but the executor has no subject.',
117+ });
118+ }),
119+ );
120+});
121+
122+describe("pluginBlobStore access clamp", () => {
123+ it.effect("personal-only view cannot read an org blob", () =>
124+ Effect.gen(function* () {
125+ const store = makeInMemoryBlobStore();
126+ // e.g. an org-shared provider config holding a service-account token.
127+ yield* store.put("o/my-plugin", "k", "org-secret");
128+
129+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
130+ expect(yield* plugin.get("k")).toBeNull();
131+ expect(yield* plugin.has("k")).toBe(false);
132+ }),
133+ );
134+
135+ it.effect("personal-only view still reads its own user blob", () =>
136+ Effect.gen(function* () {
137+ const store = makeInMemoryBlobStore();
138+ yield* store.put("u/my-plugin", "k", "user-value");
139+ yield* store.put("o/my-plugin", "k", "org-value");
140+
141+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
142+ expect(yield* plugin.get("k")).toBe("user-value");
143+ }),
144+ );
145+
146+ it.effect("org-only view with a bound subject cannot read a user blob", () =>
147+ Effect.gen(function* () {
148+ const store = makeInMemoryBlobStore();
149+ yield* store.put("u/my-plugin", "k", "user-value");
150+
151+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly);
152+ expect(yield* plugin.get("k")).toBeNull();
153+ expect(yield* plugin.has("k")).toBe(false);
154+ }),
155+ );
156+
157+ it.effect("read precedence follows the supplied owner order", () =>
158+ Effect.gen(function* () {
159+ const store = makeInMemoryBlobStore();
160+ yield* store.put("u/my-plugin", "k", "user-value");
161+ yield* store.put("o/my-plugin", "k", "org-value");
162+
163+ const orgFirst = pluginBlobStore(store, partitions("o", "u"), "my-plugin", {
164+ owners: ["org", "user"],
165+ storageWrites: "allowed",
166+ });
167+ expect(yield* orgFirst.get("k")).toBe("org-value");
168+ }),
169+ );
170+
171+ it.effect("put outside the view is rejected and does not reach the store", () =>
172+ Effect.gen(function* () {
173+ const store = makeInMemoryBlobStore();
174+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
175+ const err = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip);
176+ expect(err).toBeInstanceOf(StorageError);
177+ expect(err).toMatchObject({
178+ message:
179+ 'Blob write targets the "org" partition, which this product view does not include.',
180+ });
181+ expect(yield* store.get("o/my-plugin", "k")).toBeNull();
182+ }),
183+ );
184+
185+ it.effect("delete outside the view is rejected and leaves the row in place", () =>
186+ Effect.gen(function* () {
187+ const store = makeInMemoryBlobStore();
188+ yield* store.put("o/my-plugin", "k", "org-value");
189+
190+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
191+ const err = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip);
192+ expect(err).toBeInstanceOf(StorageError);
193+ expect(err).toMatchObject({
194+ message:
195+ 'Blob write targets the "org" partition, which this product view does not include.',
196+ });
197+ expect(yield* store.get("o/my-plugin", "k")).toBe("org-value");
198+ }),
199+ );
200+
201+ it.effect("org-only view cannot write the user partition", () =>
202+ Effect.gen(function* () {
203+ const store = makeInMemoryBlobStore();
204+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly);
205+ const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);
206+ expect(err).toBeInstanceOf(StorageError);
207+ expect(err).toMatchObject({
208+ message:
209+ 'Blob write targets the "user" partition, which this product view does not include.',
210+ });
211+ expect(yield* store.get("u/my-plugin", "k")).toBeNull();
212+ }),
213+ );
214+
215+ it.effect("read-only view refuses every mutation but keeps reading", () =>
216+ Effect.gen(function* () {
217+ const store = makeInMemoryBlobStore();
218+ yield* store.put("o/my-plugin", "k", "org-value");
219+
220+ const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", {
221+ owners: ["user", "org"],
222+ storageWrites: "denied",
223+ });
224+ const putErr = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip);
225+ expect(putErr).toBeInstanceOf(StorageError);
226+ expect(putErr.message).toBe(
227+ 'Blob write on plugin "my-plugin" is not allowed: this executor\'s storage is read-only.',
228+ );
229+ const userPutErr = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);
230+ expect(userPutErr).toBeInstanceOf(StorageError);
231+ const deleteErr = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip);
232+ expect(deleteErr).toBeInstanceOf(StorageError);
233+ // Nothing was mutated; reads keep working.
234+ expect(yield* store.get("o/my-plugin", "k")).toBe("org-value");
235+ expect(yield* plugin.get("k")).toBe("org-value");
236+ }),
237+ );
238+
239+ it.effect('a view listing "user" on a subject-less identity reads org rows only', () =>
240+ Effect.gen(function* () {
241+ const store = makeInMemoryBlobStore();
242+ yield* store.put("o/my-plugin", "k", "org-value");
243+
244+ // `createExecutor` validation rejects this shape, but the seam must not
245+ // assume it: the null partition contributes no namespace, not a failure.
246+ const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView);
247+ expect(yield* plugin.get("k")).toBe("org-value");
99248 }),
100249 );
101250 });
packages/core/sdk/src/blob.ts+77−27
@@ -4,18 +4,21 @@ · 3 unmodified lines
44 // metadata and plugin-owned config rows.
55 //
66 // Plugins see a `PluginBlobStore` that's already namespaced to the
7−// plugin id and bound to the executor's scope stack. Reads fall through
8−// the stack in order (innermost first, first hit wins); writes and
9−// deletes require an explicit scope id naming where the operation
10−// should land. That mirrors the secrets API — shadowing by key on
11−// read, explicit target on write.
7+// plugin id and clamped to the product's `BlobAccess` view. Reads fall
8+// through the view's owners in order (first listed wins); writes and
9+// deletes require an explicit owner naming where the operation should
10+// land, and are refused outside the view. That mirrors the secrets API —
11+// shadowing by key on read, explicit target on write — and it is where
12+// the executor's access decisions apply to blobs at all: the blob table
13+// sits outside the storage owner policy (isolation is the namespace
14+// string), so nothing downstream re-checks them.
1215 //
1316 // Error channel is `StorageError` — blobs only do read/write/delete, so
1417 // they never produce `UniqueViolationError`. The HTTP edge translates
1518 // `StorageError` to the opaque public `InternalError({ traceId })`.
1619 // ---------------------------------------------------------------------------
1720
18−import { Effect } from "effect";
21+import { Effect, Predicate } from "effect";
1922
2023 import { StorageError, type IFumaClient } from "./fuma-runtime";
2124 import type { Owner } from "./ids";
@@ -41,53 +44,101 @@ export interface BlobStore { · 19 unmodified lines
4144 }
4245
4346 export interface PluginBlobStore {
44− /** Read precedence: this subject's own (`user`) value first, then the
45− * org-shared value. Returns the first non-null. */
47+ /** Read precedence: the product view's owner order (`BlobAccess.owners`,
48+ * first entry wins). Returns the first non-null. */
4649 readonly get: (key: string) => Effect.Effect<string | null, StorageError>;
4750 /** Write `value` under `key` for the named owner (`"org"` shared, `"user"`
48− * private). `"user"` requires the executor to be bound to a subject. */
51+ * private). The owner must be in the product view, and `"user"` requires
52+ * the executor to be bound to a subject. */
4953 readonly put: (
5054 key: string,
5155 value: string,
5256 options: { readonly owner: Owner },
5357 ) => Effect.Effect<void, StorageError>;
54− /** Delete `key` for the named owner. */
58+ /** Delete `key` for the named owner. Bounded like `put`. */
5559 readonly delete: (
5660 key: string,
5761 options: { readonly owner: Owner },
5862 ) => Effect.Effect<void, StorageError>;
59− /** True if either the user or org partition has a value for `key`. */
63+ /** True if any partition in the product view has a value for `key`. */
6064 readonly has: (key: string) => Effect.Effect<boolean, StorageError>;
6165 }
6266
63−/** The owner partition strings an executor binding resolves to: the org
67+/** The owner partition strings an executor IDENTITY resolves to: the org
6468 * partition (always present) and this subject's user partition (null for a
65− * pure-org executor). Reads walk `[user, org]`; writes target one. */
69+ * pure-org executor). Identity-shaped on purpose — identity-bound consumers
70+ * (the pending-approval store) keep using it directly; which partitions a
71+ * plugin blob store actually reads or writes is the separate, product-owned
72+ * `BlobAccess` decision. */
6673 export interface OwnerPartitions {
6774 readonly org: string;
6875 readonly user: string | null;
6976 }
7077
78+/**
79+ * The slice of the product's access decisions the blob seam enforces: the
80+ * blob table is exempt from the storage owner policy (isolation lives in the
81+ * row namespace), so `ExecutorAccess.owners` and the read-only capability
82+ * must be applied HERE, at namespace construction, or not at all.
83+ *
84+ * Structural and required — there is no default view: the caller states the
85+ * product's decision explicitly, and core contributes only the clamps
86+ * (tenant/subject are already baked into `OwnerPartitions`).
87+ */
88+export interface BlobAccess {
89+ /** The owner partitions reads see and writes may target, in read-precedence
90+ * order — the first listed owner shadows later ones on `get`. */
91+ readonly owners: readonly Owner[];
92+ /** `"denied"` refuses every `put`/`delete` through this store (the
93+ * read-only posture, `ExecutorAccessCapabilities.storageWrites`). */
94+ readonly storageWrites: "allowed" | "denied";
95+}
96+
7197 const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`;
7298
7399 /**
74− * Bind a `BlobStore` to an owner partitioning + plugin id. Reads fall through
75− * `[user, org]` (user first); writes target an explicit owner. Used by the
76− * executor to build the `blobs` field handed to each plugin's `storage` factory.
100+ * Bind a `BlobStore` to an owner partitioning + plugin id, clamped to the
101+ * product's `BlobAccess`. Reads fall through the access owners in their
102+ * supplied order (first hit wins); writes target an explicit owner and are
103+ * refused outside the access view. Used by the executor to build the `blobs`
104+ * field handed to each plugin's `storage` factory.
77105 */
78106 export const pluginBlobStore = (
79107 store: BlobStore,
80108 partitions: OwnerPartitions,
81109 pluginId: string,
110+ access: BlobAccess,
82111 ): PluginBlobStore => {
83− const readNamespaces = (): readonly string[] =>
84− (partitions.user == null ? [partitions.org] : [partitions.user, partitions.org]).map((p) =>
85− nsFor(p, pluginId),
86− );
112+ const partitionOf = (owner: Owner): string | null =>
113+ owner === "org" ? partitions.org : partitions.user;
114+
115+ // The product view's partitions, in ITS precedence order. An owner the
116+ // identity cannot carry (`"user"` with no subject) maps to no namespace
117+ // rather than failing a read — the partition simply has no rows to see.
118+ const readNamespaces: readonly string[] = access.owners
119+ .map(partitionOf)
120+ .filter(Predicate.isNotNull)
121+ .map((partition) => nsFor(partition, pluginId));
87122
88123 const partitionFor = (owner: Owner): Effect.Effect<string, StorageError> => {
89− if (owner === "org") return Effect.succeed(partitions.org);
90− if (partitions.user == null) {
124+ if (access.storageWrites === "denied") {
125+ return Effect.fail(
126+ new StorageError({
127+ message: `Blob write on plugin "${pluginId}" is not allowed: this executor's storage is read-only.`,
128+ cause: undefined,
129+ }),
130+ );
131+ }
132+ if (!access.owners.includes(owner)) {
133+ return Effect.fail(
134+ new StorageError({
135+ message: `Blob write targets the "${owner}" partition, which this product view does not include.`,
136+ cause: undefined,
137+ }),
138+ );
139+ }
140+ const partition = partitionOf(owner);
141+ if (partition == null) {
91142 return Effect.fail(
92143 new StorageError({
93144 message: 'Blob write targets owner "user" but the executor has no subject.',
@@ -95,16 +146,15 @@ export const pluginBlobStore = ( · 1 unmodified line
95146 }),
96147 );
97148 }
98− return Effect.succeed(partitions.user);
149+ return Effect.succeed(partition);
99150 };
100151
101152 return {
102153 get: (key) =>
103154 Effect.gen(function* () {
104− const namespaces = readNamespaces();
105− const hits = yield* store.getMany(namespaces, key);
155+ const hits = yield* store.getMany(readNamespaces, key);
106156 if (hits.size === 0) return null;
107− for (const ns of namespaces) {
157+ for (const ns of readNamespaces) {
108158 const v = hits.get(ns);
109159 if (v !== undefined) return v;
110160 }
@@ -118,7 +168,7 @@ export const pluginBlobStore = ( · 7 unmodified lines
118168 Effect.flatMap(partitionFor(options.owner), (partition) =>
119169 store.delete(nsFor(partition, pluginId), key),
120170 ),
121− has: (key) => store.getMany(readNamespaces(), key).pipe(Effect.map((hits) => hits.size > 0)),
171+ has: (key) => store.getMany(readNamespaces, key).pipe(Effect.map((hits) => hits.size > 0)),
122172 };
123173 };
124174
packages/core/sdk/src/connections.test.ts+85−22
@@ -33,6 +33,7 @@ import { definePlugin, type ResolveToolsResult } from "./plugin"; · 32 unmodified lines
3333 import type { CredentialProvider } from "./provider";
3434 import { makeTestConfig, makeTestExecutor } from "./testing";
3535 import { ToolResult } from "./tool-result";
36+import { testAccess } from "@executor-js/product-access/testing";
3637
3738 // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh,
3839 // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2
@@ -163,7 +164,7 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines
163164 }))();
164165
165166 const setup = () =>
166− makeTestExecutor({ plugins: [demoPlugin] as const }).pipe(
167+ makeTestExecutor({ access: testAccess.member(), plugins: [demoPlugin] as const }).pipe(
167168 Effect.tap((executor) => executor.demo.seed()),
168169 );
169170
@@ -338,7 +339,10 @@ describe("connections.create", () => { · 168 unmodified lines
338339 }),
339340 }),
340341 }))();
341− const config = makeTestConfig({ plugins: [gatedPlugin] as const });
342+ const config = makeTestConfig({
343+ access: testAccess.member(),
344+ plugins: [gatedPlugin] as const,
345+ });
342346 const executor = yield* createExecutor(config);
343347 yield* executor.gated.seed();
344348
@@ -435,7 +439,7 @@ describe("connections.create", () => { · 90 unmodified lines
435439 }),
436440 }),
437441 }))();
438− const config = makeTestConfig({ plugins: [plugin] as const });
442+ const config = makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const });
439443 const first = yield* createExecutor(config);
440444 yield* first.recoverable.seed();
441445
@@ -545,7 +549,10 @@ describe("connections.create", () => { · 103 unmodified lines
545549 return wrap(db);
546550 };
547551
548− const config = makeTestConfig({ plugins: [demoPlugin] as const });
552+ const config = makeTestConfig({
553+ access: testAccess.member(),
554+ plugins: [demoPlugin] as const,
555+ });
549556 const executor = yield* createExecutor({
550557 ...config,
551558 db: blindfoldConnectionReads(config.db),
@@ -1079,6 +1086,7 @@ describe("connections.create credential-write compensation", () => { · 527 unmodified lines
10791086 : Effect.sync(() => void store.set(String(id), value)),
10801087 });
10811088 const executor = yield* makeTestExecutor({
1089+ access: testAccess.member(),
10821090 plugins: [durabilityPlugin(provider)] as const,
10831091 });
10841092 yield* executor.durable.seed();
@@ -1116,7 +1124,10 @@ describe("connections.create credential-write compensation", () => { · 31 unmodified lines
11161124 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))
11171125 : Effect.sync(() => void store.set(String(id), value)),
11181126 });
1119− const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const });
1127+ const executor = yield* makeTestExecutor({
1128+ access: testAccess.member(),
1129+ plugins: [durabilityPlugin(provider)] as const,
1130+ });
11201131 yield* executor.durable.seed();
11211132
11221133 const result = yield* Effect.result(
@@ -1151,7 +1162,10 @@ describe("connections.create credential-write compensation", () => { · 28 unmodified lines
11511162 : Effect.sync(() => void store.set(String(id), value)),
11521163 delete: undefined,
11531164 });
1154− const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const });
1165+ const executor = yield* makeTestExecutor({
1166+ access: testAccess.member(),
1167+ plugins: [durabilityPlugin(provider)] as const,
1168+ });
11551169 yield* executor.durable.seed();
11561170
11571171 const warnings: string[] = [];
@@ -1202,7 +1216,10 @@ describe("connections.create credential-write compensation", () => { · 44 unmodified lines
12021216 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))
12031217 : Effect.sync(() => void store.set(String(id), value)),
12041218 });
1205− const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });
1219+ const config = makeTestConfig({
1220+ access: testAccess.member(),
1221+ plugins: [durabilityPlugin(provider)] as const,
1222+ });
12061223 const executor = yield* createExecutor({
12071224 ...config,
12081225 db: failableCompensationRowDelete(config.db, () => failRowDelete),
@@ -1276,6 +1293,7 @@ describe("connections.create credential-write compensation", () => { · 67 unmodified lines
12761293 },
12771294 });
12781295 const executor = yield* makeTestExecutor({
1296+ access: testAccess.member(),
12791297 plugins: [durabilityPlugin(provider)] as const,
12801298 });
12811299 yield* executor.durable.seed();
@@ -1355,7 +1373,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines
13551373 },
13561374 });
13571375 const raceState = { armed: false };
1358− const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });
1376+ const config = makeTestConfig({
1377+ access: testAccess.member(),
1378+ plugins: [durabilityPlugin(provider)] as const,
1379+ });
13591380 const executor = yield* createExecutor({
13601381 ...config,
13611382 db: staleCompensationRead(config.db, raceState),
@@ -1435,7 +1456,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines
14351456 : Effect.sync(() => void store.set(String(id), value)),
14361457 });
14371458 const readState = { armed: false };
1438− const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });
1459+ const config = makeTestConfig({
1460+ access: testAccess.member(),
1461+ plugins: [durabilityPlugin(provider)] as const,
1462+ });
14391463 const executor = yield* createExecutor({
14401464 ...config,
14411465 db: failableConfirmationRead(config.db, readState),
@@ -1510,7 +1534,10 @@ describe("connections.create credential-write compensation", () => { · 68 unmodified lines
15101534 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))
15111535 : Effect.sync(() => void store.set(String(id), value)),
15121536 });
1513− const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });
1537+ const config = makeTestConfig({
1538+ access: testAccess.member(),
1539+ plugins: [durabilityPlugin(provider)] as const,
1540+ });
15141541 const executor = yield* createExecutor({
15151542 ...config,
15161543 db: failableConnectionDeletes(config.db, () => failRowDelete),
@@ -1586,7 +1613,10 @@ describe("connections.create credential-write compensation", () => { · 69 unmodified lines
15861613 ? Effect.die("provider crashed")
15871614 : Effect.sync(() => void store.set(String(id), value)),
15881615 });
1589− const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });
1616+ const config = makeTestConfig({
1617+ access: testAccess.member(),
1618+ plugins: [durabilityPlugin(provider)] as const,
1619+ });
15901620 const executor = yield* createExecutor({
15911621 ...config,
15921622 db: failableCompensationRowDelete(config.db, () => failRowDelete),
@@ -1646,7 +1676,10 @@ describe("connections.create credential-write compensation", () => { · 53 unmodified lines
16461676 ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never))
16471677 : Effect.sync(() => void store.set(String(id), value)),
16481678 });
1649− const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });
1679+ const config = makeTestConfig({
1680+ access: testAccess.member(),
1681+ plugins: [durabilityPlugin(provider)] as const,
1682+ });
16501683 const executor = yield* createExecutor({
16511684 ...config,
16521685 db: failableCompensationRowDelete(config.db, () => failRowDelete),
@@ -1696,7 +1729,11 @@ describe("connections.create credential-write compensation", () => { · 43 unmodified lines
16961729 describe("connections.list / get", () => {
16971730 it.effect("only includes full health diagnostics in verbose core tool output", () =>
16981731 Effect.gen(function* () {
1699− const config = makeTestConfig({ plugins: [demoPlugin] as const, coreTools: {} });
1732+ const config = makeTestConfig({
1733+ access: testAccess.member(),
1734+ plugins: [demoPlugin] as const,
1735+ coreTools: {},
1736+ });
17001737 const executor = yield* createExecutor(config);
17011738 yield* executor.demo.seed();
17021739 yield* executor.connections.create({
@@ -1876,7 +1913,10 @@ describe("tool catalog sync safety", () => { · 173 unmodified lines
18761913 }),
18771914 }),
18781915 }))();
1879− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
1916+ const config = makeTestConfig({
1917+ access: testAccess.member(),
1918+ plugins: [guardedPlugin] as const,
1919+ });
18801920 const executor = yield* createExecutor(config);
18811921 yield* executor.guarded.seed();
18821922 yield* executor.connections.create({
@@ -1942,7 +1982,10 @@ describe("tool catalog sync safety", () => { · 59 unmodified lines
19421982 }),
19431983 }),
19441984 }))();
1945− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
1985+ const config = makeTestConfig({
1986+ access: testAccess.member(),
1987+ plugins: [guardedPlugin] as const,
1988+ });
19461989 const executor = yield* createExecutor(config);
19471990 yield* executor.guarded.seed();
19481991 yield* executor.connections.create({
@@ -2005,7 +2048,10 @@ describe("tool catalog sync safety", () => { · 56 unmodified lines
20052048 }),
20062049 }),
20072050 }))();
2008− const config = makeTestConfig({ plugins: [storedStatePlugin] as const });
2051+ const config = makeTestConfig({
2052+ access: testAccess.member(),
2053+ plugins: [storedStatePlugin] as const,
2054+ });
20092055 const executor = yield* createExecutor(config);
20102056 yield* executor["stored-state"].seed();
20112057 yield* executor.connections.create({
@@ -2064,7 +2110,7 @@ describe("tool catalog sync safety", () => { · 52 unmodified lines
20642110 }),
20652111 }))();
20662112 const executor = yield* createExecutor(
2067− makeTestConfig({ plugins: [guardedPlugin] as const }),
2113+ makeTestConfig({ access: testAccess.member(), plugins: [guardedPlugin] as const }),
20682114 );
20692115 yield* executor.guarded.seed();
20702116 yield* executor.connections.create({
@@ -2122,7 +2168,10 @@ describe("tool catalog sync safety", () => { · 51 unmodified lines
21222168 }),
21232169 }),
21242170 }))();
2125− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
2171+ const config = makeTestConfig({
2172+ access: testAccess.member(),
2173+ plugins: [guardedPlugin] as const,
2174+ });
21262175 const executor = yield* createExecutor(config);
21272176 yield* executor.guarded.seed();
21282177 yield* executor.connections.create({
@@ -2201,7 +2250,10 @@ describe("tool catalog sync safety", () => { · 72 unmodified lines
22012250 }),
22022251 }),
22032252 }))();
2204− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
2253+ const config = makeTestConfig({
2254+ access: testAccess.member(),
2255+ plugins: [guardedPlugin] as const,
2256+ });
22052257 const executor = yield* createExecutor(config);
22062258 yield* executor.guarded.seed();
22072259 yield* executor.connections.create({
@@ -2282,7 +2334,10 @@ describe("tool catalog sync safety", () => { · 74 unmodified lines
22822334 }),
22832335 }),
22842336 }))();
2285− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
2337+ const config = makeTestConfig({
2338+ access: testAccess.member(),
2339+ plugins: [guardedPlugin] as const,
2340+ });
22862341 const executor = yield* createExecutor(config);
22872342 yield* executor.guarded.seed();
22882343 yield* executor.connections.create({
@@ -2368,7 +2423,10 @@ describe("tool catalog sync safety", () => { · 79 unmodified lines
23682423 }),
23692424 }))();
23702425
2371− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
2426+ const config = makeTestConfig({
2427+ access: testAccess.member(),
2428+ plugins: [guardedPlugin] as const,
2429+ });
23722430 const executor = yield* createExecutor({
23732431 ...config,
23742432 db: instrumentTransactions(config.db, {
@@ -2457,7 +2515,10 @@ describe("tool catalog sync safety", () => { · 82 unmodified lines
24572515 }),
24582516 }))();
24592517
2460− const config = makeTestConfig({ plugins: [guardedPlugin] as const });
2518+ const config = makeTestConfig({
2519+ access: testAccess.member(),
2520+ plugins: [guardedPlugin] as const,
2521+ });
24612522 const executor = yield* createExecutor(config);
24622523 yield* executor.guarded.seed();
24632524 for (const name of ["broken", "healthy"]) {
@@ -2676,6 +2737,7 @@ const makeHealthHarness = (options?: { · 212 unmodified lines
26762737
26772738 return Effect.gen(function* () {
26782739 const config = makeTestConfig({
2740+ access: testAccess.member(),
26792741 plugins: [plugin] as const,
26802742 coreTools: { webBaseUrl: "http://localhost:3000" },
26812743 });
@@ -3421,6 +3483,7 @@ describe("health probe gate key integrity", () => { · 739 unmodified lines
34213483 // Both executors share ONE root db handle — and therefore one gate map;
34223484 // only the key separates their probes.
34233485 const configA = makeTestConfig({
3486+ access: testAccess.member(),
34243487 plugins: [probingPlugin()] as const,
34253488 tenant: "a",
34263489 subject: "user:b",
packages/core/sdk/src/core-schema.ts+17−1
@@ -49,7 +49,23 @@ const unscopedExecutorTable = <const TColumns extends UserColumns>( · 48 unmodified lines
4949 id: keyColumn("id"),
5050 });
5151 out.unique(`${name}_id_uidx`, ["id"]);
52− return out.policy({ name: executorUnscopedPolicyName });
52+ // No visibility condition (isolation lives in the namespace string), but
53+ // the read-only / delete-only postures still hold for the FumaDB-backed
54+ // blob store. External blob backends never see table policies, so the
55+ // executor ALSO wraps its base BlobStore when storage writes are denied —
56+ // this is defense in depth for the table-backed path, not the sole guard.
57+ return out.policy<ExecutorOwnerPolicyContext>({
58+ name: executorUnscopedPolicyName,
59+ onCreate: ({ context }) => assertReachReadOnly(name, "write", context),
60+ onUpdate: ({ context }) => {
61+ assertReachReadOnly(name, "write", context);
62+ return true;
63+ },
64+ onDelete: ({ context }) => {
65+ assertReachReadOnly(name, "delete", context);
66+ return true;
67+ },
68+ });
5369 };
5470
5571 /** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */
packages/core/sdk/src/errors.ts+3−4
@@ -149,10 +149,9 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass<I · 148 unmodified lines
149149 }
150150 }
151151
152−/** A connection create or workspace-level mutation (an `owner: "org"` row, or
153− * the tenant-shared integration catalog) was attempted on an executor bound
154− * with `orgWrites: "denied"` — a member who may USE workspace resources but
155− * not configure them. Reads, tool execution, and operational writes (token
152+/** A user-intent settings mutation was denied by the product's
153+ * `ExecutorAccess.settingsWrite` decision for its target.
154+ * Reads, tool execution, and operational writes (token
156155 * refresh, catalog re-sync) are unaffected; only the user-intent settings
157156 * surfaces raise this. */
158157 export class OrgWriteDeniedError
packages/core/sdk/src/executor.test.ts+44−7
@@ -20,6 +20,7 @@ import type { CredentialProvider } from "./provider"; · 19 unmodified lines
2020 import { IntegrationDetectionResult } from "./types";
2121 import { makeTestConfig, makeTestExecutor, memoryCredentialsPlugin } from "./testing";
2222 import { serveOAuthTestServer } from "./testing/oauth-test-server";
23+import { testAccess } from "@executor-js/product-access/testing";
2324
2425 // removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id
2526 // resolution, secrets/sources/scope-stack. The integration coverage below is
@@ -215,6 +216,7 @@ describe("createExecutor", () => { · 189 unmodified lines
215216 it.effect("rolls back plugin and core writes from ctx.transaction failures", () =>
216217 Effect.gen(function* () {
217218 const executor = yield* makeTestExecutor({
219+ access: testAccess.member(),
218220 plugins: [demoPlugin] as const,
219221 });
220222 const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites());
@@ -237,6 +239,7 @@ describe("createExecutor", () => { · 16 unmodified lines
237239 close: () => Effect.sync(() => void (closed = true)),
238240 }))();
239241 const executor = yield* makeTestExecutor({
242+ access: testAccess.member(),
240243 plugins: [closingPlugin] as const,
241244 });
242245 yield* executor.close();
@@ -248,6 +251,7 @@ describe("createExecutor", () => { · 5 unmodified lines
248251 Effect.gen(function* () {
249252 const events: Array<{ kind: string; pluginKey: string; slug: string }> = [];
250253 const executor = yield* makeTestExecutor({
254+ access: testAccess.member(),
251255 plugins: [demoPlugin] as const,
252256 onIntegrationChange: (event) =>
253257 Effect.sync(() => {
@@ -281,6 +285,7 @@ describe("createExecutor", () => { · 27 unmodified lines
281285 it.effect("a failing onIntegrationChange observer never fails the operation", () =>
282286 Effect.gen(function* () {
283287 const executor = yield* makeTestExecutor({
288+ access: testAccess.member(),
284289 plugins: [demoPlugin] as const,
285290 onIntegrationChange: () => Effect.die("observer exploded"),
286291 });
@@ -294,6 +299,7 @@ describe("createExecutor", () => { · 7 unmodified lines
294299 Effect.gen(function* () {
295300 const events: string[] = [];
296301 const executor = yield* makeTestExecutor({
302+ access: testAccess.member(),
297303 plugins: [demoPlugin] as const,
298304 onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))),
299305 });
@@ -306,6 +312,7 @@ describe("createExecutor", () => { · 6 unmodified lines
306312 it.effect("projects core tools as the built-in Executor integration", () =>
307313 Effect.gen(function* () {
308314 const executor = yield* makeTestExecutor({
315+ access: testAccess.member(),
309316 coreTools: { webBaseUrl: "http://localhost:3000" },
310317 });
311318 const integrations = yield* executor.integrations.list();
@@ -351,6 +358,7 @@ describe("createExecutor", () => { · 39 unmodified lines
351358 it.effect("can omit provider tools from the built-in Executor integration", () =>
352359 Effect.gen(function* () {
353360 const executor = yield* makeTestExecutor({
361+ access: testAccess.member(),
354362 coreTools: {
355363 webBaseUrl: "http://localhost:3000",
356364 includeProviders: false,
@@ -372,6 +380,7 @@ describe("createExecutor", () => { · 15 unmodified lines
372380 it.effect("creates provider-backed connections through the built-in Executor tools", () =>
373381 Effect.gen(function* () {
374382 const executor = yield* makeTestExecutor({
383+ access: testAccess.member(),
375384 plugins: [demoPlugin] as const,
376385 coreTools: { webBaseUrl: "http://localhost:3000" },
377386 });
@@ -414,6 +423,7 @@ describe("createExecutor", () => { · 36 unmodified lines
414423 it.effect("creates a provider-backed legacy slug-none connection through the core tool", () =>
415424 Effect.gen(function* () {
416425 const executor = yield* makeTestExecutor({
426+ access: testAccess.member(),
417427 plugins: [demoPlugin] as const,
418428 coreTools: { webBaseUrl: "http://localhost:3000" },
419429 });
@@ -466,6 +476,7 @@ describe("createExecutor", () => { · 46 unmodified lines
466476 it.effect("removes catalog integrations through the built-in Executor tools", () =>
467477 Effect.gen(function* () {
468478 const executor = yield* makeTestExecutor({
479+ access: testAccess.member(),
469480 plugins: [demoPlugin] as const,
470481 coreTools: {},
471482 });
@@ -526,6 +537,7 @@ describe("createExecutor", () => { · 54 unmodified lines
526537 it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () =>
527538 Effect.gen(function* () {
528539 const executor = yield* makeTestExecutor({
540+ access: testAccess.member(),
529541 plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,
530542 coreTools: {},
531543 });
@@ -604,6 +616,7 @@ describe("createExecutor", () => { · 72 unmodified lines
604616 it.effect("preserves actionable health from an incomplete tool catalog", () =>
605617 Effect.gen(function* () {
606618 const executor = yield* makeTestExecutor({
619+ access: testAccess.member(),
607620 plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,
608621 coreTools: {},
609622 });
@@ -639,6 +652,7 @@ describe("createExecutor", () => { · 29 unmodified lines
639652 it.effect("hands pasted credential entry to the web UI", () =>
640653 Effect.gen(function* () {
641654 const executor = yield* makeTestExecutor({
655+ access: testAccess.member(),
642656 coreTools: { webBaseUrl: "http://localhost:3000" },
643657 });
644658
@@ -672,6 +686,7 @@ describe("createExecutor", () => { · 27 unmodified lines
672686 Effect.gen(function* () {
673687 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
674688 const executor = yield* makeTestExecutor({
689+ access: testAccess.member(),
675690 plugins: [demoPlugin] as const,
676691 coreTools: { webBaseUrl: "http://localhost:3000" },
677692 redirectUri: null,
@@ -739,7 +754,7 @@ describe("createExecutor", () => { · 61 unmodified lines
739754 detector("high-detector", "high"),
740755 detector("medium-detector", "medium"),
741756 ] as const;
742− const executor = yield* makeTestExecutor({ plugins });
757+ const executor = yield* makeTestExecutor({ access: testAccess.member(), plugins });
743758 const results = yield* executor.integrations.detect("https://example.com/thing");
744759 // Every detector recognizes the URL; the list contains all three.
745760 expect(results.map((r) => r.kind).sort()).toEqual([
@@ -753,6 +768,7 @@ describe("createExecutor", () => { · 7 unmodified lines
753768 it.effect("tools.schema returns roots with shared reachable definitions", () =>
754769 Effect.gen(function* () {
755770 const executor = yield* makeTestExecutor({
771+ access: testAccess.member(),
756772 plugins: [demoPlugin] as const,
757773 });
758774 yield* executor.demo.seed();
@@ -778,6 +794,7 @@ describe("createExecutor", () => { · 19 unmodified lines
778794 it.effect("execute dispatches a connection-produced tool to the owning plugin", () =>
779795 Effect.gen(function* () {
780796 const executor = yield* makeTestExecutor({
797+ access: testAccess.member(),
781798 plugins: [demoPlugin] as const,
782799 });
783800 yield* executor.demo.seed();
@@ -800,6 +817,7 @@ describe("createExecutor", () => { · 16 unmodified lines
800817 it.effect("execute on a missing address fails with ToolNotFoundError", () =>
801818 Effect.gen(function* () {
802819 const executor = yield* makeTestExecutor({
820+ access: testAccess.member(),
803821 plugins: [demoPlugin] as const,
804822 });
805823 yield* executor.demo.seed();
@@ -843,6 +861,7 @@ describe("createExecutor", () => { · 37 unmodified lines
843861 describe("muscle memory (observed output shapes)", () => {
844862 const provisioned = Effect.fn(function* () {
845863 const executor = yield* makeTestExecutor({
864+ access: testAccess.member(),
846865 plugins: [demoPlugin] as const,
847866 coreTools: { webBaseUrl: "http://localhost:3000" },
848867 });
@@ -1049,7 +1068,7 @@ const seedRunConnection = < · 200 unmodified lines
10491068 const recordToolRowLaunch = (maxOps?: number) =>
10501069 Effect.gen(function* () {
10511070 const recorder = makeReadOrderRecorder();
1052− const config = makeTestConfig({ plugins: [demoPlugin] as const });
1071+ const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const });
10531072 const executor = yield* createExecutor({
10541073 ...config,
10551074 db: withRecordedReads(config.db, recorder.record),
@@ -1073,6 +1092,7 @@ const recordCredentialLaunch = (maxOps?: number) => · 17 unmodified lines
10731092 const calls = { count: 0 };
10741093 const provider = countingProvider(calls, (run) => recorder.record("credential.get", run));
10751094 const config = makeTestConfig({
1095+ access: testAccess.member(),
10761096 plugins: [invokeConcurrencyPlugin(provider)] as const,
10771097 });
10781098 const executor = yield* createExecutor({
@@ -1199,6 +1219,7 @@ describe("execute read concurrency", () => { · 120 unmodified lines
11991219 Effect.gen(function* () {
12001220 const calls = { count: 0 };
12011221 const executor = yield* makeTestExecutor({
1222+ access: testAccess.member(),
12021223 plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const,
12031224 });
12041225 yield* seedRunConnection(executor);
@@ -1227,7 +1248,10 @@ describe("execute read concurrency", () => { · 22 unmodified lines
12271248
12281249 it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () =>
12291250 Effect.gen(function* () {
1230− const config = makeTestConfig({ plugins: [demoPlugin] as const });
1251+ const config = makeTestConfig({
1252+ access: testAccess.member(),
1253+ plugins: [demoPlugin] as const,
1254+ });
12311255 const executor = yield* createExecutor(config);
12321256 yield* seedRunConnection(executor);
12331257
@@ -1367,7 +1391,10 @@ describe("speculative read abandonment", () => { · 133 unmodified lines
13671391 it.effect("unknown-tool error surfaces while the speculative reads hang forever", () =>
13681392 Effect.gen(function* () {
13691393 const faults = makeReadFaults();
1370− const config = makeTestConfig({ plugins: [demoPlugin] as const });
1394+ const config = makeTestConfig({
1395+ access: testAccess.member(),
1396+ plugins: [demoPlugin] as const,
1397+ });
13711398 const executor = yield* createExecutor({
13721399 ...config,
13731400 db: withFaultedReads(config.db, faults.fault),
@@ -1393,7 +1420,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines
13931420 it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () =>
13941421 Effect.gen(function* () {
13951422 const faults = makeReadFaults();
1396− const config = makeTestConfig({ plugins: [demoPlugin] as const });
1423+ const config = makeTestConfig({
1424+ access: testAccess.member(),
1425+ plugins: [demoPlugin] as const,
1426+ });
13971427 const executor = yield* createExecutor({
13981428 ...config,
13991429 db: withFaultedReads(config.db, faults.fault),
@@ -1419,7 +1449,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines
14191449 it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () =>
14201450 Effect.gen(function* () {
14211451 const faults = makeReadFaults();
1422− const config = makeTestConfig({ plugins: [demoPlugin] as const });
1452+ const config = makeTestConfig({
1453+ access: testAccess.member(),
1454+ plugins: [demoPlugin] as const,
1455+ });
14231456 const executor = yield* createExecutor({
14241457 ...config,
14251458 db: withFaultedReads(config.db, faults.fault),
@@ -1452,7 +1485,10 @@ describe("speculative read abandonment", () => { · 26 unmodified lines
14521485 () =>
14531486 Effect.gen(function* () {
14541487 const faults = makeReadFaults();
1455− const config = makeTestConfig({ plugins: [demoPlugin] as const });
1488+ const config = makeTestConfig({
1489+ access: testAccess.member(),
1490+ plugins: [demoPlugin] as const,
1491+ });
14561492 const executor = yield* createExecutor({
14571493 ...config,
14581494 db: withFaultedReads(config.db, faults.fault),
@@ -1479,6 +1515,7 @@ describe("speculative read abandonment", () => { · 20 unmodified lines
14791515 const faults = makeReadFaults();
14801516 const armable = armableFailingProvider();
14811517 const config = makeTestConfig({
1518+ access: testAccess.member(),
14821519 plugins: [invokeConcurrencyPlugin(armable.provider)] as const,
14831520 });
14841521 const executor = yield* createExecutor({
packages/core/sdk/src/executor.ts+276−220
@@ -62,7 +62,6 @@ import { · 61 unmodified lines
6262 type OAuthClientRow,
6363 type ToolInvocationRow,
6464 type ToolRow,
65− type ToolPolicyRow,
6665 } from "./core-schema";
6766 import {
6867 ElicitationDeclinedError,
@@ -73,7 +72,7 @@ import { · 4 unmodified lines
7372 type OnElicitation,
7473 type InvokeOptions,
7574 } from "./elicitation";
76−import { currentOrgWriteAccess, type OrgWriteAccess } from "./org-write-access";
75+import { executorAccessViolation, type ExecutorAccess, type ToolPolicyEvaluator } from "./access";
7776 import {
7877 restoreCredentialSnapshotsWithRecheck,
7978 snapshotCredentialWrites,
@@ -155,9 +154,7 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; · 75 unmodified lines
155154 import {
156155 comparePolicyRow,
157156 isValidPattern,
158− matchPattern,
159157 positionForNewPattern,
160− resolveEffectivePolicy,
161158 rowToToolPolicy,
162159 type CreateToolPolicyInput,
163160 type EffectivePolicy,
@@ -181,7 +178,6 @@ import type { · 17 unmodified lines
181178 StaticToolDecl,
182179 StorageDeps,
183180 ToolPolicyProvider,
184− ToolPolicyProviderRule,
185181 ToolInvocationCredential,
186182 } from "./plugin";
187183 import {
@@ -493,10 +489,11 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines
493489
494490 /**
495491 * The PLATFORM VIEW: read-only, tenant-wide reads across every subject.
496− * Present only when the executor was built with `platformView: true`
497− * (default off) — every other surface on this executor stays bound to the
498− * single `{ tenant, subject }` product view and is unaffected by this one.
499− * Internal admin surface; the public HTTP shape is a separate concern.
492+ * Present only when the executor was built with a product access granting
493+ * `capabilities.adminReads` — every other surface on this executor stays
494+ * bound to the single `{ tenant, subject }` product view and is unaffected
495+ * by this one. Internal admin surface; the public HTTP shape is a separate
496+ * concern.
500497 */
501498 readonly admin?: ExecutorAdmin;
502499 /** Saved generative-UI artifacts, visible to the bound owner scope. */
@@ -810,47 +807,27 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 307 unmodified lines
810807 */
811808 readonly onIntegrationChange?: (event: IntegrationChangeEvent) => Effect.Effect<void>;
812809 /**
813− * Opt into the PLATFORM VIEW: a read-only, tenant-wide `executor.admin`
814− * surface that reads across every subject in the tenant (see
815− * {@link ExecutorAdmin}). Default OFF — `admin` is simply absent, so the
816− * escape hatch has to be asked for by a host that has authorized an
817− * org-level caller.
810+ * The product's access decisions for this binding — REQUIRED, with no core
811+ * default. The product (host composition root) states which owner
812+ * partitions this binding reads and in what precedence, whether it may
813+ * configure workspace-level state (consulted live at every guarded sink),
814+ * and its view capabilities (`capabilities.adminReads` exposes the
815+ * tenant-observing `admin` surface, whose reads span every subject;
816+ * `capabilities.storageWrites` sets the storage posture).
817+ * Core validates the shape against the binding, enforces the decisions at
818+ * its sinks, and clamps everything with the storage owner policy — tenant
819+ * isolation is never product-configurable. See {@link ExecutorAccess} and
820+ * `@executor-js/product-access` for the product rule implementations.
818821 *
819− * Enabling it makes the WHOLE executor read-only, not just `admin`: the base
820− * owner context carries `writes: "denied"`, so `connections`, `policies`,
821− * `integrations` and `oauth` refuse every create/update/delete at the storage
822− * boundary. An executor built for an org-level caller is an observer, and
823− * `admin` being its only tenant-wide surface is not the same as it being its
824− * only guarded one.
825− *
826− * READS still differ by surface: only `admin` is tenant-wide. Every other
827− * surface stays bound to `{ tenant, subject }` — widening them would expose
828− * every subject's connection rows, credential item ids included.
829− */
830− readonly platformView?: boolean;
831− /**
832− * Whether this binding may CONFIGURE workspace-level state: `owner: "org"`
833− * rows (shared connections, org tool policies, org OAuth clients) and the
834− * tenant-shared integration catalog. Hosts derive it from the acting
835− * member's role — admins bind `"allowed"`, plain members `"denied"`.
836− * `"request"` reads the fiber-local {@link CurrentOrgWriteAccess} at every
837− * guarded sink. Session hosts bind that reference from the freshly
838− * authenticated request without caching a positive authorization decision
839− * for the session lifetime.
840− * Defaults to `"allowed"` for non-session callers with no role model
841− * (local's single user, the CLI, tests).
842− *
843− * `"denied"` gates only the USER-INTENT settings surfaces (`policies`,
844− * `connections` create/update/remove in Workspace scope, `integrations`
845− * update/remove/healthCheck, OAuth client CRUD and connect flows in Workspace
846− * scope, new-integration registration). Members may still create and manage
847− * Personal connections and OAuth apps. They also USE workspace resources:
848− * reads, tool execution over org connections, and the operational writes
849− * those imply (token refresh, tool-catalog re-sync) are deliberately
850− * untouched — which is why this is a surface gate, not a storage-policy axis
851− * like `platformView`'s blanket `writes: "denied"`.
822+ * `capabilities.storageWrites: "denied"` makes the WHOLE executor
823+ * read-only, not just `admin`: the base owner context carries
824+ * `writes: "denied"`, so `connections`, `policies`, `integrations` and
825+ * `oauth` refuse every create/update/delete at the storage boundary. READS
826+ * still differ by surface: only `admin` is tenant-wide; every other
827+ * surface stays bound to `{ tenant, subject }` — widening them would
828+ * expose every subject's connection rows, credential item ids included.
852829 */
853− readonly orgWrites?: OrgWriteAccess | "request";
830+ readonly access: ExecutorAccess;
854831 }
855832
856833 /** Default freshness window for remote-catalog connections (see
@@ -1522,9 +1499,11 @@ const makePluginStorageFacade = (input: { · 665 unmodified lines
15221499 readonly core: CoreDb;
15231500 readonly pluginId: string;
15241501 readonly owner: OwnerBinding;
1502+ /** Product-supplied owner precedence (`ExecutorAccess.owners`):
1503+ * which partitions reads see, first entry shadowing later ones. */
1504+ readonly readOwners: readonly Owner[];
15251505 }): PluginStorageFacade => {
1526− // Owner partitions: org always, plus this subject's user partition.
1527− const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"];
1506+ const readOwners = input.readOwners;
15281507
15291508 const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => {
15301509 if (owner === "org") return { owner: "org", subject: ORG_SUBJECT };
@@ -1555,7 +1534,12 @@ const makePluginStorageFacade = (input: { · 24 unmodified lines
15551534 );
15561535 };
15571536
1558− const ownerRank = (owner: Owner): number => readOwners.indexOf(owner);
1537+ // A row whose owner is outside the view (only reachable if this facade is
1538+ // ever handed a wider db handle) sorts LAST, never shadowing view rows.
1539+ const ownerRank = (owner: Owner): number => {
1540+ const rank = readOwners.indexOf(owner);
1541+ return rank === -1 ? readOwners.length : rank;
1542+ };
15591543
15601544 const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) =>
15611545 [...rows].sort((left, right) => {
@@ -1919,6 +1903,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 357 unmodified lines
19191903 const tenant = String(config.tenant);
19201904 const subject = config.subject != null ? String(config.subject) : null;
19211905
1906+ // The product's access decisions, validated against the binding before
1907+ // anything else is built: a composition root that hands a member-shaped
1908+ // access to a subject-less binding (or vice versa) is a programmer error
1909+ // that must fail the boot, not silently narrow.
1910+ const access = config.access;
1911+ const accessViolation = executorAccessViolation(access, subject);
1912+ if (accessViolation != null) {
1913+ return yield* new StorageError({ message: accessViolation, cause: undefined });
1914+ }
1915+ // The product's visible/writable partitions in precedence order — stamped
1916+ // onto every storage context and threaded to every owner-ordered read.
1917+ const accessOwners = access.owners;
1918+
19221919 const ownerBinding: OwnerBinding = {
19231920 tenant: config.tenant,
19241921 subject: config.subject ?? null,
@@ -1946,17 +1943,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 21 unmodified lines
19461943 )
19471944 : Effect.void;
19481945
1949− // Workspace-settings gate (`ExecutorConfig.orgWrites`). Called at the top
1950− // of every user-intent workspace-level mutation: with an explicit owner it
1951− // refuses only `"org"` targets; with no owner it guards a tenant-shared
1952− // surface outright. Deliberately NOT wired into the storage owner policy —
1953− // operational org-row writes (token refresh, tool-catalog re-sync) must
1954− // keep working for a denied member.
1946+ // Settings gate (`ExecutorAccess.settingsWrite`). Called at the top of
1947+ // every user-intent settings mutation with the TARGET core is about to
1948+ // touch — an owner-scoped row, or (with no owner) a tenant-shared
1949+ // workspace surface. WHICH targets a given principal may configure is
1950+ // entirely the product's rule; core only describes the target, asks
1951+ // live on every call (a request-bound implementation is re-read after
1952+ // pauses and on resume; nothing is cached), and enforces a denial as
1953+ // `OrgWriteDeniedError`. Deliberately NOT wired into the storage owner
1954+ // policy — operational org-row writes (token refresh, tool-catalog
1955+ // re-sync) must keep working for a settings-denied member.
19551956 const guardOrgWrite = (owner?: Owner): Effect.Effect<void, OrgWriteDeniedError> =>
19561957 Effect.gen(function* () {
1957− const access =
1958− config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites;
1959− if (access === "denied" && (owner === undefined || owner === "org")) {
1958+ const decision = yield* access.settingsWrite(
1959+ owner === undefined ? { kind: "workspace" } : { kind: "owner", owner },
1960+ );
1961+ if (decision === "denied") {
19601962 return yield* new OrgWriteDeniedError();
19611963 }
19621964 });
@@ -2010,7 +2012,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 47 unmodified lines
20102012 const ownerContext: ExecutorOwnerPolicyContext = {
20112013 tenant,
20122014 subject,
2013− ...(config.platformView === true ? { writes: "denied" as const } : {}),
2015+ // The product's row visibility/write partitions — enforced (never
2016+ // chosen) by the storage owner policy, which clamps them to the
2017+ // binding's tenant and subject.
2018+ owners: accessOwners,
2019+ ...(access.capabilities.storageWrites === "denied" ? { writes: "denied" as const } : {}),
20142020 };
20152021 const rootDb = withQueryContext(rootDbUntyped, ownerContext);
20162022 // Shared across executors over one database, so the gate key must carry the
@@ -2035,7 +2041,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines
20352041 } satisfies ExecutorOwnerPolicyContext,
20362042 }),
20372043 );
2038− const blobs = config.blobs ?? makeFumaBlobStore(fuma);
2044+ // The blob table sits OUTSIDE the storage owner policy (isolation is the
2045+ // namespace string), and a host-supplied backend (`config.blobs`: R2, …)
2046+ // never sees the FumaDB table policies at all. So the read-only posture
2047+ // is applied HERE, once, on the base store — pending approvals and every
2048+ // plugin namespace inherit it. Per-partition visibility is the plugin
2049+ // blob store's own `BlobAccess` clamp below.
2050+ const rawBlobs = config.blobs ?? makeFumaBlobStore(fuma);
2051+ const readOnlyBlobStore = (store: BlobStore): BlobStore => {
2052+ const refuse = (operation: string) =>
2053+ Effect.fail(
2054+ new StorageError({
2055+ message: `Blob ${operation} is not allowed: this executor's storage is read-only.`,
2056+ cause: undefined,
2057+ }),
2058+ );
2059+ return {
2060+ get: (namespace, key) => store.get(namespace, key),
2061+ getMany: (namespaces, key) => store.getMany(namespaces, key),
2062+ has: (namespace, key) => store.has(namespace, key),
2063+ put: () => refuse("write"),
2064+ delete: () => refuse("delete"),
2065+ };
2066+ };
2067+ const blobs =
2068+ access.capabilities.storageWrites === "denied" ? readOnlyBlobStore(rawBlobs) : rawBlobs;
20392069 const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect);
20402070
20412071 // Runtime-observed output shapes ("muscle memory"): learned on the
@@ -2046,6 +2076,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
20462076 core,
20472077 pluginId: SHAPE_MEMORY_PLUGIN_ID,
20482078 owner: ownerBinding,
2079+ readOwners: accessOwners,
20492080 }),
20502081 );
20512082
@@ -2062,7 +2093,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 10 unmodified lines
20622093 // runtime can recognize and retry a row stranded before its write ran.
20632094 const credentialWriteRuntimeId = crypto.randomUUID();
20642095
2065− const staticToolOwner = (): Owner => (subject == null ? "org" : "user");
2096+ // The partition static tools present under: the product's first-ranked
2097+ // owner (a bound member's "user", a subject-less binding's "org").
2098+ const staticToolOwner = (): Owner => accessOwners[0]!;
20662099 const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName =>
20672100 ConnectionName.make(integration.id === EXECUTOR_INTEGRATION_ID ? "coreTools" : "static");
20682101
@@ -3276,11 +3309,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1207 unmodified lines
32763309 const existing = yield* findIntegrationRow(input.slug);
32773310 const config = input.config === undefined ? null : input.config;
32783311 if (existing) {
3279− // Extension methods also run for subjectless boot/system executors,
3280− // which must be able to converge existing catalog rows. A bound
3281− // subject is an end-user principal, so its replacement is the same
3282− // workspace mutation as creation and requires the live role guard.
3283− if (subject !== null) yield* guardOrgWrite();
3312+ // Replacement of an existing catalog row is the same workspace
3313+ // mutation as creation. WHO may perform it — an end-user admin, or
3314+ // a subject-less boot-convergence service — is entirely the
3315+ // product's `settingsWrite` rule (workspaceServiceAccess allows
3316+ // it); core keeps no subject-less exemption of its own.
3317+ yield* guardOrgWrite();
32843318 yield* core.updateMany("integration", {
32853319 where: (b: AnyCb) => b("slug", "=", String(input.slug)),
32863320 set: {
@@ -5387,103 +5421,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2100 unmodified lines
53875421 });
53885422
53895423 // ------------------------------------------------------------------
5390− // Active policy source.
5424+ // Effective tool policy — the PRODUCT's evaluation, core's enforcement.
5425+ //
5426+ // Core assembles the rule material (the stored rows as a lazy read, and
5427+ // the plugin-registered provider when a toolkit session bound one) and
5428+ // hands it to `ExecutorAccess.toolPolicy`, once per surface operation.
5429+ // Owner ranking, cross-owner merging, the plugin-default fallback and
5430+ // the capability-allowlist default all live in that product hook; core
5431+ // only enforces the returned `EffectivePolicy` (block refuses, approval
5432+ // gates).
53915433 // ------------------------------------------------------------------
53925434
5393− type ActivePolicyRuleSet =
5394− | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }
5395− | {
5396− readonly kind: "provider";
5397− readonly provider: ToolPolicyProvider;
5398− readonly rules: readonly ToolPolicyProviderRule[] | null;
5399− }
5400− | {
5401− readonly kind: "prepared";
5402− readonly resolve: (input: {
5403− readonly toolId: string;
5404− readonly defaultRequiresApproval?: boolean;
5405− }) => EffectivePolicy;
5406− };
5407−
5408− const compareProviderPolicyRule = (
5409− a: ToolPolicyProviderRule,
5410− b: ToolPolicyProviderRule,
5411− ): number => {
5412− if (a.position < b.position) return -1;
5413− if (a.position > b.position) return 1;
5414− return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5415− };
5416−
5417− const resolveProviderPolicyFromRules = (
5418− toolId: string,
5419− rules: readonly ToolPolicyProviderRule[],
5420− ): EffectivePolicy => {
5421− for (const rule of [...rules].sort(compareProviderPolicyRule)) {
5422− if (!matchPattern(rule.pattern, toolId)) continue;
5423− return {
5424− action: rule.action,
5425− source: "user",
5426− pattern: rule.pattern,
5427− policyId: rule.id,
5428− };
5429− }
5430− // Toolkit-style providers are capability allowlists. No matching rule
5431− // means the tool is outside the capability boundary.
5432− return {
5433− action: "block",
5434− source: "user",
5435− pattern: "*",
5436− };
5437− };
5438−
5439− const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
5440− activeToolPolicyProvider
5441− ? // Batched per-operation resolver: fetch all policy + connection state
5442− // once, then resolve every tool in this operation against that
5443− // snapshot. Avoids the per-tool resolve N+1 on the list surface.
5444− activeToolPolicyProvider.prepare
5445− ? activeToolPolicyProvider.prepare().pipe(
5446− Effect.map((resolve) => ({
5447− kind: "prepared" as const,
5448− resolve,
5449− })),
5450− )
5451− : activeToolPolicyProvider.resolve
5452− ? Effect.succeed({
5453− kind: "provider" as const,
5454− provider: activeToolPolicyProvider,
5455− rules: null,
5456− })
5457− : activeToolPolicyProvider.list().pipe(
5458− Effect.map((rules) => ({
5459− kind: "provider" as const,
5460− provider: activeToolPolicyProvider!,
5461− rules,
5462− })),
5463− )
5464− : core
5465− .findMany("tool_policy", {})
5466− .pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
5467−
5468− const resolvePolicyFromRuleSet = (
5469− toolId: string,
5470− ruleSet: ActivePolicyRuleSet,
5471− defaultRequiresApproval?: boolean,
5472− ): Effect.Effect<EffectivePolicy, StorageFailure> =>
5473− ruleSet.kind === "prepared"
5474− ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval }))
5475− : ruleSet.kind === "provider"
5476− ? ruleSet.provider.resolve
5477− ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
5478− : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
5479− : Effect.succeed(
5480− resolveEffectivePolicy(
5481− toolId,
5482− ruleSet.rows,
5483− ownerRankForRow,
5484− defaultRequiresApproval,
5485− ),
5486− );
5435+ const makeToolPolicyEvaluator = (): Effect.Effect<ToolPolicyEvaluator, StorageFailure> =>
5436+ access.toolPolicy({
5437+ policyRows: core.findMany("tool_policy", {}),
5438+ provider: activeToolPolicyProvider,
5439+ });
54875440
54885441 // ------------------------------------------------------------------
54895442 // Tools (read surface)
@@ -5528,7 +5481,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 38 unmodified lines
55285481 // network side effects on a read-only credential. Skip it entirely:
55295482 // read-only-ness of the platform read path is a stated invariant here,
55305483 // not an accident of the best-effort catch below.
5531− if (config.platformView === true) return;
5484+ if (access.capabilities.storageWrites === "denied") return;
55325485 const integrations = yield* core.findMany("integration", {});
55335486 if (integrations.length === 0) return;
55345487 const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));
@@ -5692,7 +5645,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 157 unmodified lines
56925645 select: TOOL_INVOCATION_COLUMNS,
56935646 });
56945647 const includeBlocked = filter?.includeBlocked ?? false;
5695− const policyRules = yield* listActivePolicyRuleSet();
5648+ const policyEvaluator = yield* makeToolPolicyEvaluator();
56965649 // Only tools whose integration is still in the catalog. A tool row
56975650 // whose integration was removed is an orphan (a removal that could
56985651 // not reach this subject's rows): listing it invites an invoke that
@@ -5704,11 +5657,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
57045657 const tool = rowToTool(row);
57055658 if (!matchesToolFilter(tool, filter)) continue;
57065659 if (!includeBlocked) {
5707− const effective = yield* resolvePolicyFromRuleSet(
5708− normalizedPolicyId(tool),
5709− policyRules,
5710− tool.annotations?.requiresApproval,
5711− );
5660+ const effective = yield* policyEvaluator.resolve({
5661+ toolId: normalizedPolicyId(tool),
5662+ defaultRequiresApproval: tool.annotations?.requiresApproval,
5663+ });
57125664 if (effective.action === "block") continue;
57135665 }
57145666 tools.push(tool);
@@ -5717,11 +5669,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
57175669 const tool = staticToolToTool(entry);
57185670 if (!matchesToolFilter(tool, filter)) continue;
57195671 if (!includeBlocked) {
5720− const effective = yield* resolvePolicyFromRuleSet(
5721− normalizedPolicyId(tool),
5722− policyRules,
5723− tool.annotations?.requiresApproval,
5724− );
5672+ const effective = yield* policyEvaluator.resolve({
5673+ toolId: normalizedPolicyId(tool),
5674+ defaultRequiresApproval: tool.annotations?.requiresApproval,
5675+ });
57255676 if (effective.action === "block") continue;
57265677 }
57275678 tools.push(tool);
@@ -5733,15 +5684,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
57335684 address: ToolAddress,
57345685 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>
57355686 Effect.gen(function* () {
5736− const policyRules = yield* listActivePolicyRuleSet();
5687+ const policyEvaluator = yield* makeToolPolicyEvaluator();
57375688 const staticEntry = staticTools.get(String(address));
57385689 if (staticEntry) {
57395690 const tool = staticToolToTool(staticEntry);
5740− const effective = yield* resolvePolicyFromRuleSet(
5741− normalizedPolicyId(tool),
5742− policyRules,
5743− tool.annotations?.requiresApproval,
5744− );
5691+ const effective = yield* policyEvaluator.resolve({
5692+ toolId: normalizedPolicyId(tool),
5693+ defaultRequiresApproval: tool.annotations?.requiresApproval,
5694+ });
57455695 if (effective.action === "block") return null;
57465696 const preview = yield* Effect.tryPromise({
57475697 try: () =>
@@ -5779,11 +5729,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines
57795729 });
57805730 if (!row) return null;
57815731 const tool = rowToTool(row);
5782− const effective = yield* resolvePolicyFromRuleSet(
5783− normalizedPolicyId(tool),
5784− policyRules,
5785− tool.annotations?.requiresApproval,
5786− );
5732+ const effective = yield* policyEvaluator.resolve({
5733+ toolId: normalizedPolicyId(tool),
5734+ defaultRequiresApproval: tool.annotations?.requiresApproval,
5735+ });
57875736 if (effective.action === "block") return null;
57885737
57895738 const runtime = runtimes.get(row.plugin_id);
@@ -5941,11 +5890,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 151 unmodified lines
59415890 });
59425891
59435892 // ------------------------------------------------------------------
5944− // Policies — owner-ranked (user=0 inner, org=1 outer).
5893+ // Policies — CRUD over the stored rows. LIST ordering follows the
5894+ // product's owner precedence; how rules RESOLVE into an effective
5895+ // decision is the product's `ExecutorAccess.toolPolicy` hook.
59455896 // ------------------------------------------------------------------
59465897
5947− const ownerRankForRow = (row: { readonly owner: string }): number =>
5948− row.owner === "user" ? 0 : 1;
5898+ const ownerListRank = (row: { readonly owner: string }): number => {
5899+ const rank = accessOwners.indexOf(row.owner as Owner);
5900+ return rank === -1 ? accessOwners.length : rank;
5901+ };
59495902
59505903 // Tool policies gate by tool identity (`<integration>.<tool>`), independent of
59515904 // which connection serves it; the org/user split is handled by owner-scoped
@@ -5961,7 +5914,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines
59615914 .pipe(
59625915 Effect.map((rows) =>
59635916 [...rows]
5964− .sort((a, b) => ownerRankForRow(a) - ownerRankForRow(b) || comparePolicyRow(a, b))
5917+ .sort((a, b) => ownerListRank(a) - ownerListRank(b) || comparePolicyRow(a, b))
59655918 .map(rowToToolPolicy),
59665919 ),
59675920 );
@@ -6089,7 +6042,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 121 unmodified lines
60896042 requiresApproval = annotations?.requiresApproval;
60906043 }
60916044 }
6092− return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);
6045+ // The stored-rules surface: this deliberately resolves the WORKSPACE
6046+ // rules a settings UI edits — a session capability allowlist (the
6047+ // plugin provider) is a different question and is not consulted here.
6048+ const evaluator = yield* access.toolPolicy({
6049+ policyRows: Effect.succeed(policyRows),
6050+ provider: null,
6051+ });
6052+ return yield* evaluator.resolve({ toolId, defaultRequiresApproval: requiresApproval });
60936053 });
60946054
60956055 // ------------------------------------------------------------------
@@ -6305,6 +6265,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 209 unmodified lines
63056265 }
63066266 });
63076267
6268+ // An approval pause can outlive its pre-pause snapshot by minutes or
6269+ // days: an admin may rewrite policies while the call waits on a human.
6270+ // The granted approval stays granted — it is never asked again — but the
6271+ // decision to RUN must be fresh, so after an accepted approval the
6272+ // effective policy is re-resolved from the live rule source before any
6273+ // credential is resolved or handler executes. A rule that now BLOCKS the
6274+ // tool wins over the stale snapshot; a fresh `require_approval` is
6275+ // already satisfied by the approval that was just granted. Callers that
6276+ // retained storage rows across the pause re-read those themselves.
6277+ const recheckPolicyAfterApproval = (
6278+ toolId: string,
6279+ address: ToolAddress,
6280+ defaultRequiresApproval: boolean | undefined,
6281+ ): Effect.Effect<void, ToolBlockedError | StorageFailure> =>
6282+ Effect.gen(function* () {
6283+ const policyEvaluator = yield* makeToolPolicyEvaluator();
6284+ const fresh = yield* policyEvaluator.resolve({ toolId, defaultRequiresApproval });
6285+ if (fresh.action === "block") {
6286+ return yield* new ToolBlockedError({
6287+ address,
6288+ pattern: fresh.pattern ?? "*",
6289+ });
6290+ }
6291+ });
6292+
63086293 // ------------------------------------------------------------------
63096294 // execute — the invoke path.
63106295 // ------------------------------------------------------------------
@@ -6388,12 +6373,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 77 unmodified lines
63886373 // not the 5-segment dynamic form.
63896374 const staticEntry = staticTools.get(String(address));
63906375 if (staticEntry) {
6391− const policyRules = yield* listActivePolicyRuleSet();
6392− const policy = yield* resolvePolicyFromRuleSet(
6393− String(address),
6394− policyRules,
6395− staticEntry.tool.annotations?.requiresApproval,
6396− );
6376+ const policyEvaluator = yield* makeToolPolicyEvaluator();
6377+ const policy = yield* policyEvaluator.resolve({
6378+ toolId: String(address),
6379+ defaultRequiresApproval: staticEntry.tool.annotations?.requiresApproval,
6380+ });
63976381 if (policy.action === "block") {
63986382 return yield* new ToolBlockedError({
63996383 address,
@@ -6401,6 +6385,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
64016385 });
64026386 }
64036387 yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler);
6388+ if (approvalRequired(staticEntry.tool.annotations, policy)) {
6389+ yield* recheckPolicyAfterApproval(
6390+ String(address),
6391+ address,
6392+ staticEntry.tool.annotations?.requiresApproval,
6393+ );
6394+ }
64046395 return yield* wrapInvocationError(
64056396 staticEntry.tool.handler({
64066397 ctx: staticEntry.ctx,
@@ -6472,7 +6463,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines
64726463 }),
64736464 { startImmediately: true },
64746465 );
6475− const policyRulesFiber = yield* Effect.forkChild(listActivePolicyRuleSet());
6466+ const policyEvaluatorFiber = yield* Effect.forkChild(makeToolPolicyEvaluator());
64766467 const connectionRowFiber = yield* Effect.forkChild(
64776468 findConnectionRow({
64786469 owner: parsed.owner,
@@ -6508,13 +6499,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
65086499
65096500 // Resolve policy (owner-ranked).
65106501 const toolForPolicy = rowToTool(row);
6511− const policyRules = yield* Fiber.join(policyRulesFiber);
6502+ const policyEvaluator = yield* Fiber.join(policyEvaluatorFiber);
65126503 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
6513− const policy = yield* resolvePolicyFromRuleSet(
6514− normalizedPolicyId(toolForPolicy),
6515− policyRules,
6516− annotations?.requiresApproval,
6517− );
6504+ const policy = yield* policyEvaluator.resolve({
6505+ toolId: normalizedPolicyId(toolForPolicy),
6506+ defaultRequiresApproval: annotations?.requiresApproval,
6507+ });
65186508 if (policy.action === "block") {
65196509 return yield* new ToolBlockedError({
65206510 address,
@@ -6571,6 +6561,56 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines
65716561 }
65726562 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);
65736563
6564+ // Fresh decisions after the pause (see `recheckPolicyAfterApproval`):
6565+ // besides the policy, re-read the rows this call retained across
6566+ // the approval. A connection or tool removed while the call waited
6567+ // on a human must fail as not-found here — before any credential
6568+ // is resolved — not execute against rows that no longer exist.
6569+ let approvedToolRow = row;
6570+ let approvedConnectionRow = connectionRow;
6571+ if (approvalRequired(resolvedAnnotations, policy)) {
6572+ yield* recheckPolicyAfterApproval(
6573+ normalizedPolicyId(toolForPolicy),
6574+ address,
6575+ resolvedAnnotations?.requiresApproval,
6576+ );
6577+ const freshToolRow = yield* core.findFirst("tool", {
6578+ where: (b: AnyCb) =>
6579+ b.and(
6580+ byOwner(parsed.owner)(b),
6581+ b("integration", "=", String(parsed.integration)),
6582+ b("connection", "=", String(parsed.connection)),
6583+ b("name", "=", String(parsed.tool)),
6584+ ),
6585+ select: TOOL_INVOCATION_COLUMNS,
6586+ });
6587+ if (!freshToolRow) {
6588+ return yield* new ToolNotFoundError({ address });
6589+ }
6590+ // The plugin runtime and handler were bound to the PRE-pause row.
6591+ // If the integration was torn down and re-registered under a
6592+ // different plugin while the call waited, the tool the user
6593+ // approved no longer exists in that form — fail as not-found
6594+ // rather than hand the replacement row to the old plugin.
6595+ if (freshToolRow.plugin_id !== row.plugin_id) {
6596+ return yield* new ToolNotFoundError({ address });
6597+ }
6598+ approvedToolRow = freshToolRow;
6599+ const freshConnectionRow = yield* findConnectionRow({
6600+ owner: parsed.owner,
6601+ integration: parsed.integration,
6602+ name: parsed.connection,
6603+ });
6604+ if (!freshConnectionRow) {
6605+ return yield* new ConnectionNotFoundError({
6606+ owner: parsed.owner,
6607+ integration: parsed.integration,
6608+ name: parsed.connection,
6609+ });
6610+ }
6611+ approvedConnectionRow = freshConnectionRow;
6612+ }
6613+
65746614 // Resolve every named credential input (`variable → value`); `value` is
65756615 // the primary `token` for single-input + OAuth callers. The
65766616 // integration-row read is independent of credential resolution, so
@@ -6594,9 +6634,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines
65946634 // integration read cannot gate the credential error and a failed
65956635 // one is deliberately abandoned, never silently dropped as an
65966636 // unobserved value.
6597− const valuesFiber = yield* Effect.forkChild(resolveConnectionValues(connectionRow), {
6598− startImmediately: true,
6599− });
6637+ const valuesFiber = yield* Effect.forkChild(
6638+ resolveConnectionValues(approvedConnectionRow),
6639+ {
6640+ startImmediately: true,
6641+ },
6642+ );
66006643 const integrationRowFiber = yield* Effect.forkChild(
66016644 findIntegrationRow(parsed.integration),
66026645 );
@@ -6611,7 +6654,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
66116654 if (!integrationRow) {
66126655 return yield* new IntegrationNotFoundError({ slug: parsed.integration });
66136656 }
6614− const grantedScopes = grantedScopesFromRow(connectionRow);
6657+ const grantedScopes = grantedScopesFromRow(approvedConnectionRow);
66156658 const invokeTool = runtime.plugin.invokeTool;
66166659 const invokeWith = (
66176660 resolved: Record<string, string | null>,
@@ -6620,7 +6663,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
66206663 owner: parsed.owner,
66216664 integration: parsed.integration,
66226665 connection: parsed.connection,
6623− template: AuthTemplateSlug.make(connectionRow.template),
6666+ template: AuthTemplateSlug.make(approvedConnectionRow.template),
66246667 value: resolved[PRIMARY_INPUT_VARIABLE] ?? null,
66256668 values: resolved,
66266669 config: decodeJsonColumn(integrationRow.config),
@@ -6629,7 +6672,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
66296672 return wrapInvocationError(
66306673 invokeTool({
66316674 ctx: runtime.ctx,
6632− toolRow: row,
6675+ toolRow: approvedToolRow,
66336676 credential,
66346677 args,
66356678 elicit: buildElicit(address, args, handler),
@@ -6656,7 +6699,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
66566699 // guidance rather than a masked one.
66576700 const { result, usedValues } = yield* Effect.gen(function* () {
66586701 if (!isUnauthorizedToolFailure(first)) return { result: first, usedValues: values };
6659− const refreshed = yield* forceRefreshConnectionValues(connectionRow).pipe(
6702+ const refreshed = yield* forceRefreshConnectionValues(approvedConnectionRow).pipe(
66606703 // A failed re-mint is not this call's failure to report: the upstream
66616704 // already produced an auth failure with recovery guidance, which is
66626705 // strictly more actionable than a refresh-plumbing error. Keep it.
@@ -6671,7 +6714,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
66716714 usedValues: refreshed,
66726715 };
66736716 });
6674− yield* healPersistedHealthOnUse(connectionRow, result, usedValues);
6717+ yield* healPersistedHealthOnUse(approvedConnectionRow, result, usedValues);
66756718 return result;
66766719 });
66776720 // Interrupting an already-completed (or already-joined) fiber is a
@@ -6684,7 +6727,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6 unmodified lines
66846727 // so an interruption that lands before the join reaches it promptly.
66856728 return yield* Effect.ensuring(
66866729 invokeDynamicTool,
6687− Fiber.interruptAll([toolRowFiber, policyRulesFiber, connectionRowFiber]),
6730+ Fiber.interruptAll([toolRowFiber, policyEvaluatorFiber, connectionRowFiber]),
66886731 );
66896732 }).pipe(
66906733 // Expected tool failures (`ToolResult.fail`) resolve through the
@@ -6788,16 +6831,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 97 unmodified lines
67886831 // register credential providers.
67896832 // ------------------------------------------------------------------
67906833
6834+ // IDENTITY partitions, deliberately NOT narrowed by `access.owners`:
6835+ // these name where the binding's identity lives, while WHICH of them a
6836+ // plugin blob store may read or write is the separate product decision
6837+ // (`BlobAccess`) applied per store below. Identity-bound consumers keep
6838+ // the full shape — see the pending-approval store.
67916839 const blobPartitions: OwnerPartitions = {
67926840 org: `o:${tenant}`,
67936841 user: subject != null ? `u:${tenant}:${subject}` : null,
67946842 };
67956843
6796− // Pending approvals file under the narrowest partition this executor has:
6797− // a subject-bound executor keeps them private to that member, and a pure-org
6798− // executor (no subject) files them at the org. Either way the partition IS
6799− // the ownership check — another caller's executor reads a different
6800− // namespace and simply does not see the record.
6844+ // Pending approvals file under the narrowest IDENTITY partition this
6845+ // executor has: a subject-bound executor keeps them private to that
6846+ // member, and a pure-org executor (no subject) files them at the org.
6847+ // The partition IS the ownership check — another caller's executor reads
6848+ // a different namespace and simply does not see the record. CONTINUATION
6849+ // IDENTITY, not resource visibility: this routing must never follow the
6850+ // product's narrowed `access.owners` view — rerouting an org-only
6851+ // member's approvals into the SHARED org namespace would let any
6852+ // coworker's binding consume them.
68016853 const pendingApprovals = makePendingApprovalStore(
68026854 blobs,
68036855 blobPartitions.user ?? blobPartitions.org,
@@ -6815,10 +6867,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 11 unmodified lines
68156867 core,
68166868 pluginId: plugin.id,
68176869 owner: ownerBinding,
6870+ readOwners: accessOwners,
68186871 });
68196872 const storageDeps: StorageDeps = {
68206873 owner: ownerBinding,
6821− blobs: pluginBlobStore(blobs, blobPartitions, plugin.id),
6874+ blobs: pluginBlobStore(blobs, blobPartitions, plugin.id, {
6875+ owners: accessOwners,
6876+ storageWrites: access.capabilities.storageWrites,
6877+ }),
68226878 pluginStorage,
68236879 };
68246880 const storage = plugin.storage(storageDeps);
@@ -7140,7 +7196,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 315 unmodified lines
71407196
71417197 // Default OFF: without the opt-in there is no `admin` key at all, so the
71427198 // tenant-wide handle is never even constructed.
7143− const admin = config.platformView === true ? makeAdmin() : undefined;
7199+ const admin = access.capabilities.adminReads ? makeAdmin() : undefined;
71447200
71457201 // ------------------------------------------------------------------
71467202 // close
packages/core/sdk/src/index.ts+12−3
@@ -188,11 +188,12 @@ export { · 187 unmodified lines
188188 ownerForItemId,
189189 } from "./provider-item-owner";
190190
191−// Tool policies.
191+// Tool policies — vocabulary + matching/placement utilities. Resolution
192+// semantics live in `@executor-js/product-access/policy`.
192193 export {
193194 matchPattern,
194195 isValidPattern,
195− effectivePolicyFromSorted,
196+ comparePolicyRow,
196197 ToolPolicyActionSchema,
197198 type ToolPolicy,
198199 type CreateToolPolicyInput,
@@ -446,10 +447,18 @@ export { · 247 unmodified lines
446447 export {
447448 CurrentOrgWriteAccess,
448449 currentOrgWriteAccess,
450+ executorAccessViolation,
449451 makeOrgWriteAccessState,
452+ type AccessDecision,
453+ type ExecutorAccess,
454+ type ExecutorAccessCapabilities,
450455 type OrgWriteAccess,
451456 type OrgWriteAccessState,
452−} from "./org-write-access";
457+ type SettingsWriteTarget,
458+ type ToolPolicyEvaluationInput,
459+ type ToolPolicyEvaluator,
460+ type ToolPolicySources,
461+} from "./access";
453462
454463 // CLI / runtime config.
455464 export {
packages/core/sdk/src/integration-removal-cascade.test.ts+9−3
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 import { definePlugin } from "./plugin";
1717 import type { CredentialProvider } from "./provider";
1818 import { makeTestConfig } from "./testing";
19+import { testAccess } from "@executor-js/product-access/testing";
1920
2021 // ---------------------------------------------------------------------------
2122 // Removing an integration must cascade to EVERY member's rows under it, not
@@ -62,7 +63,11 @@ const demoPlugin = definePlugin(() => ({ · 40 unmodified lines
6263
6364 const setup = () =>
6465 Effect.gen(function* () {
65− const config = makeTestConfig({ plugins: [demoPlugin] as const, subject: ALICE });
66+ const config = makeTestConfig({
67+ access: testAccess.member(),
68+ plugins: [demoPlugin] as const,
69+ subject: ALICE,
70+ });
6671 const alice = yield* createExecutor(config);
6772 const bob = yield* createExecutor({
6873 ...config,
@@ -119,7 +124,7 @@ describe("integrations.remove cascade", () => { · 50 unmodified lines
119124 it.effect("a member (org writes denied) still cannot remove", () =>
120125 Effect.gen(function* () {
121126 const { config } = yield* setup();
122− const member = yield* createExecutor({ ...config, orgWrites: "denied" });
127+ const member = yield* createExecutor({ ...config, access: testAccess.member("denied") });
123128 yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore));
124129 const error = yield* Effect.flip(member.integrations.remove(INTEG));
125130 expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true);
@@ -129,7 +134,7 @@ describe("integrations.remove cascade", () => { · 3 unmodified lines
129134 it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () =>
130135 Effect.gen(function* () {
131136 const { config } = yield* setup();
132− const platform = yield* createExecutor({ ...config, platformView: true });
137+ const platform = yield* createExecutor({ ...config, access: testAccess.platform() });
133138 yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore));
134139 yield* platform.integrations.remove(INTEG).pipe(
135140 Effect.flatMap(() => Effect.die("expected the platform view to refuse the removal")),
@@ -170,6 +175,7 @@ describe("orphaned rows are not served", () => { · 34 unmodified lines
170175 withQueryContext(config.testDb.db, {
171176 tenant: String(config.tenant),
172177 subject: BOB,
178+ owners: ["user", "org"],
173179 }).findMany("tool", {}),
174180 );
175181 expect(stored.map((row) => String(row.integration)).sort()).toEqual([
packages/core/sdk/src/oauth-ema-lifecycle.test.ts+25−6
@@ -24,6 +24,7 @@ import type { OAuthService } from "./oauth-client"; · 23 unmodified lines
2424 import { definePlugin } from "./plugin";
2525 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
2626 import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";
27+import { testAccess } from "@executor-js/product-access/testing";
2728
2829 const INTEG = IntegrationSlug.make("acme");
2930 const TEMPLATE = AuthTemplateSlug.make("oauth");
@@ -157,7 +158,10 @@ describe("enterprise-managed connections", () => { · 127 unmodified lines
157158 Effect.scoped(
158159 Effect.gen(function* () {
159160 const servers = yield* enterpriseServers({});
160− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
161+ const { executor } = yield* makeTestWorkspaceHarness({
162+ access: testAccess.member(),
163+ plugins,
164+ });
161165 yield* executor.acme.seed();
162166 yield* registerClients(executor.oauth.createClient, servers);
163167
@@ -182,7 +186,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines
182186 Effect.scoped(
183187 Effect.gen(function* () {
184188 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
185− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
189+ const { executor } = yield* makeTestWorkspaceHarness({
190+ access: testAccess.member(),
191+ plugins,
192+ });
186193 yield* executor.acme.seed();
187194 yield* registerClients(executor.oauth.createClient, servers);
188195 yield* executor.oauth.start(startEnterpriseConnect(servers));
@@ -207,7 +214,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines
207214 Effect.scoped(
208215 Effect.gen(function* () {
209216 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
210− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
217+ const { executor } = yield* makeTestWorkspaceHarness({
218+ access: testAccess.member(),
219+ plugins,
220+ });
211221 yield* executor.acme.seed();
212222 yield* registerClients(executor.oauth.createClient, servers);
213223 yield* executor.oauth.start(startEnterpriseConnect(servers));
@@ -241,7 +251,10 @@ describe("enterprise-managed connections", () => { · 27 unmodified lines
241251 Effect.scoped(
242252 Effect.gen(function* () {
243253 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
244− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
254+ const { executor } = yield* makeTestWorkspaceHarness({
255+ access: testAccess.member(),
256+ plugins,
257+ });
245258 yield* executor.acme.seed();
246259 yield* registerClients(executor.oauth.createClient, servers);
247260 yield* executor.oauth.start(startEnterpriseConnect(servers));
@@ -287,7 +300,10 @@ describe("enterprise-managed connections", () => { · 39 unmodified lines
287300 errorDescription: "This client is not approved for the requested MCP server.",
288301 },
289302 });
290− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
303+ const { executor } = yield* makeTestWorkspaceHarness({
304+ access: testAccess.member(),
305+ plugins,
306+ });
291307 yield* executor.acme.seed();
292308 yield* registerClients(executor.oauth.createClient, servers);
293309
@@ -316,7 +332,10 @@ describe("enterprise-managed connections", () => { · 22 unmodified lines
316332 Effect.scoped(
317333 Effect.gen(function* () {
318334 const servers = yield* enterpriseServers({ advertiseProfile: false });
319− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
335+ const { executor } = yield* makeTestWorkspaceHarness({
336+ access: testAccess.member(),
337+ plugins,
338+ });
320339 yield* executor.acme.seed();
321340 yield* registerClients(executor.oauth.createClient, servers);
322341
packages/core/sdk/src/oauth-ema-rollout.test.ts+2−0
@@ -34,6 +34,7 @@ import type { · 33 unmodified lines
3434 import { definePlugin } from "./plugin";
3535 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
3636 import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";
37+import { testAccess } from "@executor-js/product-access/testing";
3738
3839 const INTEG = IntegrationSlug.make("acme");
3940 const TEMPLATE = AuthTemplateSlug.make("oauth");
@@ -233,6 +234,7 @@ const tokenExchangeCount = (servers: EnterpriseServers) => · 193 unmodified lines
233234
234235 const harness = (rollout: EnterpriseManagedRollout | undefined) =>
235236 makeTestWorkspaceHarness({
237+ access: testAccess.member(),
236238 plugins,
237239 tenant: TENANT,
238240 subject: SUBJECT,
packages/core/sdk/src/oauth-first-party.test.ts+15−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 import { definePlugin } from "./plugin";
1919 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
2020 import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
21+import { testAccess } from "@executor-js/product-access/testing";
2122
2223 // First-party OAuth clients: host-operated apps declared in executor config
2324 // (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from
@@ -119,6 +120,7 @@ describe("first-party oauth clients", () => { · 95 unmodified lines
119120 Effect.gen(function* () {
120121 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
121122 const { executor } = yield* makeTestWorkspaceHarness({
123+ access: testAccess.member(),
122124 plugins,
123125 firstPartyOAuthClients: [firstPartyClientFor(server)],
124126 });
@@ -170,6 +172,7 @@ describe("first-party oauth clients", () => { · 45 unmodified lines
170172 Effect.gen(function* () {
171173 const server = yield* serveOAuthTestServer({ scopes: ["repo"] });
172174 const { executor } = yield* makeTestWorkspaceHarness({
175+ access: testAccess.member(),
173176 plugins,
174177 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }],
175178 });
@@ -204,6 +207,7 @@ describe("first-party oauth clients", () => { · 28 unmodified lines
204207 Effect.gen(function* () {
205208 const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] });
206209 const { executor } = yield* makeTestWorkspaceHarness({
210+ access: testAccess.member(),
207211 plugins,
208212 firstPartyOAuthClients: [
209213 {
@@ -240,6 +244,7 @@ describe("first-party oauth clients", () => { · 30 unmodified lines
240244 Effect.gen(function* () {
241245 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
242246 const harness = yield* makeTestWorkspaceHarness({
247+ access: testAccess.member(),
243248 plugins,
244249 firstPartyOAuthClients: [firstPartyClientFor(server)],
245250 });
@@ -288,6 +293,7 @@ describe("first-party oauth clients", () => { · 42 unmodified lines
288293 Effect.gen(function* () {
289294 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
290295 const { executor } = yield* makeTestWorkspaceHarness({
296+ access: testAccess.member(),
291297 plugins,
292298 firstPartyOAuthClients: [
293299 {
@@ -330,6 +336,7 @@ describe("first-party oauth clients", () => { · 36 unmodified lines
330336 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
331337 let enabled = true;
332338 const { executor } = yield* makeTestWorkspaceHarness({
339+ access: testAccess.member(),
333340 plugins,
334341 subject: "review-user",
335342 tenant: "review-org",
@@ -368,6 +375,7 @@ describe("first-party oauth clients", () => { · 32 unmodified lines
368375 Effect.gen(function* () {
369376 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
370377 const harness = yield* makeTestWorkspaceHarness({
378+ access: testAccess.member(),
371379 plugins,
372380 firstPartyOAuthClients: [
373381 {
@@ -439,6 +447,7 @@ describe("first-party oauth clients", () => { · 65 unmodified lines
439447 Effect.gen(function* () {
440448 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
441449 const { executor } = yield* makeTestWorkspaceHarness({
450+ access: testAccess.member(),
442451 plugins,
443452 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }],
444453 });
@@ -467,6 +476,7 @@ describe("first-party oauth clients", () => { · 22 unmodified lines
467476 Effect.gen(function* () {
468477 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
469478 const { executor } = yield* makeTestWorkspaceHarness({
479+ access: testAccess.member(),
470480 plugins,
471481 firstPartyOAuthClients: [firstPartyClientFor(server)],
472482 });
@@ -496,7 +506,10 @@ describe("first-party oauth clients", () => { · 23 unmodified lines
496506 Effect.scoped(
497507 Effect.gen(function* () {
498508 yield* serveOAuthTestServer({ scopes: ["read"] });
499− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
509+ const { executor } = yield* makeTestWorkspaceHarness({
510+ access: testAccess.member(),
511+ plugins,
512+ });
500513 yield* executor.acme.seed();
501514
502515 const error = yield* executor.oauth
@@ -523,6 +536,7 @@ describe("first-party oauth clients", () => { · 20 unmodified lines
523536 Effect.gen(function* () {
524537 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
525538 const { executor } = yield* makeTestWorkspaceHarness({
539+ access: testAccess.member(),
526540 plugins,
527541 firstPartyOAuthClients: [firstPartyClientFor(server)],
528542 });
packages/core/sdk/src/oauth-flow.test.ts+116−30
@@ -24,6 +24,7 @@ import type { CredentialProvider } from "./provider"; · 23 unmodified lines
2424 import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
2525 import { ToolResult } from "./tool-result";
2626 import { serveOAuthTestServer } from "./testing/oauth-test-server";
27+import { testAccess } from "@executor-js/product-access/testing";
2728
2829 // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow
2930 // and OAuth access-token refresh end to end against the test authorization
@@ -197,7 +198,10 @@ describe("oauth.start / oauth.complete", () => { · 167 unmodified lines
197198 Effect.scoped(
198199 Effect.gen(function* () {
199200 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
200− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
201+ const { executor } = yield* makeTestWorkspaceHarness({
202+ access: testAccess.member(),
203+ plugins,
204+ });
201205 yield* executor.acme.seed();
202206
203207 yield* executor.oauth.createClient({
@@ -302,6 +306,7 @@ describe("oauth.start / oauth.complete", () => { · 98 unmodified lines
302306 }))();
303307 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
304308 const { executor } = yield* makeTestWorkspaceHarness({
309+ access: testAccess.member(),
305310 plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const,
306311 waitUntil: (promise) => keptAlive.push(promise),
307312 });
@@ -363,7 +368,10 @@ describe("oauth.start / oauth.complete", () => { · 55 unmodified lines
363368 scopes: ["read"],
364369 defaultTokenEndpointAuthMethod: "client_secret_basic",
365370 });
366− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
371+ const { executor, config } = yield* makeTestWorkspaceHarness({
372+ access: testAccess.member(),
373+ plugins,
374+ });
367375 yield* executor.acme.seed();
368376
369377 yield* executor.oauth.createClient({
@@ -428,7 +436,10 @@ describe("oauth.start / oauth.complete", () => { · 58 unmodified lines
428436 scopes: ["read"],
429437 defaultTokenEndpointAuthMethod: "client_secret_basic",
430438 });
431− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
439+ const { executor, config } = yield* makeTestWorkspaceHarness({
440+ access: testAccess.member(),
441+ plugins,
442+ });
432443 yield* executor.acme.seed();
433444
434445 yield* executor.oauth.createClient({
@@ -486,6 +497,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines
486497 Effect.gen(function* () {
487498 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
488499 const { executor } = yield* makeTestWorkspaceHarness({
500+ access: testAccess.member(),
489501 plugins,
490502 oauthCallbackStateOrgSlug: "acme",
491503 });
@@ -546,7 +558,10 @@ describe("oauth.start / oauth.complete", () => { · 54 unmodified lines
546558 scopes: ["offline_access", "read"],
547559 omitTokenResponseScopes: ["offline_access"],
548560 });
549− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
561+ const { executor } = yield* makeTestWorkspaceHarness({
562+ access: testAccess.member(),
563+ plugins,
564+ });
550565 yield* executor.acme.seed(["offline_access", "read"]);
551566
552567 yield* executor.oauth.createClient({
@@ -590,7 +605,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines
590605 scopes: ["openid", "email", "profile", "read"],
591606 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
592607 });
593− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
608+ const { executor } = yield* makeTestWorkspaceHarness({
609+ access: testAccess.member(),
610+ plugins,
611+ });
594612 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
595613
596614 yield* executor.oauth.createClient({
@@ -634,7 +652,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines
634652 scopes: ["openid", "email", "profile", "read"],
635653 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
636654 });
637− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
655+ const { executor } = yield* makeTestWorkspaceHarness({
656+ access: testAccess.member(),
657+ plugins,
658+ });
638659 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
639660
640661 yield* executor.oauth.createClient({
@@ -679,7 +700,10 @@ describe("oauth.start / oauth.complete", () => { · 38 unmodified lines
679700 scopes: ["openid", "email", "profile", "read"],
680701 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
681702 });
682− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
703+ const { executor } = yield* makeTestWorkspaceHarness({
704+ access: testAccess.member(),
705+ plugins,
706+ });
683707 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
684708
685709 yield* executor.oauth.createClient({
@@ -739,7 +763,10 @@ describe("oauth.start / oauth.complete", () => { · 53 unmodified lines
739763 scopes: ["openid", "email", "profile", "read"],
740764 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
741765 });
742− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
766+ const { executor } = yield* makeTestWorkspaceHarness({
767+ access: testAccess.member(),
768+ plugins,
769+ });
743770 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
744771
745772 yield* executor.oauth.createClient({
@@ -797,7 +824,10 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines
797824 scopes: ["openid", "email", "profile", "read"],
798825 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
799826 });
800− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
827+ const { executor } = yield* makeTestWorkspaceHarness({
828+ access: testAccess.member(),
829+ plugins,
830+ });
801831 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
802832
803833 yield* executor.oauth.createClient({
@@ -852,7 +882,7 @@ describe("oauth.start / oauth.complete", () => { · 48 unmodified lines
852882 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
853883 refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" },
854884 });
855− const harness = yield* makeTestWorkspaceHarness({ plugins });
885+ const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
856886 const { executor, config } = harness;
857887 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
858888
@@ -910,6 +940,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines
910940 // is no silent localhost default. The redirect flow must fail loudly
911941 // rather than handing the provider a wrong `http://127.0.0.1/callback`.
912942 const { executor } = yield* makeTestWorkspaceHarness({
943+ access: testAccess.member(),
913944 plugins,
914945 redirectUri: null,
915946 });
@@ -951,6 +982,7 @@ describe("oauth.start / oauth.complete", () => { · 35 unmodified lines
951982 // No redirectUri configured, but client_credentials never redirects —
952983 // it must still mint the connection inline.
953984 const { executor } = yield* makeTestWorkspaceHarness({
985+ access: testAccess.member(),
954986 plugins,
955987 redirectUri: null,
956988 });
@@ -983,7 +1015,10 @@ describe("oauth.start / oauth.complete", () => { · 26 unmodified lines
9831015 Effect.scoped(
9841016 Effect.gen(function* () {
9851017 const server = yield* serveOAuthTestServer();
986− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
1018+ const { executor } = yield* makeTestWorkspaceHarness({
1019+ access: testAccess.member(),
1020+ plugins,
1021+ });
9871022 yield* executor.acme.seed();
9881023 yield* executor.oauth.createClient({
9891024 owner: "org",
@@ -1011,7 +1046,10 @@ describe("oauth.start / oauth.complete", () => { · 21 unmodified lines
10111046 Effect.scoped(
10121047 Effect.gen(function* () {
10131048 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1014− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
1049+ const { executor } = yield* makeTestWorkspaceHarness({
1050+ access: testAccess.member(),
1051+ plugins,
1052+ });
10151053 yield* executor.acme.seed();
10161054
10171055 // The app is registered under the WORKSPACE (org) — "shared with
@@ -1062,7 +1100,10 @@ describe("oauth.start / oauth.complete", () => { · 44 unmodified lines
10621100 Effect.scoped(
10631101 Effect.gen(function* () {
10641102 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1065− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
1103+ const { executor } = yield* makeTestWorkspaceHarness({
1104+ access: testAccess.member(),
1105+ plugins,
1106+ });
10661107 yield* executor.acme.seed();
10671108
10681109 // A PRIVATE app owned by the member.
@@ -1100,7 +1141,10 @@ describe("oauth.start / oauth.complete", () => { · 31 unmodified lines
11001141 Effect.scoped(
11011142 Effect.gen(function* () {
11021143 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1103− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
1144+ const { executor, config } = yield* makeTestWorkspaceHarness({
1145+ access: testAccess.member(),
1146+ plugins,
1147+ });
11041148 // Deliberately NOT seeded: the slug names nothing in the catalog — the
11051149 // shape of a reconnect against a connection whose integration was
11061150 // removed, or an agent replaying a stale slug.
@@ -1141,7 +1185,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 34 unmodified lines
11411185 Effect.scoped(
11421186 Effect.gen(function* () {
11431187 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1144− const harness = yield* makeTestWorkspaceHarness({ plugins });
1188+ const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
11451189 const { executor, config } = harness;
11461190 yield* executor.acme.seed();
11471191
@@ -1225,7 +1269,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 77 unmodified lines
12251269
12261270 // One database handle and one credential store under two execution
12271271 // stacks — what a self-host holds while two MCP sessions are open.
1228− const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };
1272+ const config = {
1273+ ...makeTestConfig({ access: testAccess.member(), plugins }),
1274+ fetch: park.fetch,
1275+ };
12291276 const sessionA = yield* createExecutor(config);
12301277 const sessionB = yield* createExecutor(config);
12311278 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
@@ -1322,7 +1369,12 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 90 unmodified lines
13221369 // the store layer and mask the gate-key collision this test is about.
13231370 const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const;
13241371 const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const;
1325− const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" });
1372+ const shared = makeTestConfig({
1373+ access: testAccess.member(),
1374+ plugins: pluginsA,
1375+ tenant: "a",
1376+ subject: "user:b",
1377+ });
13261378 const configA = { ...shared, fetch: parkA.fetch };
13271379 const configB = {
13281380 ...shared,
@@ -1379,7 +1431,11 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines
13791431
13801432 // Expire BOTH rows so both tenants must refresh. `shared.db` is bound
13811433 // to tenant A; tenant B's partition needs its own scoped handle.
1382− const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" });
1434+ const dbB = withQueryContext(shared.testDb.db, {
1435+ tenant: "a:user",
1436+ subject: "b",
1437+ owners: ["user", "org"],
1438+ });
13831439 yield* Effect.promise(() =>
13841440 shared.db.updateMany("connection", {
13851441 where: (b) => b("name", "=", "mine"),
@@ -1452,7 +1508,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 66 unmodified lines
14521508 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
14531509 const park = makeTokenRequestPark();
14541510
1455− const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };
1511+ const config = {
1512+ ...makeTestConfig({ access: testAccess.member(), plugins }),
1513+ fetch: park.fetch,
1514+ };
14561515 const sessionA = yield* createExecutor(config);
14571516 const sessionB = yield* createExecutor(config);
14581517 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
@@ -1598,6 +1657,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 139 unmodified lines
15981657 // handle, which is what the extra `withQueryContext` wrapper is.
15991658 const config = {
16001659 ...makeTestConfig({
1660+ access: testAccess.member(),
16011661 plugins: [oauthPlugin] as const,
16021662 tenant: SHARED_STORE_TENANT,
16031663 subject: SHARED_STORE_SUBJECT,
@@ -1722,7 +1782,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 118 unmodified lines
17221782 Effect.scoped(
17231783 Effect.gen(function* () {
17241784 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1725− const harness = yield* makeTestWorkspaceHarness({ plugins });
1785+ const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
17261786 const { executor, config } = harness;
17271787 yield* executor.acme.seed();
17281788
@@ -1790,7 +1850,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 61 unmodified lines
17901850 tokenExpiresInSeconds: 0,
17911851 invalidRefreshTokenDescription: "Grant not found",
17921852 });
1793− const harness = yield* makeTestWorkspaceHarness({ plugins });
1853+ const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
17941854 const { executor, config } = harness;
17951855 yield* executor.acme.seed();
17961856
@@ -1876,7 +1936,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 79 unmodified lines
18761936 tokenExpiresInSeconds: 0,
18771937 invalidRefreshTokenDescription: "Grant revoked",
18781938 });
1879− const harness = yield* makeTestWorkspaceHarness({ plugins });
1939+ const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
18801940 const { executor, config } = harness;
18811941 yield* executor.acme.seed();
18821942
@@ -1983,7 +2043,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 100 unmodified lines
19832043 Effect.scoped(
19842044 Effect.gen(function* () {
19852045 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1986− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2046+ const { executor } = yield* makeTestWorkspaceHarness({
2047+ access: testAccess.member(),
2048+ plugins,
2049+ });
19872050 yield* executor.acme.seed();
19882051
19892052 yield* executor.oauth.createClient({
@@ -2040,7 +2103,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines
20402103 scopes: ["openid", "email", "profile", "offline_access", "read", "write"],
20412104 omitTokenResponseScopes: ["email", "profile", "write"],
20422105 });
2043− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2106+ const { executor, config } = yield* makeTestWorkspaceHarness({
2107+ access: testAccess.member(),
2108+ plugins,
2109+ });
20442110 yield* executor.acme.seed([
20452111 "openid",
20462112 "email",
@@ -2098,7 +2164,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 51 unmodified lines
20982164 Effect.scoped(
20992165 Effect.gen(function* () {
21002166 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
2101− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2167+ const { executor, config } = yield* makeTestWorkspaceHarness({
2168+ access: testAccess.member(),
2169+ plugins,
2170+ });
21022171 yield* executor.acme.seed(["read", "write"]);
21032172
21042173 yield* executor.oauth.createClient({
@@ -2159,7 +2228,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 54 unmodified lines
21592228 Effect.scoped(
21602229 Effect.gen(function* () {
21612230 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2162− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2231+ const { executor, config } = yield* makeTestWorkspaceHarness({
2232+ access: testAccess.member(),
2233+ plugins,
2234+ });
21632235 yield* executor.acme.seed();
21642236
21652237 // Reroute the https regional/advertised hosts back to the loopback test
@@ -2248,7 +2320,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 82 unmodified lines
22482320 Effect.scoped(
22492321 Effect.gen(function* () {
22502322 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2251− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2323+ const { executor, config } = yield* makeTestWorkspaceHarness({
2324+ access: testAccess.member(),
2325+ plugins,
2326+ });
22522327 yield* executor.acme.seed();
22532328
22542329 const tokenCalls: TokenEndpointCall[] = [];
@@ -2423,6 +2498,7 @@ const connectRejecting = (options?: { readonly tokenExpiresInSeconds?: number }) · 168 unmodified lines
24232498 return issued.length > 0 ? issued[issued.length - 1]! : null;
24242499 });
24252500 const harness = yield* makeTestWorkspaceHarness({
2501+ access: testAccess.member(),
24262502 plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const,
24272503 });
24282504 const { executor, config } = harness;
@@ -2612,6 +2688,7 @@ describe("reactive OAuth refresh on upstream 401", () => { · 183 unmodified lines
26122688 }))();
26132689
26142690 const { executor } = yield* makeTestWorkspaceHarness({
2691+ access: testAccess.member(),
26152692 plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const,
26162693 });
26172694 yield* executor.acme.seed();
@@ -2692,7 +2769,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 74 unmodified lines
26922769 Effect.scoped(
26932770 Effect.gen(function* () {
26942771 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2695− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2772+ const { executor, config } = yield* makeTestWorkspaceHarness({
2773+ access: testAccess.member(),
2774+ plugins,
2775+ });
26962776 yield* executor.acme.seed();
26972777
26982778 // `resource: null` — explicitly none, not merely unset.
@@ -2761,7 +2841,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 62 unmodified lines
27612841 Effect.scoped(
27622842 Effect.gen(function* () {
27632843 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2764− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2844+ const { executor } = yield* makeTestWorkspaceHarness({
2845+ access: testAccess.member(),
2846+ plugins,
2847+ });
27652848 yield* executor.acme.seed();
27662849
27672850 yield* executor.oauth.createClient({
@@ -2799,7 +2882,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 31 unmodified lines
27992882 Effect.scoped(
28002883 Effect.gen(function* () {
28012884 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2802− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2885+ const { executor } = yield* makeTestWorkspaceHarness({
2886+ access: testAccess.member(),
2887+ plugins,
2888+ });
28032889 yield* executor.acme.seed();
28042890
28052891 yield* executor.oauth.createClient({
packages/core/sdk/src/oauth-list-clients.test.ts+19−4
@@ -7,6 +7,7 @@ import { Effect, Predicate } from "effect"; · 6 unmodified lines
77
88 import { OAuthClientSlug } from "./ids";
99 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
10+import { testAccess } from "@executor-js/product-access/testing";
1011
1112 // listClients returns metadata-only summaries of the clients visible to the
1213 // caller — the tenant's org clients plus the caller's own user clients — and
@@ -21,7 +22,10 @@ describe("oauth.listClients", () => { · 8 unmodified lines
2122 it.effect("rejects metadata URLs persisted as authorization URLs", () =>
2223 Effect.scoped(
2324 Effect.gen(function* () {
24− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
25+ const { executor } = yield* makeTestWorkspaceHarness({
26+ access: testAccess.member(),
27+ plugins,
28+ });
2529
2630 const error = yield* Effect.flip(
2731 executor.oauth.createClient({
@@ -50,7 +54,10 @@ describe("oauth.listClients", () => { · 22 unmodified lines
5054 it.effect("returns owner-visible clients as summaries without the secret", () =>
5155 Effect.scoped(
5256 Effect.gen(function* () {
53− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
57+ const { executor } = yield* makeTestWorkspaceHarness({
58+ access: testAccess.member(),
59+ plugins,
60+ });
5461
5562 yield* executor.oauth.createClient({
5663 owner: "org",
@@ -113,7 +120,10 @@ describe("oauth.listClients", () => { · 56 unmodified lines
113120 it.effect("rejects HTTP Basic authentication without a client secret", () =>
114121 Effect.scoped(
115122 Effect.gen(function* () {
116− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
123+ const { executor } = yield* makeTestWorkspaceHarness({
124+ access: testAccess.member(),
125+ plugins,
126+ });
117127
118128 const error = yield* Effect.flip(
119129 executor.oauth.createClient({
@@ -147,6 +157,7 @@ describe("oauth.listClients", () => { · 27 unmodified lines
147157 const tenant = "shared-tenant";
148158
149159 const a = yield* makeTestWorkspaceHarness({
160+ access: testAccess.member(),
150161 plugins,
151162 tenant,
152163 subject: "subject-a",
@@ -172,6 +183,7 @@ describe("oauth.listClients", () => { · 19 unmodified lines
172183 });
173184
174185 const b = yield* makeTestWorkspaceHarness({
186+ access: testAccess.member(),
175187 plugins,
176188 tenant,
177189 subject: "subject-b",
@@ -190,7 +202,10 @@ describe("oauth.listClients", () => { · 12 unmodified lines
190202 it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () =>
191203 Effect.scoped(
192204 Effect.gen(function* () {
193− const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
205+ const { config, executor } = yield* makeTestWorkspaceHarness({
206+ access: testAccess.member(),
207+ plugins,
208+ });
194209
195210 yield* executor.oauth.createClient({
196211 owner: "org",
packages/core/sdk/src/oauth-register-dynamic.test.ts+62−16
@@ -12,6 +12,7 @@ import { OAuthRegisterDynamicError } from "./oauth-client"; · 11 unmodified lines
1212 import { definePlugin } from "./plugin";
1313 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
1414 import { serveOAuthTestServer } from "./testing/oauth-test-server";
15+import { testAccess } from "@executor-js/product-access/testing";
1516
1617 // RFC 7591 Dynamic Client Registration, end to end:
1718 // probe → registerDynamicClient (no pasted client id/secret) → listClients
@@ -64,7 +65,7 @@ describe("oauth.registerDynamicClient", () => { · 46 unmodified lines
6465 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
6566 const { executor } = yield* makeTestWorkspaceHarness({
6667 plugins,
67− orgWrites: "denied",
68+ access: testAccess.member("denied"),
6869 });
6970
7071 const error = yield* executor.oauth
@@ -94,7 +95,10 @@ describe("oauth.registerDynamicClient", () => { · 23 unmodified lines
9495 Effect.scoped(
9596 Effect.gen(function* () {
9697 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
97− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
98+ const { executor } = yield* makeTestWorkspaceHarness({
99+ access: testAccess.member(),
100+ plugins,
101+ });
98102 yield* executor.acme.seed();
99103
100104 // Probe surfaces the registration endpoint + advertised auth methods so
@@ -197,7 +201,10 @@ describe("oauth.registerDynamicClient", () => { · 96 unmodified lines
197201 Effect.scoped(
198202 Effect.gen(function* () {
199203 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
200− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
204+ const { executor } = yield* makeTestWorkspaceHarness({
205+ access: testAccess.member(),
206+ plugins,
207+ });
201208 yield* executor.acme.seed();
202209 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
203210
@@ -230,7 +237,10 @@ describe("oauth.registerDynamicClient", () => { · 26 unmodified lines
230237 Effect.scoped(
231238 Effect.gen(function* () {
232239 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
233− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
240+ const { executor } = yield* makeTestWorkspaceHarness({
241+ access: testAccess.member(),
242+ plugins,
243+ });
234244 yield* executor.acme.seed();
235245 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
236246
@@ -276,7 +286,10 @@ describe("oauth.registerDynamicClient", () => { · 39 unmodified lines
276286 Effect.scoped(
277287 Effect.gen(function* () {
278288 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
279− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
289+ const { executor } = yield* makeTestWorkspaceHarness({
290+ access: testAccess.member(),
291+ plugins,
292+ });
280293 yield* executor.acme.seed();
281294 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
282295
@@ -327,7 +340,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines
327340 // reused, so a fresh DCR registration happens. The GC migration then
328341 // backfills/GCs any duplicate this transient window mints.
329342 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
330− const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
343+ const { config, executor } = yield* makeTestWorkspaceHarness({
344+ access: testAccess.member(),
345+ plugins,
346+ });
331347 yield* executor.acme.seed();
332348 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
333349 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
@@ -379,7 +395,10 @@ describe("oauth.registerDynamicClient", () => { · 45 unmodified lines
379395 const server = yield* serveOAuthTestServer({
380396 scopes: ["openid", "offline_access"],
381397 });
382− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
398+ const { executor } = yield* makeTestWorkspaceHarness({
399+ access: testAccess.member(),
400+ plugins,
401+ });
383402 yield* executor.acme.seed();
384403
385404 yield* executor.oauth.registerDynamicClient({
@@ -413,7 +432,10 @@ describe("oauth.registerDynamicClient", () => { · 27 unmodified lines
413432 // row's origin_issuer, the reuse lookup keys on it and mints no
414433 // duplicate. This is the steady state the migration establishes.
415434 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
416− const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
435+ const { config, executor } = yield* makeTestWorkspaceHarness({
436+ access: testAccess.member(),
437+ plugins,
438+ });
417439 yield* executor.acme.seed();
418440 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
419441 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
@@ -467,7 +489,10 @@ describe("oauth.registerDynamicClient", () => { · 47 unmodified lines
467489 Effect.scoped(
468490 Effect.gen(function* () {
469491 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
470− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
492+ const { executor } = yield* makeTestWorkspaceHarness({
493+ access: testAccess.member(),
494+ plugins,
495+ });
471496 yield* executor.acme.seed();
472497 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
473498 const resourceA = `${server.issuerUrl}/mcp/a`;
@@ -533,7 +558,10 @@ describe("oauth.registerDynamicClient", () => { · 59 unmodified lines
533558 Effect.scoped(
534559 Effect.gen(function* () {
535560 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
536− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
561+ const { executor } = yield* makeTestWorkspaceHarness({
562+ access: testAccess.member(),
563+ plugins,
564+ });
537565 yield* executor.acme.seed();
538566 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
539567 const resourceA = `${server.issuerUrl}/mcp/a`;
@@ -638,7 +666,10 @@ describe("oauth.registerDynamicClient", () => { · 98 unmodified lines
638666 Effect.scoped(
639667 Effect.gen(function* () {
640668 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
641− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
669+ const { executor } = yield* makeTestWorkspaceHarness({
670+ access: testAccess.member(),
671+ plugins,
672+ });
642673 yield* executor.acme.seed();
643674 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
644675
@@ -733,7 +764,10 @@ describe("oauth.registerDynamicClient", () => { · 88 unmodified lines
733764 Effect.scoped(
734765 Effect.gen(function* () {
735766 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
736− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
767+ const { executor } = yield* makeTestWorkspaceHarness({
768+ access: testAccess.member(),
769+ plugins,
770+ });
737771 yield* executor.acme.seed();
738772 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
739773
@@ -784,7 +818,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines
784818 // Mirror Mercury: reject any client_name containing the brand.
785819 approveClientName: (name) => !name.includes("Acme"),
786820 });
787− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
821+ const { executor } = yield* makeTestWorkspaceHarness({
822+ access: testAccess.member(),
823+ plugins,
824+ });
788825 yield* executor.acme.seed();
789826 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
790827
@@ -821,7 +858,10 @@ describe("oauth.registerDynamicClient", () => { · 30 unmodified lines
821858 scopes: ["read"],
822859 approveClientName: () => false,
823860 });
824− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
861+ const { executor } = yield* makeTestWorkspaceHarness({
862+ access: testAccess.member(),
863+ plugins,
864+ });
825865 yield* executor.acme.seed();
826866 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
827867
@@ -866,7 +906,10 @@ describe("oauth.registerDynamicClient", () => { · 38 unmodified lines
866906 approveRedirectUri: (uri) =>
867907 uri.startsWith("http://localhost") || uri.startsWith("http://127."),
868908 });
869− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
909+ const { executor } = yield* makeTestWorkspaceHarness({
910+ access: testAccess.member(),
911+ plugins,
912+ });
870913 yield* executor.acme.seed();
871914 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
872915
@@ -910,7 +953,10 @@ describe("oauth.registerDynamicClient", () => { · 37 unmodified lines
910953 scopes: ["read"],
911954 approveRedirectUri: () => false, // reject every redirect URI, even loopback
912955 });
913− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
956+ const { executor } = yield* makeTestWorkspaceHarness({
957+ access: testAccess.member(),
958+ plugins,
959+ });
914960 yield* executor.acme.seed();
915961 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
916962
packages/core/sdk/src/oauth-remove-client.test.ts+24−3
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 import { definePlugin } from "./plugin";
1919 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
2020 import { serveOAuthTestServer } from "./testing/oauth-test-server";
21+import { testAccess } from "@executor-js/product-access/testing";
2122
2223 // removeClient permanently deletes an owner-scoped oauth_client row, keyed by
2324 // (owner, slug). The owner policy on `oauth_client` prevents removing another
@@ -32,7 +33,10 @@ describe("oauth.removeClient", () => { · 8 unmodified lines
3233 it.effect("removes a client so it no longer lists", () =>
3334 Effect.scoped(
3435 Effect.gen(function* () {
35− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
36+ const { executor } = yield* makeTestWorkspaceHarness({
37+ access: testAccess.member(),
38+ plugins,
39+ });
3640
3741 yield* executor.oauth.createClient({
3842 owner: "user",
@@ -58,7 +62,10 @@ describe("oauth.removeClient", () => { · 19 unmodified lines
5862 it.effect("is idempotent — removing a non-existent client succeeds", () =>
5963 Effect.scoped(
6064 Effect.gen(function* () {
61− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
65+ const { executor } = yield* makeTestWorkspaceHarness({
66+ access: testAccess.member(),
67+ plugins,
68+ });
6269
6370 // No client was ever created; removing it must not error.
6471 yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed"));
@@ -72,7 +79,10 @@ describe("oauth.removeClient", () => { · 7 unmodified lines
7279 it.effect("removing an org client leaves a user client intact (and vice versa)", () =>
7380 Effect.scoped(
7481 Effect.gen(function* () {
75− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
82+ const { executor } = yield* makeTestWorkspaceHarness({
83+ access: testAccess.member(),
84+ plugins,
85+ });
7686
7787 yield* executor.oauth.createClient({
7888 owner: "org",
@@ -115,6 +125,7 @@ describe("oauth.removeClient", () => { · 36 unmodified lines
115125 const tenant = "shared-tenant";
116126
117127 const a = yield* makeTestWorkspaceHarness({
128+ access: testAccess.member(),
118129 plugins,
119130 tenant,
120131 subject: "subject-a",
@@ -131,6 +142,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines
131142 });
132143
133144 const b = yield* makeTestWorkspaceHarness({
145+ access: testAccess.member(),
134146 plugins,
135147 tenant,
136148 subject: "subject-b",
@@ -155,6 +167,7 @@ describe("oauth.removeClient", () => { · 18 unmodified lines
155167 const tenant = "shared-tenant";
156168
157169 const a = yield* makeTestWorkspaceHarness({
170+ access: testAccess.member(),
158171 plugins,
159172 tenant,
160173 subject: "subject-a",
@@ -171,6 +184,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines
171184 });
172185
173186 const b = yield* makeTestWorkspaceHarness({
187+ access: testAccess.member(),
174188 plugins,
175189 tenant,
176190 subject: "subject-b",
@@ -194,6 +208,7 @@ describe("oauth.removeClient", () => { · 17 unmodified lines
194208 Effect.scoped(
195209 Effect.gen(function* () {
196210 const { executor } = yield* makeTestWorkspaceHarness({
211+ access: testAccess.member(),
197212 plugins,
198213 coreTools: {},
199214 });
@@ -306,6 +321,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 106 unmodified lines
306321 Effect.gen(function* () {
307322 const store = new Map<string, string>();
308323 const { executor } = yield* makeTestWorkspaceHarness({
324+ access: testAccess.member(),
309325 plugins: [txPlugin(store)] as const,
310326 });
311327 yield* executor.oauth.createClient(userClient("user-secret"));
@@ -339,6 +355,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 27 unmodified lines
339355 Effect.gen(function* () {
340356 const store = new Map<string, string>();
341357 const { executor } = yield* makeTestWorkspaceHarness({
358+ access: testAccess.member(),
342359 plugins: [txPlugin(store)] as const,
343360 });
344361 yield* executor.oauth.createClient(userClient("user-secret"));
@@ -360,6 +377,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 15 unmodified lines
360377 Effect.gen(function* () {
361378 const store = new Map<string, string>();
362379 const { executor } = yield* makeTestWorkspaceHarness({
380+ access: testAccess.member(),
363381 plugins: [txPlugin(store)] as const,
364382 });
365383 yield* executor.oauth.createClient(userClient("user-secret"));
@@ -385,6 +403,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 19 unmodified lines
385403 const tenant = "shared-tenant";
386404
387405 const a = yield* makeTestWorkspaceHarness({
406+ access: testAccess.member(),
388407 plugins,
389408 tenant,
390409 subject: "subject-a",
@@ -394,6 +413,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 3 unmodified lines
394413 expect(secretValue(store)).toBe("a-secret");
395414
396415 const b = yield* makeTestWorkspaceHarness({
416+ access: testAccess.member(),
397417 plugins,
398418 tenant,
399419 subject: "subject-b",
@@ -454,6 +474,7 @@ describe("removing a client does not delete a recreated client's secret", () => · 54 unmodified lines
454474 });
455475 const store = new Map<string, string>();
456476 const { executor } = yield* makeTestWorkspaceHarness({
477+ access: testAccess.member(),
457478 plugins: [txPlugin(store), integrationPlugin] as const,
458479 redirectUri: null,
459480 });
packages/core/sdk/src/oauth-scope-union.test.ts+35−9
@@ -16,6 +16,7 @@ import { definePlugin, type IntegrationRecord } from "./plugin"; · 15 unmodified lines
1616 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
1717 import { serveTestHttpApp } from "./testing";
1818 import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
19+import { testAccess } from "@executor-js/product-access/testing";
1920
2021 // Integration-driven scopes: at connect, `oauth.start` requests the integration's
2122 // scopes — its DECLARED oauth scopes when it has any, otherwise the scopes
@@ -187,7 +188,7 @@ const setupMcpScopeClient = ( · 165 unmodified lines
187188 ) =>
188189 Effect.gen(function* () {
189190 const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const;
190− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
191+ const { executor } = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
191192 yield* executor.mcp.seed();
192193 yield* executor.oauth.createClient({
193194 owner: "org",
@@ -213,7 +214,10 @@ describe("oauth.start integration-driven scopes", () => { · 19 unmodified lines
213214 memoryCredentialsPlugin(),
214215 makeScopePlugin({ scopes: DECLARED_SCOPES }),
215216 ] as const;
216− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
217+ const { executor } = yield* makeTestWorkspaceHarness({
218+ access: testAccess.member(),
219+ plugins,
220+ });
217221 yield* executor.acme.seed();
218222
219223 // The app is pure identity — no scope set.
@@ -265,7 +269,10 @@ describe("oauth.start integration-driven scopes", () => { · 45 unmodified lines
265269 authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`,
266270 }),
267271 ] as const;
268− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
272+ const { executor } = yield* makeTestWorkspaceHarness({
273+ access: testAccess.member(),
274+ plugins,
275+ });
269276 yield* executor.acme.seed();
270277 yield* executor.oauth.createClient({
271278 owner: "org",
@@ -303,7 +310,10 @@ describe("oauth.start integration-driven scopes", () => { · 31 unmodified lines
303310 memoryCredentialsPlugin(),
304311 makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }),
305312 ] as const;
306− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
313+ const { executor } = yield* makeTestWorkspaceHarness({
314+ access: testAccess.member(),
315+ plugins,
316+ });
307317 yield* executor.acme.seed();
308318
309319 yield* executor.oauth.createClient({
@@ -340,7 +350,10 @@ describe("oauth.start integration-driven scopes", () => { · 30 unmodified lines
340350 // `scopes: null` ⇒ the integration declares an oauth method with no
341351 // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested.
342352 const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const;
343− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
353+ const { executor } = yield* makeTestWorkspaceHarness({
354+ access: testAccess.member(),
355+ plugins,
356+ });
344357 yield* executor.acme.seed();
345358
346359 yield* executor.oauth.createClient({
@@ -411,7 +424,10 @@ describe("oauth.start integration-driven scopes", () => { · 64 unmodified lines
411424 memoryCredentialsPlugin(),
412425 makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }),
413426 ] as const;
414− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
427+ const { executor } = yield* makeTestWorkspaceHarness({
428+ access: testAccess.member(),
429+ plugins,
430+ });
415431 yield* executor.acme.seed();
416432
417433 yield* executor.oauth.createClient({
@@ -635,7 +651,10 @@ describe("oauth.start integration-driven scopes", () => { · 217 unmodified lines
635651 { discoversScopes: true, discoveryUrl: server.mcpResourceUrl },
636652 ),
637653 ] as const;
638− const { executor } = yield* makeTestWorkspaceHarness({ plugins });
654+ const { executor } = yield* makeTestWorkspaceHarness({
655+ access: testAccess.member(),
656+ plugins,
657+ });
639658 yield* executor.mcp.seed();
640659
641660 // No `resource` on the client — the wire parameter is absent by
@@ -681,6 +700,7 @@ describe("oauth.start integration-driven scopes", () => { · 39 unmodified lines
681700 makeMcpScopePlugin({ scopes: null }),
682701 ] as const;
683702 const { executor } = yield* makeTestWorkspaceHarness({
703+ access: testAccess.member(),
684704 plugins,
685705 firstPartyOAuthClients: [
686706 {
@@ -835,7 +855,10 @@ describe("oauth.start recorded scope fallback", () => { · 148 unmodified lines
835855 memoryCredentialsPlugin(),
836856 makeScopePlugin({ scopes: DECLARED_SCOPES }),
837857 ] as const;
838− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
858+ const { executor, config } = yield* makeTestWorkspaceHarness({
859+ access: testAccess.member(),
860+ plugins,
861+ });
839862 yield* executor.acme.seed();
840863
841864 // A client_credentials client so `start` mints inline (no redirect),
@@ -889,7 +912,10 @@ describe("oauth.start recorded scope fallback", () => { · 47 unmodified lines
889912 memoryCredentialsPlugin(),
890913 makeMcpScopePlugin({ scopes: null }),
891914 ] as const;
892− const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
915+ const { executor, config } = yield* makeTestWorkspaceHarness({
916+ access: testAccess.member(),
917+ plugins,
918+ });
893919 yield* executor.mcp.seed();
894920
895921 yield* executor.oauth.createClient({
packages/core/sdk/src/oauth-service.ts+3−3
@@ -232,9 +232,9 @@ export interface OAuthServiceDeps { · 231 unmodified lines
232232 readonly owner: Owner;
233233 readonly subject: string;
234234 };
235− /** Workspace-settings gate from the executor binding
236− * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the
237− * user-intent client/connect surfaces. */
235+ /** Settings gate from the executor binding (`ExecutorAccess.settingsWrite`):
236+ * enforces the product's decision for the owner targeted by a user-intent
237+ * client/connect operation. */
238238 readonly guardOrgWrite: (owner: Owner) => Effect.Effect<void, OrgWriteDeniedError>;
239239 readonly defaultWritableProvider: () => CredentialProvider | null;
240240 /** Write the connection row with OAuth lifecycle fields + produce its tools. */
packages/core/sdk/src/oauth-session-cleanup.test.ts+6−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 import { definePlugin } from "./plugin";
2121 import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config";
2222 import { serveOAuthTestServer } from "./testing/oauth-test-server";
23+import { testAccess } from "@executor-js/product-access/testing";
2324
2425 // An in-flight authorization flow parks its PKCE verifier in `oauth_session` in
2526 // plaintext, which is fine while the flow can still spend it. What is not fine is
@@ -127,6 +128,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 101 unmodified lines
127128 Effect.gen(function* () {
128129 const server = yield* serveOAuthTestServer({});
129130 const { executor, config } = yield* makeTestWorkspaceHarness({
131+ access: testAccess.member(),
130132 plugins: [memoryCredentialsPlugin, acmePlugin] as const,
131133 });
132134 yield* executor.acme.seed();
@@ -205,6 +207,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 72 unmodified lines
205207 Effect.gen(function* () {
206208 const server = yield* serveOAuthTestServer({});
207209 const { executor, config } = yield* makeTestWorkspaceHarness({
210+ access: testAccess.member(),
208211 plugins: [memoryCredentialsPlugin, acmePlugin] as const,
209212 });
210213 yield* executor.acme.seed();
@@ -280,6 +283,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 69 unmodified lines
280283 const plugins = [memoryCredentialsPlugin, acmePlugin] as const;
281284
282285 const a = yield* makeTestWorkspaceHarness({
286+ access: testAccess.member(),
283287 plugins,
284288 tenant,
285289 subject: "subject-a",
@@ -298,6 +302,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 12 unmodified lines
298302
299303 // A second member of the same tenant, on the same database.
300304 const b = yield* makeTestWorkspaceHarness({
305+ access: testAccess.member(),
301306 plugins,
302307 tenant,
303308 subject: "subject-b",
@@ -363,6 +368,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 59 unmodified lines
363368 Effect.gen(function* () {
364369 const server = yield* serveOAuthTestServer({});
365370 const base = makeTestConfig({
371+ access: testAccess.member(),
366372 plugins: [memoryCredentialsPlugin, acmePlugin] as const,
367373 });
368374 const executor = yield* createExecutor({
packages/core/sdk/src/org-write-access.ts+0−33
@@ -1,33 +0,0 @@
1−import { Context, Effect, Ref } from "effect";
2−
3−/** Workspace-settings authorization bound to the currently executing request. */
4−export type OrgWriteAccess = "allowed" | "denied";
5−
6−/**
7− * Fiber-local workspace-settings authorization for request-bound executors.
8− *
9− * The denied default makes a missing request binding fail closed. Non-session
10− * executors continue to use their explicit {@link ExecutorConfig.orgWrites}
11− * value (or the allowed default) and never consult this reference.
12− */
13−export interface OrgWriteAccessState {
14− /** Mutable value inherited by a detached execution and refreshed on resume. */
15− readonly current: Ref.Ref<OrgWriteAccess>;
16−}
17−
18−/** Create an isolated request/execution authorization state. */
19−export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({
20− current: Ref.makeUnsafe(access),
21−});
22−
23−/** Request-local workspace-write authorization inherited by child fibers. */
24−export const CurrentOrgWriteAccess = Context.Reference<OrgWriteAccessState>(
25− "@executor-js/sdk/CurrentOrgWriteAccess",
26− { defaultValue: () => makeOrgWriteAccessState("denied") },
27−);
28−
29−/** Read the effective authorization at a workspace-write sink. */
30−export const currentOrgWriteAccess: Effect.Effect<OrgWriteAccess> = Effect.gen(function* () {
31− const state = yield* CurrentOrgWriteAccess;
32− return yield* Ref.get(state.current);
33−});
packages/core/sdk/src/org-writes.test.ts+22−12
@@ -12,14 +12,15 @@ import { · 11 unmodified lines
1212 ToolName,
1313 } from "./ids";
1414 import { createExecutor } from "./executor";
15−import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./org-write-access";
15+import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./access";
1616 import { definePlugin } from "./plugin";
1717 import type { CredentialProvider } from "./provider";
1818 import { makeTestConfig } from "./testing";
1919 import { serveOAuthTestServer } from "./testing/oauth-test-server";
20+import { testAccess } from "@executor-js/product-access/testing";
2021
2122 // ---------------------------------------------------------------------------
22−// `ExecutorConfig.orgWrites` — the workspace-settings gate.
23+// `ExecutorAccess.workspaceWrites` — the workspace-settings gate.
2324 //
2425 // A `"denied"` binding (a plain member) may USE workspace resources — read
2526 // them, execute tools over org connections — but every user-intent
@@ -29,8 +30,8 @@ import { serveOAuthTestServer } from "./testing/oauth-test-server"; · 3 unmodified lines
2930 // `"allowed"` (admins, and hosts with no role model) behaves exactly as before.
3031 //
3132 // The fixtures build TWO executors over ONE test database: an admin
32−// (default `orgWrites`) that seeds the workspace, and a member
33−// (`orgWrites: "denied"`) that the assertions run against.
33+// (workspace writes allowed) that seeds the workspace, and a member
34+// (workspace writes denied) that the assertions run against.
3435 // ---------------------------------------------------------------------------
3536
3637 const memoryProvider = (): CredentialProvider => {
@@ -83,9 +84,9 @@ const demoPlugin = definePlugin(() => ({ · 46 unmodified lines
8384
8485 const setup = () =>
8586 Effect.gen(function* () {
86− const config = makeTestConfig({ plugins: [demoPlugin] as const });
87+ const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const });
8788 const admin = yield* createExecutor(config);
88− const member = yield* createExecutor({ ...config, orgWrites: "denied" });
89+ const member = yield* createExecutor({ ...config, access: testAccess.member("denied") });
8990 yield* Effect.addFinalizer(() =>
9091 admin.close().pipe(Effect.andThen(member.close()), Effect.ignore),
9192 );
@@ -101,13 +102,16 @@ const expectOrgWriteDenied = <A, R>(effect: Effect.Effect<A, unknown, R>) => · 9 unmodified lines
101102 }),
102103 );
103104
104−describe("orgWrites: denied", () => {
105+describe("workspace writes denied", () => {
105106 it.effect("reads a live session binding at every workspace-write sink", () =>
106107 Effect.gen(function* () {
107− const config = makeTestConfig({ plugins: [demoPlugin] as const });
108+ const config = makeTestConfig({
109+ access: testAccess.member(),
110+ plugins: [demoPlugin] as const,
111+ });
108112 const executor = yield* createExecutor({
109113 ...config,
110− orgWrites: "request",
114+ access: testAccess.requestBound(),
111115 });
112116 yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore));
113117 const policy = yield* executor.demo.seed().pipe(
@@ -249,12 +253,18 @@ describe("orgWrites: denied", () => { · 135 unmodified lines
249253
250254 it.effect("allows subjectless system re-registration during boot convergence", () =>
251255 Effect.gen(function* () {
252− const config = makeTestConfig({ plugins: [demoPlugin] as const });
256+ const config = makeTestConfig({
257+ access: testAccess.member(),
258+ plugins: [demoPlugin] as const,
259+ });
253260 const admin = yield* createExecutor(config);
254261 const { subject: _subject, ...systemConfig } = config;
262+ // Boot convergence is the PRODUCT's rule now: the workspace-service
263+ // posture allows it. A subject-less binding whose product DENIES
264+ // settings writes is refused instead — see settings-authority.test.ts.
255265 const system = yield* createExecutor({
256266 ...systemConfig,
257− orgWrites: "denied",
267+ access: testAccess.org(),
258268 });
259269 yield* Effect.addFinalizer(() =>
260270 admin.close().pipe(Effect.andThen(system.close()), Effect.ignore),
@@ -357,7 +367,7 @@ describe("orgWrites: denied", () => { · 96 unmodified lines
357367 );
358368 });
359369
360−describe("orgWrites: default (allowed)", () => {
370+describe("workspace writes allowed", () => {
361371 it.effect("admin bindings mutate workspace-level state as before", () =>
362372 Effect.gen(function* () {
363373 const { admin } = yield* setup();
packages/core/sdk/src/owner-policy-reach.test.ts+24−3
@@ -95,7 +95,11 @@ describe("owner policy reach", () => { · 94 unmodified lines
9595 withDb(async (db) => {
9696 await seedTwoSubjects(db);
9797
98− const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });
98+ const bound = withQueryContext(db.db, {
99+ tenant: TENANT,
100+ subject: SUBJECT_A,
101+ owners: ["user", "org"],
102+ });
99103 const rows = await bound.findMany("connection", {});
100104
101105 expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]);
@@ -111,6 +115,7 @@ describe("owner policy reach", () => { · 9 unmodified lines
111115 const platform = withQueryContext(db.db, {
112116 tenant: TENANT,
113117 subject: SUBJECT_A,
118+ owners: ["user", "org"],
114119 reach: "tenant",
115120 });
116121 const rows = await platform.findMany("connection", {});
@@ -129,6 +134,7 @@ describe("owner policy reach", () => { · 12 unmodified lines
129134 const platform = withQueryContext(db.db, {
130135 tenant: TENANT,
131136 subject: SUBJECT_A,
137+ owners: ["user", "org"],
132138 reach: "tenant",
133139 });
134140 const rows = await platform.findMany("connection", {});
@@ -149,6 +155,7 @@ describe("owner policy reach", () => { · 14 unmodified lines
149155 const platform = withQueryContext(db.db, {
150156 tenant: TENANT,
151157 subject: null,
158+ owners: ["org"],
152159 reach: "tenant",
153160 });
154161 const rows = await platform.findMany("connection", {});
@@ -168,6 +175,7 @@ describe("owner policy reach", () => { · 13 unmodified lines
168175 const platform = withQueryContext(db.db, {
169176 tenant: TENANT,
170177 subject: SUBJECT_A,
178+ owners: ["user", "org"],
171179 reach: "tenant",
172180 });
173181
@@ -197,6 +205,7 @@ describe("owner policy reach", () => { · 23 unmodified lines
197205 const platform = withQueryContext(db.db, {
198206 tenant: TENANT,
199207 subject: SUBJECT_A,
208+ owners: ["user", "org"],
200209 reach: "tenant",
201210 });
202211
@@ -225,6 +234,7 @@ describe("owner policy reach", () => { · 22 unmodified lines
225234 const platform = withQueryContext(db.db, {
226235 tenant: TENANT,
227236 subject: SUBJECT_A,
237+ owners: ["user", "org"],
228238 reach: "tenant",
229239 });
230240
@@ -249,6 +259,7 @@ describe("owner policy reach", () => { · 18 unmodified lines
249259 const platform = withQueryContext(db.db, {
250260 tenant: TENANT,
251261 subject: SUBJECT_A,
262+ owners: ["user", "org"],
252263 reach: "tenant",
253264 });
254265
@@ -272,12 +283,17 @@ describe("owner policy reach", () => { · 17 unmodified lines
272283 withDb(async (db) => {
273284 // The guard must not disturb the product view: an ordinary bound
274285 // context writes exactly as before.
275− const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });
286+ const bound = withQueryContext(db.db, {
287+ tenant: TENANT,
288+ subject: SUBJECT_A,
289+ owners: ["user", "org"],
290+ });
276291
277292 await bound.create("connection", {
278293 tenant: TENANT,
279294 owner: "user",
280295 subject: SUBJECT_A,
296+ owners: ["user", "org"],
281297 integration: "github",
282298 name: "written-by-bound-view",
283299 template: "apiKey",
@@ -296,13 +312,18 @@ describe("owner policy reach", () => { · 12 unmodified lines
296312 it.effect("a bound context still cannot write outside its own subject", () =>
297313 Effect.promise(() =>
298314 withDb(async (db) => {
299− const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });
315+ const bound = withQueryContext(db.db, {
316+ tenant: TENANT,
317+ subject: SUBJECT_A,
318+ owners: ["user", "org"],
319+ });
300320
301321 await expect(
302322 bound.create("connection", {
303323 tenant: TENANT,
304324 owner: "user",
305325 subject: SUBJECT_B,
326+ owners: ["user", "org"],
306327 integration: "github",
307328 name: "cross-subject",
308329 template: "apiKey",
packages/core/sdk/src/owner-policy.ts+60−11
@@ -2,6 +2,7 @@ import { type Condition, type ConditionBuilder } from "@executor-js/fumadb/query · 1 unmodified line
22 import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema";
33
44 import { StorageError } from "./fuma-runtime";
5+import type { Owner } from "./ids";
56
67 /* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned
78 * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the
@@ -53,6 +54,19 @@ export interface ExecutorOwnerPolicyContext { · 45 unmodified lines
5354 /** The acting member, or null for a pure-org executor (no `owner:"user"`
5455 * reads/writes are allowed when null). */
5556 readonly subject: string | null;
57+ /**
58+ * The row-visibility/write decision (`ExecutorAccess.owners` for
59+ * principal contexts): which owner partitions this context sees, and may
60+ * create/patch rows in. REQUIRED — there is no full-view default; a
61+ * context that reaches the policy without it fails closed (reads match
62+ * nothing, writes are refused). Host-internal contexts that only touch
63+ * tenant-scoped tables state `[]` explicitly.
64+ *
65+ * This NARROWS, never widens: whatever it lists, the tenant clause and the
66+ * bound-subject clause on user rows still apply, and `reach: "tenant"`
67+ * contexts remain read-only (or delete-only) by construction.
68+ */
69+ readonly owners: readonly Owner[];
5670 /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the
5771 * platform view: it sees the whole tenant but writes exactly as a bound
5872 * context does, so it can never mutate another subject's rows. */
@@ -106,17 +120,29 @@ export const ownerVisibilityCondition = ( · 47 unmodified lines
106120 // The platform view: partition by tenant alone. Still never cross-tenant —
107121 // `tenant` is the one clause that is NEVER relaxed, at any reach.
108122 if (context.reach === "tenant") return builder("tenant", "=", context.tenant);
109− const orgClause = builder.and(
110− builder("tenant", "=", context.tenant),
111− builder("owner", "=", "org"),
112− );
113− if (context.subject == null) return orgClause;
114− const userClause = builder.and(
115− builder("tenant", "=", context.tenant),
116− builder("owner", "=", "user"),
117− builder("subject", "=", context.subject),
118− );
119− return builder.or(orgClause, userClause);
123+ // The product's visible partitions. A malformed context that reaches the
124+ // policy without them (only possible past the type system) fails CLOSED:
125+ // no partitions means no rows. Core contributes only the clamps inside
126+ // each clause — never a partition of its own.
127+ const owners: readonly Owner[] = Array.isArray(context.owners) ? context.owners : [];
128+ const clauses: (Condition | boolean)[] = [];
129+ if (owners.includes("org")) {
130+ clauses.push(builder.and(builder("tenant", "=", context.tenant), builder("owner", "=", "org")));
131+ }
132+ if (owners.includes("user") && context.subject != null) {
133+ clauses.push(
134+ builder.and(
135+ builder("tenant", "=", context.tenant),
136+ builder("owner", "=", "user"),
137+ builder("subject", "=", context.subject),
138+ ),
139+ );
140+ }
141+ // A context whose product view lists no partition this binding can carry
142+ // (personal-only with no subject) sees nothing — fail closed, not open.
143+ if (clauses.length === 0) return false;
144+ if (clauses.length === 1) return clauses[0]!;
145+ return builder.or(...clauses);
120146 };
121147
122148 /**
@@ -172,6 +198,21 @@ export const assertOwnerWritable = ( · 49 unmodified lines
172198 if (values.tenant !== ctx.tenant) {
173199 policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`);
174200 }
201+ // The product's write partitions: a row may only be created in a partition
202+ // the product view lists (update/delete are already filtered by
203+ // `ownerVisibilityCondition`). A context missing the decision fails
204+ // closed. Purely narrowing — the per-owner clamps below still apply to
205+ // whatever the product allows.
206+ if (!Array.isArray(ctx.owners)) {
207+ policyViolation(
208+ `Storage write on table "${tableName}" is missing the context's owner partitions.`,
209+ );
210+ }
211+ if ((values.owner === "org" || values.owner === "user") && !ctx.owners.includes(values.owner)) {
212+ policyViolation(
213+ `Storage write on table "${tableName}" targets the "${values.owner}" partition, which this product view does not include.`,
214+ );
215+ }
175216 if (values.owner === "org") {
176217 if (values.subject !== ORG_SUBJECT) {
177218 policyViolation(`Storage write on table "${tableName}" set a subject on an org row.`);
@@ -204,6 +245,14 @@ export const assertOwnerPatch = ( · 26 unmodified lines
204245 if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) {
205246 policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`);
206247 }
248+ if (
249+ (patch.owner === "org" || patch.owner === "user") &&
250+ !(Array.isArray(ctx.owners) && ctx.owners.includes(patch.owner))
251+ ) {
252+ policyViolation(
253+ `Storage write on table "${tableName}" cannot move a row into the "${patch.owner}" partition, which this product view does not include.`,
254+ );
255+ }
207256 if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) {
208257 policyViolation(
209258 `Storage write on table "${tableName}" cannot move a row outside the bound subject.`,
packages/core/sdk/src/platform-view.test.ts+7−1
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 } from "./ids";
2323 import { definePlugin } from "./plugin";
2424 import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
25+import { testAccess } from "@executor-js/product-access/testing";
2526
2627 // The platform view: `executor.admin`, an OPT-IN read-only surface that reads
2728 // across every subject in the tenant. Written against the real SQLite bring-up
@@ -254,7 +255,12 @@ const makePlatformExecutor = ( · 226 unmodified lines
254255 db: db.db,
255256 plugins: [providerPlugin],
256257 onElicitation: "accept-all",
257− ...(options?.platformView === false ? {} : { platformView: true }),
258+ access:
259+ options?.platformView === false
260+ ? options?.subject === null
261+ ? testAccess.org()
262+ : testAccess.member()
263+ : testAccess.platform({ subject: options?.subject !== null }),
258264 }).pipe(Effect.orDie);
259265
260266 const requireAdmin = (executor: Executor) => {
packages/core/sdk/src/plugin-after-commit.test.ts+5−3
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 import { definePlugin } from "./plugin";
1414 import { makeTestExecutor } from "./test-config";
1515 import { serveOAuthTestServer } from "./testing/oauth-test-server";
16+import { testAccess } from "@executor-js/product-access/testing";
1617
1718 // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is
1819 // what makes its database work atomic with the row deletions. The same property
@@ -98,9 +99,10 @@ const revokingPlugin = (revoked: string[]) => · 79 unmodified lines
9899 })();
99100
100101 const setup = (revoked: string[]) =>
101− makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe(
102− Effect.tap((executor) => executor.demo.seed()),
103− );
102+ makeTestExecutor({
103+ access: testAccess.member(),
104+ plugins: [revokingPlugin(revoked)] as const,
105+ }).pipe(Effect.tap((executor) => executor.demo.seed()));
104106
105107 const REF = {
106108 owner: "org",
packages/core/sdk/src/plugin-storage.test.ts+9−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 type PluginStorageCollectionWhere,
1313 } from "./plugin-storage";
1414 import { makeTestConfig, makeTestExecutor } from "./testing";
15+import { testAccess } from "@executor-js/product-access/testing";
1516
1617 const ToolCall = Schema.Struct({
1718 runId: Schema.String,
@@ -167,6 +168,7 @@ describe("plugin storage collections", () => { · 149 unmodified lines
167168 it.effect("queries declared indexes through the executor's SQLite FumaDB target", () =>
168169 Effect.gen(function* () {
169170 const executor = yield* makeTestExecutor({
171+ access: testAccess.member(),
170172 backend: "sqlite",
171173 plugins: [executionHistoryPlugin] as const,
172174 });
@@ -229,6 +231,7 @@ describe("plugin storage collections", () => { · 56 unmodified lines
229231 it.effect("bulk puts and removes plugin storage rows", () =>
230232 Effect.gen(function* () {
231233 const executor = yield* makeTestExecutor({
234+ access: testAccess.member(),
232235 backend: "sqlite",
233236 plugins: [executionHistoryPlugin] as const,
234237 });
@@ -275,6 +278,7 @@ describe("plugin storage collections", () => { · 40 unmodified lines
275278 it.effect("stores and overwrites every row when a bulk write spans multiple batches", () =>
276279 Effect.gen(function* () {
277280 const executor = yield* makeTestExecutor({
281+ access: testAccess.member(),
278282 backend: "sqlite",
279283 plugins: [executionHistoryPlugin] as const,
280284 });
@@ -312,6 +316,7 @@ describe("plugin storage collections", () => { · 31 unmodified lines
312316 it.effect("rolls back every plugin storage row when a bulk write fails", () =>
313317 Effect.gen(function* () {
314318 const config = makeTestConfig({
319+ access: testAccess.member(),
315320 backend: "sqlite",
316321 plugins: [executionHistoryPlugin] as const,
317322 });
@@ -369,6 +374,7 @@ describe("plugin storage collections", () => { · 51 unmodified lines
369374 it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () =>
370375 Effect.gen(function* () {
371376 const config = makeTestConfig({
377+ access: testAccess.member(),
372378 backend: "sqlite",
373379 plugins: [executionHistoryPlugin] as const,
374380 });
@@ -435,6 +441,7 @@ describe("plugin storage collections", () => { · 60 unmodified lines
435441 () =>
436442 Effect.gen(function* () {
437443 const executor = yield* makeTestExecutor({
444+ access: testAccess.member(),
438445 backend: "sqlite",
439446 plugins: [executionHistoryPlugin] as const,
440447 });
@@ -463,6 +470,7 @@ describe("plugin storage collections", () => { · 22 unmodified lines
463470 // One executor bound to a subject sees both org and user owner rows; a
464471 // user-owned row shadows an org-owned row under the same key on read.
465472 const executor = yield* makeTestExecutor({
473+ access: testAccess.member(),
466474 backend: "sqlite",
467475 plugins: [executionHistoryPlugin] as const,
468476 });
@@ -508,6 +516,7 @@ describe("plugin storage collections", () => { · 39 unmodified lines
508516 it.effect("rejects runtime queries against undeclared index fields", () =>
509517 Effect.gen(function* () {
510518 const executor = yield* makeTestExecutor({
519+ access: testAccess.member(),
511520 backend: "sqlite",
512521 plugins: [executionHistoryPlugin] as const,
513522 });
packages/core/sdk/src/plugin.ts+3−3
@@ -168,9 +168,9 @@ export interface PluginCtx<TStore = unknown> { · 167 unmodified lines
168168 * external work or writes to storage outside the catalog transaction. */
169169 readonly authorizeWrite: () => Effect.Effect<void, OrgWriteDeniedError>;
170170 /** Register / replace this plugin's integration in the catalog. Both
171− * operations are workspace-level changes gated by the executor's
172− * `orgWrites` binding for end-user principals. Subjectless system
173− * executors may re-register an existing row during boot convergence. */
171+ * operations are workspace-level changes gated by the product's
172+ * `ExecutorAccess.settingsWrite` decision, including replacement by
173+ * subjectless system executors during boot convergence. */
174174 readonly register: (
175175 input: RegisterIntegrationInput,
176176 ) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>;
packages/core/sdk/src/policies.test.ts+12−8
@@ -15,15 +15,15 @@ import { · 14 unmodified lines
1515 import { ElicitationResponse, type ElicitationHandler } from "./elicitation";
1616 import { createExecutor } from "./executor";
1717 import type { FumaDb } from "./fuma-runtime";
18−import {
19− effectivePolicyFromSorted,
20− isValidPattern,
21− matchPattern,
22− resolveToolPolicy,
23−} from "./policies";
18+// Resolution semantics are PRODUCT rules — these tests exercise the real
19+// implementations from @executor-js/product-access (via the workspace root
20+// devDependency), never a copy.
21+import { effectivePolicyFromSorted, resolveToolPolicy } from "@executor-js/product-access/policy";
22+import { isValidPattern, matchPattern } from "./policies";
2423 import { definePlugin, tool } from "./plugin";
2524 import type { CredentialProvider } from "./provider";
2625 import { makeTestConfig, makeTestExecutor } from "./testing";
26+import { testAccess } from "@executor-js/product-access/testing";
2727
2828 // ---------------------------------------------------------------------------
2929 // Pure unit tests — pattern matcher + resolution. No executor required.
@@ -332,7 +332,7 @@ const addr = (integration: IntegrationSlug, tool: string): ToolAddress => · 302 unmodified lines
332332 ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`);
333333
334334 const setupExecutor = () =>
335− makeTestExecutor({ plugins: [policyTestPlugin()] as const }).pipe(
335+ makeTestExecutor({ access: testAccess.member(), plugins: [policyTestPlugin()] as const }).pipe(
336336 Effect.tap((executor) =>
337337 Effect.gen(function* () {
338338 yield* executor.ptest.seed();
@@ -533,7 +533,10 @@ describe("executor.policies", () => { · 194 unmodified lines
533533 it.effect("fails when the policy vanishes during update", () =>
534534 Effect.gen(function* () {
535535 let armed = false;
536− const config = makeTestConfig({ plugins: [policyTestPlugin()] as const });
536+ const config = makeTestConfig({
537+ access: testAccess.member(),
538+ plugins: [policyTestPlugin()] as const,
539+ });
537540 const executor = yield* createExecutor({
538541 ...config,
539542 db: removePolicyAfterUpdate(config.db, () => armed),
@@ -685,6 +688,7 @@ describe("active tool-policy provider", () => { · 145 unmodified lines
685688 it.effect("uses provider rules as an allowlist for list, schema, and execute", () =>
686689 Effect.gen(function* () {
687690 const executor = yield* makeTestExecutor({
691+ access: testAccess.member(),
688692 plugins: [staticPlugin, policyProviderPlugin] as const,
689693 });
690694
packages/core/sdk/src/policies.ts+15−112
@@ -1,15 +1,17 @@
11 // ---------------------------------------------------------------------------
2−// Tool policies — pattern matcher + policy resolution. Pure functions; the
3−// executor stitches them into `tools.list`, `execute`, and the public
4−// `executor.policies` CRUD surface. Plugins consume the same surface.
2+// Tool policies — the decision VOCABULARY (types, schemas, projections) plus
3+// the pure pattern-matching and rule-placement utilities the executor's CRUD
4+// surface needs. Policies are owner-scoped (org | user) rows.
55 //
6−// v2: policies are owner-scoped (org | user) instead of scope-stacked. Each
7−// owner contributes its first matching rule by local position; the final answer
8−// is the most restrictive matched action across owners, so a user preference
9−// cannot weaken an org guardrail (org = outer, user = inner).
6+// HOW rules resolve into an effective decision — owner ranking, the
7+// most-restrictive merge across owners, the plugin-default fallback and the
8+// capability-allowlist default — is a PRODUCT rule: it lives in
9+// `@executor-js/product-access/policy` and reaches core only through the
10+// `ExecutorAccess.toolPolicy` hook, whose `EffectivePolicy` answers core
11+// enforces at list/schema/invoke/approval-recheck.
1012 // ---------------------------------------------------------------------------
1113
12−import { Match, Schema } from "effect";
14+import { Schema } from "effect";
1315 import { generateKeyBetween } from "fractional-indexing";
1416
1517 import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema";
@@ -121,10 +123,11 @@ export const isValidPattern = (pattern: string): boolean => { · 105 unmodified lines
121123 };
122124
123125 // ---------------------------------------------------------------------------
124−// Resolution — each owner contributes its first matching rule by local
125−// position; the most restrictive matched action across owners wins. Caller
126−// passes an `ownerRank` so the resolver doesn't need to know which owner is
127−// the outer guardrail.
126+// Ordering / placement utilities. HOW matched rules combine into an
127+// effective decision (owner ranking, most-restrictive merge, fallbacks) is a
128+// PRODUCT rule and lives in `@executor-js/product-access/policy`; core only
129+// consumes the resulting `EffectivePolicy` through
130+// `ExecutorAccess.toolPolicy` and enforces it.
128131 // ---------------------------------------------------------------------------
129132
130133 export const comparePolicyRow = (
@@ -180,106 +183,6 @@ export const positionForNewPattern = ( · 49 unmodified lines
180183 return generateKeyBetween(prev, next);
181184 };
182185
183−const actionRestrictionRank = (action: ToolPolicyAction): number =>
184− Match.value(action).pipe(
185− Match.when("block", () => 3),
186− Match.when("require_approval", () => 2),
187− Match.when("approve", () => 1),
188− Match.exhaustive,
189− );
190−
191−const moreRestrictive = <T extends { readonly action: ToolPolicyAction }>(
192− current: T | undefined,
193− candidate: T,
194−): T => {
195− if (!current) return candidate;
196− const currentRank = actionRestrictionRank(current.action);
197− const candidateRank = actionRestrictionRank(candidate.action);
198− return candidateRank > currentRank ? candidate : current;
199−};
200−
201−export const resolveToolPolicy = (
202− toolId: string,
203− policies: readonly ToolPolicyRow[],
204− ownerRank: (row: Pick<ToolPolicyRow, "owner">) => number,
205−): PolicyMatch | undefined => {
206− if (policies.length === 0) return undefined;
207− const sorted = [...policies].sort((a, b) => {
208− const sa = ownerRank(a);
209− const sb = ownerRank(b);
210− if (sa !== sb) return sa - sb;
211− return comparePolicyRow(a, b);
212− });
213− const firstMatchByOwner = new Map<string, PolicyMatch>();
214− for (const row of sorted) {
215− if (firstMatchByOwner.has(row.owner)) continue;
216− if (matchPattern(row.pattern, toolId)) {
217− firstMatchByOwner.set(row.owner, {
218− action: row.action as ToolPolicyAction,
219− pattern: row.pattern,
220− policyId: row.id,
221− });
222− }
223− }
224− let selected: PolicyMatch | undefined;
225− for (const match of firstMatchByOwner.values()) {
226− selected = moreRestrictive(selected, match);
227− }
228− return selected;
229−};
230−
231−// ---------------------------------------------------------------------------
232−// Layered resolution — user-authored rules + plugin default `requiresApproval`.
233−// ---------------------------------------------------------------------------
234−
235−const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
236− defaultRequiresApproval
237− ? { action: "require_approval", source: "plugin-default" }
238− : { action: "approve", source: "plugin-default" };
239−
240−const liftUser = (match: PolicyMatch): EffectivePolicy => ({
241− action: match.action,
242− source: "user",
243− pattern: match.pattern,
244− policyId: match.policyId,
245−});
246−
247−export const resolveEffectivePolicy = (
248− toolId: string,
249− policies: readonly ToolPolicyRow[],
250− ownerRank: (row: Pick<ToolPolicyRow, "owner">) => number,
251− defaultRequiresApproval?: boolean,
252−): EffectivePolicy => {
253− const match = resolveToolPolicy(toolId, policies, ownerRank);
254− return match ? liftUser(match) : liftPlugin(defaultRequiresApproval);
255−};
256−
257−export const effectivePolicyFromSorted = (
258− toolId: string,
259− sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> &
260− Partial<Pick<ToolPolicy, "owner">>)[],
261− defaultRequiresApproval?: boolean,
262−): EffectivePolicy => {
263− const firstMatchByOwner = new Map<string, EffectivePolicy>();
264− for (const p of sortedPolicies) {
265− const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__";
266− if (firstMatchByOwner.has(ownerKey)) continue;
267− if (matchPattern(p.pattern, toolId)) {
268− firstMatchByOwner.set(ownerKey, {
269− action: p.action,
270− source: "user",
271− pattern: p.pattern,
272− policyId: p.id,
273− });
274− }
275− }
276− let selected: EffectivePolicy | undefined;
277− for (const match of firstMatchByOwner.values()) {
278− selected = moreRestrictive(selected, match);
279− }
280− return selected ?? liftPlugin(defaultRequiresApproval);
281−};
282−
283186 // ---------------------------------------------------------------------------
284187 // Row → public projection.
285188 // ---------------------------------------------------------------------------
packages/core/sdk/src/promise-executor.ts+11−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 type InvokeOptions as EffectInvokeOptions,
2222 type OnElicitation,
2323 } from "./executor";
24+import type { ExecutorAccess } from "./access";
2425 import type { ElicitationContext, ElicitationResponse } from "./elicitation";
2526 import type { FumaDb, FumaTables } from "./fuma-runtime";
2627 import { Subject, Tenant } from "./ids";
@@ -127,6 +128,15 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 100 unmodified lines
127128 * an options arg.
128129 */
129130 readonly onElicitation: PromiseOnElicitation;
131+ /**
132+ * Product access decisions for the binding — REQUIRED, exactly as on the
133+ * Effect SDK's `ExecutorConfig.access`. This façade is core SDK, not a
134+ * product: it supplies no posture of its own. Embedders state theirs
135+ * explicitly — `@executor-js/product-access` ships the standard ones
136+ * (`singleUserAccess()` for a subject-bound single owner,
137+ * `workspaceServiceAccess()` for a subject-less workspace binding).
138+ */
139+ readonly access: ExecutorAccess;
130140 }
131141
132142 // ---------------------------------------------------------------------------
@@ -213,6 +223,7 @@ export const createExecutor = async <const TPlugins extends readonly AnyPlugin[] · 80 unmodified lines
213223 plugins,
214224 ...(config.providers ? { providers: config.providers } : {}),
215225 onElicitation: toEffectOnElicitation(config.onElicitation),
226+ access: config.access,
216227 ...(db ? { db } : {}),
217228 };
218229
packages/core/sdk/src/promise.test.ts+5−0
@@ -5,6 +5,7 @@ import { definePlugin, tool } from "./plugin"; · 4 unmodified lines
55 import type { ToolDef } from "./tool";
66 import { IntegrationSlug, ToolName } from "./ids";
77 import { Effect, Schema } from "effect";
8+import { testAccess } from "@executor-js/product-access/testing";
89
910 // A minimal static-tool plugin built on the Effect surface, consumed
1011 // through the Promise façade. Exercises the proxy's ability to promisify
@@ -41,6 +42,7 @@ describe("promise/createExecutor", () => { · 30 unmodified lines
4142 it("returns Promise-shaped executor and invokes static tools", async () => {
4243 const plugins = [echoPlugin()] as const;
4344 const executor = await createExecutor({
45+ access: testAccess.org(),
4446 plugins,
4547 onElicitation: "accept-all",
4648 });
@@ -54,6 +56,7 @@ describe("promise/createExecutor", () => { · 7 unmodified lines
5456 it("promisifies plugin extension methods", async () => {
5557 const plugins = [echoPlugin()] as const;
5658 const executor = await createExecutor({
59+ access: testAccess.org(),
5760 plugins,
5861 onElicitation: "accept-all",
5962 });
@@ -94,6 +97,7 @@ describe("promise/createExecutor", () => { · 34 unmodified lines
9497
9598 const plugins = [approvedPlugin()] as const;
9699 const executor = await createExecutor({
100+ access: testAccess.org(),
97101 plugins,
98102 onElicitation: "accept-all", // default → auto-approve
99103 });
@@ -157,6 +161,7 @@ describe("promise/createExecutor", () => { · 57 unmodified lines
157161
158162 const plugins = [inventoryPlugin()] as const;
159163 const executor = await createExecutor({
164+ access: testAccess.org(),
160165 plugins,
161166 providers: [memoryProvider],
162167 onElicitation: "accept-all",
packages/core/sdk/src/provider-call-timeout.test.ts+10−3
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
2929 import { definePlugin } from "./plugin";
3030 import type { CredentialProvider } from "./provider";
3131 import { makeTestConfig } from "./test-config";
32+import { testAccess } from "@executor-js/product-access/testing";
3233
3334 const STORE = ProviderKey.make("remote-store");
3435 const INTEG = IntegrationSlug.make("acme");
@@ -55,7 +56,7 @@ const plugin = (provider: CredentialProvider) => · 20 unmodified lines
5556 const executorWithConnection = (provider: CredentialProvider) =>
5657 Effect.gen(function* () {
5758 const executor = yield* createExecutor(
58− makeTestConfig({ plugins: [plugin(provider)] as const }),
59+ makeTestConfig({ access: testAccess.member(), plugins: [plugin(provider)] as const }),
5960 );
6061 yield* executor.acme.seed();
6162 yield* executor.connections.create({
@@ -110,7 +111,9 @@ describe("a credential provider that stops answering", () => { · 48 unmodified lines
110111 get: (id) => Effect.sync(() => items.get(String(id)) ?? null),
111112 set: (id, value) => Effect.sync(() => void items.set(String(id), value)),
112113 };
113− const executor = yield* createExecutor(makeTestConfig({ plugins: [plugin(lit)] as const }));
114+ const executor = yield* createExecutor(
115+ makeTestConfig({ access: testAccess.member(), plugins: [plugin(lit)] as const }),
116+ );
114117 yield* executor.acme.seed();
115118 yield* executor.connections.create({
116119 owner: "org",
@@ -144,7 +147,10 @@ describe("a credential provider that stops answering", () => { · 27 unmodified lines
144147 }
145148
146149 const executor = yield* createExecutor(
147− makeTestConfig({ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const }),
150+ makeTestConfig({
151+ access: testAccess.member(),
152+ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const,
153+ }),
148154 );
149155 yield* executor.acme.seed();
150156 yield* executor.connections.create({
@@ -184,6 +190,7 @@ describe("a credential provider that stops answering", () => { · 33 unmodified lines
184190
185191 const executor = yield* createExecutor(
186192 makeTestConfig({
193+ access: testAccess.member(),
187194 plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const,
188195 }),
189196 );
packages/core/sdk/src/settings-authority.test.ts+118−0
@@ -0,0 +1,118 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Result } from "effect";
3+import { testAccess } from "@executor-js/product-access/testing";
4+
5+import type { ExecutorAccess } from "./access";
6+import { createExecutor } from "./executor";
7+import { IntegrationSlug, Subject } from "./ids";
8+import { definePlugin } from "./plugin";
9+import { makeTestConfig } from "./testing";
10+
11+// ---------------------------------------------------------------------------
12+// `ExecutorAccess.settingsWrite` is the product's decision, and core enforces
13+// it VERBATIM at every user-intent settings sink — including catalog
14+// replacement from a SUBJECT-LESS executor. A host that hands a subject-less
15+// binding a denying `settingsWrite` has stated its rule; core must not keep a
16+// residual rule of its own ("subject-less replacement of an existing row is
17+// always boot convergence") that overrides the supplied denial. Products that
18+// WANT boot convergence say so through their access implementation
19+// (`workspaceServiceAccess`), which these tests also pin.
20+//
21+// The fixtures build executors over ONE test database: a workspace-service
22+// seeder that registers the catalog row, then differently-postured executors
23+// that attempt to replace it through the same plugin-extension path.
24+// ---------------------------------------------------------------------------
25+
26+const SLUG = IntegrationSlug.make("settings-authority");
27+const SEEDED_DESCRIPTION = "seeded by the workspace service";
28+const REPLACED_DESCRIPTION = "replaced despite a denied settings decision";
29+
30+const catalogPlugin = definePlugin(() => ({
31+ id: "settings-authority-demo" as const,
32+ storage: () => ({}),
33+ extension: (ctx) => ({
34+ register: (description: string) =>
35+ ctx.core.integrations.register({ slug: SLUG, description, config: {} }),
36+ }),
37+}))();
38+
39+/** The workspace-service posture with the product's settings decision
40+ * replaced by an unconditional denial — the host's explicit word. */
41+const denyingServiceAccess = (): ExecutorAccess => ({
42+ ...testAccess.org(),
43+ settingsWrite: () => Effect.succeed("denied" as const),
44+});
45+
46+const setup = () =>
47+ Effect.gen(function* () {
48+ const config = makeTestConfig({
49+ subject: null,
50+ access: testAccess.org(),
51+ plugins: [catalogPlugin] as const,
52+ });
53+ const seeder = yield* createExecutor(config);
54+ yield* Effect.addFinalizer(() =>
55+ seeder
56+ .close()
57+ .pipe(
58+ Effect.ignore,
59+ Effect.andThen(Effect.promise(() => config.testDb.close()).pipe(Effect.ignore)),
60+ ),
61+ );
62+ yield* seeder["settings-authority-demo"].register(SEEDED_DESCRIPTION);
63+ return { config, seeder };
64+ });
65+
66+describe("settingsWrite authority over catalog replacement", () => {
67+ it.effect("a supplied denial refuses replacement from a subject-less executor", () =>
68+ Effect.gen(function* () {
69+ const { config, seeder } = yield* setup();
70+ const denied = yield* createExecutor({ ...config, access: denyingServiceAccess() });
71+ yield* Effect.addFinalizer(() => denied.close().pipe(Effect.ignore));
72+
73+ const outcome = yield* Effect.result(
74+ denied["settings-authority-demo"].register(REPLACED_DESCRIPTION),
75+ );
76+ // The product said "denied"; core must enforce it even with no bound
77+ // subject — there is no core-owned boot-convergence exemption.
78+ expect(Result.isFailure(outcome)).toBe(true);
79+ expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } });
80+
81+ const after = yield* seeder.integrations.get(SLUG);
82+ expect(after?.description).toBe(SEEDED_DESCRIPTION);
83+ }).pipe(Effect.scoped),
84+ );
85+
86+ it.effect("boot convergence stays available when the product allows it", () =>
87+ Effect.gen(function* () {
88+ const { seeder } = yield* setup();
89+ // The same subject-less posture with the product's own (allowing)
90+ // decision converges the existing row — the rule lives in the product,
91+ // not in core.
92+ yield* seeder["settings-authority-demo"].register("converged at boot");
93+ const after = yield* seeder.integrations.get(SLUG);
94+ expect(after?.description).toBe("converged at boot");
95+ }).pipe(Effect.scoped),
96+ );
97+
98+ it.effect("baseline: a bound member's denial already refuses replacement", () =>
99+ Effect.gen(function* () {
100+ const { config, seeder } = yield* setup();
101+ const member = yield* createExecutor({
102+ ...config,
103+ subject: Subject.make("member-1"),
104+ access: testAccess.member("denied"),
105+ });
106+ yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore));
107+
108+ const outcome = yield* Effect.result(
109+ member["settings-authority-demo"].register(REPLACED_DESCRIPTION),
110+ );
111+ expect(Result.isFailure(outcome)).toBe(true);
112+ expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } });
113+
114+ const after = yield* seeder.integrations.get(SLUG);
115+ expect(after?.description).toBe(SEEDED_DESCRIPTION);
116+ }).pipe(Effect.scoped),
117+ );
118+});
packages/core/sdk/src/shared.ts+2−1
@@ -88,10 +88,11 @@ export { · 87 unmodified lines
8888 } from "./elicitation";
8989
9090 // Tool-policy helpers + projections (pure functions / Schema).
91+// Resolution semantics (owner ranking, most-restrictive merge, fallbacks)
92+// live in `@executor-js/product-access/policy`, which builds on these.
9193 export {
9294 matchPattern,
9395 isValidPattern,
94− effectivePolicyFromSorted,
9596 comparePolicyRow,
9697 patternSpecificity,
9798 positionForNewPattern,
packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts+20−4
@@ -88,7 +88,11 @@ describe("legacy bigint storage class migration", () => { · 87 unmodified lines
8888 });
8989 expect(await storageClassOf(db, "c_legacy")).toBe("integer");
9090
91− const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
91+ const scoped = withQueryContext(db.db, {
92+ tenant: TENANT,
93+ subject: SUBJECT,
94+ owners: ["user", "org"],
95+ });
9296 // Not "returns a bad value" — the read THROWS, which is why the gateway
9397 // lost every saved integration rather than one field of one row.
9498 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
@@ -118,7 +122,11 @@ describe("legacy bigint storage class migration", () => { · 23 unmodified lines
118122 expect(await storageClassOf(db, "c_healthy")).toBe("blob");
119123 expect(await storageClassOf(db, "c_null")).toBe("null");
120124
121− const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
125+ const scoped = withQueryContext(db.db, {
126+ tenant: TENANT,
127+ subject: SUBJECT,
128+ owners: ["user", "org"],
129+ });
122130 const rows = await scoped.findMany("connection", {});
123131 expect(
124132 rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]),
@@ -143,7 +151,11 @@ describe("legacy bigint storage class migration", () => { · 18 unmodified lines
143151 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1);
144152 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0);
145153
146− const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
154+ const scoped = withQueryContext(db.db, {
155+ tenant: TENANT,
156+ subject: SUBJECT,
157+ owners: ["user", "org"],
158+ });
147159 const rows = await scoped.findMany("connection", {});
148160 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
149161 }),
@@ -169,7 +181,11 @@ describe("legacy bigint storage class migration", () => { · 19 unmodified lines
169181
170182 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2);
171183
172− const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
184+ const scoped = withQueryContext(db.db, {
185+ tenant: TENANT,
186+ subject: SUBJECT,
187+ owners: ["user", "org"],
188+ });
173189 const integrations = await scoped.findMany("integration", {});
174190 expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([
175191 LEGACY_EXPIRES_AT,
packages/core/sdk/src/subject-registry.test.ts+6−0
@@ -9,6 +9,7 @@ import { definePlugin } from "./plugin"; · 8 unmodified lines
99 import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
1010 import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
1111 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
12+import { testAccess } from "@executor-js/product-access/testing";
1213
1314 // `touchSubject` is the only writer of the `subject` table. Written against the
1415 // real SQLite bring-up (the same statements local/self-host/D1 boot with) so
@@ -32,6 +33,7 @@ const scopedTo = (db: SqliteTestFumaDb, tenant: string) => · 17 unmodified lines
3233 withQueryContext(db.db, {
3334 tenant,
3435 subject: null,
36+ owners: [],
3537 } satisfies ExecutorOwnerPolicyContext);
3638
3739 const storedSubjects = (
@@ -345,6 +347,7 @@ const subjectsOf = ( · 307 unmodified lines
345347 const rows = await withQueryContext(harness.config.db as never, {
346348 tenant,
347349 subject: null,
350+ owners: [],
348351 } satisfies ExecutorOwnerPolicyContext).findMany("subject", {});
349352 return rows.map((row) => String(row.external_id));
350353 });
@@ -354,6 +357,7 @@ describe("connections.create subject sighting", () => { · 3 unmodified lines
354357 Effect.scoped(
355358 Effect.gen(function* () {
356359 const harness = yield* makeTestWorkspaceHarness({
360+ access: testAccess.member(),
357361 plugins,
358362 tenant: TENANT,
359363 });
@@ -377,6 +381,7 @@ describe("connections.create subject sighting", () => { · 17 unmodified lines
377381 // The row names the acting principal, not the connection's owner tier:
378382 // a member who only ever creates org connections still exists.
379383 const harness = yield* makeTestWorkspaceHarness({
384+ access: testAccess.member(),
380385 plugins,
381386 tenant: TENANT,
382387 });
@@ -398,6 +403,7 @@ describe("connections.create subject sighting", () => { · 15 unmodified lines
398403 Effect.scoped(
399404 Effect.gen(function* () {
400405 const harness = yield* makeTestWorkspaceHarness({
406+ access: testAccess.org(),
401407 plugins,
402408 tenant: TENANT,
403409 subject: null,
packages/core/sdk/src/subject-registry.ts+3−0
@@ -133,6 +133,9 @@ export const touchSubject = (db: FumaDb<any>, input: TouchSubjectInput): Effect. · 132 unmodified lines
133133 withQueryContext(db, {
134134 tenant: input.tenant,
135135 subject: externalId,
136+ // This host-internal writer touches ONLY the tenant-scoped `subject`
137+ // table; it holds no owner-scoped view at all, stated explicitly.
138+ owners: [],
136139 } satisfies ExecutorOwnerPolicyContext),
137140 );
138141 // No `tenant` clause: the tenant policy adds it to every read/update.
packages/core/sdk/src/test-config.ts+18−9
@@ -1,5 +1,6 @@
11 import { Context, Effect, Layer } from "effect";
22 import { withQueryContext } from "@executor-js/fumadb/query";
3+import type { ExecutorAccess } from "./access";
34 import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor";
45 import type { FumaDb } from "./fuma-runtime";
56 import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids";
@@ -134,15 +135,18 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 128 unmodified lines
134135 readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];
135136 readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];
136137 readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"];
137− /** Workspace-settings permission for the test binding (see
138− * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts
139− * with no role model. */
140− readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];
138+ /** Product access decisions for the test binding (see
139+ * `ExecutorConfig.access`). REQUIRED — this fixture is generic and has no
140+ * posture of its own. Tests state one explicitly, normally from
141+ * `@executor-js/product-access/testing` (`testAccess.member()`, …; the
142+ * sdk's own tests resolve that package through the workspace ROOT
143+ * devDependency, keeping the package graph acyclic). */
144+ readonly access: ExecutorAccess;
141145 readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"];
142146 };
143147
144148 export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
145− options?: TestConfigOptions<TPlugins>,
149+ options: TestConfigOptions<TPlugins>,
146150 ): Omit<ExecutorConfig<TPlugins>, "db"> & {
147151 readonly db: FumaDb;
148152 readonly testDb: TestFumaDb;
@@ -156,9 +160,14 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
156160 backend: options?.backend ?? "sqlite",
157161 dataDir: options?.dataDir,
158162 });
163+ // The fixture's SEEDING/INSPECTION handle: tests read and write rows
164+ // through it directly, so it carries the binding's full identity view,
165+ // stated explicitly. The executor built from this config re-binds its own
166+ // context from `options.access` and never inherits this one.
159167 const db = withQueryContext(testDb.db, {
160168 tenant,
161169 subject,
170+ owners: subject != null ? ["user", "org"] : ["org"],
162171 } satisfies ExecutorOwnerPolicyContext);
163172
164173 // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow
@@ -178,7 +187,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 13 unmodified lines
178187 onElicitation: "accept-all",
179188 onIntegrationChange: options?.onIntegrationChange,
180189 ...(redirectUri != null ? { redirectUri } : {}),
181− ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),
190+ access: options.access,
182191 oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
183192 firstPartyOAuthClients: options?.firstPartyOAuthClients,
184193 enterpriseManagedRollout: options?.enterpriseManagedRollout,
@@ -209,7 +218,7 @@ export class TestWorkspace extends Context.Service<TestWorkspace, TestWorkspaceH · 24 unmodified lines
209218 }
210219
211220 export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
212− options?: TestConfigOptions<TPlugins>,
221+ options: TestConfigOptions<TPlugins>,
213222 ) =>
214223 Effect.acquireRelease(
215224 Effect.gen(function* () {
@@ -233,7 +242,7 @@ export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlug · 17 unmodified lines
233242 );
234243
235244 export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
236− options?: TestConfigOptions<TPlugins>,
245+ options: TestConfigOptions<TPlugins>,
237246 ) =>
238247 Layer.effect(TestWorkspace)(
239248 makeTestWorkspaceHarness(options).pipe(
@@ -242,7 +251,7 @@ export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin · 2 unmodified lines
242251 );
243252
244253 export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
245− options?: TestConfigOptions<TPlugins>,
254+ options: TestConfigOptions<TPlugins>,
246255 ) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor));
247256
248257 /** Built-in in-memory writable credential provider, contributed as a plugin
packages/core/sdk/src/testing.test.ts+5−1
@@ -7,10 +7,14 @@ import { · 6 unmodified lines
77 OAuthTestServer,
88 TestWorkspace,
99 } from "./testing";
10+import { testAccess } from "@executor-js/product-access/testing";
1011
1112 const plugins = [memoryCredentialsPlugin()] as const;
1213
13−const TestLayer = Layer.mergeAll(makeTestWorkspaceLayer({ plugins }), OAuthTestServer.layer());
14+const TestLayer = Layer.mergeAll(
15+ makeTestWorkspaceLayer({ access: testAccess.member(), plugins }),
16+ OAuthTestServer.layer(),
17+);
1418
1519 layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => {
1620 it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () =>
packages/core/sdk/src/tool-not-found-empty-catalog.test.ts+4−1
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 import { definePlugin } from "./plugin";
3131 import type { CredentialProvider } from "./provider";
3232 import { makeTestConfig } from "./test-config";
33+import { testAccess } from "@executor-js/product-access/testing";
3334
3435 const STORE = ProviderKey.make("memory");
3536 const INTEG = IntegrationSlug.make("demo");
@@ -65,7 +66,9 @@ const POPULATED = pluginWith([ · 29 unmodified lines
6566
6667 const failInvoking = (plugin: ReturnType<typeof pluginWith>, tool: string) =>
6768 Effect.gen(function* () {
68− const executor = yield* createExecutor({ ...makeTestConfig({ plugins: [plugin] as const }) });
69+ const executor = yield* createExecutor({
70+ ...makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const }),
71+ });
6972 yield* executor.demo.seed();
7073 yield* executor.connections.create({
7174 owner: "org",
packages/core/sdk/src/tools-list-merge.test.ts+5−3
@@ -4,6 +4,7 @@ import { Effect } from "effect"; · 3 unmodified lines
44 import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";
55 import { definePlugin } from "./plugin";
66 import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
7+import { testAccess } from "@executor-js/product-access/testing";
78
89 // ---------------------------------------------------------------------------
910 // Cross-owner merge regression (the "Axiom shows 0 tools" bug).
@@ -55,9 +56,10 @@ const demoPlugin = definePlugin(() => ({ · 45 unmodified lines
5556 // Default test executor binds `subject: "test-subject"`, so user-owned
5657 // connections/tools can be created and merge with org rows.
5758 const setup = () =>
58− makeTestExecutor({ plugins: [memoryCredentialsPlugin(), demoPlugin] as const }).pipe(
59− Effect.tap((executor) => executor.demo.seed()),
60− );
59+ makeTestExecutor({
60+ access: testAccess.member(),
61+ plugins: [memoryCredentialsPlugin(), demoPlugin] as const,
62+ }).pipe(Effect.tap((executor) => executor.demo.seed()));
6163
6264 describe("tools.list cross-owner merge", () => {
6365 it.effect("lists user-owned tools when owner is omitted (the fix)", () =>
packages/hosts/cloudflare/src/blob-store.test.ts+4−1
@@ -93,7 +93,10 @@ describe("makeR2BlobStore", () => { · 92 unmodified lines
9393 Effect.gen(function* () {
9494 const { bucket } = makeFakeBucket();
9595 const store = makeR2BlobStore(bucket);
96− const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi");
96+ const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi", {
97+ owners: ["user", "org"],
98+ storageWrites: "allowed",
99+ });
97100
98101 yield* plugin.put("spec/h1", "org-spec", { owner: "org" });
99102 expect(yield* plugin.get("spec/h1")).toBe("org-spec");
packages/hosts/mcp-apps-shell/package.json+1−0
@@ -79,6 +79,7 @@ · 78 unmodified lines
7979 "@effect/vitest": "catalog:",
8080 "@executor-js/execution": "workspace:*",
8181 "@executor-js/host-mcp": "workspace:*",
82+ "@executor-js/product-access": "workspace:*",
8283 "@executor-js/sdk": "workspace:*",
8384 "@tailwindcss/browser": "^4.2.2",
8485 "@tailwindcss/vite": "catalog:",
packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts+5−1
@@ -41,6 +41,7 @@ import type * as Cause from "effect/Cause"; · 40 unmodified lines
4141 import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
4242
4343 import { loadMcpAppsShellHtml } from "../shell-html";
44+import { testAccess } from "@executor-js/product-access/testing";
4445
4546 type ShellServer = {
4647 readonly url: string;
@@ -1305,7 +1306,10 @@ const startMcpHarness = async (openApi: OpenApiServer): Promise<McpHarness> => { · 1258 unmodified lines
13051306 const executor = await Effect.runPromise(
13061307 Effect.gen(function* () {
13071308 const built = yield* createExecutor(
1308− makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }),
1309+ makeTestConfig({
1310+ access: testAccess.member(),
1311+ plugins: [inventoryPlugin(openApi.postRequests)],
1312+ }),
13091313 );
13101314 // Tools only exist per connection, so register the integration and open
13111315 // one org `main` connection — that is what makes
packages/hosts/mcp/package.json+1−0
@@ -46,6 +46,7 @@ · 45 unmodified lines
4646 "dependencies": {
4747 "@cfworker/json-schema": "^4.1.1",
4848 "@executor-js/execution": "workspace:*",
49+ "@executor-js/product-access": "workspace:*",
4950 "@executor-js/sdk": "workspace:*",
5051 "@modelcontextprotocol/ext-apps": "^1.7.4",
5152 "@modelcontextprotocol/sdk": "^1.29.0",
packages/hosts/mcp/src/in-memory-session-store.test.ts+19−5
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 } from "./in-memory-session-store";
1414 import { defaultMcpResource, type Principal } from "./seams";
1515 import { createExecutorMcpServer } from "./tool-server";
16+import { testAccess } from "@executor-js/product-access/testing";
1617
1718 const TEST_PRINCIPAL: Principal = {
1819 accountId: "acct_test",
@@ -161,7 +162,10 @@ const openSession = async ( · 142 unmodified lines
161162
162163 it("keeps overlapping warm-session workspace writes bound to their request roles", async () => {
163164 const executor = await Effect.runPromise(
164− createExecutor({ ...makeTestConfig(), orgWrites: "request" }),
165+ createExecutor({
166+ ...makeTestConfig({ access: testAccess.member() }),
167+ access: testAccess.requestBound(),
168+ }),
165169 );
166170 const started = new Map<string, () => void>();
167171 const startedPromises = ["member", "admin"].map(
@@ -240,7 +244,10 @@ it("keeps overlapping warm-session workspace writes bound to their request roles · 72 unmodified lines
240244
241245 it("binds a paused workspace write to the resuming principal after demotion", async () => {
242246 const executor = await Effect.runPromise(
243− createExecutor({ ...makeTestConfig(), orgWrites: "request" }),
247+ createExecutor({
248+ ...makeTestConfig({ access: testAccess.member() }),
249+ access: testAccess.requestBound(),
250+ }),
244251 );
245252 const executionId = "exec_resume_demotion";
246253 const pattern = "paused-resume-demotion.*";
@@ -313,7 +320,10 @@ it("binds a paused workspace write to the resuming principal after demotion", as · 66 unmodified lines
313320
314321 it("uses the browser approver's demoted role after an admin starts waiting", async () => {
315322 const executor = await Effect.runPromise(
316− createExecutor({ ...makeTestConfig({ coreTools: {} }), orgWrites: "request" }),
323+ createExecutor({
324+ ...makeTestConfig({ access: testAccess.member(), coreTools: {} }),
325+ access: testAccess.requestBound(),
326+ }),
317327 );
318328 const executionId = "exec_browser_resume_demotion";
319329 const pattern = "browser-resume-demotion.*";
@@ -695,7 +705,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 375 unmodified lines
695705 it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => {
696706 let executorClosed = 0;
697707 let customClosed = 0;
698− const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig()));
708+ const realExecutor = await Effect.runPromise(
709+ createExecutor(makeTestConfig({ access: testAccess.member() })),
710+ );
699711 const testExecutor = {
700712 ...realExecutor,
701713 close: () =>
@@ -743,7 +755,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 41 unmodified lines
743755
744756 it("shuts down the scoped executor when sessions.close() is called", async () => {
745757 let executorClosed = 0;
746− const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig()));
758+ const realExecutor = await Effect.runPromise(
759+ createExecutor(makeTestConfig({ access: testAccess.member() })),
760+ );
747761 const testExecutor = {
748762 ...realExecutor,
749763 close: () =>
packages/hosts/mcp/src/seams.ts+5−2
@@ -1,6 +1,7 @@
11 import { Context, Effect, Layer, Schema } from "effect";
22 import type { Cause } from "effect";
33
4+import { orgWriteAccessForRole } from "@executor-js/product-access";
45 import type { OrgWriteAccess } from "@executor-js/sdk";
56
67 // ---------------------------------------------------------------------------
@@ -72,9 +73,11 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 65 unmodified lines
7273 /** Internal header overwritten by a trusted session store before MCP dispatch. */
7374 export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access";
7475
75−/** Derive the effective workspace-write access for one authenticated request. */
76+/** Derive the effective workspace-write access for one authenticated request
77+ * — the product's role rule (`@executor-js/product-access`), applied here at
78+ * the serving boundary that stamps the request-bound carrier. */
7679 export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess =>
77− principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied";
80+ orgWriteAccessForRole(principal);
7881
7982 /**
8083 * Stamp request-bound workspace-write access for the MCP SDK request handler.
packages/kernel/runtime-dynamic-worker/package.json+1−0
@@ -25,6 +25,7 @@ · 24 unmodified lines
2525 "@executor-js/execution": "workspace:*",
2626 "@executor-js/fumadb": "workspace:*",
2727 "@executor-js/plugin-openapi": "workspace:*",
28+ "@executor-js/product-access": "workspace:*",
2829 "@executor-js/sdk": "workspace:*",
2930 "@types/node": "catalog:",
3031 "drizzle-orm": "catalog:",
packages/kernel/runtime-dynamic-worker/src/integration.test.ts+2−0
@@ -45,6 +45,7 @@ import { · 44 unmodified lines
4545 type FumaDb,
4646 type FumaTables,
4747 } from "@executor-js/sdk";
48+import { testAccess } from "@executor-js/product-access/testing";
4849 import { makeExecutorToolInvoker } from "@executor-js/execution";
4950 import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi";
5051
@@ -231,6 +232,7 @@ const buildSandboxBridge = (spec: string, slug: string, baseUrl = "https://upstr · 180 unmodified lines
231232 providers: [memoryProvider()],
232233 plugins,
233234 onElicitation: "accept-all",
235+ access: testAccess.member(),
234236 });
235237 // v2: addSpec registers the integration; tools are produced per-connection,
236238 // so an org `main` connection is required for the operation to be callable.
packages/plugins/desktop-settings/package.json+1−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
4747 "react": "catalog:"
4848 },
4949 "devDependencies": {
50+ "@executor-js/product-access": "workspace:*",
5051 "@types/react": "catalog:",
5152 "tsup": "catalog:",
5253 "typescript": "catalog:"
packages/plugins/desktop-settings/src/server.test.ts+2−0
@@ -5,11 +5,13 @@ import { ToolAddress } from "@executor-js/sdk"; · 4 unmodified lines
55 import { makeTestExecutor } from "@executor-js/sdk/testing";
66
77 import { desktopSettingsPlugin } from "./server";
8+import { testAccess } from "@executor-js/product-access/testing";
89
910 describe("desktopSettingsPlugin", () => {
1011 it.effect("returns a browser handoff URL for Desktop-only settings", () =>
1112 Effect.gen(function* () {
1213 const executor = yield* makeTestExecutor({
14+ access: testAccess.member(),
1315 plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const,
1416 });
1517
packages/plugins/file-secrets/package.json+1−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
4747 "@executor-js/sdk": "workspace:*"
4848 },
4949 "devDependencies": {
50+ "@executor-js/product-access": "workspace:*",
5051 "@types/node": "catalog:",
5152 "bun-types": "catalog:",
5253 "effect": "catalog:",
packages/plugins/file-secrets/src/data-dir.test.ts+11−2
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
1515
1616 import { fileSecretsPlugin } from "./index";
17+import { testAccess } from "@executor-js/product-access/testing";
1718
1819 const INTEGRATION = IntegrationSlug.make("durable-secrets");
1920 const CONNECTION = ConnectionName.make("main");
@@ -69,7 +70,11 @@ describe("file secrets data directory", () => { · 49 unmodified lines
6970 vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome);
7071 const firstAuthPath = yield* Effect.scoped(
7172 Effect.gen(function* () {
72− const first = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });
73+ const first = yield* makeTestWorkspaceHarness({
74+ access: testAccess.member(),
75+ dataDir,
76+ plugins: plugins(),
77+ });
7378 yield* first.executor.connectionFixture.registerIntegration();
7479 const connection = yield* first.executor.connections.create({
7580 owner: "org",
@@ -96,7 +101,11 @@ describe("file secrets data directory", () => { · 20 unmodified lines
96101 vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome);
97102 yield* Effect.scoped(
98103 Effect.gen(function* () {
99− const recreated = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });
104+ const recreated = yield* makeTestWorkspaceHarness({
105+ access: testAccess.member(),
106+ dataDir,
107+ plugins: plugins(),
108+ });
100109 const connections = yield* recreated.executor.connections.list({
101110 integration: INTEGRATION,
102111 });
packages/plugins/file-secrets/src/index.test.ts+8−1
@@ -17,13 +17,17 @@ import { ProviderKey } from "@executor-js/sdk"; · 16 unmodified lines
1717 import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
1818
1919 import { fileSecretsPlugin } from "./index";
20+import { testAccess } from "@executor-js/product-access/testing";
2021
2122 const FILE_PROVIDER = ProviderKey.make("file");
2223
2324 const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) =>
2425 Effect.scoped(
2526 Effect.gen(function* () {
26− const workspace = yield* makeTestWorkspaceHarness({ plugins: [plugin] as const });
27+ const workspace = yield* makeTestWorkspaceHarness({
28+ access: testAccess.member(),
29+ plugins: [plugin] as const,
30+ });
2731 const items = yield* workspace.executor.providers.items(FILE_PROVIDER);
2832 return {
2933 filePath: workspace.executor.fileSecrets.filePath,
@@ -136,6 +140,7 @@ describe("file secrets auth location", () => { · 106 unmodified lines
136140 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
137141 writeAuthFile(legacyFilePath, "not-json");
138142 const workspace = yield* makeTestWorkspaceHarness({
143+ access: testAccess.member(),
139144 plugins: [fileSecretsPlugin()] as const,
140145 });
141146
@@ -158,6 +163,7 @@ describe("file secrets auth location", () => { · 16 unmodified lines
158163 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
159164 mkdirSync(legacyFilePath, { recursive: true });
160165 const workspace = yield* makeTestWorkspaceHarness({
166+ access: testAccess.member(),
161167 plugins: [fileSecretsPlugin()] as const,
162168 });
163169
@@ -185,6 +191,7 @@ describe("file secrets auth location", () => { · 21 unmodified lines
185191 const legacyContents = '{"legacy-token":"legacy-secret"}';
186192 writeAuthFile(legacyFilePath, legacyContents);
187193 const workspace = yield* makeTestWorkspaceHarness({
194+ access: testAccess.member(),
188195 plugins: [fileSecretsPlugin()] as const,
189196 });
190197
packages/plugins/graphql/package.json+1−0
@@ -71,6 +71,7 @@ · 70 unmodified lines
7171 "@effect/atom-react": "catalog:",
7272 "@effect/vitest": "catalog:",
7373 "@executor-js/api": "workspace:*",
74+ "@executor-js/product-access": "workspace:*",
7475 "@executor-js/react": "workspace:*",
7576 "@types/node": "catalog:",
7677 "@types/react": "catalog:",
packages/plugins/graphql/src/sdk/configure.test.ts+2−0
@@ -21,10 +21,12 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 20 unmodified lines
2121
2222 import { graphqlPlugin } from "./plugin";
2323 import type { GraphqlAuthMethod } from "./types";
24+import { testAccess } from "@executor-js/product-access/testing";
2425
2526 const makeExecutor = () =>
2627 createExecutor(
2728 makeTestConfig({
29+ access: testAccess.member(),
2830 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
2931 }),
3032 );
packages/plugins/graphql/src/sdk/invocation-timeout.test.ts+2−0
@@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 15 unmodified lines
1616
1717 import { makeGreetingGraphqlSchema } from "../testing";
1818 import { graphqlPlugin } from "./plugin";
19+import { testAccess } from "@executor-js/product-access/testing";
1920
2021 const INVOCATION_TIMEOUT_MS = 100;
2122
@@ -61,6 +62,7 @@ describe("GraphQL invocation timeout", () => { · 39 unmodified lines
6162 const server = yield* startHangingResponseServer(closed);
6263 const executor = yield* createExecutor(
6364 makeTestConfig({
65+ access: testAccess.member(),
6466 plugins: [
6567 memoryCredentialsPlugin(),
6668 graphqlPlugin({
packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts+5−1
@@ -23,12 +23,16 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 22 unmodified lines
2323 import { graphqlPlugin } from "./plugin";
2424 import { variable } from "@executor-js/sdk/http-auth";
2525 import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
26+import { testAccess } from "@executor-js/product-access/testing";
2627
2728 const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
2829
2930 const makeExecutor = () =>
3031 createExecutor(
31− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
32+ makeTestConfig({
33+ access: testAccess.member(),
34+ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
35+ }),
3236 );
3337
3438 const toolAddr = (integration: string, connection: string, tool: string): ToolAddress =>
packages/plugins/graphql/src/sdk/plugin.test.ts+12−3
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
3535 serveGraphqlTestServer,
3636 waitForRecordedRequests,
3737 } from "../testing";
38+import { testAccess } from "@executor-js/product-access/testing";
3839
3940 // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/
4041 // usagesForConnection, multi-scope shadowing, and `executor.sources.*` /
@@ -118,7 +119,10 @@ const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphql · 77 unmodified lines
118119
119120 const makeExecutor = () =>
120121 createExecutor(
121− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
122+ makeTestConfig({
123+ access: testAccess.member(),
124+ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
125+ }),
122126 );
123127
124128 const recordingBlobStore = () => {
@@ -160,11 +164,14 @@ describe("graphqlPlugin real protocol server", () => { · 35 unmodified lines
160164 it.effect("denies member schema persistence before writing an org blob", () =>
161165 Effect.gen(function* () {
162166 const blobs = recordingBlobStore();
163− const config = makeTestConfig({ plugins: [graphqlPlugin()] as const });
167+ const config = makeTestConfig({
168+ access: testAccess.member(),
169+ plugins: [graphqlPlugin()] as const,
170+ });
164171 const member = yield* createExecutor({
165172 ...config,
166173 blobs: blobs.store,
167− orgWrites: "denied",
174+ access: testAccess.member("denied"),
168175 });
169176
170177 const error = yield* member.graphql
@@ -420,6 +427,7 @@ describe("graphqlPlugin real protocol server", () => { · 249 unmodified lines
420427 }),
421428 );
422429 const config = makeTestConfig({
430+ access: testAccess.member(),
423431 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
424432 });
425433 const executor = yield* createExecutor({ ...config, httpClientLayer });
@@ -1236,6 +1244,7 @@ describe("graphqlPlugin", () => { · 810 unmodified lines
12361244 it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () =>
12371245 Effect.gen(function* () {
12381246 const config = makeTestConfig({
1247+ access: testAccess.member(),
12391248 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
12401249 });
12411250 const executor = yield* createExecutor(config);
packages/plugins/keychain/package.json+1−0
@@ -49,6 +49,7 @@ · 48 unmodified lines
4949 },
5050 "devDependencies": {
5151 "@effect/vitest": "catalog:",
52+ "@executor-js/product-access": "workspace:*",
5253 "@types/node": "catalog:",
5354 "bun-types": "catalog:",
5455 "effect": "catalog:",
packages/plugins/keychain/src/index.test.ts+4−3
@@ -5,6 +5,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 4 unmodified lines
55 import { keychainPlugin } from "./index";
66 import { makeKeychainProvider } from "./provider";
77 import { setPassword, deletePassword } from "./keyring";
8+import { testAccess } from "@executor-js/product-access/testing";
89
910 // removed: v1 tests routed through `executor.secrets.set/get/remove` with
1011 // `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived
@@ -33,9 +34,7 @@ describe("keychain plugin", () => { · 22 unmodified lines
3334 it.effect("exposes keychain metadata and registers a provider when reachable", () =>
3435 Effect.gen(function* () {
3536 const executor = yield* createExecutor(
36− makeTestConfig({
37− plugins: [keychainPlugin()] as const,
38− }),
37+ makeTestConfig({ access: testAccess.member(), plugins: [keychainPlugin()] as const }),
3938 );
4039
4140 expect(executor.keychain.displayName).toBeTypeOf("string");
@@ -60,6 +59,7 @@ describe("keychain plugin", () => { · 18 unmodified lines
6059
6160 const executor = yield* createExecutor(
6261 makeTestConfig({
62+ access: testAccess.member(),
6363 plugins: [keychainPlugin({ serviceName })] as const,
6464 }),
6565 );
@@ -86,6 +86,7 @@ describe("keychain plugin", () => { · 20 unmodified lines
8686 Effect.gen(function* () {
8787 const executor = yield* createExecutor(
8888 makeTestConfig({
89+ access: testAccess.member(),
8990 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,
9091 }),
9192 );
packages/plugins/mcp/package.json+1−0
@@ -76,6 +76,7 @@ · 75 unmodified lines
7676 "@effect/atom-react": "catalog:",
7777 "@effect/vitest": "catalog:",
7878 "@executor-js/api": "workspace:*",
79+ "@executor-js/product-access": "workspace:*",
7980 "@executor-js/react": "workspace:*",
8081 "@modelcontextprotocol/server": "2.0.0",
8182 "@types/node": "catalog:",
packages/plugins/mcp/src/sdk/catalog-sync.test.ts+9−2
@@ -35,6 +35,7 @@ import { mcpPlugin } from "./plugin"; · 34 unmodified lines
3535 import { createMcpConnector } from "./connection";
3636 import { discoverTools } from "./discover";
3737 import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing";
38+import { testAccess } from "@executor-js/product-access/testing";
3839
3940 const INTEG = IntegrationSlug.make("catalog_mcp");
4041 const CONNECTION = ConnectionName.make("main");
@@ -48,7 +49,10 @@ const makeCatalogTestExecutor = ( · 7 unmodified lines
4849 },
4950 ) =>
5051 createExecutor({
51− ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
52+ ...makeTestConfig({
53+ access: testAccess.member(),
54+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
55+ }),
5256 ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),
5357 ...(options?.toolsSyncGraceMs === undefined
5458 ? {}
@@ -357,7 +361,10 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines
357361 Effect.gen(function* () {
358362 const fixture = yield* serveLatchedListServer();
359363 const executor = yield* createExecutor({
360− ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
364+ ...makeTestConfig({
365+ access: testAccess.member(),
366+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
367+ }),
361368 // Everything is expired on every read, so a single tools read has the
362369 // whole set to rebuild.
363370 toolsSyncTtlMs: 0,
packages/plugins/mcp/src/sdk/elicitation.test.ts+10−2
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
2323
2424 import { mcpPlugin } from "./plugin";
2525 import { makeElicitationMcpServer, serveMcpServer } from "../testing";
26+import { testAccess } from "@executor-js/product-access/testing";
2627
2728 const isFormElicitation = Schema.is(FormElicitation);
2829
@@ -60,7 +61,12 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 31 unmodified lines
6061
6162 const makeTestExecutor = (serverUrl: string) =>
6263 Effect.acquireRelease(
63− createExecutor(makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const })),
64+ createExecutor(
65+ makeTestConfig({
66+ access: testAccess.member(),
67+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
68+ }),
69+ ),
6470 (executor) => executor.close().pipe(Effect.ignore),
6571 ).pipe(
6672 Effect.tap((executor) =>
@@ -373,7 +379,9 @@ describe("MCP elicitation (end-to-end)", () => { · 306 unmodified lines
373379 it.effect("addServer preserves the configured display name as the integration description", () =>
374380 Effect.gen(function* () {
375381 const server = yield* serveElicitationTestServer;
376− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
382+ const executor = yield* createExecutor(
383+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
384+ );
377385
378386 yield* executor.mcp.addServer({
379387 name: "Gmail",
packages/plugins/mcp/src/sdk/image-content.test.ts+5−1
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 makeImageMcpServer,
1818 serveMcpServer,
1919 } from "../testing";
20+import { testAccess } from "@executor-js/product-access/testing";
2021
2122 const INTEGRATION = IntegrationSlug.make("image_mcp");
2223 const TEMPLATE = AuthTemplateSlug.make("none");
@@ -32,7 +33,10 @@ describe("MCP image content", () => { · 9 unmodified lines
3233 Effect.gen(function* () {
3334 const server = yield* serveMcpServer(makeImageMcpServer);
3435 const executor = yield* createExecutor(
35− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
36+ makeTestConfig({
37+ access: testAccess.member(),
38+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
39+ }),
3640 );
3741
3842 yield* executor.mcp.addServer({
packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts+17−4
@@ -24,6 +24,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 23 unmodified lines
2424 import { mcpPlugin } from "./plugin";
2525 import { variable } from "@executor-js/sdk/http-auth";
2626 import { makeEchoMcpServer, serveMcpServer } from "../testing";
27+import { testAccess } from "@executor-js/product-access/testing";
2728
2829 const serveRecordingServer = serveMcpServer(() =>
2930 makeEchoMcpServer({
@@ -40,7 +41,10 @@ describe("MCP multi-placement auth", () => { · 10 unmodified lines
4041 Effect.gen(function* () {
4142 const server = yield* serveRecordingServer;
4243 const executor = yield* createExecutor(
43− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
44+ makeTestConfig({
45+ access: testAccess.member(),
46+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
47+ }),
4448 );
4549
4650 yield* executor.mcp.addServer({
@@ -88,7 +92,10 @@ describe("MCP multi-placement auth", () => { · 41 unmodified lines
8892 Effect.gen(function* () {
8993 const server = yield* serveRecordingServer;
9094 const executor = yield* createExecutor(
91− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
95+ makeTestConfig({
96+ access: testAccess.member(),
97+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
98+ }),
9299 );
93100
94101 yield* executor.mcp.addServer({
@@ -126,7 +133,10 @@ describe("MCP multi-placement auth", () => { · 31 unmodified lines
126133 Effect.gen(function* () {
127134 const server = yield* serveRecordingServer;
128135 const executor = yield* createExecutor(
129− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
136+ makeTestConfig({
137+ access: testAccess.member(),
138+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
139+ }),
130140 );
131141
132142 // Two declared methods; each connection picks one by template slug.
@@ -193,7 +203,10 @@ describe("MCP multi-placement auth", () => { · 60 unmodified lines
193203 Effect.gen(function* () {
194204 const server = yield* serveRecordingServer;
195205 const executor = yield* createExecutor(
196− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
206+ makeTestConfig({
207+ access: testAccess.member(),
208+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
209+ }),
197210 );
198211
199212 yield* executor.mcp.addServer({
packages/plugins/mcp/src/sdk/owner-isolation.test.ts+5−1
@@ -23,6 +23,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 22 unmodified lines
2323
2424 import { mcpPlugin } from "./plugin";
2525 import { makeEchoMcpServer, serveMcpServer } from "../testing";
26+import { testAccess } from "@executor-js/product-access/testing";
2627
2728 const INTEG = IntegrationSlug.make("iso_mcp");
2829 const TEMPLATE = AuthTemplateSlug.make("bearer");
@@ -43,7 +44,10 @@ describe("MCP owner isolation", () => { · 14 unmodified lines
4344 Effect.gen(function* () {
4445 const server = yield* serveAuthRecordingMcpServer;
4546 const executor = yield* createExecutor(
46− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
47+ makeTestConfig({
48+ access: testAccess.member(),
49+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
50+ }),
4751 );
4852
4953 // One integration with a Bearer header auth template; the connection's
packages/plugins/mcp/src/sdk/plugin.test.ts+80−24
@@ -29,6 +29,7 @@ import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin · 28 unmodified lines
2929 import { McpInvocationError } from "./errors";
3030 import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
3131 import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";
32+import { testAccess } from "@executor-js/product-access/testing";
3233
3334 // removed: the v1 addSource / scopes / secrets / credential-binding / usages /
3435 // sources.configure / multi-scope shadowing suites. v2 has no scope stack, no
@@ -261,6 +262,7 @@ const seedCallToolExecutor = (input: { · 226 unmodified lines
261262 Effect.gen(function* () {
262263 const server = yield* serveCallToolServer(input.callTool);
263264 const config = makeTestConfig({
265+ access: testAccess.member(),
264266 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
265267 });
266268 const executor = yield* createExecutor(config);
@@ -590,9 +592,7 @@ describe("mcpPlugin", () => { · 323 unmodified lines
590592 it.effect("creates executor with mcp plugin", () =>
591593 Effect.gen(function* () {
592594 const executor = yield* createExecutor(
593− makeTestConfig({
594− plugins: [mcpPlugin()] as const,
595− }),
595+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
596596 );
597597
598598 expect(executor.mcp).toBeDefined();
@@ -631,7 +631,9 @@ describe("mcpPlugin", () => { · 32 unmodified lines
631631
632632 it.effect("integration catalog has no configured MCP integrations initially", () =>
633633 Effect.gen(function* () {
634− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
634+ const executor = yield* createExecutor(
635+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
636+ );
635637 const integrations = yield* executor.integrations.list();
636638 expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0);
637639 }),
@@ -639,7 +641,9 @@ describe("mcpPlugin", () => { · 1 unmodified line
639641
640642 it.effect("projects an MCP server family into the integration catalog", () =>
641643 Effect.gen(function* () {
642− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
644+ const executor = yield* createExecutor(
645+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
646+ );
643647 yield* executor.mcp.addServer({
644648 name: "Cloudflare Docs",
645649 family: "cloudflare",
@@ -656,7 +660,9 @@ describe("mcpPlugin", () => { · 10 unmodified lines
656660
657661 it.effect("connection tools list is empty until a connection is created", () =>
658662 Effect.gen(function* () {
659− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
663+ const executor = yield* createExecutor(
664+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
665+ );
660666 const tools = yield* executor.tools.list();
661667 expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0);
662668 }),
@@ -667,7 +673,10 @@ describe("mcpPlugin", () => { · 4 unmodified lines
667673 Effect.gen(function* () {
668674 const server = yield* serveOAuthTestServer({ scopes: [] });
669675 const executor = yield* createExecutor(
670− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
676+ makeTestConfig({
677+ access: testAccess.member(),
678+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
679+ }),
671680 );
672681 const attachedClient = OAuthClientSlug.make("axiom-mcp");
673682 const unrelatedClient = OAuthClientSlug.make("manual-app");
@@ -726,7 +735,10 @@ describe("mcpPlugin", () => { · 52 unmodified lines
726735 Effect.scoped(
727736 Effect.gen(function* () {
728737 const executor = yield* createExecutor(
729− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
738+ makeTestConfig({
739+ access: testAccess.member(),
740+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
741+ }),
730742 );
731743
732744 yield* executor.mcp.addServer({
@@ -769,7 +781,9 @@ describe("mcpPlugin", () => { · 36 unmodified lines
769781 // adding an API key to an OAuth server must NOT displace the OAuth method.
770782 it.effect("configureAuth merge-appends a custom method without clobbering oauth", () =>
771783 Effect.gen(function* () {
772− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
784+ const executor = yield* createExecutor(
785+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
786+ );
773787
774788 yield* executor.mcp.addServer({
775789 name: "OAuth MCP",
@@ -796,7 +810,9 @@ describe("mcpPlugin", () => { · 20 unmodified lines
796810
797811 it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () =>
798812 Effect.gen(function* () {
799− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
813+ const executor = yield* createExecutor(
814+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
815+ );
800816
801817 yield* executor.mcp.addServer({
802818 name: "Open MCP",
@@ -826,7 +842,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines
826842 scopes: ["channels:history", "users:read"],
827843 });
828844 const executor = yield* createExecutor(
829− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
845+ makeTestConfig({
846+ access: testAccess.member(),
847+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
848+ }),
830849 );
831850
832851 yield* executor.mcp.addServer({
@@ -870,7 +889,10 @@ describe("mcpPlugin", () => { · 37 unmodified lines
870889 Effect.gen(function* () {
871890 const server = yield* serveOAuthTestServer({ scopes: ["mcp"] });
872891 const executor = yield* createExecutor(
873− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
892+ makeTestConfig({
893+ access: testAccess.member(),
894+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
895+ }),
874896 );
875897
876898 yield* executor.mcp.addServer({
@@ -911,7 +933,10 @@ describe("mcpPlugin", () => { · 34 unmodified lines
911933 it.effect("registers integration + connection with 0 tools when discovery fails", () =>
912934 Effect.gen(function* () {
913935 const executor = yield* createExecutor(
914− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
936+ makeTestConfig({
937+ access: testAccess.member(),
938+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
939+ }),
915940 );
916941
917942 const slugStr = "broken_source";
@@ -941,7 +966,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines
941966
942967 it.effect("static probeEndpoint returns actionable tool failures", () =>
943968 Effect.gen(function* () {
944− const config = makeTestConfig({ plugins: [mcpPlugin()] as const });
969+ const config = makeTestConfig({
970+ access: testAccess.member(),
971+ plugins: [mcpPlugin()] as const,
972+ });
945973 const executor = yield* createExecutor(config);
946974
947975 const result = yield* executor.execute(ToolAddress.make("executor.mcp.probeEndpoint"), {
@@ -1294,7 +1322,10 @@ describe("mcpPlugin", () => { · 346 unmodified lines
12941322 ),
12951323 ),
12961324 );
1297− const config = makeTestConfig({ plugins: [mcpPlugin()] as const });
1325+ const config = makeTestConfig({
1326+ access: testAccess.member(),
1327+ plugins: [mcpPlugin()] as const,
1328+ });
12981329 const executor = yield* createExecutor(config);
12991330
13001331 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
@@ -1330,7 +1361,10 @@ describe("mcpPlugin", () => { · 29 unmodified lines
13301361 : HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }),
13311362 ),
13321363 );
1333− const config = makeTestConfig({ plugins: [mcpPlugin()] as const });
1364+ const config = makeTestConfig({
1365+ access: testAccess.member(),
1366+ plugins: [mcpPlugin()] as const,
1367+ });
13341368 const executor = yield* createExecutor(config);
13351369
13361370 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
@@ -1372,7 +1406,10 @@ describe("mcpPlugin", () => { · 35 unmodified lines
13721406 return HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 });
13731407 }),
13741408 );
1375− const config = makeTestConfig({ plugins: [mcpPlugin()] as const });
1409+ const config = makeTestConfig({
1410+ access: testAccess.member(),
1411+ plugins: [mcpPlugin()] as const,
1412+ });
13761413 const executor = yield* createExecutor(config);
13771414
13781415 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
@@ -1400,7 +1437,10 @@ const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); · 21 unmodified lines
14001437
14011438 const seedAnnotationsExecutor = (serverUrl: string) =>
14021439 createExecutor(
1403− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
1440+ makeTestConfig({
1441+ access: testAccess.member(),
1442+ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
1443+ }),
14041444 ).pipe(
14051445 Effect.tap((executor) =>
14061446 Effect.gen(function* () {
@@ -1508,7 +1548,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 101 unmodified lines
15081548 // a candidate.
15091549 it.effect("returns low-confidence candidate when path has /mcp segment", () =>
15101550 Effect.gen(function* () {
1511− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1551+ const executor = yield* createExecutor(
1552+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1553+ );
15121554 const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp");
15131555 const mcp = results.find((r) => r.kind === "mcp");
15141556 expect(mcp).toBeDefined();
@@ -1518,7 +1560,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines
15181560
15191561 it.effect("matches mcp on hostname label", () =>
15201562 Effect.gen(function* () {
1521− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1563+ const executor = yield* createExecutor(
1564+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1565+ );
15221566 const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/");
15231567 const mcp = results.find((r) => r.kind === "mcp");
15241568 expect(mcp?.confidence).toBe("low");
@@ -1527,7 +1571,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 2 unmodified lines
15271571
15281572 it.effect("does not match mcp as a substring", () =>
15291573 Effect.gen(function* () {
1530− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1574+ const executor = yield* createExecutor(
1575+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1576+ );
15311577 // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so
15321578 // the URL-token fallback must not fire.
15331579 const results = yield* executor.integrations.detect("http://127.0.0.1:1/mcpstore");
@@ -1537,7 +1583,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines
15371583
15381584 it.effect("returns null when no token match and no wire-shape match", () =>
15391585 Effect.gen(function* () {
1540− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1586+ const executor = yield* createExecutor(
1587+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1588+ );
15411589 const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1");
15421590 expect(results.find((r) => r.kind === "mcp")).toBeUndefined();
15431591 }),
@@ -1590,7 +1638,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 46 unmodified lines
15901638 it.effect("stamps a sanitized endpoint on the detect span", () =>
15911639 Effect.gen(function* () {
15921640 const spans: Array<Tracer.NativeSpan> = [];
1593− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1641+ const executor = yield* createExecutor(
1642+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1643+ );
15941644
15951645 yield* executor.integrations
15961646 .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`)
@@ -1620,7 +1670,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 23 unmodified lines
16201670 it.effect("stamps a sanitized endpoint on the probe_endpoint span", () =>
16211671 Effect.gen(function* () {
16221672 const spans: Array<Tracer.NativeSpan> = [];
1623− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1673+ const executor = yield* createExecutor(
1674+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1675+ );
16241676
16251677 yield* executor.mcp
16261678 .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`)
@@ -1649,6 +1701,7 @@ describe("stdio static env", () => { · 22 unmodified lines
16491701 Effect.scoped(
16501702 Effect.gen(function* () {
16511703 const config = makeTestConfig({
1704+ access: testAccess.member(),
16521705 plugins: [
16531706 memoryCredentialsPlugin(),
16541707 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
@@ -1725,6 +1778,7 @@ describe("stdio static env", () => { · 70 unmodified lines
17251778 Effect.scoped(
17261779 Effect.gen(function* () {
17271780 const config = makeTestConfig({
1781+ access: testAccess.member(),
17281782 plugins: [
17291783 memoryCredentialsPlugin(),
17301784 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
@@ -1777,6 +1831,7 @@ describe("stdio static env", () => { · 46 unmodified lines
17771831 Effect.scoped(
17781832 Effect.gen(function* () {
17791833 const config = makeTestConfig({
1834+ access: testAccess.member(),
17801835 plugins: [
17811836 memoryCredentialsPlugin(),
17821837 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
@@ -1832,6 +1887,7 @@ describe("stdio static env", () => { · 49 unmodified lines
18321887 Effect.scoped(
18331888 Effect.gen(function* () {
18341889 const config = makeTestConfig({
1890+ access: testAccess.member(),
18351891 plugins: [
18361892 memoryCredentialsPlugin(),
18371893 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts+4−1
@@ -29,6 +29,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 28 unmodified lines
2929
3030 import { mcpPlugin } from "./plugin";
3131 import { probeMcpEndpointShape } from "./probe-shape";
32+import { testAccess } from "@executor-js/product-access/testing";
3233
3334 const MCP_INITIALIZE_BODY = JSON.stringify({
3435 jsonrpc: "2.0",
@@ -176,7 +177,9 @@ const messageFromUnknown = (cause: unknown): string => · 141 unmodified lines
176177
177178 const runEndpointProbe = (url: string): Effect.Effect<EndpointProbeOutcome> =>
178179 Effect.gen(function* () {
179− const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
180+ const executor = yield* createExecutor(
181+ makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
182+ );
180183 return yield* executor.mcp.probeEndpoint(url).pipe(
181184 Effect.map(
182185 (r) =>
packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts+2−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
1313
1414 import { mcpPlugin } from "./plugin";
15+import { testAccess } from "@executor-js/product-access/testing";
1516
1617 const FILE_ID = "F012ABC3456";
1718 const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
@@ -128,6 +129,7 @@ describe("Slack Connect file fallback", () => { · 110 unmodified lines
128129 Effect.gen(function* () {
129130 const config = {
130131 ...makeTestConfig({
132+ access: testAccess.member(),
131133 plugins: [
132134 memoryCredentialsPlugin(),
133135 mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }),
packages/plugins/onepassword/package.json+1−0
@@ -60,6 +60,7 @@ · 59 unmodified lines
6060 "devDependencies": {
6161 "@effect/vitest": "catalog:",
6262 "@executor-js/api": "workspace:*",
63+ "@executor-js/product-access": "workspace:*",
6364 "@types/node": "catalog:",
6465 "@types/react": "catalog:",
6566 "bun-types": "catalog:",
packages/plugins/onepassword/src/sdk/plugin.test.ts+8−6
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 import type { OnePasswordService } from "./service";
1616 import { OnePasswordError } from "./errors";
1717 import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types";
18+import { testAccess } from "@executor-js/product-access/testing";
1819
1920 // removed: v1 routed configure/removeConfig through an explicit `ScopeId`
2021 // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and
@@ -63,7 +64,7 @@ describe("onepassword plugin", () => { · 42 unmodified lines
6364 it.effect("registers onepassword as a credential provider", () =>
6465 Effect.gen(function* () {
6566 const executor = yield* createExecutor(
66− makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
67+ makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
6768 );
6869 const providers = yield* executor.providers.list();
6970 expect(providers).toContain(ONEPASSWORD);
@@ -73,7 +74,7 @@ describe("onepassword plugin", () => { · 3 unmodified lines
7374 it.effect("configure upserts accounts by id and removeConfig removes them one by one", () =>
7475 Effect.gen(function* () {
7576 const executor = yield* createExecutor(
76− makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
77+ makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
7778 );
7879
7980 const initial = yield* executor.onepassword.getConfig();
@@ -124,7 +125,7 @@ describe("onepassword plugin", () => { · 44 unmodified lines
124125 it.effect("removeConfig without an id removes everything", () =>
125126 Effect.gen(function* () {
126127 const executor = yield* createExecutor(
127− makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
128+ makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
128129 );
129130 yield* executor.onepassword.configure({
130131 name: "Work",
@@ -144,7 +145,7 @@ describe("onepassword plugin", () => { · 13 unmodified lines
144145 it.effect("getConfig redacts every account's service-account token", () =>
145146 Effect.gen(function* () {
146147 const executor = yield* createExecutor(
147− makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
148+ makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
148149 );
149150
150151 yield* executor.onepassword.configure({
@@ -171,7 +172,7 @@ describe("onepassword plugin", () => { · 20 unmodified lines
171172 it.effect("exposes provider configuration as agent-callable static tools", () =>
172173 Effect.gen(function* () {
173174 const executor = yield* createExecutor(
174− makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
175+ makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
175176 );
176177
177178 const configured = yield* executor.execute(
@@ -225,7 +226,7 @@ describe("onepassword plugin", () => { · 47 unmodified lines
225226 it.effect("status reports not-configured before configure", () =>
226227 Effect.gen(function* () {
227228 const executor = yield* createExecutor(
228− makeTestConfig({ plugins: [onepasswordPlugin()] as const }),
229+ makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
229230 );
230231 const status = yield* executor.onepassword.status();
231232 expect(status.connected).toBe(false);
@@ -248,6 +249,7 @@ describe("onepassword store", () => { · 16 unmodified lines
248249 makeInMemoryBlobStore(),
249250 { org: "org_test", user: null },
250251 "onepassword",
252+ { owners: ["org"], storageWrites: "allowed" },
251253 );
252254 return { blobs, store: makeOnePasswordStore(blobs) };
253255 };
packages/plugins/openapi/package.json+1−0
@@ -86,6 +86,7 @@ · 85 unmodified lines
8686 "@effect/atom-react": "catalog:",
8787 "@effect/vitest": "catalog:",
8888 "@executor-js/api": "workspace:*",
89+ "@executor-js/product-access": "workspace:*",
8990 "@executor-js/react": "workspace:*",
9091 "@types/js-yaml": "4.0.9",
9192 "@types/node": "catalog:",
packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts+4−0
@@ -9,6 +9,7 @@ import type { AuthenticationInput } from "@executor-js/plugin-openapi"; · 8 unmodified lines
99
1010 import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
1111 import { googleCatalog } from "./presets";
12+import { testAccess } from "@executor-js/product-access/testing";
1213
1314 const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
1415 const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
@@ -179,6 +180,7 @@ it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived i · 164 unmodified lines
179180 Effect.gen(function* () {
180181 const executor = yield* createExecutor(
181182 makeTestConfig({
183+ access: testAccess.member(),
182184 plugins: [
183185 openApiPlugin({
184186 httpClientLayer: discoveryHttpClientLayer,
@@ -211,6 +213,7 @@ it.effect( · 26 unmodified lines
211213 );
212214 const executor = yield* createExecutor(
213215 makeTestConfig({
216+ access: testAccess.member(),
214217 plugins: [
215218 openApiPlugin({
216219 httpClientLayer: discoveryHttpClientLayer,
@@ -257,6 +260,7 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 40 unmodified lines
257260 );
258261 const executor = yield* createExecutor(
259262 makeTestConfig({
263+ access: testAccess.member(),
260264 plugins: [
261265 openApiPlugin({
262266 httpClientLayer: gmailDiscoveryHttpClientLayer,
packages/plugins/openapi/src/sdk/configure.test.ts+25−8
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
3535 serveOpenApiHttpApiTestServer,
3636 unwrapInvocation,
3737 } from "../testing";
38+import { testAccess } from "@executor-js/product-access/testing";
3839
3940 const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
4041 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -90,7 +91,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 49 unmodified lines
9091 it.effect("adds a custom apiKey method and getConfig reflects it", () =>
9192 Effect.scoped(
9293 Effect.gen(function* () {
93− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
94+ const executor = yield* createExecutor(
95+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
96+ );
9497
9598 yield* executor.openapi.addSpec({
9699 spec: { kind: "blob", value: specText() },
@@ -119,7 +122,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 22 unmodified lines
119122 it.effect("appends to an existing spec-derived template without dropping entries", () =>
120123 Effect.scoped(
121124 Effect.gen(function* () {
122− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
125+ const executor = yield* createExecutor(
126+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
127+ );
123128
124129 const seedTemplate: AuthenticationInput = {
125130 slug: "seed",
@@ -145,7 +150,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 19 unmodified lines
145150 it.effect("generates a custom_<id> slug for a method submitted without one", () =>
146151 Effect.scoped(
147152 Effect.gen(function* () {
148− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
153+ const executor = yield* createExecutor(
154+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
155+ );
149156
150157 yield* executor.openapi.addSpec({
151158 spec: { kind: "blob", value: specText() },
@@ -167,7 +174,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 15 unmodified lines
167174 it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () =>
168175 Effect.scoped(
169176 Effect.gen(function* () {
170− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
177+ const executor = yield* createExecutor(
178+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
179+ );
171180
172181 yield* executor.openapi.addSpec({
173182 spec: { kind: "blob", value: specText() },
@@ -208,7 +217,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 34 unmodified lines
208217 it.effect("replace mode overwrites the auth template array", () =>
209218 Effect.scoped(
210219 Effect.gen(function* () {
211− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
220+ const executor = yield* createExecutor(
221+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
222+ );
212223
213224 const seedTemplate: AuthenticationInput = {
214225 slug: "seed",
@@ -236,7 +247,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 21 unmodified lines
236247 Effect.scoped(
237248 Effect.gen(function* () {
238249 const server = yield* servePluginTestApi();
239− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
250+ const executor = yield* createExecutor(
251+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
252+ );
240253
241254 yield* executor.openapi.addSpec({
242255 spec: { kind: "blob", value: server.specJson },
@@ -272,7 +285,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 29 unmodified lines
272285 Effect.scoped(
273286 Effect.gen(function* () {
274287 const server = yield* servePluginTestApi();
275− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
288+ const executor = yield* createExecutor(
289+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
290+ );
276291
277292 yield* executor.openapi.addSpec({
278293 spec: { kind: "blob", value: server.specJson },
@@ -320,7 +335,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 41 unmodified lines
320335 it.effect("getConfig returns null for an unknown integration", () =>
321336 Effect.scoped(
322337 Effect.gen(function* () {
323− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
338+ const executor = yield* createExecutor(
339+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
340+ );
324341 expect(yield* executor.openapi.getConfig("nope")).toBeNull();
325342 }),
326343 ),
packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts+22−23
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 } from "@executor-js/plugin-openapi/testing";
3131
3232 import { openApiPlugin } from "./plugin";
33+import { testAccess } from "@executor-js/product-access/testing";
3334
3435 type Captured = {
3536 contentType: string;
@@ -76,30 +77,28 @@ const plugins = [ · 40 unmodified lines
7677 memoryCredentialsPlugin(),
7778 ] as const;
7879
79−layer(
80− makeTestWorkspaceLayer({
81− plugins,
82− }),
83− { timeout: "15 seconds" },
84−)("OpenAPI non-JSON request body serialization", (it) => {
85− it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () =>
86− Effect.gen(function* () {
87− const { server, captured } = yield* startEchoServer();
88− const { config } = yield* TestWorkspace;
89− const executor = yield* createExecutor({ ...config, plugins });
80+layer(makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), { timeout: "15 seconds" })(
81+ "OpenAPI non-JSON request body serialization",
82+ (it) => {
83+ it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () =>
84+ Effect.gen(function* () {
85+ const { server, captured } = yield* startEchoServer();
86+ const { config } = yield* TestWorkspace;
87+ const executor = yield* createExecutor({ ...config, plugins });
9088
91− const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" });
89+ const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" });
9290
93− yield* executor.execute(conn.address("forms.submit"), {
94− body: { name: "Acme", email: "a@b.com" },
95− });
91+ yield* executor.execute(conn.address("forms.submit"), {
92+ body: { name: "Acme", email: "a@b.com" },
93+ });
9694
97− expect(captured.contentType).toBe("application/x-www-form-urlencoded");
98− expect(captured.body).not.toBe("[object Object]");
95+ expect(captured.contentType).toBe("application/x-www-form-urlencoded");
96+ expect(captured.body).not.toBe("[object Object]");
9997
100− const parsed = new URLSearchParams(captured.body);
101− expect(parsed.get("name")).toBe("Acme");
102− expect(parsed.get("email")).toBe("a@b.com");
103− }),
104− );
105−});
98+ const parsed = new URLSearchParams(captured.body);
99+ expect(parsed.get("name")).toBe("Acme");
100+ expect(parsed.get("email")).toBe("a@b.com");
101+ }),
102+ );
103+ },
104+);
packages/plugins/openapi/src/sdk/health-check-redaction.test.ts+2−0
@@ -34,6 +34,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 33 unmodified lines
3434
3535 import { serveOpenApiHttpApiTestServer } from "../testing";
3636 import { openApiPlugin } from "./plugin";
37+import { testAccess } from "@executor-js/product-access/testing";
3738
3839 /** The credential the connection authenticates with. Longer than the sample's
3940 * 120-char value cap on purpose: truncating before scrubbing would leave a
@@ -84,6 +85,7 @@ const withProbe = <A, E, R>( · 44 unmodified lines
8485 });
8586 const executor = yield* createExecutor(
8687 makeTestConfig({
88+ access: testAccess.member(),
8789 plugins: [
8890 openApiPlugin({ httpClientLayer: FetchHttpClient.layer }),
8991 memoryCredentialsPlugin(),
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+4−1
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
3737
3838 import { openApiPlugin } from "./plugin";
3939 import { type AuthenticationInput } from "./types";
40+import { testAccess } from "@executor-js/product-access/testing";
4041
4142 // ---------------------------------------------------------------------------
4243 // Test API — a single endpoint that echoes the Authorization header so the
@@ -86,7 +87,9 @@ describe("OpenAPI owner-isolated bearer", () => { · 43 unmodified lines
8687 Effect.scoped(
8788 Effect.gen(function* () {
8889 const server = yield* serveVercel();
89− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
90+ const executor = yield* createExecutor(
91+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
92+ );
9093
9194 // Admin uploads the spec once; the integration declares a bearer
9295 // template but no token value.
packages/plugins/openapi/src/sdk/non-json-body.test.ts+106−35
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 } from "@executor-js/plugin-openapi/testing";
3333
3434 import { openApiPlugin } from "./plugin";
35+import { testAccess } from "@executor-js/product-access/testing";
3536
3637 const JsonNameBody = Schema.fromJsonString(
3738 Schema.Struct({
@@ -172,7 +173,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 134 unmodified lines
172173 payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),
173174 });
174175
175− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
176+ const executor = yield* createExecutor(
177+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
178+ );
176179
177180 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" });
178181
@@ -222,7 +225,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 43 unmodified lines
222225 ),
223226 });
224227
225− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
228+ const executor = yield* createExecutor(
229+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
230+ );
226231 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" });
227232
228233 const schema = yield* executor.tools.schema(conn.address("body.upload"));
@@ -298,7 +303,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 69 unmodified lines
298303 ),
299304 });
300305
301− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
306+ const executor = yield* createExecutor(
307+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
308+ );
302309 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" });
303310
304311 const schema = yield* executor.tools.schema(conn.address("body.uploadPages"));
@@ -378,7 +385,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 73 unmodified lines
378385 }),
379386 });
380387
381− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
388+ const executor = yield* createExecutor(
389+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
390+ );
382391 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" });
383392
384393 const exit = yield* executor
@@ -424,7 +433,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines
424433 }),
425434 });
426435
427− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
436+ const executor = yield* createExecutor(
437+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
438+ );
428439 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" });
429440
430441 // The rewrite is scoped to multipart bodies: a JSON body keeps its
@@ -465,7 +476,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 34 unmodified lines
465476 }),
466477 });
467478
468− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
479+ const executor = yield* createExecutor(
480+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
481+ );
469482 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" });
470483
471484 yield* executor.execute(conn.address("body.createNote"), {
@@ -511,7 +524,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines
511524 }),
512525 });
513526
514− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
527+ const executor = yield* createExecutor(
528+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
529+ );
515530 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" });
516531
517532 const schema = yield* executor.tools.schema(conn.address("body.uploadMixed"));
@@ -564,7 +579,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 46 unmodified lines
564579 },
565580 });
566581
567− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
582+ const executor = yield* createExecutor(
583+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
584+ );
568585 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" });
569586
570587 // Documented limitation: component schemas are carried through
@@ -584,7 +601,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines
584601 payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })),
585602 });
586603
587− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
604+ const executor = yield* createExecutor(
605+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
606+ );
588607
589608 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "xml" });
590609
@@ -603,7 +622,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
603622 transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")),
604623 });
605624
606− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
625+ const executor = yield* createExecutor(
626+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
627+ );
607628
608629 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tx" });
609630
@@ -622,7 +643,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
622643 payload: Schema.String.pipe(HttpApiSchema.asText()),
623644 });
624645
625− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
646+ const executor = yield* createExecutor(
647+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
648+ );
626649
627650 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tp" });
628651
@@ -639,7 +662,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 10 unmodified lines
639662 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
640663 });
641664
642− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
665+ const executor = yield* createExecutor(
666+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
667+ );
643668
644669 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin" });
645670
@@ -658,7 +683,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
658683 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
659684 });
660685
661− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
686+ const executor = yield* createExecutor(
687+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
688+ );
662689
663690 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" });
664691
@@ -677,7 +704,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
677704 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
678705 });
679706
680− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
707+ const executor = yield* createExecutor(
708+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
709+ );
681710
682711 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" });
683712
@@ -699,7 +728,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 15 unmodified lines
699728 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
700729 });
701730
702− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
731+ const executor = yield* createExecutor(
732+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
733+ );
703734
704735 const conn = yield* addOpenApiTestConnection(executor, server, {
705736 slug: "bin_nested_b64_bad",
@@ -723,7 +754,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines
723754 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
724755 });
725756
726− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
757+ const executor = yield* createExecutor(
758+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
759+ );
727760
728761 const conn = yield* addOpenApiTestConnection(executor, server, {
729762 slug: "bin_b64_missing",
@@ -743,7 +776,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines
743776 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
744777 });
745778
746− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
779+ const executor = yield* createExecutor(
780+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
781+ );
747782
748783 const conn = yield* addOpenApiTestConnection(executor, server, {
749784 slug: "bin_object_body",
@@ -767,7 +802,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines
767802 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
768803 });
769804
770− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
805+ const executor = yield* createExecutor(
806+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
807+ );
771808
772809 const conn = yield* addOpenApiTestConnection(executor, server, {
773810 slug: "bin_string_body",
@@ -837,7 +874,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 63 unmodified lines
837874 }),
838875 });
839876
840− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
877+ const executor = yield* createExecutor(
878+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
879+ );
841880 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" });
842881
843882 const schema = yield* executor.tools.schema(conn.address("gmail.getAttachment"));
@@ -921,7 +960,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 77 unmodified lines
921960 }),
922961 });
923962
924− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
963+ const executor = yield* createExecutor(
964+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
965+ );
925966 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" });
926967
927968 const result = yield* executor.execute(conn.address("gmailText.getAttachment"), {
@@ -989,7 +1030,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 61 unmodified lines
9891030 }),
9901031 });
9911032
992− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1033+ const executor = yield* createExecutor(
1034+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1035+ );
9931036 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" });
9941037
9951038 const result = yield* executor.execute(conn.address("zipAttachment.getAttachment"), {
@@ -1107,7 +1150,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 111 unmodified lines
11071150 ),
11081151 });
11091152
1110− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1153+ const executor = yield* createExecutor(
1154+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1155+ );
11111156 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" });
11121157
11131158 const result = yield* executor.execute(conn.address("gmailMeta.getAttachment"), {
@@ -1156,7 +1201,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 42 unmodified lines
11561201 }),
11571202 });
11581203
1159− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1204+ const executor = yield* createExecutor(
1205+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1206+ );
11601207 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" });
11611208
11621209 const schema = yield* executor.tools.schema(conn.address("files.download"));
@@ -1210,7 +1257,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 47 unmodified lines
12101257 }),
12111258 });
12121259
1213− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1260+ const executor = yield* createExecutor(
1261+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1262+ );
12141263 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" });
12151264
12161265 const result = yield* executor.execute(conn.address("files.download"), {
@@ -1247,7 +1296,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 30 unmodified lines
12471296 payload: multiContentPayload,
12481297 });
12491298
1250− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1299+ const executor = yield* createExecutor(
1300+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1301+ );
12511302
12521303 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc" });
12531304
@@ -1265,7 +1316,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 11 unmodified lines
12651316 payload: multiContentPayload,
12661317 });
12671318
1268− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1319+ const executor = yield* createExecutor(
1320+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1321+ );
12691322
12701323 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc2" });
12711324
@@ -1295,7 +1348,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 23 unmodified lines
12951348 }),
12961349 });
12971350
1298− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1351+ const executor = yield* createExecutor(
1352+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1353+ );
12991354
13001355 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" });
13011356
@@ -1322,7 +1377,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 20 unmodified lines
13221377 }),
13231378 });
13241379
1325− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1380+ const executor = yield* createExecutor(
1381+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1382+ );
13261383
13271384 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" });
13281385
@@ -1352,7 +1409,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 23 unmodified lines
13521409 },
13531410 }),
13541411 });
1355− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1412+ const executor = yield* createExecutor(
1413+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1414+ );
13561415
13571416 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" });
13581417
@@ -1375,7 +1434,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 16 unmodified lines
13751434 const { server } = yield* startEchoServer({
13761435 payload: multiContentPayload,
13771436 });
1378− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1437+ const executor = yield* createExecutor(
1438+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1439+ );
13791440
13801441 const conn = yield* addOpenApiTestConnection(executor, server, {
13811442 slug: "mc3",
@@ -1404,7 +1465,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 22 unmodified lines
14041465 const { server } = yield* startEchoServer({
14051466 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
14061467 });
1407− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1468+ const executor = yield* createExecutor(
1469+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1470+ );
14081471
14091472 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" });
14101473
@@ -1450,7 +1513,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines
14501513 ),
14511514 });
14521515
1453− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1516+ const executor = yield* createExecutor(
1517+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1518+ );
14541519
14551520 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mpe" });
14561521
@@ -1492,7 +1557,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 35 unmodified lines
14921557 ),
14931558 });
14941559
1495− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1560+ const executor = yield* createExecutor(
1561+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1562+ );
14961563
14971564 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fe" });
14981565
@@ -1523,7 +1590,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 24 unmodified lines
15231590 ),
15241591 });
15251592
1526− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1593+ const executor = yield* createExecutor(
1594+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1595+ );
15271596
15281597 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fd" });
15291598
@@ -1550,7 +1619,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 20 unmodified lines
15501619 ),
15511620 });
15521621
1553− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1622+ const executor = yield* createExecutor(
1623+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1624+ );
15541625
15551626 // No encoding → OAS3 defaults: style=form, explode=true.
15561627 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" });
packages/plugins/openapi/src/sdk/plugin.test.ts+104−35
@@ -49,6 +49,7 @@ import { · 48 unmodified lines
4949 serveOpenApiHttpApiTestServer,
5050 unwrapInvocation,
5151 } from "../testing";
52+import { testAccess } from "@executor-js/product-access/testing";
5253
5354 const TOOL_ERROR_TYPESCRIPT =
5455 "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }";
@@ -393,7 +394,9 @@ describe("OpenAPI Plugin", () => { · 338 unmodified lines
393394 Effect.gen(function* () {
394395 const server = yield* servePluginTestApi();
395396
396− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
397+ const executor = yield* createExecutor(
398+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
399+ );
397400
398401 const preview = yield* executor.openapi.previewSpec(server.specJson);
399402
@@ -408,7 +411,10 @@ describe("OpenAPI Plugin", () => { · 8 unmodified lines
408411 Effect.gen(function* () {
409412 const server = yield* serveOAuthDiscoverableOpenApiSpec();
410413 const executor = yield* createExecutor(
411− makeTestConfig({ plugins: testPlugins(server.httpClientLayer) }),
414+ makeTestConfig({
415+ access: testAccess.member(),
416+ plugins: testPlugins(server.httpClientLayer),
417+ }),
412418 );
413419
414420 const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/"));
@@ -456,7 +462,9 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines
456462
457463 it.effect("exposes static openapi executor control tools via execute", () =>
458464 Effect.gen(function* () {
459− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
465+ const executor = yield* createExecutor(
466+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
467+ );
460468
461469 // v2: static control tools are NOT part of `tools.list()` (that's the
462470 // persisted per-connection catalog) and aren't `tools.schema()`-resolvable;
@@ -473,7 +481,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines
473481
474482 it.effect("invokes static previewSpec through executor.execute", () =>
475483 Effect.gen(function* () {
476− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
484+ const executor = yield* createExecutor(
485+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
486+ );
477487
478488 const preview = unwrapInvocation(
479489 yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {
@@ -495,7 +505,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines
495505
496506 it.effect("invokes static addSpec through executor.execute", () =>
497507 Effect.gen(function* () {
498− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
508+ const executor = yield* createExecutor(
509+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
510+ );
499511
500512 const result = unwrapInvocation(
501513 yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), {
@@ -517,7 +529,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines
517529 Effect.scoped(
518530 Effect.gen(function* () {
519531 const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
520− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
532+ const executor = yield* createExecutor(
533+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
534+ );
521535
522536 yield* executor.openapi.addSpec({
523537 spec: { kind: "url", url: specServer.specUrl },
@@ -565,7 +579,10 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines
565579
566580 it.effect("static previewSpec returns actionable tool failures", () =>
567581 Effect.gen(function* () {
568− const config = makeTestConfig({ plugins: [openApiPlugin()] as const });
582+ const config = makeTestConfig({
583+ access: testAccess.member(),
584+ plugins: [openApiPlugin()] as const,
585+ });
569586 const executor = yield* createExecutor(config);
570587
571588 const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {
@@ -587,7 +604,7 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines
587604 it.effect("requires approval before adding an integration through the runtime tool", () =>
588605 Effect.gen(function* () {
589606 const executor = yield* createExecutor(
590− makeTestConfig({ plugins: [openApiPlugin()] as const }),
607+ makeTestConfig({ access: testAccess.member(), plugins: [openApiPlugin()] as const }),
591608 );
592609
593610 const declined = yield* executor
@@ -607,7 +624,9 @@ describe("OpenAPI Plugin", () => { · 13 unmodified lines
607624 Effect.scoped(
608625 Effect.gen(function* () {
609626 const server = yield* servePluginTestApi();
610− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
627+ const executor = yield* createExecutor(
628+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
629+ );
611630
612631 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
613632
@@ -625,7 +644,9 @@ describe("OpenAPI Plugin", () => { · 11 unmodified lines
625644 Effect.scoped(
626645 Effect.gen(function* () {
627646 const server = yield* servePluginTestApi();
628− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
647+ const executor = yield* createExecutor(
648+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
649+ );
629650
630651 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
631652
@@ -642,7 +663,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines
642663 Effect.scoped(
643664 Effect.gen(function* () {
644665 const server = yield* servePluginTestApi();
645− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
666+ const executor = yield* createExecutor(
667+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
668+ );
646669
647670 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
648671 const calls = { count: 0 };
@@ -671,7 +694,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines
671694 Effect.scoped(
672695 Effect.gen(function* () {
673696 const server = yield* servePluginTestApi();
674− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
697+ const executor = yield* createExecutor(
698+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
699+ );
675700
676701 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
677702 const calls = { count: 0 };
@@ -705,7 +730,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines
705730 Effect.scoped(
706731 Effect.gen(function* () {
707732 const server = yield* servePluginTestApi();
708− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
733+ const executor = yield* createExecutor(
734+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
735+ );
709736
710737 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
711738 const calls = { count: 0 };
@@ -738,7 +765,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines
738765 Effect.scoped(
739766 Effect.gen(function* () {
740767 const server = yield* servePluginTestApi();
741− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
768+ const executor = yield* createExecutor(
769+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
770+ );
742771
743772 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
744773 const calls = { count: 0 };
@@ -771,7 +800,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines
771800 Effect.scoped(
772801 Effect.gen(function* () {
773802 const server = yield* servePluginTestApi();
774− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
803+ const executor = yield* createExecutor(
804+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
805+ );
775806
776807 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
777808
@@ -812,7 +843,9 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines
812843 Effect.scoped(
813844 Effect.gen(function* () {
814845 const server = yield* servePluginTestApi();
815− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
846+ const executor = yield* createExecutor(
847+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
848+ );
816849
817850 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
818851
@@ -829,7 +862,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines
829862 Effect.scoped(
830863 Effect.gen(function* () {
831864 const server = yield* servePluginTestApi();
832− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
865+ const executor = yield* createExecutor(
866+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
867+ );
833868
834869 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
835870 const failure = yield* executor
@@ -853,7 +888,9 @@ describe("OpenAPI Plugin", () => { · 17 unmodified lines
853888 Effect.scoped(
854889 Effect.gen(function* () {
855890 const server = yield* servePluginTestApi();
856− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
891+ const executor = yield* createExecutor(
892+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
893+ );
857894 const multiServer = {
858895 ...server,
859896 specJson: makeOpenApiTestSpecJson(TestApi, {
@@ -889,7 +926,9 @@ describe("OpenAPI Plugin", () => { · 29 unmodified lines
889926 const server = yield* serveOpenApiEchoTestServer({
890927 transformSpec: withEchoCookieParameter,
891928 });
892− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
929+ const executor = yield* createExecutor(
930+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
931+ );
893932 const conn = yield* addOpenApiTestConnection(executor, server, {
894933 slug: "cookie_params",
895934 headers: { Cookie: "configured=yes" },
@@ -923,7 +962,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines
923962 const server = yield* serveOpenApiEchoTestServer({
924963 transformSpec: withEchoCookieParameter,
925964 });
926− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
965+ const executor = yield* createExecutor(
966+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
967+ );
927968 const conn = yield* addOpenApiTestConnection(executor, server, {
928969 slug: "cookie_unknown",
929970 });
@@ -951,7 +992,9 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines
951992 Effect.scoped(
952993 Effect.gen(function* () {
953994 const server = yield* servePluginTestApi();
954− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
995+ const executor = yield* createExecutor(
996+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
997+ );
955998
956999 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" });
9571000
@@ -980,7 +1023,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines
9801023 Effect.scoped(
9811024 Effect.gen(function* () {
9821025 const server = yield* servePluginTestApi();
983− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1026+ const executor = yield* createExecutor(
1027+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1028+ );
9841029
9851030 yield* executor.openapi.addSpec({
9861031 spec: { kind: "blob", value: server.specJson },
@@ -1012,7 +1057,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines
10121057 Effect.scoped(
10131058 Effect.gen(function* () {
10141059 const server = yield* servePluginTestApi();
1015− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1060+ const executor = yield* createExecutor(
1061+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1062+ );
10161063
10171064 yield* executor.openapi.addSpec({
10181065 spec: { kind: "blob", value: server.specJson },
@@ -1044,7 +1091,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines
10441091 Effect.scoped(
10451092 Effect.gen(function* () {
10461093 const server = yield* servePluginTestApi();
1047− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1094+ const executor = yield* createExecutor(
1095+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1096+ );
10481097
10491098 // The spec declares bearer auth; the caller passes NO template — the
10501099 // agentic add path (MCP/API) does exactly this. Without server-side
@@ -1097,7 +1146,9 @@ describe("OpenAPI Plugin", () => { · 46 unmodified lines
10971146 Effect.scoped(
10981147 Effect.gen(function* () {
10991148 const server = yield* servePluginTestApi();
1100− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1149+ const executor = yield* createExecutor(
1150+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1151+ );
11011152
11021153 // No baseUrl override: the spec declares `servers`, so the host is
11031154 // resolved per call from the operation's servers rather than baked into
@@ -1127,7 +1178,9 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines
11271178 Effect.scoped(
11281179 Effect.gen(function* () {
11291180 const server = yield* servePluginTestApi();
1130− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1181+ const executor = yield* createExecutor(
1182+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1183+ );
11311184
11321185 // The add page sends [] when the user deletes every detected method.
11331186 // That intent must survive — deriving methods back from the spec here
@@ -1158,7 +1211,9 @@ describe("OpenAPI Plugin", () => { · 24 unmodified lines
11581211
11591212 it.effect("addSpec accepts Graph-sized OpenAPI blobs", () =>
11601213 Effect.gen(function* () {
1161− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1214+ const executor = yield* createExecutor(
1215+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1216+ );
11621217 const largeDescription = "x".repeat(36 * 1024 * 1024);
11631218
11641219 const added = yield* executor.openapi.addSpec({
@@ -1191,7 +1246,9 @@ paths: · 26 unmodified lines
11911246 "addSpec accepts Microsoft Graph-scale operation catalogs from one spec",
11921247 () =>
11931248 Effect.gen(function* () {
1194− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1249+ const executor = yield* createExecutor(
1250+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1251+ );
11951252
11961253 const added = yield* executor.openapi.addSpec({
11971254 spec: { kind: "blob", value: microsoftGraphScaleSpecText() },
@@ -1208,7 +1265,9 @@ paths: · 10 unmodified lines
12081265 Effect.scoped(
12091266 Effect.gen(function* () {
12101267 const server = yield* servePluginTestApi();
1211− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1268+ const executor = yield* createExecutor(
1269+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1270+ );
12121271
12131272 yield* addOpenApiTestConnection(executor, server, { slug: "removable" });
12141273 expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain(
@@ -1232,7 +1291,9 @@ paths: · 17 unmodified lines
12321291 Effect.scoped(
12331292 Effect.gen(function* () {
12341293 const server = yield* servePluginTestApi();
1235− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1294+ const executor = yield* createExecutor(
1295+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1296+ );
12361297
12371298 // First add carries an apiKey auth template + a distinctive description.
12381299 // A silent upsert on re-add would clobber both.
@@ -1294,7 +1355,9 @@ paths: · 55 unmodified lines
12941355 // The mutable spec server is a real 127.0.0.1 listener — reach it over
12951356 // the default fetch-based client, like production would.
12961357 const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
1297− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1358+ const executor = yield* createExecutor(
1359+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1360+ );
12981361
12991362 const added = yield* executor.openapi.addSpec({
13001363 spec: { kind: "url", url: specServer.specUrl },
@@ -1370,7 +1433,7 @@ paths: · 69 unmodified lines
13701433 it.effect("denies member updateSpec before writing an org blob", () =>
13711434 Effect.gen(function* () {
13721435 const blobs = recordingBlobStore();
1373− const config = makeTestConfig({ plugins: testPlugins() });
1436+ const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() });
13741437 const admin = yield* createExecutor({ ...config, blobs: blobs.store });
13751438 yield* admin.openapi.addSpec({
13761439 spec: { kind: "blob", value: testApiSpecText() },
@@ -1380,7 +1443,7 @@ paths: · 3 unmodified lines
13801443 const member = yield* createExecutor({
13811444 ...config,
13821445 blobs: blobs.store,
1383− orgWrites: "denied",
1446+ access: testAccess.member("denied"),
13841447 });
13851448
13861449 const error = yield* member.openapi
@@ -1398,7 +1461,9 @@ paths: · 11 unmodified lines
13981461 Effect.scoped(
13991462 Effect.gen(function* () {
14001463 const server = yield* servePluginTestApi();
1401− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1464+ const executor = yield* createExecutor(
1465+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1466+ );
14021467 yield* addOpenApiTestConnection(executor, server, { slug: "pasted" });
14031468
14041469 // A pasted-blob integration has no source URL — re-fetch must say so.
@@ -1450,6 +1515,7 @@ paths: · 45 unmodified lines
14501515 const plugins = testPlugins();
14511516
14521517 const alice = yield* makeTestWorkspaceHarness({
1518+ access: testAccess.member(),
14531519 plugins,
14541520 tenant,
14551521 subject: "alice",
@@ -1470,6 +1536,7 @@ paths: · 14 unmodified lines
14701536 });
14711537
14721538 const bob = yield* makeTestWorkspaceHarness({
1539+ access: testAccess.member(),
14731540 plugins,
14741541 tenant,
14751542 subject: "bob",
@@ -1513,7 +1580,9 @@ paths: · 37 unmodified lines
15131580
15141581 it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () =>
15151582 Effect.gen(function* () {
1516− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
1583+ const executor = yield* createExecutor(
1584+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1585+ );
15171586 const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip);
15181587 expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true);
15191588 }),
packages/plugins/openapi/src/sdk/real-specs.test.ts+2−0
@@ -28,6 +28,7 @@ import { openApiPlugin } from "./plugin"; · 27 unmodified lines
2828 import { deriveAuthenticationTemplateFromPreview } from "./derive-auth";
2929 import { previewSpec as previewSpecRaw } from "./preview";
3030 import type { ExtractionResult } from "./types";
31+import { testAccess } from "@executor-js/product-access/testing";
3132
3233 const previewSpec = (input: string) =>
3334 previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer));
@@ -91,6 +92,7 @@ const getRegisteredToolSchema = (slug: string, specText: string, toolName: strin · 57 unmodified lines
9192
9293 const executor = yield* createExecutor(
9394 makeTestConfig({
95+ access: testAccess.member(),
9496 plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const,
9597 }),
9698 );
packages/plugins/openapi/src/sdk/response-body-timeout.test.ts+4−1
@@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 15 unmodified lines
1616 import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing";
1717
1818 import { openApiPlugin } from "./plugin";
19+import { testAccess } from "@executor-js/product-access/testing";
1920
2021 const BODY_READ_DEADLINE_MS = 15_000;
2122 const RESPONSE_BODY_TIMEOUT_MS = 100;
@@ -42,7 +43,9 @@ const testPlugins = () => · 20 unmodified lines
4243
4344 const buildExecutor = (baseUrl: string) =>
4445 Effect.gen(function* () {
45− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
46+ const executor = yield* createExecutor(
47+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
48+ );
4649 yield* executor.openapi.addSpec(
4750 makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, {
4851 slug: "body_timeout",
packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts+4−1
@@ -18,6 +18,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 17 unmodified lines
1818 import { invokeWithLayer } from "./invoke";
1919 import { openApiPlugin } from "./plugin";
2020 import type { OperationBinding } from "./types";
21+import { testAccess } from "@executor-js/product-access/testing";
2122
2223 const RESPONSE_HEADERS_TIMEOUT_MS = 100;
2324 const STREAM_TOOL = "logs.getLogs";
@@ -44,7 +45,9 @@ const testPlugins = () => · 20 unmodified lines
4445
4546 const buildExecutor = (baseUrl: string) =>
4647 Effect.gen(function* () {
47− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
48+ const executor = yield* createExecutor(
49+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
50+ );
4851 yield* executor.openapi.addSpec(
4952 makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }),
5053 );
packages/plugins/openapi/src/sdk/spec-blob.test.ts+15−6
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
3838 serveOpenApiHttpApiTestServer,
3939 unwrapInvocation,
4040 } from "../testing";
41+import { testAccess } from "@executor-js/product-access/testing";
4142
4243 const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
4344 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -113,7 +114,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 69 unmodified lines
113114 it.effect("addSpec stores a content pointer, not the inline spec text", () =>
114115 Effect.scoped(
115116 Effect.gen(function* () {
116− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
117+ const executor = yield* createExecutor(
118+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
119+ );
117120 const text = specText();
118121
119122 yield* executor.openapi.addSpec({
@@ -135,7 +138,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 15 unmodified lines
135138 api: TestApi,
136139 handlersLayer: EchoGroupLive,
137140 });
138− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
141+ const executor = yield* createExecutor(
142+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
143+ );
139144
140145 yield* executor.openapi.addSpec({
141146 spec: { kind: "blob", value: server.specJson },
@@ -168,7 +173,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 26 unmodified lines
168173 () =>
169174 Effect.scoped(
170175 Effect.gen(function* () {
171− const config = makeTestConfig({ plugins: testPlugins() });
176+ const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() });
172177 const executor = yield* createExecutor(config);
173178 const text = specTextWithDefinition();
174179 const hash = yield* sha256Hex(text);
@@ -245,7 +250,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 70 unmodified lines
245250 it.effect("stale sync preserves tools and definitions when the spec blob is missing", () =>
246251 Effect.scoped(
247252 Effect.gen(function* () {
248− const config = makeTestConfig({ plugins: testPlugins() });
253+ const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() });
249254 const executor = yield* createExecutor(config);
250255 const text = specTextWithDefinition();
251256 const hash = yield* sha256Hex(text);
@@ -318,7 +323,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 66 unmodified lines
318323 it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () =>
319324 Effect.scoped(
320325 Effect.gen(function* () {
321− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
326+ const executor = yield* createExecutor(
327+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
328+ );
322329 const emptySpec = JSON.stringify({
323330 openapi: "3.1.0",
324331 info: { title: "Empty", version: "1.0.0" },
@@ -409,7 +416,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 84 unmodified lines
409416 it.effect("remove + re-add of the same spec is idempotent over the shared blob", () =>
410417 Effect.scoped(
411418 Effect.gen(function* () {
412− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
419+ const executor = yield* createExecutor(
420+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
421+ );
413422 const text = specText();
414423
415424 yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" });
packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts+10−3
@@ -9,6 +9,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 8 unmodified lines
99 import { openApiPlugin } from "./plugin";
1010 import { applySpecOverrides, type SpecOverrides } from "./spec-overrides";
1111 import { serveMutableOpenApiSpecTestServer } from "../testing";
12+import { testAccess } from "@executor-js/product-access/testing";
1213
1314 const testPlugins = () =>
1415 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;
@@ -60,7 +61,9 @@ describe("OpenAPI spec override lifecycle", () => { · 45 unmodified lines
6061 it.effect("applies scope overrides to preview and persists raw and patched hashes", () =>
6162 Effect.scoped(
6263 Effect.gen(function* () {
63− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
64+ const executor = yield* createExecutor(
65+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
66+ );
6467 const sourceText = encodeJsonText(oauthSpec);
6568
6669 const preview = yield* executor.openapi.previewSpec({
@@ -93,7 +96,9 @@ describe("OpenAPI spec override lifecycle", () => { · 26 unmodified lines
9396 it.effect("changes and clears overrides for an inline spec without repasting the source", () =>
9497 Effect.scoped(
9598 Effect.gen(function* () {
96− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
99+ const executor = yield* createExecutor(
100+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
101+ );
97102 const sourceText = encodeJsonText(oauthSpec);
98103 yield* executor.openapi.addSpec({
99104 spec: { kind: "blob", value: sourceText },
@@ -130,7 +135,9 @@ describe("OpenAPI spec override lifecycle", () => { · 30 unmodified lines
130135 .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String }));
131136 const InitialApi = HttpApi.make("initial").add(Items);
132137 const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi });
133− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
138+ const executor = yield* createExecutor(
139+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
140+ );
134141 const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }];
135142
136143 yield* executor.openapi.addSpec({
packages/plugins/openapi/src/sdk/streaming-response.test.ts+7−2
@@ -17,6 +17,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 16 unmodified lines
1717
1818 import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke";
1919 import { openApiPlugin } from "./plugin";
20+import { testAccess } from "@executor-js/product-access/testing";
2021
2122 const testPlugins = () =>
2223 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;
@@ -35,7 +36,9 @@ const StreamingApi = HttpApi.make("streamingResponseTest") · 12 unmodified lines
3536
3637 const buildExecutor = (baseUrl: string) =>
3738 Effect.gen(function* () {
38− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
39+ const executor = yield* createExecutor(
40+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
41+ );
3942 yield* executor.openapi.addSpec(
4043 makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }),
4144 );
@@ -293,7 +296,9 @@ describe("OpenAPI streaming responses", () => { · 251 unmodified lines
293296 );
294297 });
295298
296− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
299+ const executor = yield* createExecutor(
300+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
301+ );
297302 yield* executor.openapi.addSpec({
298303 spec: {
299304 kind: "blob",
packages/plugins/openapi/src/sdk/tool-row-projection.test.ts+4−1
@@ -25,6 +25,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 24 unmodified lines
2525
2626 import { openApiPlugin } from "./plugin";
2727 import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing";
28+import { testAccess } from "@executor-js/product-access/testing";
2829
2930 const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
3031 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -58,7 +59,9 @@ const setup = Effect.gen(function* () { · 27 unmodified lines
5859 api: TestApi,
5960 handlersLayer: EchoGroupLive,
6061 });
61− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
62+ const executor = yield* createExecutor(
63+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
64+ );
6265 yield* executor.openapi.addSpec({
6366 spec: { kind: "blob", value: server.specJson },
6467 slug: "proj_api",
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+8−3
@@ -48,6 +48,7 @@ import { · 47 unmodified lines
4848 } from "../testing";
4949
5050 import { openApiPlugin } from "./plugin";
51+import { testAccess } from "@executor-js/product-access/testing";
5152
5253 class AdapterDefect extends Data.TaggedError("AdapterDefect") {}
5354
@@ -149,7 +150,7 @@ const FailureApi = HttpApi.make("failuresTest") · 95 unmodified lines
149150 const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) =>
150151 Effect.gen(function* () {
151152 const executor = yield* createExecutor(
152− makeTestConfig({ plugins: testPlugins(httpClientLayer) }),
153+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins(httpClientLayer) }),
153154 );
154155 yield* executor.openapi.addSpec(
155156 makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }),
@@ -167,7 +168,9 @@ const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) · 11 unmodified lines
167168
168169 const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) =>
169170 Effect.gen(function* () {
170− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
171+ const executor = yield* createExecutor(
172+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
173+ );
171174 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" });
172175 return { executor, address: conn.address(LIST_THINGS) };
173176 });
@@ -349,7 +352,9 @@ describe("OpenAPI upstream failure modes", () => { · 175 unmodified lines
349352 headers: { "content-type": "application/json" },
350353 body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}',
351354 }));
352− const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
355+ const executor = yield* createExecutor(
356+ makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
357+ );
353358 // Declare the operation's scope in the spec blob before registering it,
354359 // so the extracted binding carries requiredScopes.
355360 const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
packages/plugins/toolkits/package.json+1−0
@@ -56,6 +56,7 @@ · 55 unmodified lines
5656 "@effect/atom-react": "catalog:",
5757 "@effect/vitest": "catalog:",
5858 "@executor-js/api": "workspace:*",
59+ "@executor-js/product-access": "workspace:*",
5960 "@executor-js/react": "workspace:*",
6061 "@tanstack/react-router": "catalog:",
6162 "@types/node": "catalog:",
packages/plugins/toolkits/src/server.test.ts+6−0
@@ -3,11 +3,13 @@ import { Effect, Predicate, Result } from "effect"; · 2 unmodified lines
33 import { makeTestExecutor } from "@executor-js/sdk/testing";
44
55 import { toolkitsPlugin } from "./server";
6+import { testAccess } from "@executor-js/product-access/testing";
67
78 describe("toolkitsPlugin", () => {
89 it.effect("creates toolkits and manages ordered policy rules", () =>
910 Effect.gen(function* () {
1011 const executor = yield* makeTestExecutor({
12+ access: testAccess.member(),
1113 plugins: [toolkitsPlugin()] as const,
1214 });
1315
@@ -51,6 +53,7 @@ describe("toolkitsPlugin", () => { · 37 unmodified lines
5153 it.effect("rejects duplicate visible slugs", () =>
5254 Effect.gen(function* () {
5355 const executor = yield* makeTestExecutor({
56+ access: testAccess.member(),
5457 plugins: [toolkitsPlugin()] as const,
5558 });
5659 yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" });
@@ -67,6 +70,7 @@ describe("toolkitsPlugin", () => { · 10 unmodified lines
6770 it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () =>
6871 Effect.gen(function* () {
6972 const executor = yield* makeTestExecutor({
73+ access: testAccess.member(),
7074 plugins: [toolkitsPlugin()] as const,
7175 });
7276
@@ -135,6 +139,7 @@ describe("toolkitsPlugin", () => { · 62 unmodified lines
135139 it.effect("treats a persisted connection-root approve as an access policy", () =>
136140 Effect.gen(function* () {
137141 const executor = yield* makeTestExecutor({
142+ access: testAccess.member(),
138143 plugins: [toolkitsPlugin()] as const,
139144 });
140145
@@ -169,6 +174,7 @@ describe("toolkitsPlugin", () => { · 28 unmodified lines
169174 it.effect("applies a broad approve policy over a narrower connection", () =>
170175 Effect.gen(function* () {
171176 const executor = yield* makeTestExecutor({
177+ access: testAccess.member(),
172178 plugins: [toolkitsPlugin()] as const,
173179 });
174180
packages/react/package.json+1−0
@@ -36,6 +36,7 @@ · 35 unmodified lines
3636 "@base-ui/react": "^1.3.0",
3737 "@effect/atom-react": "catalog:",
3838 "@executor-js/api": "workspace:*",
39+ "@executor-js/product-access": "workspace:*",
3940 "@executor-js/sdk": "workspace:*",
4041 "@hookform/resolvers": "^5.2.2",
4142 "@lobehub/icons": "^5.4.0",
packages/react/src/pages/integration-detail.tsx+1−1
@@ -10,10 +10,10 @@ import { · 9 unmodified lines
1010 AuthTemplateSlug,
1111 IntegrationSlug,
1212 ToolAddress,
13− effectivePolicyFromSorted,
1413 type Connection,
1514 type Owner,
1615 } from "@executor-js/sdk/shared";
16+import { effectivePolicyFromSorted } from "@executor-js/product-access/policy";
1717 import {
1818 checkConnectionHealth,
1919 connectionsAllAtom,
packages/react/src/pages/tools.tsx+2−1
@@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; · 1 unmodified line
22 import { Link } from "@tanstack/react-router";
33 import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared";
5+import { ToolAddress } from "@executor-js/sdk/shared";
6+import { effectivePolicyFromSorted } from "@executor-js/product-access/policy";
67
78 import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms";
89 import { usePolicyActions } from "../hooks/use-policy-actions";
scripts/publish-packages.ts+1−0
@@ -31,6 +31,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 30 unmodified lines
3131 "packages/kernel/core",
3232 "packages/kernel/runtime-quickjs",
3333 "packages/core/sdk",
34+ "packages/core/product-access",
3435 "packages/core/config",
3536 "packages/core/execution",
3637 "packages/core/cli",
scripts/smoke-docs-install.ts+40−5
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 * name (`@executor-js/sdk`) instead of relying on workspace resolution.
88 */
99 import { $ } from "bun";
10+import { Schema } from "effect";
1011 import { existsSync, readdirSync } from "node:fs";
1112 import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
1213 import { tmpdir } from "node:os";
@@ -14,10 +15,15 @@ import { dirname, join, resolve } from "node:path"; · 1 unmodified line
1415 import { fileURLToPath } from "node:url";
1516
1617 const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17−const documentedPackages = ["@executor-js/sdk", "@executor-js/plugin-openapi"] as const;
18+const documentedPackages = [
19+ "@executor-js/sdk",
20+ "@executor-js/product-access",
21+ "@executor-js/plugin-openapi",
22+] as const;
1823 const publicPackageDirs = [
1924 "packages/core/fumadb",
2025 "packages/core/sdk",
26+ "packages/core/product-access",
2127 "packages/core/config",
2228 "packages/plugins/openapi",
2329 ] as const;
@@ -27,6 +33,21 @@ const readPackageName = async (pkgDir: string): Promise<string> => { · 3 unmodified lines
2733 return (JSON.parse(raw) as { name: string }).name;
2834 };
2935
36+// `effect` is the SDK's (and product-access's) required peer, and the docs
37+// install command tells consumers to add it themselves. `npm install
38+// --legacy-peer-deps` below skips auto-installing peers, so the fixture must
39+// declare it like a real consumer would — pinned from the workspace catalog
40+// so it matches what the packed manifests resolved `catalog:` to.
41+const decodeCatalog = Schema.decodeUnknownSync(
42+ Schema.Struct({ catalog: Schema.Struct({ effect: Schema.NonEmptyString }) }),
43+);
44+
45+const readCatalogEffectVersion = async (): Promise<string> => {
46+ const raw = await readFile(join(repoRoot, "package.json"), "utf8");
47+ const manifest = decodeCatalog(JSON.parse(raw));
48+ return manifest.catalog.effect;
49+};
50+
3051 const findTarball = (pkgDir: string, packageName: string): string => {
3152 const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz"));
3253 if (!tarball) {
@@ -48,7 +69,7 @@ for (const relDir of publicPackageDirs) { · 15 unmodified lines
4869 const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-"));
4970
5071 try {
51− const dependencies: Record<string, string> = {};
72+ const dependencies: Record<string, string> = { effect: await readCatalogEffectVersion() };
5273 const overrides: Record<string, string> = {};
5374 for (const [name, tarball] of tarballs) {
5475 overrides[name] = `file:${tarball}`;
@@ -89,9 +110,23 @@ try { · 34 unmodified lines
89110 }
90111
91112 console.log(`[docs-smoke] import documented SDK packages`);
92− await $`node --input-type=module --eval ${`const sdk = await import("@executor-js/sdk"); const openapi = await import("@executor-js/plugin-openapi"); if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor"); if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`}`.cwd(
93− tmp,
94− );
113+ // The documented minimal embed: the SDK root export is the Promise façade
114+ // and ships no access posture of its own, so the probe constructs a
115+ // subject-less workspace-service executor with the posture from
116+ // `@executor-js/product-access` (ephemeral in-memory backend, nothing
117+ // invoked over the network) and closes it.
118+ const importProbe = [
119+ `const sdk = await import("@executor-js/sdk");`,
120+ `const access = await import("@executor-js/product-access");`,
121+ `const openapi = await import("@executor-js/plugin-openapi");`,
122+ `if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor");`,
123+ `if (typeof access.workspaceServiceAccess !== "function") throw new Error("missing workspaceServiceAccess");`,
124+ `if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`,
125+ `const executor = await sdk.createExecutor({ access: access.workspaceServiceAccess(), onElicitation: "accept-all" });`,
126+ `if (typeof executor.close !== "function") throw new Error("missing executor.close");`,
127+ `await executor.close();`,
128+ ].join("\n");
129+ await $`node --input-type=module --eval ${importProbe}`.cwd(tmp);
95130 } finally {
96131 await rm(tmp, { recursive: true, force: true });
97132 }
scripts/smoke-test-packed.ts+1−0
@@ -37,6 +37,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 36 unmodified lines
3737 "packages/kernel/core",
3838 "packages/kernel/runtime-quickjs",
3939 "packages/core/sdk",
40+ "packages/core/product-access",
4041 "packages/core/config",
4142 "packages/core/execution",
4243 "packages/core/cli",