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
5 "fixed": [5 "fixed": [
6 [6 [
7 "@executor-js/sdk",7 "@executor-js/sdk",
8 "@executor-js/product-access",
8 "@executor-js/codemode-core",9 "@executor-js/codemode-core",
9 "@executor-js/runtime-quickjs",10 "@executor-js/runtime-quickjs",
10 "@executor-js/execution",11 "@executor-js/execution",
.changeset/product-access-split.md+21−0
@@ -0,0 +1,21 @@
1---
2"executor": minor
3---
4
5Extract 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
9write partitions (`owners`), user-intent settings authorization
10(`settingsWrite`), view capabilities (`adminReads` / `storageWrites`), and
11effective 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.
16Core keeps enforcing tenant isolation, the storage owner policy clamps, and
17approval mechanics; it no longer decides any product rule. Test helpers
18changed too: `makeTestConfig` and the workspace harness require `access`
19(postures in `@executor-js/product-access/testing`). No stored schema or data
20migration — persisted rows, tenants, owners, subjects and addresses are
21unchanged.
.github/workflows/pkg-pr-new.yml+1−0
@@ -149,6 +149,7 @@ jobs: · 148 unmodified lines
149 './apps/cli/dist/executor'149 './apps/cli/dist/executor'
150 './packages/core/storage-core'150 './packages/core/storage-core'
151 './packages/core/sdk'151 './packages/core/sdk'
152 './packages/core/product-access'
152 './packages/core/config'153 './packages/core/config'
153 './packages/core/execution'154 './packages/core/execution'
154 './packages/core/cli'155 './packages/core/cli'
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
139also available):139also available):
140 140
141```ts141```ts
142import { createExecutor } from "@executor-js/sdk/promise";142import { workspaceServiceAccess } from "@executor-js/product-access";
143import { openApiPlugin } from "@executor-js/plugin-openapi/promise";143import { createExecutor } from "@executor-js/sdk";
144 144import { openApiPlugin } from "@executor-js/plugin-openapi";
145const executor = await createExecutor({ plugins: [openApiPlugin()] });145
146const executor = await createExecutor({
147 plugins: [openApiPlugin()],
148 onElicitation: "accept-all",
149 // Product access decisions are REQUIRED and stated explicitly — the SDK
150 // ships no default posture. `@executor-js/product-access` has the standard
151 // ones (this is the subject-less single-workspace posture).
152 access: workspaceServiceAccess(),
153});
146 154
147// add an integration, create a connection, then list and call tools155// add an integration, create a connection, then list and call tools
148const tools = await executor.tools.list({ integration: "inventory" });156const tools = await executor.tools.list({ integration: "inventory" });
apps/cloud/package.json+1−0
@@ -55,6 +55,7 @@ · 54 unmodified lines
55 "@executor-js/plugin-openapi": "workspace:*",55 "@executor-js/plugin-openapi": "workspace:*",
56 "@executor-js/plugin-toolkits": "workspace:*",56 "@executor-js/plugin-toolkits": "workspace:*",
57 "@executor-js/plugin-workos-vault": "workspace:*",57 "@executor-js/plugin-workos-vault": "workspace:*",
58 "@executor-js/product-access": "workspace:*",
58 "@executor-js/react": "workspace:*",59 "@executor-js/react": "workspace:*",
59 "@executor-js/runtime-dynamic-worker": "workspace:*",60 "@executor-js/runtime-dynamic-worker": "workspace:*",
60 "@executor-js/runtime-quickjs": "workspace:*",61 "@executor-js/runtime-quickjs": "workspace:*",
apps/cloud/src/admin/admin-users-api.ts+1−1
@@ -17,7 +17,7 @@ · 16 unmodified lines
17// acting member, and this plane deliberately serves the whole tenant.17// acting member, and this plane deliberately serves the whole tenant.
18//18//
19// The executor is built by `makePlatformExecutor` — `{ tenant, subject:19// The executor is built by `makePlatformExecutor` — `{ tenant, subject:
20// undefined, platformView: true }` — so the reads are tenant-wide and read-only20// undefined, access: platformObserverAccess() }` — so the reads are tenant-wide and read-only
21// by storage policy, and no `subject` row is minted for the caller.21// by storage policy, and no `subject` row is minted for the caller.
22//22//
23// Cross-tenant isolation is structural, not a check in this file: the tenant is23// 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
174 * `subject: null` + the read-only tenant reach rather than inventing a subject.174 * `subject: null` + the read-only tenant reach rather than inventing a subject.
175 *175 *
176 * The `/admin/*` mount turns this into an executor with `{ tenant:176 * The `/admin/*` mount turns this into an executor with `{ tenant:
177 * organizationId, subject: undefined, platformView: true }`.177 * organizationId, subject: undefined, access: platformObserverAccess() }`.
178 */178 */
179export interface PlatformAuth {179export interface PlatformAuth {
180 readonly kind: "platform";180 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
39import executorConfig from "../executor.config";39import executorConfig from "../executor.config";
40import { DbService } from "./db/db";40import { DbService } from "./db/db";
41import { createDrizzleFumaDb } from "./db/fuma";41import { createDrizzleFumaDb } from "./db/fuma";
42import { testAccess } from "@executor-js/product-access/testing";
42 43
43// ---------------------------------------------------------------------------44// ---------------------------------------------------------------------------
44// Test-only plugin: exposes one in-memory tool that elicits once. Lets the45// Test-only plugin: exposes one in-memory tool that elicits once. Lets the
@@ -124,6 +125,7 @@ const buildScopedExecutor = ( · 79 unmodified lines
124 plugins,125 plugins,
125 httpClientLayer: FetchHttpClient.layer,126 httpClientLayer: FetchHttpClient.layer,
126 onElicitation: "accept-all",127 onElicitation: "accept-all",
128 access: testAccess.member(),
127 });129 });
128 });130 });
129 131
apps/cloud/src/mcp/session-durable-object.ts+2−1
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
26} from "@executor-js/host-mcp/tool-server";26} from "@executor-js/host-mcp/tool-server";
27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
29import { requestBoundMemberAccess } from "@executor-js/product-access";
29import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";30import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
30import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";31import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
31import {32import {
@@ -349,7 +350,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 317 unmodified lines
349 sessionMeta.organizationName,350 sessionMeta.organizationName,
350 {351 {
351 mcpResource: sessionMeta.resource,352 mcpResource: sessionMeta.resource,
352 orgWrites: "request",353 access: requestBoundMemberAccess(),
353 },354 },
354 ).pipe(355 ).pipe(
355 // The metered stack tracks each execution to Autumn. It requires356 // 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
32import { makeTestConfig } from "@executor-js/sdk/testing";32import { makeTestConfig } from "@executor-js/sdk/testing";
33 33
34import { UrlRedactingSpanProcessor } from "./redact-span-urls";34import { UrlRedactingSpanProcessor } from "./redact-span-urls";
35import { testAccess } from "@executor-js/product-access/testing";
35 36
36// Synthetic placeholders — never a real authorization code or CSRF state.37// Synthetic placeholders — never a real authorization code or CSRF state.
37const CODE = "synthetic-authorization-code-9f2c";38const CODE = "synthetic-authorization-code-9f2c";
@@ -55,7 +56,7 @@ describe("oauth callback telemetry", () => { · 17 unmodified lines
55 it.effect("exports no span attribute containing the authorization code or state", () =>56 it.effect("exports no span attribute containing the authorization code or state", () =>
56 Effect.gen(function* () {57 Effect.gen(function* () {
57 const { exporter, provider, tracerLayer } = makeTracing();58 const { exporter, provider, tracerLayer } = makeTracing();
58 const executor = yield* createExecutor(makeTestConfig({}));59 const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() }));
59 60
60 const web = yield* Effect.acquireRelease(61 const web = yield* Effect.acquireRelease(
61 Effect.sync(() =>62 Effect.sync(() =>
apps/host-cloudflare/package.json+1−0
@@ -30,6 +30,7 @@ · 29 unmodified lines
30 "@executor-js/plugin-openapi": "workspace:*",30 "@executor-js/plugin-openapi": "workspace:*",
31 "@executor-js/plugin-provider-service-split": "workspace:*",31 "@executor-js/plugin-provider-service-split": "workspace:*",
32 "@executor-js/plugin-toolkits": "workspace:*",32 "@executor-js/plugin-toolkits": "workspace:*",
33 "@executor-js/product-access": "workspace:*",
33 "@executor-js/react": "workspace:*",34 "@executor-js/react": "workspace:*",
34 "@executor-js/runtime-dynamic-worker": "workspace:*",35 "@executor-js/runtime-dynamic-worker": "workspace:*",
35 "@executor-js/runtime-quickjs": "workspace:*",36 "@executor-js/runtime-quickjs": "workspace:*",
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
9import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";9import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
10import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";10import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
11import type { ExecutorDbHandle } from "@executor-js/api/server";11import type { ExecutorDbHandle } from "@executor-js/api/server";
12import { requestBoundMemberAccess } from "@executor-js/product-access";
12import {13import {
13 McpAgentSessionDOBase,14 McpAgentSessionDOBase,
14 type BuiltMcpServer,15 type BuiltMcpServer,
@@ -151,7 +152,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 136 unmodified lines
151 sessionMeta.userId,152 sessionMeta.userId,
152 sessionMeta.organizationId,153 sessionMeta.organizationId,
153 sessionMeta.organizationName,154 sessionMeta.organizationName,
154 { mcpResource: sessionMeta.resource, orgWrites: "request" },155 { mcpResource: sessionMeta.resource, access: requestBoundMemberAccess() },
155 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));156 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
156 // Browser elicitation mode (the base owns the approval store + the HTTP157 // Browser elicitation mode (the base owns the approval store + the HTTP
157 // approval RPCs): a gated execution pauses and returns an approvalUrl into158 // 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
35 "@executor-js/plugin-openapi": "workspace:*",35 "@executor-js/plugin-openapi": "workspace:*",
36 "@executor-js/plugin-provider-service-split": "workspace:*",36 "@executor-js/plugin-provider-service-split": "workspace:*",
37 "@executor-js/plugin-toolkits": "workspace:*",37 "@executor-js/plugin-toolkits": "workspace:*",
38 "@executor-js/product-access": "workspace:*",
38 "@executor-js/react": "workspace:*",39 "@executor-js/react": "workspace:*",
39 "@executor-js/runtime-quickjs": "workspace:*",40 "@executor-js/runtime-quickjs": "workspace:*",
40 "@executor-js/sdk": "workspace:*",41 "@executor-js/sdk": "workspace:*",
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
12import { mintInviteCode } from "./testing/mint-invite";12import { mintInviteCode } from "./testing/mint-invite";
13import { SelfHostScopedExecutorSeams } from "./execution";13import { SelfHostScopedExecutorSeams } from "./execution";
14import type { SelfHostPlugins } from "./plugins";14import type { SelfHostPlugins } from "./plugins";
15import { memberAccess } from "@executor-js/product-access";
15 16
16// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,17// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,
17// fresh per-request plugins, host config) over the shared `makeScopedExecutor`,18// fresh per-request plugins, host config) over the shared `makeScopedExecutor`,
@@ -22,9 +23,9 @@ const createScopedExecutor = ( · 4 unmodified lines
22 organizationId: string,23 organizationId: string,
23 organizationName: string,24 organizationName: string,
24) =>25) =>
25 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(26 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
26 Effect.provide(SelfHostScopedExecutorSeams),27 access: memberAccess("allowed"),
27 );28 }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
28 29
29// End-to-end: an org-owned connection's tools are reachable from a user's MCP30// End-to-end: an org-owned connection's tools are reachable from a user's MCP
30// `execute` sandbox.31// `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
11import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";11import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
12import { SelfHostScopedExecutorSeams } from "./execution";12import { SelfHostScopedExecutorSeams } from "./execution";
13import type { SelfHostPlugins } from "./plugins";13import type { SelfHostPlugins } from "./plugins";
14import { memberAccess } from "@executor-js/product-access";
14 15
15// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,16// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,
16// fresh per-request plugins, host config) over the shared `makeScopedExecutor`,17// fresh per-request plugins, host config) over the shared `makeScopedExecutor`,
@@ -21,9 +22,9 @@ const createScopedExecutor = ( · 4 unmodified lines
21 organizationId: string,22 organizationId: string,
22 organizationName: string,23 organizationName: string,
23) =>24) =>
24 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(25 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
25 Effect.provide(SelfHostScopedExecutorSeams),26 access: memberAccess("allowed"),
26 );27 }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
27 28
28const dataDir = mkdtempSync(join(tmpdir(), "eh-src-"));29const dataDir = mkdtempSync(join(tmpdir(), "eh-src-"));
29process.env.EXECUTOR_DATA_DIR = dataDir;30process.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
12import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";12import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
13import { SelfHostScopedExecutorSeams } from "./execution";13import { SelfHostScopedExecutorSeams } from "./execution";
14import type { SelfHostPlugins } from "./plugins";14import type { SelfHostPlugins } from "./plugins";
15import { memberAccess } from "@executor-js/product-access";
15 16
16// In v2 a connection IS the credential: its inline `value` is written through the17// In v2 a connection IS the credential: its inline `value` is written through the
17// default writable provider — here the encrypted-secrets provider, which stores18// default writable provider — here the encrypted-secrets provider, which stores
@@ -28,9 +29,9 @@ const createScopedExecutor = ( · 10 unmodified lines
28 organizationId: string,29 organizationId: string,
29 organizationName: string,30 organizationName: string,
30) =>31) =>
31 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(32 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
32 Effect.provide(SelfHostScopedExecutorSeams),33 access: memberAccess("allowed"),
33 );34 }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
34 35
35let dbLayer!: Layer.Layer<SelfHostDb>;36let dbLayer!: Layer.Layer<SelfHostDb>;
36let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;37let 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
11import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";11import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
12import { SelfHostScopedExecutorSeams } from "./execution";12import { SelfHostScopedExecutorSeams } from "./execution";
13import type { SelfHostPlugins } from "./plugins";13import type { SelfHostPlugins } from "./plugins";
14import { memberAccess } from "@executor-js/product-access";
14 15
15// The `subject` table is populated at the request seam: `makeScopedExecutor` is16// The `subject` table is populated at the request seam: `makeScopedExecutor` is
16// what every HTTP request and MCP session on every host passes through, so17// what every HTTP request and MCP session on every host passes through, so
@@ -19,9 +20,9 @@ import type { SelfHostPlugins } from "./plugins"; · 2 unmodified lines
19// the point is that the production wiring reaches the writer at all.20// the point is that the production wiring reaches the writer at all.
20 21
21const createScopedExecutor = (accountId: string, organizationId: string) =>22const createScopedExecutor = (accountId: string, organizationId: string) =>
22 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe(23 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default", {
23 Effect.provide(SelfHostScopedExecutorSeams),24 access: memberAccess("allowed"),
24 );25 }).pipe(Effect.provide(SelfHostScopedExecutorSeams));
25 26
26const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-"));27const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-"));
27process.env.EXECUTOR_DATA_DIR = dataDir;28process.env.EXECUTOR_DATA_DIR = dataDir;
apps/local/package.json+1−0
@@ -41,6 +41,7 @@ · 40 unmodified lines
41 "@executor-js/plugin-openapi": "workspace:*",41 "@executor-js/plugin-openapi": "workspace:*",
42 "@executor-js/plugin-provider-service-split": "workspace:*",42 "@executor-js/plugin-provider-service-split": "workspace:*",
43 "@executor-js/plugin-toolkits": "workspace:*",43 "@executor-js/plugin-toolkits": "workspace:*",
44 "@executor-js/product-access": "workspace:*",
44 "@executor-js/react": "workspace:*",45 "@executor-js/react": "workspace:*",
45 "@executor-js/runtime-quickjs": "workspace:*",46 "@executor-js/runtime-quickjs": "workspace:*",
46 "@executor-js/sdk": "workspace:*",47 "@executor-js/sdk": "workspace:*",
apps/local/src/auth-tool-failures.test.ts+2−0
@@ -61,6 +61,7 @@ import { · 60 unmodified lines
61 createExecutor,61 createExecutor,
62} from "@executor-js/sdk";62} from "@executor-js/sdk";
63import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";63import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";
64import { testAccess } from "@executor-js/product-access/testing";
64 65
65import { ErrorCaptureLive } from "./observability";66import { ErrorCaptureLive } from "./observability";
66import { createSqliteFumaDb } from "./db/sqlite-fumadb";67import { createSqliteFumaDb } from "./db/sqlite-fumadb";
@@ -109,6 +110,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 42 unmodified lines
109 db: sqlite.db,110 db: sqlite.db,
110 plugins,111 plugins,
111 onElicitation: "accept-all",112 onElicitation: "accept-all",
113 access: testAccess.member(),
112 }),114 }),
113 );115 );
114 116
apps/local/src/db/legacy-bigint-boot.test.ts+12−4
@@ -17,13 +17,21 @@ import { Effect } from "effect"; · 16 unmodified lines
17import { withQueryContext } from "@executor-js/fumadb/query";17import { withQueryContext } from "@executor-js/fumadb/query";
18 18
19import { collectTables } from "@executor-js/api/server";19import { collectTables } from "@executor-js/api/server";
20import { runSqliteDataMigrations } from "@executor-js/sdk";20import { runSqliteDataMigrations, type ExecutorOwnerPolicyContext } from "@executor-js/sdk";
21 21
22import { localDataMigrations } from "./data-migrations";22import { localDataMigrations } from "./data-migrations";
23import { createSqliteFumaDb } from "./sqlite-fumadb";23import { createSqliteFumaDb } from "./sqlite-fumadb";
24 24
25const TENANT = "executor-workspace-1771";25const TENANT = "executor-workspace-1771";
26const SUBJECT = "local";26const 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.
30const FIXTURE_CONTEXT: ExecutorOwnerPolicyContext = {
31 tenant: TENANT,
32 subject: SUBJECT,
33 owners: ["user", "org"],
34};
27// Epoch millis, the shape an OAuth token expiry takes.35// Epoch millis, the shape an OAuth token expiry takes.
28const LEGACY_EXPIRES_AT = 1787321623456;36const LEGACY_EXPIRES_AT = 1787321623456;
29 37
@@ -71,7 +79,7 @@ describe("local boot over a legacy bigint database", () => { · 41 unmodified lines
71 await seedLegacyConnection(dbPath);79 await seedLegacyConnection(dbPath);
72 80
73 const sqlite = await openDb(dbPath);81 const sqlite = await openDb(dbPath);
74 const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });82 const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT);
75 // The reported symptom: not a wrong value, a throw — so the gateway lost83 // The reported symptom: not a wrong value, a throw — so the gateway lost
76 // every saved integration at once.84 // every saved integration at once.
77 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);85 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
@@ -88,7 +96,7 @@ describe("local boot over a legacy bigint database", () => { · 10 unmodified lines
88 );96 );
89 expect(applied).toContain("2026-08-28-bigint-storage-class");97 expect(applied).toContain("2026-08-28-bigint-storage-class");
90 98
91 const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });99 const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT);
92 const rows = await scoped.findMany("connection", {});100 const rows = await scoped.findMany("connection", {});
93 expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([101 expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([
94 ["default", LEGACY_EXPIRES_AT],102 ["default", LEGACY_EXPIRES_AT],
@@ -112,7 +120,7 @@ describe("local boot over a legacy bigint database", () => { · 17 unmodified lines
112 );120 );
113 expect(applied).not.toContain("2026-08-28-bigint-storage-class");121 expect(applied).not.toContain("2026-08-28-bigint-storage-class");
114 122
115 const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT });123 const scoped = withQueryContext(second.db, FIXTURE_CONTEXT);
116 const rows = await scoped.findMany("connection", {});124 const rows = await scoped.findMany("connection", {});
117 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);125 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
118 await second.close();126 await second.close();
apps/local/src/db/v1-v2-boot-drive.test.ts+2−0
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
36 runSqliteDataMigrations,36 runSqliteDataMigrations,
37} from "@executor-js/sdk";37} from "@executor-js/sdk";
38import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";38import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";
39import { testAccess } from "@executor-js/product-access/testing";
39import { openApiPlugin } from "@executor-js/plugin-openapi";40import { openApiPlugin } from "@executor-js/plugin-openapi";
40import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing";41import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing";
41import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";42import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
@@ -123,6 +124,7 @@ const bootRealStack = async (dbPath: string) => { · 81 unmodified lines
123 memoryCredentialsPlugin(),124 memoryCredentialsPlugin(),
124 ] as const,125 ] as const,
125 onElicitation: "accept-all",126 onElicitation: "accept-all",
127 access: testAccess.member(),
126 }),128 }),
127 );129 );
128 return {130 return {
apps/local/src/executor.ts+4−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 type Executor,13 type Executor,
14} from "@executor-js/sdk";14} from "@executor-js/sdk";
15import { collectTables } from "@executor-js/api/server";15import { collectTables } from "@executor-js/api/server";
16import { singleUserAccess } from "@executor-js/product-access";
16import { loadPluginsFromJsonc } from "@executor-js/config";17import { loadPluginsFromJsonc } from "@executor-js/config";
17import type { McpPluginExtension } from "@executor-js/plugin-mcp";18import type { McpPluginExtension } from "@executor-js/plugin-mcp";
18 19
@@ -220,6 +221,9 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 201 unmodified lines
220 { plugin_key: event.pluginKey },221 { plugin_key: event.pluginKey },
221 ),222 ),
222 onElicitation: "accept-all",223 onElicitation: "accept-all",
224 // Single-user product: one human owns the deployment and may
225 // configure everything (see @executor-js/product-access).
226 access: singleUserAccess(),
223 oauthEndpointUrlPolicy: { allowHttp: true },227 oauthEndpointUrlPolicy: { allowHttp: true },
224 // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback`228 // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback`
225 // route on the same origin as the web UI. Derived from `webBaseUrl`229 // route on the same origin as the web UI. Derived from `webBaseUrl`
apps/local/src/mcp-browser-resume.test.ts+2−0
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
34 34
35import { createMcpRequestHandler } from "./mcp";35import { createMcpRequestHandler } from "./mcp";
36import { createSqliteFumaDb } from "./db/sqlite-fumadb";36import { createSqliteFumaDb } from "./db/sqlite-fumadb";
37import { testAccess } from "@executor-js/product-access/testing";
37 38
38const TEST_BASE_URL = "http://local.test";39const TEST_BASE_URL = "http://local.test";
39 40
@@ -90,6 +91,7 @@ const makeExecutor = async (tmpDir: string): Promise<Executor> => { · 50 unmodified lines
90 db: sqlite.db,91 db: sqlite.db,
91 plugins,92 plugins,
92 onElicitation: "accept-all",93 onElicitation: "accept-all",
94 access: testAccess.member(),
93 }),95 }),
94 );96 );
95 97
apps/local/src/mcp-oauth.test.ts+2−0
@@ -49,6 +49,7 @@ import { · 48 unmodified lines
49 createExecutor,49 createExecutor,
50} from "@executor-js/sdk";50} from "@executor-js/sdk";
51import { serveOAuthTestServer } from "@executor-js/sdk/testing";51import { serveOAuthTestServer } from "@executor-js/sdk/testing";
52import { testAccess } from "@executor-js/product-access/testing";
52import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";53import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
53import { mcpPlugin } from "@executor-js/plugin-mcp";54import { mcpPlugin } from "@executor-js/plugin-mcp";
54import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";55import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
@@ -98,6 +99,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 43 unmodified lines
98 db: sqlite.db,99 db: sqlite.db,
99 plugins,100 plugins,
100 onElicitation: "accept-all",101 onElicitation: "accept-all",
102 access: testAccess.member(),
101 oauthEndpointUrlPolicy: { allowHttp: true },103 oauthEndpointUrlPolicy: { allowHttp: true },
102 // EXPLICIT OAuth callback — required now that the localhost default is104 // EXPLICIT OAuth callback — required now that the localhost default is
103 // gone; the local daemon serves `/api/oauth/callback` on the web origin.105 // gone; the local daemon serves `/api/oauth/callback` on the web origin.
bun.lock+43−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "@effect/tsgo": "^0.5.2",11 "@effect/tsgo": "^0.5.2",
12 "@effect/vitest": "catalog:",12 "@effect/vitest": "catalog:",
13 "@executor-js/plugin-provider-service-split": "workspace:*",13 "@executor-js/plugin-provider-service-split": "workspace:*",
14 "@executor-js/product-access": "workspace:*",
14 "@typescript/native-preview": "^7.0.0-dev.20260410.1",15 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
15 "@vitest/expect": "catalog:",16 "@vitest/expect": "catalog:",
16 "@vitest/mocker": "catalog:",17 "@vitest/mocker": "catalog:",
@@ -76,6 +77,7 @@ · 59 unmodified lines
76 "@executor-js/plugin-openapi": "workspace:*",77 "@executor-js/plugin-openapi": "workspace:*",
77 "@executor-js/plugin-toolkits": "workspace:*",78 "@executor-js/plugin-toolkits": "workspace:*",
78 "@executor-js/plugin-workos-vault": "workspace:*",79 "@executor-js/plugin-workos-vault": "workspace:*",
80 "@executor-js/product-access": "workspace:*",
79 "@executor-js/react": "workspace:*",81 "@executor-js/react": "workspace:*",
80 "@executor-js/runtime-dynamic-worker": "workspace:*",82 "@executor-js/runtime-dynamic-worker": "workspace:*",
81 "@executor-js/runtime-quickjs": "workspace:*",83 "@executor-js/runtime-quickjs": "workspace:*",
@@ -186,6 +188,7 @@ · 104 unmodified lines
186 "@executor-js/plugin-openapi": "workspace:*",188 "@executor-js/plugin-openapi": "workspace:*",
187 "@executor-js/plugin-provider-service-split": "workspace:*",189 "@executor-js/plugin-provider-service-split": "workspace:*",
188 "@executor-js/plugin-toolkits": "workspace:*",190 "@executor-js/plugin-toolkits": "workspace:*",
191 "@executor-js/product-access": "workspace:*",
189 "@executor-js/react": "workspace:*",192 "@executor-js/react": "workspace:*",
190 "@executor-js/runtime-dynamic-worker": "workspace:*",193 "@executor-js/runtime-dynamic-worker": "workspace:*",
191 "@executor-js/runtime-quickjs": "workspace:*",194 "@executor-js/runtime-quickjs": "workspace:*",
@@ -239,6 +242,7 @@ · 47 unmodified lines
239 "@executor-js/plugin-openapi": "workspace:*",242 "@executor-js/plugin-openapi": "workspace:*",
240 "@executor-js/plugin-provider-service-split": "workspace:*",243 "@executor-js/plugin-provider-service-split": "workspace:*",
241 "@executor-js/plugin-toolkits": "workspace:*",244 "@executor-js/plugin-toolkits": "workspace:*",
245 "@executor-js/product-access": "workspace:*",
242 "@executor-js/react": "workspace:*",246 "@executor-js/react": "workspace:*",
243 "@executor-js/runtime-quickjs": "workspace:*",247 "@executor-js/runtime-quickjs": "workspace:*",
244 "@executor-js/sdk": "workspace:*",248 "@executor-js/sdk": "workspace:*",
@@ -296,6 +300,7 @@ · 51 unmodified lines
296 "@executor-js/plugin-openapi": "workspace:*",300 "@executor-js/plugin-openapi": "workspace:*",
297 "@executor-js/plugin-provider-service-split": "workspace:*",301 "@executor-js/plugin-provider-service-split": "workspace:*",
298 "@executor-js/plugin-toolkits": "workspace:*",302 "@executor-js/plugin-toolkits": "workspace:*",
303 "@executor-js/product-access": "workspace:*",
299 "@executor-js/react": "workspace:*",304 "@executor-js/react": "workspace:*",
300 "@executor-js/runtime-quickjs": "workspace:*",305 "@executor-js/runtime-quickjs": "workspace:*",
301 "@executor-js/sdk": "workspace:*",306 "@executor-js/sdk": "workspace:*",
@@ -397,6 +402,7 @@ · 95 unmodified lines
397 "@executor-js/plugin-onepassword": "workspace:*",402 "@executor-js/plugin-onepassword": "workspace:*",
398 "@executor-js/plugin-openapi": "workspace:*",403 "@executor-js/plugin-openapi": "workspace:*",
399 "@executor-js/plugin-workos-vault": "workspace:*",404 "@executor-js/plugin-workos-vault": "workspace:*",
405 "@executor-js/product-access": "workspace:*",
400 "@executor-js/sdk": "workspace:*",406 "@executor-js/sdk": "workspace:*",
401 "effect": "catalog:",407 "effect": "catalog:",
402 },408 },
@@ -410,6 +416,7 @@ · 7 unmodified lines
410 "version": "0.0.54",416 "version": "0.0.54",
411 "dependencies": {417 "dependencies": {
412 "@executor-js/plugin-openapi": "workspace:*",418 "@executor-js/plugin-openapi": "workspace:*",
419 "@executor-js/product-access": "workspace:*",
413 "@executor-js/sdk": "workspace:*",420 "@executor-js/sdk": "workspace:*",
414 "effect": "catalog:",421 "effect": "catalog:",
415 },422 },
@@ -425,6 +432,7 @@ · 9 unmodified lines
425 "@executor-js/plugin-graphql": "workspace:*",432 "@executor-js/plugin-graphql": "workspace:*",
426 "@executor-js/plugin-mcp": "workspace:*",433 "@executor-js/plugin-mcp": "workspace:*",
427 "@executor-js/plugin-openapi": "workspace:*",434 "@executor-js/plugin-openapi": "workspace:*",
435 "@executor-js/product-access": "workspace:*",
428 "@executor-js/sdk": "workspace:*",436 "@executor-js/sdk": "workspace:*",
429 },437 },
430 "devDependencies": {438 "devDependencies": {
@@ -484,6 +492,7 @@ · 53 unmodified lines
484 "dependencies": {492 "dependencies": {
485 "@executor-js/execution": "workspace:*",493 "@executor-js/execution": "workspace:*",
486 "@executor-js/host-mcp": "workspace:*",494 "@executor-js/host-mcp": "workspace:*",
495 "@executor-js/product-access": "workspace:*",
487 "@executor-js/sdk": "workspace:*",496 "@executor-js/sdk": "workspace:*",
488 "effect": "catalog:",497 "effect": "catalog:",
489 },498 },
@@ -546,6 +555,7 @@ · 56 unmodified lines
546 },555 },
547 "devDependencies": {556 "devDependencies": {
548 "@effect/vitest": "catalog:",557 "@effect/vitest": "catalog:",
558 "@executor-js/product-access": "workspace:*",
549 "@executor-js/runtime-quickjs": "workspace:*",559 "@executor-js/runtime-quickjs": "workspace:*",
550 "@types/node": "catalog:",560 "@types/node": "catalog:",
551 "bun-types": "catalog:",561 "bun-types": "catalog:",
@@ -603,6 +613,25 @@ · 51 unmodified lines
603 "vitest": "catalog:",613 "vitest": "catalog:",
604 },614 },
605 },615 },
616 "packages/core/product-access": {
617 "name": "@executor-js/product-access",
618 "version": "1.6.8",
619 "dependencies": {
620 "@executor-js/sdk": "workspace:*",
621 },
622 "devDependencies": {
623 "@effect/vitest": "catalog:",
624 "@types/node": "catalog:",
625 "bun-types": "catalog:",
626 "effect": "catalog:",
627 "tsup": "catalog:",
628 "typescript": "catalog:",
629 "vitest": "catalog:",
630 },
631 "peerDependencies": {
632 "effect": "catalog:",
633 },
634 },
606 "packages/core/sdk": {635 "packages/core/sdk": {
607 "name": "@executor-js/sdk",636 "name": "@executor-js/sdk",
608 "version": "1.6.8",637 "version": "1.6.8",
@@ -704,6 +733,7 @@ · 95 unmodified lines
704 "dependencies": {733 "dependencies": {
705 "@cfworker/json-schema": "^4.1.1",734 "@cfworker/json-schema": "^4.1.1",
706 "@executor-js/execution": "workspace:*",735 "@executor-js/execution": "workspace:*",
736 "@executor-js/product-access": "workspace:*",
707 "@executor-js/sdk": "workspace:*",737 "@executor-js/sdk": "workspace:*",
708 "@modelcontextprotocol/ext-apps": "^1.7.4",738 "@modelcontextprotocol/ext-apps": "^1.7.4",
709 "@modelcontextprotocol/sdk": "^1.29.0",739 "@modelcontextprotocol/sdk": "^1.29.0",
@@ -734,6 +764,7 @@ · 24 unmodified lines
734 "@effect/vitest": "catalog:",764 "@effect/vitest": "catalog:",
735 "@executor-js/execution": "workspace:*",765 "@executor-js/execution": "workspace:*",
736 "@executor-js/host-mcp": "workspace:*",766 "@executor-js/host-mcp": "workspace:*",
767 "@executor-js/product-access": "workspace:*",
737 "@executor-js/sdk": "workspace:*",768 "@executor-js/sdk": "workspace:*",
738 "@tailwindcss/browser": "^4.2.2",769 "@tailwindcss/browser": "^4.2.2",
739 "@tailwindcss/vite": "catalog:",770 "@tailwindcss/vite": "catalog:",
@@ -820,6 +851,7 @@ · 80 unmodified lines
820 "@executor-js/execution": "workspace:*",851 "@executor-js/execution": "workspace:*",
821 "@executor-js/fumadb": "workspace:*",852 "@executor-js/fumadb": "workspace:*",
822 "@executor-js/plugin-openapi": "workspace:*",853 "@executor-js/plugin-openapi": "workspace:*",
854 "@executor-js/product-access": "workspace:*",
823 "@executor-js/sdk": "workspace:*",855 "@executor-js/sdk": "workspace:*",
824 "@types/node": "catalog:",856 "@types/node": "catalog:",
825 "drizzle-orm": "catalog:",857 "drizzle-orm": "catalog:",
@@ -895,6 +927,7 @@ · 69 unmodified lines
895 "react": "catalog:",927 "react": "catalog:",
896 },928 },
897 "devDependencies": {929 "devDependencies": {
930 "@executor-js/product-access": "workspace:*",
898 "@types/react": "catalog:",931 "@types/react": "catalog:",
899 "tsup": "catalog:",932 "tsup": "catalog:",
900 "typescript": "catalog:",933 "typescript": "catalog:",
@@ -945,6 +978,7 @@ · 44 unmodified lines
945 "@executor-js/sdk": "workspace:*",978 "@executor-js/sdk": "workspace:*",
946 },979 },
947 "devDependencies": {980 "devDependencies": {
981 "@executor-js/product-access": "workspace:*",
948 "@types/node": "catalog:",982 "@types/node": "catalog:",
949 "bun-types": "catalog:",983 "bun-types": "catalog:",
950 "effect": "catalog:",984 "effect": "catalog:",
@@ -969,6 +1003,7 @@ · 18 unmodified lines
969 "@effect/atom-react": "catalog:",1003 "@effect/atom-react": "catalog:",
970 "@effect/vitest": "catalog:",1004 "@effect/vitest": "catalog:",
971 "@executor-js/api": "workspace:*",1005 "@executor-js/api": "workspace:*",
1006 "@executor-js/product-access": "workspace:*",
972 "@executor-js/react": "workspace:*",1007 "@executor-js/react": "workspace:*",
973 "@types/node": "catalog:",1008 "@types/node": "catalog:",
974 "@types/react": "catalog:",1009 "@types/react": "catalog:",
@@ -1003,6 +1038,7 @@ · 28 unmodified lines
1003 },1038 },
1004 "devDependencies": {1039 "devDependencies": {
1005 "@effect/vitest": "catalog:",1040 "@effect/vitest": "catalog:",
1041 "@executor-js/product-access": "workspace:*",
1006 "@types/node": "catalog:",1042 "@types/node": "catalog:",
1007 "bun-types": "catalog:",1043 "bun-types": "catalog:",
1008 "effect": "catalog:",1044 "effect": "catalog:",
@@ -1031,6 +1067,7 @@ · 22 unmodified lines
1031 "@effect/atom-react": "catalog:",1067 "@effect/atom-react": "catalog:",
1032 "@effect/vitest": "catalog:",1068 "@effect/vitest": "catalog:",
1033 "@executor-js/api": "workspace:*",1069 "@executor-js/api": "workspace:*",
1070 "@executor-js/product-access": "workspace:*",
1034 "@executor-js/react": "workspace:*",1071 "@executor-js/react": "workspace:*",
1035 "@modelcontextprotocol/server": "2.0.0",1072 "@modelcontextprotocol/server": "2.0.0",
1036 "@types/node": "catalog:",1073 "@types/node": "catalog:",
@@ -1068,6 +1105,7 @@ · 31 unmodified lines
1068 "devDependencies": {1105 "devDependencies": {
1069 "@effect/vitest": "catalog:",1106 "@effect/vitest": "catalog:",
1070 "@executor-js/api": "workspace:*",1107 "@executor-js/api": "workspace:*",
1108 "@executor-js/product-access": "workspace:*",
1071 "@types/node": "catalog:",1109 "@types/node": "catalog:",
1072 "@types/react": "catalog:",1110 "@types/react": "catalog:",
1073 "bun-types": "catalog:",1111 "bun-types": "catalog:",
@@ -1105,6 +1143,7 @@ · 31 unmodified lines
1105 "@effect/atom-react": "catalog:",1143 "@effect/atom-react": "catalog:",
1106 "@effect/vitest": "catalog:",1144 "@effect/vitest": "catalog:",
1107 "@executor-js/api": "workspace:*",1145 "@executor-js/api": "workspace:*",
1146 "@executor-js/product-access": "workspace:*",
1108 "@executor-js/react": "workspace:*",1147 "@executor-js/react": "workspace:*",
1109 "@types/js-yaml": "4.0.9",1148 "@types/js-yaml": "4.0.9",
1110 "@types/node": "catalog:",1149 "@types/node": "catalog:",
@@ -1158,6 +1197,7 @@ · 47 unmodified lines
1158 "@effect/atom-react": "catalog:",1197 "@effect/atom-react": "catalog:",
1159 "@effect/vitest": "catalog:",1198 "@effect/vitest": "catalog:",
1160 "@executor-js/api": "workspace:*",1199 "@executor-js/api": "workspace:*",
1200 "@executor-js/product-access": "workspace:*",
1161 "@executor-js/react": "workspace:*",1201 "@executor-js/react": "workspace:*",
1162 "@tanstack/react-router": "catalog:",1202 "@tanstack/react-router": "catalog:",
1163 "@types/node": "catalog:",1203 "@types/node": "catalog:",
@@ -1224,6 +1264,7 @@ · 60 unmodified lines
1224 "@base-ui/react": "^1.3.0",1264 "@base-ui/react": "^1.3.0",
1225 "@effect/atom-react": "catalog:",1265 "@effect/atom-react": "catalog:",
1226 "@executor-js/api": "workspace:*",1266 "@executor-js/api": "workspace:*",
1267 "@executor-js/product-access": "workspace:*",
1227 "@executor-js/sdk": "workspace:*",1268 "@executor-js/sdk": "workspace:*",
1228 "@hookform/resolvers": "^5.2.2",1269 "@hookform/resolvers": "^5.2.2",
1229 "@lobehub/icons": "^5.4.0",1270 "@lobehub/icons": "^5.4.0",
@@ -1869,6 +1910,8 @@ · 639 unmodified lines
1869 1910
1870 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],1911 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
1871 1912
1913 "@executor-js/product-access": ["@executor-js/product-access@workspace:packages/core/product-access"],
1914
1872 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],1915 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
1873 1916
1874 "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],1917 "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],
examples/all-plugins/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
16 "@executor-js/plugin-onepassword": "workspace:*",16 "@executor-js/plugin-onepassword": "workspace:*",
17 "@executor-js/plugin-openapi": "workspace:*",17 "@executor-js/plugin-openapi": "workspace:*",
18 "@executor-js/plugin-workos-vault": "workspace:*",18 "@executor-js/plugin-workos-vault": "workspace:*",
19 "@executor-js/product-access": "workspace:*",
19 "@executor-js/sdk": "workspace:*",20 "@executor-js/sdk": "workspace:*",
20 "effect": "catalog:"21 "effect": "catalog:"
21 },22 },
examples/all-plugins/src/main.ts+3−0
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
31 ToolAddress,31 ToolAddress,
32 type CredentialProvider,32 type CredentialProvider,
33} from "@executor-js/sdk";33} from "@executor-js/sdk";
34import { workspaceServiceAccess } from "@executor-js/product-access";
34 35
35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";36import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
36import {37import {
@@ -192,6 +193,8 @@ const program = Effect.gen(function* () { · 155 unmodified lines
192 193
193 const executor = yield* createExecutor({194 const executor = yield* createExecutor({
194 tenant: Tenant.make("example-tenant"),195 tenant: Tenant.make("example-tenant"),
196 // Subject-less single-workspace example: org partition, writes allowed.
197 access: workspaceServiceAccess(),
195 plugins,198 plugins,
196 providers: [memoryProvider],199 providers: [memoryProvider],
197 onElicitation: "accept-all" as const,200 onElicitation: "accept-all" as const,
examples/docs-sdk-quickstart/package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 },10 },
11 "dependencies": {11 "dependencies": {
12 "@executor-js/plugin-openapi": "workspace:*",12 "@executor-js/plugin-openapi": "workspace:*",
13 "@executor-js/product-access": "workspace:*",
13 "@executor-js/sdk": "workspace:*",14 "@executor-js/sdk": "workspace:*",
14 "effect": "catalog:"15 "effect": "catalog:"
15 },16 },
examples/docs-sdk-quickstart/src/main.ts+4−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 type CredentialProvider,8 type CredentialProvider,
9} from "@executor-js/sdk/promise";9} from "@executor-js/sdk/promise";
10import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise";10import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise";
11import { workspaceServiceAccess } from "@executor-js/product-access";
11 12
12const inventoryApi = {13const inventoryApi = {
13 openapi: "3.0.0",14 openapi: "3.0.0",
@@ -92,6 +93,9 @@ const memoryProvider: CredentialProvider = { · 78 unmodified lines
92};93};
93 94
94const executor = await createExecutor({95const executor = await createExecutor({
96 // Subject-less single-workspace embedder: the product posture is stated
97 // explicitly — the SDK ships no default.
98 access: workspaceServiceAccess(),
95 plugins: [openApiPlugin()],99 plugins: [openApiPlugin()],
96 providers: [memoryProvider],100 providers: [memoryProvider],
97 onElicitation: "accept-all",101 onElicitation: "accept-all",
examples/promise-sdk/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
12 "@executor-js/plugin-graphql": "workspace:*",12 "@executor-js/plugin-graphql": "workspace:*",
13 "@executor-js/plugin-mcp": "workspace:*",13 "@executor-js/plugin-mcp": "workspace:*",
14 "@executor-js/plugin-openapi": "workspace:*",14 "@executor-js/plugin-openapi": "workspace:*",
15 "@executor-js/product-access": "workspace:*",
15 "@executor-js/sdk": "workspace:*"16 "@executor-js/sdk": "workspace:*"
16 },17 },
17 "devDependencies": {18 "devDependencies": {
examples/promise-sdk/src/main.ts+4−0
@@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines
20import { mcpPlugin } from "@executor-js/plugin-mcp/promise";20import { mcpPlugin } from "@executor-js/plugin-mcp/promise";
21import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise";21import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise";
22import { graphqlPlugin } from "@executor-js/plugin-graphql/promise";22import { graphqlPlugin } from "@executor-js/plugin-graphql/promise";
23import { workspaceServiceAccess } from "@executor-js/product-access";
23 24
24// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
25// 1. Create the executor with all plugins26// 1. Create the executor with all plugins
@@ -44,6 +45,9 @@ const memoryProvider: CredentialProvider = { · 18 unmodified lines
44};45};
45 46
46const executor = await createExecutor({47const executor = await createExecutor({
48 // Subject-less single-workspace embedder: the product posture is stated
49 // explicitly — the SDK ships no default.
50 access: workspaceServiceAccess(),
47 plugins,51 plugins,
48 providers: [memoryProvider],52 providers: [memoryProvider],
49 onElicitation: "accept-all",53 onElicitation: "accept-all",
package.json+2−1
@@ -39,7 +39,7 @@ · 38 unmodified lines
39 "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}",39 "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}",
40 "test:e2e": "bun run --cwd e2e test",40 "test:e2e": "bun run --cwd e2e test",
41 "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",41 "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",
42 "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",42 "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/product-access' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
43 "typecheck": "turbo run typecheck",43 "typecheck": "turbo run typecheck",
44 "typecheck:slow": "turbo run typecheck:slow",44 "typecheck:slow": "turbo run typecheck:slow",
45 "ci": "bun run lint && bun run typecheck && bun run test",45 "ci": "bun run lint && bun run typecheck && bun run test",
@@ -78,6 +78,7 @@ · 32 unmodified lines
78 "@effect/tsgo": "^0.5.2",78 "@effect/tsgo": "^0.5.2",
79 "@effect/vitest": "catalog:",79 "@effect/vitest": "catalog:",
80 "@executor-js/plugin-provider-service-split": "workspace:*",80 "@executor-js/plugin-provider-service-split": "workspace:*",
81 "@executor-js/product-access": "workspace:*",
81 "@typescript/native-preview": "^7.0.0-dev.20260410.1",82 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
82 "@vitest/expect": "catalog:",83 "@vitest/expect": "catalog:",
83 "@vitest/mocker": "catalog:",84 "@vitest/mocker": "catalog:",
packages/core/api/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
16 "dependencies": {16 "dependencies": {
17 "@executor-js/execution": "workspace:*",17 "@executor-js/execution": "workspace:*",
18 "@executor-js/host-mcp": "workspace:*",18 "@executor-js/host-mcp": "workspace:*",
19 "@executor-js/product-access": "workspace:*",
19 "@executor-js/sdk": "workspace:*",20 "@executor-js/sdk": "workspace:*",
20 "effect": "catalog:"21 "effect": "catalog:"
21 },22 },
packages/core/api/src/admin/admin-users.test.ts+4−2
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 type ExecutorAdmin,13 type ExecutorAdmin,
14} from "@executor-js/sdk";14} from "@executor-js/sdk";
15import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";15import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";
16import { testAccess } from "@executor-js/product-access/testing";
16import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal";17import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal";
17 18
18import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api";19import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api";
@@ -204,7 +205,7 @@ const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effec · 185 unmodified lines
204 tenant: Tenant.make(tenant),205 tenant: Tenant.make(tenant),
205 db: db.db,206 db: db.db,
206 onElicitation: "accept-all",207 onElicitation: "accept-all",
207 platformView: true,208 access: testAccess.platform({ subject: false }),
208 }).pipe(Effect.orDie);209 }).pipe(Effect.orDie);
209 210
210/** A product-view executor: bound to one subject, no platform view at all. */211/** A product-view executor: bound to one subject, no platform view at all. */
@@ -214,6 +215,7 @@ const productExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect · 3 unmodified lines
214 subject: Subject.make(USER_A1),215 subject: Subject.make(USER_A1),
215 db: db.db,216 db: db.db,
216 onElicitation: "accept-all",217 onElicitation: "accept-all",
218 access: testAccess.member(),
217 }).pipe(Effect.orDie);219 }).pipe(Effect.orDie);
218 220
219/**221/**
@@ -639,7 +641,7 @@ describe("admin users API", () => { · 419 unmodified lines
639 withDb((db) =>641 withDb((db) =>
640 Effect.gen(function* () {642 Effect.gen(function* () {
641 yield* seed(db);643 yield* seed(db);
642 // A host that forgot `platformView: true` must not silently report an644 // A host that forgot the platform access posture must not silently report an
643 // empty tenant — that would read as "this owner has no users".645 // empty tenant — that would read as "this owner has no users".
644 const web = yield* webHandlerFor(646 const web = yield* webHandlerFor(
645 stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize),647 stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize),
packages/core/api/src/admin/api.ts+1−1
@@ -4,7 +4,7 @@ · 3 unmodified lines
4// The product plane (`/api/*`) answers "what can I, this member, reach". This4// The product plane (`/api/*`) answers "what can I, this member, reach". This
5// one answers the OWNER's question: "who are my users, and what have they5// one answers the OWNER's question: "who are my users, and what have they
6// connected". It reads the SDK's platform view (`executor.admin`, opt-in via6// connected". It reads the SDK's platform view (`executor.admin`, opt-in via
7// `platformView: true`), which is read-only by construction — the owner policy7// `platformObserverAccess()`), which is read-only by construction — the owner policy
8// rejects writes at `reach: "tenant"` — so every endpoint here is a GET.8// rejects writes at `reach: "tenant"` — so every endpoint here is a GET.
9//9//
10// VOCABULARY: this is the translation seam. Internal code says subject / tenant10// 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
35 35
36/**36/**
37 * Narrow an executor to its platform view. `admin` is present only when the37 * 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 reads38 * executor was built with the platform access posture; a host that reaches these reads
39 * with a product-view executor is a wiring bug, so it fails loudly as a 50039 * with a product-view executor is a wiring bug, so it fails loudly as a 500
40 * rather than silently returning an empty tenant (which would read as "this40 * rather than silently returning an empty tenant (which would read as "this
41 * owner has no users").41 * 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
16import { ExecutorApi } from "../api";16import { ExecutorApi } from "../api";
17import { observabilityMiddleware } from "../observability";17import { observabilityMiddleware } from "../observability";
18import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server";18import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server";
19import { testAccess } from "@executor-js/product-access/testing";
19 20
20// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
21// The catalog response surfaces each plugin's DECLARED auth methods (projected22// The catalog response surfaces each plugin's DECLARED auth methods (projected
@@ -109,7 +110,9 @@ interface IntegrationResponseBody { · 87 unmodified lines
109describe("catalog surfaces declared auth methods", () => {110describe("catalog surfaces declared auth methods", () => {
110 it.effect("an OAuth integration with zero connections advertises an oauth method", () =>111 it.effect("an OAuth integration with zero connections advertises an oauth method", () =>
111 Effect.gen(function* () {112 Effect.gen(function* () {
112 const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));113 const executor = yield* createExecutor(
114 makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
115 );
113 const slug = IntegrationSlug.make("oauth-server");116 const slug = IntegrationSlug.make("oauth-server");
114 yield* executor.declaring.seed(slug, [OAUTH_METHOD]);117 yield* executor.declaring.seed(slug, [OAUTH_METHOD]);
115 118
@@ -133,7 +136,9 @@ describe("catalog surfaces declared auth methods", () => { · 17 unmodified lines
133 136
134 it.effect("an apikey integration advertises an apikey method", () =>137 it.effect("an apikey integration advertises an apikey method", () =>
135 Effect.gen(function* () {138 Effect.gen(function* () {
136 const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));139 const executor = yield* createExecutor(
140 makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
141 );
137 const slug = IntegrationSlug.make("apikey-server");142 const slug = IntegrationSlug.make("apikey-server");
138 yield* executor.declaring.seed(slug, [APIKEY_METHOD]);143 yield* executor.declaring.seed(slug, [APIKEY_METHOD]);
139 144
@@ -156,7 +161,9 @@ describe("catalog surfaces declared auth methods", () => { · 16 unmodified lines
156 161
157 it.effect("list surfaces authMethods and a plugin with no projector yields []", () =>162 it.effect("list surfaces authMethods and a plugin with no projector yields []", () =>
158 Effect.gen(function* () {163 Effect.gen(function* () {
159 const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));164 const executor = yield* createExecutor(
165 makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
166 );
160 yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]);167 yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]);
161 yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []);168 yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []);
162 169
@@ -180,7 +187,9 @@ describe("catalog surfaces declared auth methods", () => { · 17 unmodified lines
180 187
181 it.effect("surfaces plugin-derived display URLs without exposing config", () =>188 it.effect("surfaces plugin-derived display URLs without exposing config", () =>
182 Effect.gen(function* () {189 Effect.gen(function* () {
183 const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] }));190 const executor = yield* createExecutor(
191 makeTestConfig({ access: testAccess.member(), plugins: [declaringPlugin] }),
192 );
184 const slug = IntegrationSlug.make("autumn");193 const slug = IntegrationSlug.make("autumn");
185 yield* executor.declaring.seed(slug, [], "https://api.useautumn.com");194 yield* executor.declaring.seed(slug, [], "https://api.useautumn.com");
186 195
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
17import { ExecutorApi } from "./api";17import { ExecutorApi } from "./api";
18import { observabilityMiddleware } from "./observability";18import { observabilityMiddleware } from "./observability";
19import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server";19import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server";
20import { testAccess } from "@executor-js/product-access/testing";
20 21
21// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
22// v2 owner-scoped API behaviour.23// v2 owner-scoped API behaviour.
@@ -88,7 +89,7 @@ const vercelPlugin = definePlugin(() => ({ · 65 unmodified lines
88describe("core API owner-scoped writes (v2)", () => {89describe("core API owner-scoped writes (v2)", () => {
89 it.effect("policy create + update target an explicit owner", () =>90 it.effect("policy create + update target an explicit owner", () =>
90 Effect.gen(function* () {91 Effect.gen(function* () {
91 const executor = yield* createExecutor(makeTestConfig({}));92 const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() }));
92 const web = yield* webHandlerFor(executor);93 const web = yield* webHandlerFor(executor);
93 const context = handlerContextFor(executor);94 const context = handlerContextFor(executor);
94 95
@@ -135,6 +136,7 @@ describe("core API owner-scoped writes (v2)", () => { · 40 unmodified lines
135 it.effect("connection remove deletes the named owner row, not the other owner", () =>136 it.effect("connection remove deletes the named owner row, not the other owner", () =>
136 Effect.gen(function* () {137 Effect.gen(function* () {
137 const config = makeTestConfig({138 const config = makeTestConfig({
139 access: testAccess.member(),
138 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,140 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
139 });141 });
140 const executor = yield* createExecutor(config);142 const executor = yield* createExecutor(config);
@@ -179,6 +181,7 @@ describe("core API owner-scoped writes (v2)", () => { · 38 unmodified lines
179 it.effect("connection create accepts pasted values payloads", () =>181 it.effect("connection create accepts pasted values payloads", () =>
180 Effect.gen(function* () {182 Effect.gen(function* () {
181 const config = makeTestConfig({183 const config = makeTestConfig({
184 access: testAccess.member(),
182 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,185 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
183 });186 });
184 const executor = yield* createExecutor(config);187 const executor = yield* createExecutor(config);
@@ -220,6 +223,7 @@ describe("core API owner-scoped writes (v2)", () => { · 35 unmodified lines
220 it.effect("connection list returns both owners' rows under one integration", () =>223 it.effect("connection list returns both owners' rows under one integration", () =>
221 Effect.gen(function* () {224 Effect.gen(function* () {
222 const config = makeTestConfig({225 const config = makeTestConfig({
226 access: testAccess.member(),
223 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,227 plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
224 });228 });
225 const executor = yield* createExecutor(config);229 const executor = yield* createExecutor(config);
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
38import { Context, Data, Effect, Layer } from "effect";38import { Context, Data, Effect, Layer } from "effect";
39import type * as Cause from "effect/Cause";39import type * as Cause from "effect/Cause";
40 40
41import { memberAccessForRole } from "@executor-js/product-access";
41import type { AnyPlugin } from "@executor-js/sdk";42import type { AnyPlugin } from "@executor-js/sdk";
42import type { ExecutionEngine } from "@executor-js/execution";43import type { ExecutionEngine } from "@executor-js/execution";
43 44
@@ -247,10 +248,9 @@ export const makeExecutionStackMiddleware = < · 203 unmodified lines
247 resolved.organizationId,248 resolved.organizationId,
248 resolved.organizationName,249 resolved.organizationName,
249 {250 {
250 orgWrites:251 // The product's role rule, decided from the freshly resolved
251 resolved.orgRoleModel === "none" || resolved.orgRole === "admin"252 // principal for exactly this request's stack.
252 ? "allowed"253 access: memberAccessForRole(resolved),
253 : "denied",
254 },254 },
255 ).pipe(255 ).pipe(
256 Effect.provide(options.stackLayer, { local: true }),256 Effect.provide(options.stackLayer, { local: true }),
packages/core/api/src/server/execution-stack.ts+9−8
@@ -27,7 +27,7 @@ import { Context, Effect, Layer } from "effect"; · 26 unmodified lines
27import type * as Cause from "effect/Cause";27import type * as Cause from "effect/Cause";
28 28
29import type { McpResource } from "@executor-js/host-mcp";29import type { McpResource } from "@executor-js/host-mcp";
30import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk";30import type { AnyPlugin, Executor, ExecutorAccess, StorageFailure } from "@executor-js/sdk";
31import {31import {
32 createExecutionEngine,32 createExecutionEngine,
33 type ExecutionEngine,33 type ExecutionEngine,
@@ -112,11 +112,12 @@ export const makeExecutionStack = < · 78 unmodified lines
112 accountId: string,112 accountId: string,
113 organizationId: string,113 organizationId: string,
114 organizationName: string,114 organizationName: string,
115 options?: {115 options: {
116 readonly mcpResource?: McpResource;116 readonly mcpResource?: McpResource;
117 /** Workspace-settings permission for this binding (see117 /** The product's access decisions for this binding (see
118 * `ExecutorConfig.orgWrites`), derived from the acting member's role. */118 * `ExecutorConfig.access`), selected by the host — role-derived on the
119 readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];119 * HTTP plane, request-bound on MCP session stacks. */
120 readonly access: ExecutorAccess;
120 },121 },
121): Effect.Effect<122): Effect.Effect<
122 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },123 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
@@ -129,8 +130,8 @@ export const makeExecutionStack = < · 6 unmodified lines
129 organizationId,130 organizationId,
130 organizationName,131 organizationName,
131 {132 {
132 plugins: { mcpResource: options?.mcpResource },133 plugins: { mcpResource: options.mcpResource },
133 ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),134 access: options.access,
134 },135 },
135 ).pipe(Effect.withSpan("executor.stack.scoped_executor"));136 ).pipe(Effect.withSpan("executor.stack.scoped_executor"));
136 const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe(137 const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe(
@@ -147,7 +148,7 @@ export const makeExecutionStack = < · 10 unmodified lines
147 organizationId,148 organizationId,
148 organizationName,149 organizationName,
149 },150 },
150 { mcpResource: options?.mcpResource },151 { mcpResource: options.mcpResource },
151 ),152 ),
152 );153 );
153 return { executor, engine };154 return { executor, engine };
packages/core/api/src/server/mcp-build.ts+5−1
@@ -1,6 +1,7 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";3import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";
4import { requestBoundMemberAccess } from "@executor-js/product-access";
4import {5import {
5 McpEngineBuildError,6 McpEngineBuildError,
6 type McpBuildServer,7 type McpBuildServer,
@@ -50,7 +51,10 @@ export const makeMcpBuildServer = · 43 unmodified lines
50 principal.organizationName,51 principal.organizationName,
51 {52 {
52 mcpResource: options?.resource,53 mcpResource: options?.resource,
53 orgWrites: "request",54 // Session-lifetime stack: the workspace-settings decision is read
55 // from the request-stamped fiber-local at every guarded sink, so a
56 // session never caches a positive authorization.
57 access: requestBoundMemberAccess(),
54 },58 },
55 ).pipe(Effect.withSpan("mcp.execution_stack.build"));59 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
56 // Read inside the provided boundary: `webBaseUrl` is a host seam, and60 // 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
34import { Context, Effect, Option } from "effect";34import { Context, Effect, Option } from "effect";
35 35
36import type { McpResource } from "@executor-js/host-mcp";36import type { McpResource } from "@executor-js/host-mcp";
37import { platformObserverAccess } from "@executor-js/product-access";
37import {38import {
38 createExecutor,39 createExecutor,
39 Subject,40 Subject,
40 Tenant,41 Tenant,
41 type AnyPlugin,42 type AnyPlugin,
42 type Executor,43 type Executor,
44 type ExecutorAccess,
43 type ExecutorConfig,45 type ExecutorConfig,
44 type FirstPartyOAuthClientConfig,46 type FirstPartyOAuthClientConfig,
45 type StorageFailure,47 type StorageFailure,
@@ -258,12 +260,13 @@ export const makeScopedExecutor = < · 212 unmodified lines
258 // `EngineStackIdentity` (the engine decorator still wants it); not part of the260 // `EngineStackIdentity` (the engine decorator still wants it); not part of the
259 // v2 executor binding, which is `{ tenant, subject }` only.261 // v2 executor binding, which is `{ tenant, subject }` only.
260 _organizationName: string,262 _organizationName: string,
261 options?: {263 options: {
262 readonly plugins?: PluginsProviderContext;264 readonly plugins?: PluginsProviderContext;
263 /** Workspace-settings permission for this binding (see265 /** The product's access decisions for this binding (see
264 * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's266 * `ExecutorConfig.access`) — REQUIRED, selected by the host from
265 * role; omitted -> allowed (hosts with no role model). */267 * `@executor-js/product-access` (role-derived on the HTTP plane,
266 readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];268 * request-bound on session stacks). */
269 readonly access: ExecutorAccess;
267 },270 },
268): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>271): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
269 Effect.gen(function* () {272 Effect.gen(function* () {
@@ -336,7 +339,7 @@ export const makeScopedExecutor = < · 66 unmodified lines
336 ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),339 ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),
337 ...(waitUntil !== undefined ? { waitUntil } : {}),340 ...(waitUntil !== undefined ? { waitUntil } : {}),
338 onElicitation: "accept-all",341 onElicitation: "accept-all",
339 ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),342 access: options.access,
340 redirectUri,343 redirectUri,
341 oauthCallbackStateOrgSlug: orgSlug,344 oauthCallbackStateOrgSlug: orgSlug,
342 firstPartyOAuthClients: config.firstPartyOAuthClients,345 firstPartyOAuthClients: config.firstPartyOAuthClients,
@@ -370,9 +373,9 @@ export const makeScopedExecutor = < · 27 unmodified lines
370// An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on373// An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on
371// the whole workspace) has NO acting member, so there is no honest subject to374// the whole workspace) has NO acting member, so there is no honest subject to
372// bind. This builds the executor that shape implies: `{ tenant, subject:375// bind. This builds the executor that shape implies: `{ tenant, subject:
373// undefined, platformView: true }`.376// undefined, access: platformObserverAccess() }`.
374//377//
375// WHAT "READ-ONLY" MEANS HERE, PRECISELY. `platformView: true` puts378// WHAT "READ-ONLY" MEANS HERE, PRECISELY. The platform access posture puts
376// `writes: "denied"` on the executor's base owner-policy context and379// `writes: "denied"` on the executor's base owner-policy context and
377// `reach: "tenant"` on the `admin` handle's. So:380// `reach: "tenant"` on the `admin` handle's. So:
378// - EVERY surface on this executor — `admin`, and the ordinary `connections`381// - EVERY surface on this executor — `admin`, and the ordinary `connections`
@@ -429,6 +432,6 @@ export const makePlatformExecutor = ( · 50 unmodified lines
429 httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions),432 httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions),
430 fetch: makeHostedFetch(hostedHttpOptions),433 fetch: makeHostedFetch(hostedHttpOptions),
431 onElicitation: "accept-all",434 onElicitation: "accept-all",
432 platformView: true,435 access: platformObserverAccess(),
433 }).pipe(Effect.withSpan("executor.platform.create_executor"));436 }).pipe(Effect.withSpan("executor.platform.create_executor"));
434 });437 });
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
17## Usage17## Usage
18 18
19```ts19```ts
20import { workspaceServiceAccess } from "@executor-js/product-access";
20import { createExecutor } from "@executor-js/sdk";21import { createExecutor } from "@executor-js/sdk";
21import { createExecutionEngine } from "@executor-js/execution";22import { createExecutionEngine } from "@executor-js/execution";
22import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";23import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
23 24
24const executor = await createExecutor({25const executor = await createExecutor({
25 onElicitation: "accept-all",26 onElicitation: "accept-all",
27 access: workspaceServiceAccess(),
26});28});
27 29
28const engine = createExecutionEngine({30const engine = createExecutionEngine({
packages/core/execution/package.json+1−0
@@ -48,6 +48,7 @@ · 47 unmodified lines
48 },48 },
49 "devDependencies": {49 "devDependencies": {
50 "@effect/vitest": "catalog:",50 "@effect/vitest": "catalog:",
51 "@executor-js/product-access": "workspace:*",
51 "@executor-js/runtime-quickjs": "workspace:*",52 "@executor-js/runtime-quickjs": "workspace:*",
52 "@types/node": "catalog:",53 "@types/node": "catalog:",
53 "bun-types": "catalog:",54 "bun-types": "catalog:",
packages/core/execution/src/description.test.ts+19−5
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14import { makeTestConfig } from "@executor-js/sdk/testing";14import { makeTestConfig } from "@executor-js/sdk/testing";
15 15
16import { buildExecuteDescription, parseIntegrationInventory } from "./description";16import { buildExecuteDescription, parseIntegrationInventory } from "./description";
17import { testAccess } from "@executor-js/product-access/testing";
17 18
18const memoryProvider = (): CredentialProvider => {19const memoryProvider = (): CredentialProvider => {
19 const store = new Map<string, string>();20 const store = new Map<string, string>();
@@ -74,7 +75,10 @@ describe("buildExecuteDescription", () => { · 54 unmodified lines
74 it.effect("lists the connected integrations, not the connection prefixes", () =>75 it.effect("lists the connected integrations, not the connection prefixes", () =>
75 Effect.gen(function* () {76 Effect.gen(function* () {
76 const executor = yield* createExecutor(77 const executor = yield* createExecutor(
77 makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),78 makeTestConfig({
79 access: testAccess.member(),
80 plugins: [slackPlugin, githubPlugin] as const,
81 }),
78 );82 );
79 yield* executor["slack-plugin"].seed();83 yield* executor["slack-plugin"].seed();
80 yield* executor["github-plugin"].seed();84 yield* executor["github-plugin"].seed();
@@ -121,7 +125,10 @@ describe("buildExecuteDescription", () => { · 40 unmodified lines
121 it.effect("lists integration names only, with no descriptions", () =>125 it.effect("lists integration names only, with no descriptions", () =>
122 Effect.gen(function* () {126 Effect.gen(function* () {
123 const executor = yield* createExecutor(127 const executor = yield* createExecutor(
124 makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),128 makeTestConfig({
129 access: testAccess.member(),
130 plugins: [slackPlugin, githubPlugin] as const,
131 }),
125 );132 );
126 yield* executor["slack-plugin"].seed();133 yield* executor["slack-plugin"].seed();
127 yield* executor["github-plugin"].seed();134 yield* executor["github-plugin"].seed();
@@ -154,7 +161,9 @@ describe("buildExecuteDescription", () => { · 26 unmodified lines
154 161
155 it.effect("dedupes many connections of one integration into a single line", () =>162 it.effect("dedupes many connections of one integration into a single line", () =>
156 Effect.gen(function* () {163 Effect.gen(function* () {
157 const executor = yield* createExecutor(makeTestConfig({ plugins: [githubPlugin] as const }));164 const executor = yield* createExecutor(
165 makeTestConfig({ access: testAccess.member(), plugins: [githubPlugin] as const }),
166 );
158 yield* executor["github-plugin"].seed();167 yield* executor["github-plugin"].seed();
159 yield* executor.connections.create({168 yield* executor.connections.create({
160 owner: "org",169 owner: "org",
@@ -181,7 +190,9 @@ describe("buildExecuteDescription", () => { · 20 unmodified lines
181 190
182 it.effect("omits the Available integrations section when no connections exist", () =>191 it.effect("omits the Available integrations section when no connections exist", () =>
183 Effect.gen(function* () {192 Effect.gen(function* () {
184 const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const }));193 const executor = yield* createExecutor(
194 makeTestConfig({ access: testAccess.member(), plugins: [] as const }),
195 );
185 196
186 const description = yield* buildExecuteDescription(executor);197 const description = yield* buildExecuteDescription(executor);
187 198
@@ -195,7 +206,10 @@ describe("parseIntegrationInventory", () => { · 7 unmodified lines
195 it.effect("round-trips the slugs a built description lists", () =>206 it.effect("round-trips the slugs a built description lists", () =>
196 Effect.gen(function* () {207 Effect.gen(function* () {
197 const executor = yield* createExecutor(208 const executor = yield* createExecutor(
198 makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),209 makeTestConfig({
210 access: testAccess.member(),
211 plugins: [slackPlugin, githubPlugin] as const,
212 }),
199 );213 );
200 yield* executor["slack-plugin"].seed();214 yield* executor["slack-plugin"].seed();
201 yield* executor["github-plugin"].seed();215 yield* executor["github-plugin"].seed();
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
14 14
15import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine";15import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine";
16import { FormElicitation } from "@executor-js/sdk/core";16import { FormElicitation } from "@executor-js/sdk/core";
17import { testAccess } from "@executor-js/product-access/testing";
17 18
18// Regression for the hang reported as the executor-MCP "180s timeout" against19// Regression for the hang reported as the executor-MCP "180s timeout" against
19// Cowork (Claude web). Cowork goes down the `executeWithPause` branch because20// Cowork (Claude web). Cowork goes down the `executeWithPause` branch because
@@ -42,7 +43,10 @@ const emptyPlugin = definePlugin(() => ({ · 22 unmodified lines
42 staticIntegrations: () => [],43 staticIntegrations: () => [],
43}));44}));
44 45
45const makeExecutor = () => createExecutor(makeTestConfig({ plugins: [emptyPlugin()] as const }));46const makeExecutor = () =>
47 createExecutor(
48 makeTestConfig({ access: testAccess.member(), plugins: [emptyPlugin()] as const }),
49 );
46 50
47describe("executeWithPause failure propagation", () => {51describe("executeWithPause failure propagation", () => {
48 it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () =>52 it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () =>
@@ -98,7 +102,7 @@ describe("paused execution authorization", () => { · 49 unmodified lines
98 it.effect("uses the resumer's current org-write access after approval", () =>102 it.effect("uses the resumer's current org-write access after approval", () =>
99 Effect.gen(function* () {103 Effect.gen(function* () {
100 const executor = yield* createExecutor(104 const executor = yield* createExecutor(
101 makeTestConfig({ coreTools: {}, orgWrites: "request" }),105 makeTestConfig({ coreTools: {}, access: testAccess.requestBound() }),
102 );106 );
103 const engine = createExecutionEngine({107 const engine = createExecutionEngine({
104 executor,108 executor,
@@ -170,7 +174,7 @@ describe("paused execution authorization", () => { · 65 unmodified lines
170 const executor = yield* createExecutor(174 const executor = yield* createExecutor(
171 makeTestConfig({175 makeTestConfig({
172 coreTools: {},176 coreTools: {},
173 orgWrites: "request",177 access: testAccess.requestBound(),
174 plugins: [joinPlugin()] as const,178 plugins: [joinPlugin()] as const,
175 }),179 }),
176 );180 );
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
5import { makeTestConfig } from "@executor-js/sdk/testing";5import { makeTestConfig } from "@executor-js/sdk/testing";
6import { ExecutionToolError } from "./errors";6import { ExecutionToolError } from "./errors";
7import { makeExecutorToolInvoker } from "./tool-invoker";7import { makeExecutorToolInvoker } from "./tool-invoker";
8import { testAccess } from "@executor-js/product-access/testing";
8 9
9const EmptyInputSchema = Schema.toStandardSchemaV1(10const EmptyInputSchema = Schema.toStandardSchemaV1(
10 Schema.toStandardJSONSchemaV1(Schema.Struct({})),11 Schema.toStandardJSONSchemaV1(Schema.Struct({})),
@@ -138,7 +139,9 @@ const leakyPlugin = definePlugin(() => ({ · 127 unmodified lines
138describe("internal-error leak audit (opaque defects)", () => {139describe("internal-error leak audit (opaque defects)", () => {
139 it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () =>140 it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () =>
140 Effect.gen(function* () {141 Effect.gen(function* () {
141 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));142 const executor = yield* createExecutor(
143 makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
144 );
142 const invoker = makeExecutorToolInvoker(executor, {145 const invoker = makeExecutorToolInvoker(executor, {
143 invokeOptions: { onElicitation: acceptAll },146 invokeOptions: { onElicitation: acceptAll },
144 });147 });
@@ -160,7 +163,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 15 unmodified lines
160 163
161 it.effect("plain Error with stack: stack and message do NOT escape", () =>164 it.effect("plain Error with stack: stack and message do NOT escape", () =>
162 Effect.gen(function* () {165 Effect.gen(function* () {
163 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));166 const executor = yield* createExecutor(
167 makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
168 );
164 const invoker = makeExecutorToolInvoker(executor, {169 const invoker = makeExecutorToolInvoker(executor, {
165 invokeOptions: { onElicitation: acceptAll },170 invokeOptions: { onElicitation: acceptAll },
166 });171 });
@@ -179,7 +184,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 12 unmodified lines
179describe("openapi pre-flight invocation errors (expected failures)", () => {184describe("openapi pre-flight invocation errors (expected failures)", () => {
180 it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () =>185 it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () =>
181 Effect.gen(function* () {186 Effect.gen(function* () {
182 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));187 const executor = yield* createExecutor(
188 makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
189 );
183 const invoker = makeExecutorToolInvoker(executor, {190 const invoker = makeExecutorToolInvoker(executor, {
184 invokeOptions: { onElicitation: acceptAll },191 invokeOptions: { onElicitation: acceptAll },
185 });192 });
@@ -197,7 +204,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines
197 204
198 it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () =>205 it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () =>
199 Effect.gen(function* () {206 Effect.gen(function* () {
200 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));207 const executor = yield* createExecutor(
208 makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
209 );
201 const invoker = makeExecutorToolInvoker(executor, {210 const invoker = makeExecutorToolInvoker(executor, {
202 invokeOptions: { onElicitation: acceptAll },211 invokeOptions: { onElicitation: acceptAll },
203 });212 });
@@ -215,7 +224,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines
215 224
216 it.effect("transport failure (statusCode None but cause present) stays opaque", () =>225 it.effect("transport failure (statusCode None but cause present) stays opaque", () =>
217 Effect.gen(function* () {226 Effect.gen(function* () {
218 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));227 const executor = yield* createExecutor(
228 makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
229 );
219 const invoker = makeExecutorToolInvoker(executor, {230 const invoker = makeExecutorToolInvoker(executor, {
220 invokeOptions: { onElicitation: acceptAll },231 invokeOptions: { onElicitation: acceptAll },
221 });232 });
@@ -235,7 +246,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 13 unmodified lines
235 246
236 it.effect("post-response failure (statusCode Some) stays opaque", () =>247 it.effect("post-response failure (statusCode Some) stays opaque", () =>
237 Effect.gen(function* () {248 Effect.gen(function* () {
238 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));249 const executor = yield* createExecutor(
250 makeTestConfig({ access: testAccess.member(), plugins: [leakyPlugin()] as const }),
251 );
239 const invoker = makeExecutorToolInvoker(executor, {252 const invoker = makeExecutorToolInvoker(executor, {
240 invokeOptions: { onElicitation: acceptAll },253 invokeOptions: { onElicitation: acceptAll },
241 });254 });
packages/core/execution/src/tool-invoker.test.ts+5−1
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
38 searchTools,38 searchTools,
39 type ToolDiscoveryProvider,39 type ToolDiscoveryProvider,
40} from "./tool-invoker";40} from "./tool-invoker";
41import { testAccess } from "@executor-js/product-access/testing";
41 42
42// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
43// v2 port. The v1 suite modelled namespaces as `staticSources` whose tools44// v2 port. The v1 suite modelled namespaces as `staticSources` whose tools
@@ -477,7 +478,7 @@ const provision = ( · 433 unmodified lines
477 });478 });
478 479
479const makeExecutorWith = <const TPlugins extends readonly AnyPlugin[]>(plugins: TPlugins) =>480const makeExecutorWith = <const TPlugins extends readonly AnyPlugin[]>(plugins: TPlugins) =>
480 createExecutor(makeTestConfig({ plugins }));481 createExecutor(makeTestConfig({ access: testAccess.member(), plugins }));
481 482
482const makeSearchExecutor = () =>483const makeSearchExecutor = () =>
483 Effect.gen(function* () {484 Effect.gen(function* () {
@@ -1250,6 +1251,7 @@ describe("tool discovery", () => { · 766 unmodified lines
1250 });1251 });
1251 const executor = yield* createExecutor(1252 const executor = yield* createExecutor(
1252 makeTestConfig({1253 makeTestConfig({
1254 access: testAccess.member(),
1253 plugins: [memoryCredentialsPlugin()] as const,1255 plugins: [memoryCredentialsPlugin()] as const,
1254 coreTools: {},1256 coreTools: {},
1255 }),1257 }),
@@ -1303,6 +1305,7 @@ describe("tool discovery", () => { · 47 unmodified lines
1303 Effect.gen(function* () {1305 Effect.gen(function* () {
1304 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1306 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1305 const config = makeTestConfig({1307 const config = makeTestConfig({
1308 access: testAccess.member(),
1306 plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const,1309 plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const,
1307 });1310 });
1308 const executor = yield* createExecutor(config);1311 const executor = yield* createExecutor(config);
@@ -1386,6 +1389,7 @@ describe("tool discovery", () => { · 77 unmodified lines
1386 invalidRefreshTokenDescription: "Refresh token expired",1389 invalidRefreshTokenDescription: "Refresh token expired",
1387 });1390 });
1388 const config = makeTestConfig({1391 const config = makeTestConfig({
1392 access: testAccess.member(),
1389 plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const,1393 plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const,
1390 });1394 });
1391 const executor = yield* createExecutor(config);1395 const executor = yield* createExecutor(config);
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
3The product's personal/organization access rules for
4[`@executor-js/sdk`](../sdk). The SDK executes tools and enforces tenant
5isolation; it deliberately owns no product rule. Every `createExecutor` call
6requires 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
19This package ships the standard rules. Core never falls back to them — a
20composition root states its posture explicitly.
21
22## Effect SDK
23
24```ts
25import { Effect } from "effect";
26import { createExecutor } from "@executor-js/sdk/core";
27import { singleUserAccess } from "@executor-js/product-access";
28
29const 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
43import { createExecutor } from "@executor-js/sdk"; // published root = Promise API
44import { workspaceServiceAccess } from "@executor-js/product-access";
45
46const 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
65merge, plugin-default fallback, toolkit allowlist) — browser-safe, built on
66`@executor-js/sdk/shared`. `./testing` exports `testAccess` postures for
67tests (`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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Ref } from "effect";
3
4import {
5 CurrentOrgWriteAccess,
6 makeOrgWriteAccessState,
7 type ToolPolicyRow,
8} from "@executor-js/sdk/core";
9
10import {
11 memberAccess,
12 memberAccessForRole,
13 orgWriteAccessForRole,
14 platformObserverAccess,
15 requestBoundMemberAccess,
16 singleUserAccess,
17 workspaceServiceAccess,
18} from "./index";
19import { resolveProviderPolicyFromRules, standardToolPolicy } from "./policy";
20
21describe("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
43describe("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
100describe("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
28import { Effect } from "effect";
29
30import {
31 currentOrgWriteAccess,
32 type AccessDecision,
33 type ExecutorAccess,
34 type OrgWriteAccess,
35 type Owner,
36 type SettingsWriteTarget,
37} from "@executor-js/sdk/core";
38
39import { standardToolPolicy } from "./policy";
40
41export {
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. */
52export 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 */
68export const orgWriteAccessForRole = (input: OrgRoleInput): OrgWriteAccess =>
69 input.orgRoleModel === "none" || input.orgRole === "admin" ? "allowed" : "denied";
70
71/** A bound member's partitions, personal shadowing shared. */
72const MEMBER_OWNERS: readonly Owner[] = ["user", "org"];
73/** A subject-less binding's single partition. */
74const 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 */
82const 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
89const memberCapabilities = { adminReads: false, storageWrites: "allowed" } as const;
90
91const 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 */
103export const memberAccess = (workspaceWrites: OrgWriteAccess): ExecutorAccess =>
104 memberPosture(Effect.succeed(workspaceWrites));
105
106/** {@link memberAccess} with the decision derived by {@link orgWriteAccessForRole}. */
107export 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 */
119export 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 */
126export 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 */
134export 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 */
154export 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
21import { Effect, Match } from "effect";
22
23import {
24 comparePolicyRow,
25 matchPattern,
26 type EffectivePolicy,
27 type PolicyMatch,
28 type ToolPolicy,
29} from "@executor-js/sdk/shared";
30import 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. */
41export 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
50const 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
58const 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
68const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
69 defaultRequiresApproval
70 ? { action: "require_approval", source: "plugin-default" }
71 : { action: "approve", source: "plugin-default" };
72
73const 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 */
87export 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 */
122export 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 */
137export 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
163const 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 */
177export 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 */
205export 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
13import { Effect } from "effect";
14
15import type { ExecutorAccess, OrgWriteAccess } from "@executor-js/sdk/core";
16
17import {
18 memberAccess,
19 platformObserverAccess,
20 requestBoundMemberAccess,
21 workspaceServiceAccess,
22} from "./index";
23
24/** Explicit product postures for test bindings. */
25export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1import { defineConfig } from "vitest/config";
2
3export 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
7## Install7## Install
8 8
9```sh9```sh
10bun add @executor-js/sdk10bun add @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59
11# or11# or
12npm install @executor-js/sdk12npm install @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59
13```13```
14 14
15`effect` is a required peer dependency (some package managers don't install peers automatically). `@executor-js/product-access` supplies the standard `access` postures `createExecutor` requires — every snippet below uses it.
16
15## Quick start17## Quick start
16 18
17```ts19```ts
20import { workspaceServiceAccess } from "@executor-js/product-access";
18import { createExecutor } from "@executor-js/sdk";21import { createExecutor } from "@executor-js/sdk";
19 22
20const executor = await createExecutor({23const executor = await createExecutor({
@@ -22,6 +25,9 @@ const executor = await createExecutor({ · 1 unmodified line
22 // `"accept-all"` auto-approves every prompt — fine for tests/automation.25 // `"accept-all"` auto-approves every prompt — fine for tests/automation.
23 // For an interactive host, pass a handler `(ctx) => Promise<ElicitationResponse>`.26 // For an interactive host, pass a handler `(ctx) => Promise<ElicitationResponse>`.
24 onElicitation: "accept-all",27 onElicitation: "accept-all",
28 // Required: the product's access decisions for this binding. The SDK ships
29 // no default posture; `@executor-js/product-access` has the standard ones.
30 access: workspaceServiceAccess(),
25});31});
26 32
27const tools = await executor.tools.list();33const tools = await executor.tools.list();
@@ -35,9 +41,13 @@ await executor.close(); · 7 unmodified lines
35To invoke a tool once one is registered:41To invoke a tool once one is registered:
36 42
37```ts43```ts
44import { workspaceServiceAccess } from "@executor-js/product-access";
38import { createExecutor } from "@executor-js/sdk";45import { createExecutor } from "@executor-js/sdk";
39 46
40const executor = await createExecutor({ onElicitation: "accept-all" });47const executor = await createExecutor({
48 onElicitation: "accept-all",
49 access: workspaceServiceAccess(),
50});
41 51
42const tools = await executor.tools.list();52const tools = await executor.tools.list();
43const target = tools[0];53const target = tools[0];
@@ -110,6 +120,7 @@ export const memorySecretsPlugin = definePlugin((options?: MemorySecretsConfig) · 66 unmodified lines
110const executor = await createExecutor({120const executor = await createExecutor({
111 plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const,121 plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const,
112 onElicitation: "accept-all",122 onElicitation: "accept-all",
123 access: workspaceServiceAccess(),
113});124});
114 125
115console.log(executor.memorySecrets.label); // "in-memory secrets"126console.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
17import { describe, expect, it } from "@effect/vitest";
18import { Effect, Ref } from "effect";
19
20import { standardToolPolicy } from "@executor-js/product-access/policy";
21import { testAccess } from "@executor-js/product-access/testing";
22
23import { executorAccessViolation, type ExecutorAccess } from "./access";
24import { createExecutor } from "./executor";
25import {
26 AuthTemplateSlug,
27 ConnectionName,
28 IntegrationSlug,
29 ProviderItemId,
30 ProviderKey,
31 Tenant,
32 ToolAddress,
33 ToolName,
34} from "./ids";
35import { definePlugin } from "./plugin";
36import type { CredentialProvider } from "./provider";
37import { makeTestConfig } from "./testing";
38
39const 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. */
44const 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). */
51const orgOnlyMemberAccess = (): ExecutorAccess => ({
52 ...testAccess.member(),
53 owners: ["org"],
54 toolPolicy: standardToolPolicy(["org"]),
55});
56
57const 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
73const INTEG = IntegrationSlug.make("vercel");
74const TEMPLATE = AuthTemplateSlug.make("apiKey");
75
76const 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. */
90const 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
115describe("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
141describe("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
157describe("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
197describe("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
271describe("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
334describe("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 @@
1import { Context, Effect, Ref } from "effect";
2
3import type { ToolPolicyRow } from "./core-schema";
4import type { StorageFailure } from "./fuma-runtime";
5import type { Owner } from "./ids";
6import type { ToolPolicyProvider } from "./plugin";
7import 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. */
24export type OrgWriteAccess = "allowed" | "denied";
25
26/** A single product authorization answer core enforces verbatim. */
27export 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 */
38export 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 */
54export interface ExecutorAccessCapabilities {
55 readonly adminReads: boolean;
56 readonly storageWrites: AccessDecision;
57}
58
59/** One effective-policy question, as core asks the product's evaluator. */
60export 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 */
77export 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 */
88export 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 */
127export 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 */
169export 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 */
195export 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. */
201export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({
202 current: Ref.makeUnsafe(access),
203});
204
205/** Request-local workspace-write authorization inherited by child fibers. */
206export 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. */
212export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Ref, Schema } from "effect";
3
4import { ElicitationResponse } from "./elicitation";
5import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids";
6import { definePlugin, tool } from "./plugin";
7import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
8import { testAccess } from "@executor-js/product-access/testing";
9
10const integration = IntegrationSlug.make("approvalcheck");
11const connection = ConnectionName.make("main");
12const staticAddress = ToolAddress.make("approvalcheck.control.run");
13const dynamicAddress = ToolAddress.make("tools.approvalcheck.org.main.run");
14
15const 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
76describe("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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Ref } from "effect";
3
4import { ElicitationResponse } from "./elicitation";
5import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids";
6import { definePlugin } from "./plugin";
7import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
8import { 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
19const integration = IntegrationSlug.make("swapcheck");
20const connection = ConnectionName.make("main");
21const address = ToolAddress.make("tools.swapcheck.org.main.run");
22
23describe("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
119 title: "Theirs",119 title: "Theirs",
120 });120 });
121 121
122 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });122 const scoped = withQueryContext(db.db, {
123 tenant: TENANT,
124 subject: SUBJECT,
125 owners: ["user", "org"],
126 });
123 const visible = await scoped.findMany("artifact", {});127 const visible = await scoped.findMany("artifact", {});
124 expect(visible.map((row) => row.id)).toEqual(["art_mine"]);128 expect(visible.map((row) => row.id)).toEqual(["art_mine"]);
125 129
126 const otherScoped = withQueryContext(db.db, { tenant: TENANT, subject: OTHER_SUBJECT });130 const otherScoped = withQueryContext(db.db, {
131 tenant: TENANT,
132 subject: OTHER_SUBJECT,
133 owners: ["user", "org"],
134 });
127 const otherVisible = await otherScoped.findMany("artifact", {});135 const otherVisible = await otherScoped.findMany("artifact", {});
128 expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]);136 expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]);
129 }),137 }),
packages/core/sdk/src/artifacts.test.ts+24−23
@@ -4,6 +4,7 @@ import { Effect, Predicate, Result } from "effect"; · 3 unmodified lines
4import { makeTestExecutor } from "./testing";4import { makeTestExecutor } from "./testing";
5import type { ArtifactBinding } from "./artifact";5import type { ArtifactBinding } from "./artifact";
6import { ConnectionName, IntegrationSlug } from "./ids";6import { ConnectionName, IntegrationSlug } from "./ids";
7import { testAccess } from "@executor-js/product-access/testing";
7 8
8const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({9const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({
9 integration: IntegrationSlug.make(integration),10 integration: IntegrationSlug.make(integration),
@@ -20,14 +21,14 @@ const CODE = "export default function App() { return <div>hi</div>; }"; · 10 unmodified lines
20describe("executor.artifacts", () => {21describe("executor.artifacts", () => {
21 it.effect("list is empty when nothing is saved", () =>22 it.effect("list is empty when nothing is saved", () =>
22 Effect.gen(function* () {23 Effect.gen(function* () {
23 const executor = yield* makeTestExecutor();24 const executor = yield* makeTestExecutor({ access: testAccess.member() });
24 expect(yield* executor.artifacts.list()).toEqual([]);25 expect(yield* executor.artifacts.list()).toEqual([]);
25 }),26 }),
26 );27 );
27 28
28 it.effect("save mints an id and stores the source", () =>29 it.effect("save mints an id and stores the source", () =>
29 Effect.gen(function* () {30 Effect.gen(function* () {
30 const executor = yield* makeTestExecutor();31 const executor = yield* makeTestExecutor({ access: testAccess.member() });
31 const saved = yield* executor.artifacts.save({32 const saved = yield* executor.artifacts.save({
32 title: "Active users",33 title: "Active users",
33 description: "Daily active users over time",34 description: "Daily active users over time",
@@ -46,7 +47,7 @@ describe("executor.artifacts", () => { · 12 unmodified lines
46 47
47 it.effect("round-trips connection bindings through the json column", () =>48 it.effect("round-trips connection bindings through the json column", () =>
48 Effect.gen(function* () {49 Effect.gen(function* () {
49 const executor = yield* makeTestExecutor();50 const executor = yield* makeTestExecutor({ access: testAccess.member() });
50 const bindings = {51 const bindings = {
51 vercel: binding("vercel", "user", "personalVercel"),52 vercel: binding("vercel", "user", "personalVercel"),
52 prod: binding("linear", "org", "linearProd"),53 prod: binding("linear", "org", "linearProd"),
@@ -59,7 +60,7 @@ describe("executor.artifacts", () => { · 6 unmodified lines
59 60
60 it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () =>61 it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () =>
61 Effect.gen(function* () {62 Effect.gen(function* () {
62 const executor = yield* makeTestExecutor();63 const executor = yield* makeTestExecutor({ access: testAccess.member() });
63 const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} });64 const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} });
64 // `{}` and `null` are different facts: `{}` is an artifact that calls no65 // `{}` and `null` are different facts: `{}` is an artifact that calls no
65 // integration, `null` is one written before bindings existed and whose66 // integration, `null` is one written before bindings existed and whose
@@ -71,7 +72,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
71 72
72 it.effect("leaves bindings null when a save omits them", () =>73 it.effect("leaves bindings null when a save omits them", () =>
73 Effect.gen(function* () {74 Effect.gen(function* () {
74 const executor = yield* makeTestExecutor();75 const executor = yield* makeTestExecutor({ access: testAccess.member() });
75 const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE });76 const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE });
76 expect(saved.bindings).toBe(null);77 expect(saved.bindings).toBe(null);
77 }),78 }),
@@ -79,7 +80,7 @@ describe("executor.artifacts", () => { · 1 unmodified line
79 80
80 it.effect("overwrites bindings along with the source", () =>81 it.effect("overwrites bindings along with the source", () =>
81 Effect.gen(function* () {82 Effect.gen(function* () {
82 const executor = yield* makeTestExecutor();83 const executor = yield* makeTestExecutor({ access: testAccess.member() });
83 const saved = yield* executor.artifacts.save({84 const saved = yield* executor.artifacts.save({
84 title: "Domains",85 title: "Domains",
85 code: CODE,86 code: CODE,
@@ -100,7 +101,7 @@ describe("executor.artifacts", () => { · 14 unmodified lines
100 101
101 it.effect("save defaults a missing description to null", () =>102 it.effect("save defaults a missing description to null", () =>
102 Effect.gen(function* () {103 Effect.gen(function* () {
103 const executor = yield* makeTestExecutor();104 const executor = yield* makeTestExecutor({ access: testAccess.member() });
104 const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE });105 const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE });
105 expect(saved.description).toBe(null);106 expect(saved.description).toBe(null);
106 }),107 }),
@@ -108,7 +109,7 @@ describe("executor.artifacts", () => { · 1 unmodified line
108 109
109 it.effect("list omits the source", () =>110 it.effect("list omits the source", () =>
110 Effect.gen(function* () {111 Effect.gen(function* () {
111 const executor = yield* makeTestExecutor();112 const executor = yield* makeTestExecutor({ access: testAccess.member() });
112 const saved = yield* executor.artifacts.save({113 const saved = yield* executor.artifacts.save({
113 title: "Active users",114 title: "Active users",
114 description: "described",115 description: "described",
@@ -141,7 +142,7 @@ describe("executor.artifacts", () => { · 26 unmodified lines
141 // storage layer really has.142 // storage layer really has.
142 it.live("list returns the most recently updated artifact first", () =>143 it.live("list returns the most recently updated artifact first", () =>
143 Effect.gen(function* () {144 Effect.gen(function* () {
144 const executor = yield* makeTestExecutor();145 const executor = yield* makeTestExecutor({ access: testAccess.member() });
145 const first = yield* executor.artifacts.save({ title: "First", code: CODE });146 const first = yield* executor.artifacts.save({ title: "First", code: CODE });
146 yield* Effect.sleep("1100 millis");147 yield* Effect.sleep("1100 millis");
147 const second = yield* executor.artifacts.save({ title: "Second", code: CODE });148 const second = yield* executor.artifacts.save({ title: "Second", code: CODE });
@@ -159,7 +160,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
159 160
160 it.effect("save with an id overwrites in place", () =>161 it.effect("save with an id overwrites in place", () =>
161 Effect.gen(function* () {162 Effect.gen(function* () {
162 const executor = yield* makeTestExecutor();163 const executor = yield* makeTestExecutor({ access: testAccess.member() });
163 const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE });164 const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE });
164 const updated = yield* executor.artifacts.save({165 const updated = yield* executor.artifacts.save({
165 id: saved.id,166 id: saved.id,
@@ -182,7 +183,7 @@ describe("executor.artifacts", () => { · 16 unmodified lines
182 183
183 it.effect("save fails for an id that names no visible artifact", () =>184 it.effect("save fails for an id that names no visible artifact", () =>
184 Effect.gen(function* () {185 Effect.gen(function* () {
185 const executor = yield* makeTestExecutor();186 const executor = yield* makeTestExecutor({ access: testAccess.member() });
186 const result = yield* Effect.result(187 const result = yield* Effect.result(
187 executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }),188 executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }),
188 );189 );
@@ -194,7 +195,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
194 195
195 it.effect("get fails for an unknown id", () =>196 it.effect("get fails for an unknown id", () =>
196 Effect.gen(function* () {197 Effect.gen(function* () {
197 const executor = yield* makeTestExecutor();198 const executor = yield* makeTestExecutor({ access: testAccess.member() });
198 const result = yield* Effect.result(executor.artifacts.get("art_missing"));199 const result = yield* Effect.result(executor.artifacts.get("art_missing"));
199 expect(Result.isFailure(result)).toBe(true);200 expect(Result.isFailure(result)).toBe(true);
200 if (!Result.isFailure(result)) return;201 if (!Result.isFailure(result)) return;
@@ -204,7 +205,7 @@ describe("executor.artifacts", () => { · 3 unmodified lines
204 205
205 it.effect("rename changes only the title", () =>206 it.effect("rename changes only the title", () =>
206 Effect.gen(function* () {207 Effect.gen(function* () {
207 const executor = yield* makeTestExecutor();208 const executor = yield* makeTestExecutor({ access: testAccess.member() });
208 const saved = yield* executor.artifacts.save({209 const saved = yield* executor.artifacts.save({
209 title: "Old name",210 title: "Old name",
210 description: "kept",211 description: "kept",
@@ -219,7 +220,7 @@ describe("executor.artifacts", () => { · 8 unmodified lines
219 220
220 it.effect("rename fails for an unknown id", () =>221 it.effect("rename fails for an unknown id", () =>
221 Effect.gen(function* () {222 Effect.gen(function* () {
222 const executor = yield* makeTestExecutor();223 const executor = yield* makeTestExecutor({ access: testAccess.member() });
223 const result = yield* Effect.result(224 const result = yield* Effect.result(
224 executor.artifacts.rename({ id: "art_missing", title: "Nope" }),225 executor.artifacts.rename({ id: "art_missing", title: "Nope" }),
225 );226 );
@@ -231,7 +232,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
231 232
232 it.effect("remove hard-deletes the artifact", () =>233 it.effect("remove hard-deletes the artifact", () =>
233 Effect.gen(function* () {234 Effect.gen(function* () {
234 const executor = yield* makeTestExecutor();235 const executor = yield* makeTestExecutor({ access: testAccess.member() });
235 const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE });236 const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE });
236 yield* executor.artifacts.remove({ id: saved.id });237 yield* executor.artifacts.remove({ id: saved.id });
237 238
@@ -245,7 +246,7 @@ describe("executor.artifacts", () => { · 7 unmodified lines
245 246
246 it.effect("remove is idempotent", () =>247 it.effect("remove is idempotent", () =>
247 Effect.gen(function* () {248 Effect.gen(function* () {
248 const executor = yield* makeTestExecutor();249 const executor = yield* makeTestExecutor({ access: testAccess.member() });
249 yield* executor.artifacts.remove({ id: "art_missing" });250 yield* executor.artifacts.remove({ id: "art_missing" });
250 }),251 }),
251 );252 );
@@ -254,7 +255,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines
254 Effect.gen(function* () {255 Effect.gen(function* () {
255 // Pure-org executors (host request paths with no member bound) have no256 // Pure-org executors (host request paths with no member bound) have no
256 // user partition to file a personal artifact into.257 // user partition to file a personal artifact into.
257 const executor = yield* makeTestExecutor({ subject: null });258 const executor = yield* makeTestExecutor({ access: testAccess.org(), subject: null });
258 const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE }));259 const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE }));
259 expect(Result.isFailure(result)).toBe(true);260 expect(Result.isFailure(result)).toBe(true);
260 }),261 }),
@@ -266,7 +267,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines
266 267
267 it.effect("round-trips layout markup, and reads it back as a layout preview", () =>268 it.effect("round-trips layout markup, and reads it back as a layout preview", () =>
268 Effect.gen(function* () {269 Effect.gen(function* () {
269 const executor = yield* makeTestExecutor();270 const executor = yield* makeTestExecutor({ access: testAccess.member() });
270 const saved = yield* executor.artifacts.save({271 const saved = yield* executor.artifacts.save({
271 title: "Revenue",272 title: "Revenue",
272 code: CODE,273 code: CODE,
@@ -284,7 +285,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
284 285
285 it.effect("leaves the preview null when a save omits one", () =>286 it.effect("leaves the preview null when a save omits one", () =>
286 Effect.gen(function* () {287 Effect.gen(function* () {
287 const executor = yield* makeTestExecutor();288 const executor = yield* makeTestExecutor({ access: testAccess.member() });
288 const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE });289 const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE });
289 expect(saved.preview).toBe(null);290 expect(saved.preview).toBe(null);
290 }),291 }),
@@ -295,7 +296,7 @@ describe("executor.artifacts", () => { · 4 unmodified lines
295 // The preview interprets `code`, so a save that replaces the source296 // The preview interprets `code`, so a save that replaces the source
296 // must replace the picture — including back to null. Carrying it297 // must replace the picture — including back to null. Carrying it
297 // forward would advertise a version of the artifact that is gone.298 // forward would advertise a version of the artifact that is gone.
298 const executor = yield* makeTestExecutor();299 const executor = yield* makeTestExecutor({ access: testAccess.member() });
299 const saved = yield* executor.artifacts.save({300 const saved = yield* executor.artifacts.save({
300 title: "Revenue",301 title: "Revenue",
301 code: CODE,302 code: CODE,
@@ -313,7 +314,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
313 314
314 it.effect("setPreview upgrades the preview to the settled render", () =>315 it.effect("setPreview upgrades the preview to the settled render", () =>
315 Effect.gen(function* () {316 Effect.gen(function* () {
316 const executor = yield* makeTestExecutor();317 const executor = yield* makeTestExecutor({ access: testAccess.member() });
317 const saved = yield* executor.artifacts.save({318 const saved = yield* executor.artifacts.save({
318 title: "Revenue",319 title: "Revenue",
319 code: CODE,320 code: CODE,
@@ -331,7 +332,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines
331 Effect.gen(function* () {332 Effect.gen(function* () {
332 // `updated_at` is the gallery's sort key. Opening an artifact must not333 // `updated_at` is the gallery's sort key. Opening an artifact must not
333 // move it to the front — being looked at is not an edit.334 // move it to the front — being looked at is not an edit.
334 const executor = yield* makeTestExecutor();335 const executor = yield* makeTestExecutor({ access: testAccess.member() });
335 const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE });336 const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE });
336 yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED });337 yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED });
337 expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt);338 expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt);
@@ -340,7 +341,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines
340 341
341 it.effect("setPreview fails for an artifact that is not visible", () =>342 it.effect("setPreview fails for an artifact that is not visible", () =>
342 Effect.gen(function* () {343 Effect.gen(function* () {
343 const executor = yield* makeTestExecutor();344 const executor = yield* makeTestExecutor({ access: testAccess.member() });
344 const result = yield* Effect.result(345 const result = yield* Effect.result(
345 executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }),346 executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }),
346 );347 );
packages/core/sdk/src/blob.test.ts+163−14
@@ -3,23 +3,35 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { StorageError } from "./fuma-runtime";4import { StorageError } from "./fuma-runtime";
5 5
6import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob";6import {
7 makeInMemoryBlobStore,
8 pluginBlobStore,
9 type BlobAccess,
10 type OwnerPartitions,
11} from "./blob";
7 12
8// v2: owner partitions instead of a scope stack. Reads fall through13// v2: owner partitions instead of a scope stack. Reads fall through the
9// [user, org] (user = innermost); writes/deletes name an explicit owner.14// product view's owners in their supplied order (first listed wins); writes
15// and deletes name an explicit owner and are refused outside the view.
10const partitions = (org: string, user: string | null): OwnerPartitions => ({16const partitions = (org: string, user: string | null): OwnerPartitions => ({
11 org,17 org,
12 user,18 user,
13});19});
14 20
21// The full member view — the pre-BlobAccess behavior: user shadows org on
22// read, both partitions writable.
23const fullView: BlobAccess = { owners: ["user", "org"], storageWrites: "allowed" };
24const personalOnly: BlobAccess = { owners: ["user"], storageWrites: "allowed" };
25const orgOnly: BlobAccess = { owners: ["org"], storageWrites: "allowed" };
26
15describe("pluginBlobStore", () => {27describe("pluginBlobStore", () => {
16 it.effect("get returns user (innermost) value when both owners have one", () =>28 it.effect("get returns user (first-ranked) value when both owners have one", () =>
17 Effect.gen(function* () {29 Effect.gen(function* () {
18 const store = makeInMemoryBlobStore();30 const store = makeInMemoryBlobStore();
19 yield* store.put("u/my-plugin", "k", "user-value");31 yield* store.put("u/my-plugin", "k", "user-value");
20 yield* store.put("o/my-plugin", "k", "org-value");32 yield* store.put("o/my-plugin", "k", "org-value");
21 33
22 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");34 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
23 const value = yield* plugin.get("k");35 const value = yield* plugin.get("k");
24 expect(value).toBe("user-value");36 expect(value).toBe("user-value");
25 }),37 }),
@@ -30,7 +42,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines
30 const store = makeInMemoryBlobStore();42 const store = makeInMemoryBlobStore();
31 yield* store.put("o/my-plugin", "k", "org-value");43 yield* store.put("o/my-plugin", "k", "org-value");
32 44
33 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");45 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
34 const value = yield* plugin.get("k");46 const value = yield* plugin.get("k");
35 expect(value).toBe("org-value");47 expect(value).toBe("org-value");
36 }),48 }),
@@ -39,7 +51,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
39 it.effect("get returns null when no owner has the key", () =>51 it.effect("get returns null when no owner has the key", () =>
40 Effect.gen(function* () {52 Effect.gen(function* () {
41 const store = makeInMemoryBlobStore();53 const store = makeInMemoryBlobStore();
42 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");54 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
43 const value = yield* plugin.get("k");55 const value = yield* plugin.get("k");
44 expect(value).toBeNull();56 expect(value).toBeNull();
45 }),57 }),
@@ -50,7 +62,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines
50 const store = makeInMemoryBlobStore();62 const store = makeInMemoryBlobStore();
51 yield* store.put("o/my-plugin", "k", "v");63 yield* store.put("o/my-plugin", "k", "v");
52 64
53 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");65 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
54 const found = yield* plugin.has("k");66 const found = yield* plugin.has("k");
55 expect(found).toBe(true);67 expect(found).toBe(true);
56 }),68 }),
@@ -59,7 +71,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
59 it.effect("has returns false when no owner has the key", () =>71 it.effect("has returns false when no owner has the key", () =>
60 Effect.gen(function* () {72 Effect.gen(function* () {
61 const store = makeInMemoryBlobStore();73 const store = makeInMemoryBlobStore();
62 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin");74 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", fullView);
63 const found = yield* plugin.has("k");75 const found = yield* plugin.has("k");
64 expect(found).toBe(false);76 expect(found).toBe(false);
65 }),77 }),
@@ -71,8 +83,8 @@ describe("pluginBlobStore", () => { · 5 unmodified lines
71 yield* store.put("u/plugin-a", "k", "a-value");83 yield* store.put("u/plugin-a", "k", "a-value");
72 yield* store.put("u/plugin-b", "k", "b-value");84 yield* store.put("u/plugin-b", "k", "b-value");
73 85
74 const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a");86 const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a", fullView);
75 const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b");87 const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b", fullView);
76 expect(yield* pluginA.get("k")).toBe("a-value");88 expect(yield* pluginA.get("k")).toBe("a-value");
77 expect(yield* pluginB.get("k")).toBe("b-value");89 expect(yield* pluginB.get("k")).toBe("b-value");
78 }),90 }),
@@ -81,10 +93,14 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
81 it.effect("put rejects owner:user when the executor has no subject", () =>93 it.effect("put rejects owner:user when the executor has no subject", () =>
82 Effect.gen(function* () {94 Effect.gen(function* () {
83 const store = makeInMemoryBlobStore();95 const store = makeInMemoryBlobStore();
84 // No user partition → owner:"user" writes fail.96 // No user partition → owner:"user" writes fail even when the product
85 const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin");97 // view lists "user"; the identity clamp is not the product's to relax.
98 const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView);
86 const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);99 const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);
87 expect(err).toBeInstanceOf(StorageError);100 expect(err).toBeInstanceOf(StorageError);
101 expect(err).toMatchObject({
102 message: 'Blob write targets owner "user" but the executor has no subject.',
103 });
88 // Write must not have reached the store.104 // Write must not have reached the store.
89 expect(yield* store.get("o/my-plugin", "k")).toBeNull();105 expect(yield* store.get("o/my-plugin", "k")).toBeNull();
90 }),106 }),
@@ -93,9 +109,142 @@ describe("pluginBlobStore", () => { · 2 unmodified lines
93 it.effect("delete rejects owner:user when the executor has no subject", () =>109 it.effect("delete rejects owner:user when the executor has no subject", () =>
94 Effect.gen(function* () {110 Effect.gen(function* () {
95 const store = makeInMemoryBlobStore();111 const store = makeInMemoryBlobStore();
96 const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin");112 const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView);
97 const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip);113 const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip);
98 expect(err).toBeInstanceOf(StorageError);114 expect(err).toBeInstanceOf(StorageError);
115 expect(err).toMatchObject({
116 message: 'Blob write targets owner "user" but the executor has no subject.',
117 });
118 }),
119 );
120});
121
122describe("pluginBlobStore access clamp", () => {
123 it.effect("personal-only view cannot read an org blob", () =>
124 Effect.gen(function* () {
125 const store = makeInMemoryBlobStore();
126 // e.g. an org-shared provider config holding a service-account token.
127 yield* store.put("o/my-plugin", "k", "org-secret");
128
129 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
130 expect(yield* plugin.get("k")).toBeNull();
131 expect(yield* plugin.has("k")).toBe(false);
132 }),
133 );
134
135 it.effect("personal-only view still reads its own user blob", () =>
136 Effect.gen(function* () {
137 const store = makeInMemoryBlobStore();
138 yield* store.put("u/my-plugin", "k", "user-value");
139 yield* store.put("o/my-plugin", "k", "org-value");
140
141 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
142 expect(yield* plugin.get("k")).toBe("user-value");
143 }),
144 );
145
146 it.effect("org-only view with a bound subject cannot read a user blob", () =>
147 Effect.gen(function* () {
148 const store = makeInMemoryBlobStore();
149 yield* store.put("u/my-plugin", "k", "user-value");
150
151 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly);
152 expect(yield* plugin.get("k")).toBeNull();
153 expect(yield* plugin.has("k")).toBe(false);
154 }),
155 );
156
157 it.effect("read precedence follows the supplied owner order", () =>
158 Effect.gen(function* () {
159 const store = makeInMemoryBlobStore();
160 yield* store.put("u/my-plugin", "k", "user-value");
161 yield* store.put("o/my-plugin", "k", "org-value");
162
163 const orgFirst = pluginBlobStore(store, partitions("o", "u"), "my-plugin", {
164 owners: ["org", "user"],
165 storageWrites: "allowed",
166 });
167 expect(yield* orgFirst.get("k")).toBe("org-value");
168 }),
169 );
170
171 it.effect("put outside the view is rejected and does not reach the store", () =>
172 Effect.gen(function* () {
173 const store = makeInMemoryBlobStore();
174 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
175 const err = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip);
176 expect(err).toBeInstanceOf(StorageError);
177 expect(err).toMatchObject({
178 message:
179 'Blob write targets the "org" partition, which this product view does not include.',
180 });
181 expect(yield* store.get("o/my-plugin", "k")).toBeNull();
182 }),
183 );
184
185 it.effect("delete outside the view is rejected and leaves the row in place", () =>
186 Effect.gen(function* () {
187 const store = makeInMemoryBlobStore();
188 yield* store.put("o/my-plugin", "k", "org-value");
189
190 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly);
191 const err = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip);
192 expect(err).toBeInstanceOf(StorageError);
193 expect(err).toMatchObject({
194 message:
195 'Blob write targets the "org" partition, which this product view does not include.',
196 });
197 expect(yield* store.get("o/my-plugin", "k")).toBe("org-value");
198 }),
199 );
200
201 it.effect("org-only view cannot write the user partition", () =>
202 Effect.gen(function* () {
203 const store = makeInMemoryBlobStore();
204 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly);
205 const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);
206 expect(err).toBeInstanceOf(StorageError);
207 expect(err).toMatchObject({
208 message:
209 'Blob write targets the "user" partition, which this product view does not include.',
210 });
211 expect(yield* store.get("u/my-plugin", "k")).toBeNull();
212 }),
213 );
214
215 it.effect("read-only view refuses every mutation but keeps reading", () =>
216 Effect.gen(function* () {
217 const store = makeInMemoryBlobStore();
218 yield* store.put("o/my-plugin", "k", "org-value");
219
220 const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", {
221 owners: ["user", "org"],
222 storageWrites: "denied",
223 });
224 const putErr = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip);
225 expect(putErr).toBeInstanceOf(StorageError);
226 expect(putErr.message).toBe(
227 'Blob write on plugin "my-plugin" is not allowed: this executor\'s storage is read-only.',
228 );
229 const userPutErr = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip);
230 expect(userPutErr).toBeInstanceOf(StorageError);
231 const deleteErr = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip);
232 expect(deleteErr).toBeInstanceOf(StorageError);
233 // Nothing was mutated; reads keep working.
234 expect(yield* store.get("o/my-plugin", "k")).toBe("org-value");
235 expect(yield* plugin.get("k")).toBe("org-value");
236 }),
237 );
238
239 it.effect('a view listing "user" on a subject-less identity reads org rows only', () =>
240 Effect.gen(function* () {
241 const store = makeInMemoryBlobStore();
242 yield* store.put("o/my-plugin", "k", "org-value");
243
244 // `createExecutor` validation rejects this shape, but the seam must not
245 // assume it: the null partition contributes no namespace, not a failure.
246 const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView);
247 expect(yield* plugin.get("k")).toBe("org-value");
99 }),248 }),
100 );249 );
101});250});
packages/core/sdk/src/blob.ts+77−27
@@ -4,18 +4,21 @@ · 3 unmodified lines
4// metadata and plugin-owned config rows.4// metadata and plugin-owned config rows.
5//5//
6// Plugins see a `PluginBlobStore` that's already namespaced to the6// Plugins see a `PluginBlobStore` that's already namespaced to the
7// plugin id and bound to the executor's scope stack. Reads fall through7// plugin id and clamped to the product's `BlobAccess` view. Reads fall
8// the stack in order (innermost first, first hit wins); writes and8// through the view's owners in order (first listed wins); writes and
9// deletes require an explicit scope id naming where the operation9// deletes require an explicit owner naming where the operation should
10// should land. That mirrors the secrets API — shadowing by key on10// land, and are refused outside the view. That mirrors the secrets API —
11// read, explicit target on write.11// shadowing by key on read, explicit target on write — and it is where
12// the executor's access decisions apply to blobs at all: the blob table
13// sits outside the storage owner policy (isolation is the namespace
14// string), so nothing downstream re-checks them.
12//15//
13// Error channel is `StorageError` — blobs only do read/write/delete, so16// Error channel is `StorageError` — blobs only do read/write/delete, so
14// they never produce `UniqueViolationError`. The HTTP edge translates17// they never produce `UniqueViolationError`. The HTTP edge translates
15// `StorageError` to the opaque public `InternalError({ traceId })`.18// `StorageError` to the opaque public `InternalError({ traceId })`.
16// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
17 20
18import { Effect } from "effect";21import { Effect, Predicate } from "effect";
19 22
20import { StorageError, type IFumaClient } from "./fuma-runtime";23import { StorageError, type IFumaClient } from "./fuma-runtime";
21import type { Owner } from "./ids";24import type { Owner } from "./ids";
@@ -41,53 +44,101 @@ export interface BlobStore { · 19 unmodified lines
41}44}
42 45
43export interface PluginBlobStore {46export interface PluginBlobStore {
44 /** Read precedence: this subject's own (`user`) value first, then the47 /** Read precedence: the product view's owner order (`BlobAccess.owners`,
45 * org-shared value. Returns the first non-null. */48 * first entry wins). Returns the first non-null. */
46 readonly get: (key: string) => Effect.Effect<string | null, StorageError>;49 readonly get: (key: string) => Effect.Effect<string | null, StorageError>;
47 /** Write `value` under `key` for the named owner (`"org"` shared, `"user"`50 /** Write `value` under `key` for the named owner (`"org"` shared, `"user"`
48 * private). `"user"` requires the executor to be bound to a subject. */51 * private). The owner must be in the product view, and `"user"` requires
52 * the executor to be bound to a subject. */
49 readonly put: (53 readonly put: (
50 key: string,54 key: string,
51 value: string,55 value: string,
52 options: { readonly owner: Owner },56 options: { readonly owner: Owner },
53 ) => Effect.Effect<void, StorageError>;57 ) => Effect.Effect<void, StorageError>;
54 /** Delete `key` for the named owner. */58 /** Delete `key` for the named owner. Bounded like `put`. */
55 readonly delete: (59 readonly delete: (
56 key: string,60 key: string,
57 options: { readonly owner: Owner },61 options: { readonly owner: Owner },
58 ) => Effect.Effect<void, StorageError>;62 ) => Effect.Effect<void, StorageError>;
59 /** True if either the user or org partition has a value for `key`. */63 /** True if any partition in the product view has a value for `key`. */
60 readonly has: (key: string) => Effect.Effect<boolean, StorageError>;64 readonly has: (key: string) => Effect.Effect<boolean, StorageError>;
61}65}
62 66
63/** The owner partition strings an executor binding resolves to: the org67/** The owner partition strings an executor IDENTITY resolves to: the org
64 * partition (always present) and this subject's user partition (null for a68 * 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. */
66export interface OwnerPartitions {73export interface OwnerPartitions {
67 readonly org: string;74 readonly org: string;
68 readonly user: string | null;75 readonly user: string | null;
69}76}
70 77
78/**
79 * The slice of the product's access decisions the blob seam enforces: the
80 * blob table is exempt from the storage owner policy (isolation lives in the
81 * row namespace), so `ExecutorAccess.owners` and the read-only capability
82 * must be applied HERE, at namespace construction, or not at all.
83 *
84 * Structural and required — there is no default view: the caller states the
85 * product's decision explicitly, and core contributes only the clamps
86 * (tenant/subject are already baked into `OwnerPartitions`).
87 */
88export 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
71const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`;97const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`;
72 98
73/**99/**
74 * Bind a `BlobStore` to an owner partitioning + plugin id. Reads fall through100 * Bind a `BlobStore` to an owner partitioning + plugin id, clamped to the
75 * `[user, org]` (user first); writes target an explicit owner. Used by the101 * product's `BlobAccess`. Reads fall through the access owners in their
76 * executor to build the `blobs` field handed to each plugin's `storage` factory.102 * supplied order (first hit wins); writes target an explicit owner and are
103 * refused outside the access view. Used by the executor to build the `blobs`
104 * field handed to each plugin's `storage` factory.
77 */105 */
78export const pluginBlobStore = (106export const pluginBlobStore = (
79 store: BlobStore,107 store: BlobStore,
80 partitions: OwnerPartitions,108 partitions: OwnerPartitions,
81 pluginId: string,109 pluginId: string,
110 access: BlobAccess,
82): PluginBlobStore => {111): PluginBlobStore => {
83 const readNamespaces = (): readonly string[] =>112 const partitionOf = (owner: Owner): string | null =>
84 (partitions.user == null ? [partitions.org] : [partitions.user, partitions.org]).map((p) =>113 owner === "org" ? partitions.org : partitions.user;
85 nsFor(p, pluginId),114
86 );115 // The product view's partitions, in ITS precedence order. An owner the
116 // identity cannot carry (`"user"` with no subject) maps to no namespace
117 // rather than failing a read — the partition simply has no rows to see.
118 const readNamespaces: readonly string[] = access.owners
119 .map(partitionOf)
120 .filter(Predicate.isNotNull)
121 .map((partition) => nsFor(partition, pluginId));
87 122
88 const partitionFor = (owner: Owner): Effect.Effect<string, StorageError> => {123 const partitionFor = (owner: Owner): Effect.Effect<string, StorageError> => {
89 if (owner === "org") return Effect.succeed(partitions.org);124 if (access.storageWrites === "denied") {
90 if (partitions.user == null) {125 return Effect.fail(
126 new StorageError({
127 message: `Blob write on plugin "${pluginId}" is not allowed: this executor's storage is read-only.`,
128 cause: undefined,
129 }),
130 );
131 }
132 if (!access.owners.includes(owner)) {
133 return Effect.fail(
134 new StorageError({
135 message: `Blob write targets the "${owner}" partition, which this product view does not include.`,
136 cause: undefined,
137 }),
138 );
139 }
140 const partition = partitionOf(owner);
141 if (partition == null) {
91 return Effect.fail(142 return Effect.fail(
92 new StorageError({143 new StorageError({
93 message: 'Blob write targets owner "user" but the executor has no subject.',144 message: 'Blob write targets owner "user" but the executor has no subject.',
@@ -95,16 +146,15 @@ export const pluginBlobStore = ( · 1 unmodified line
95 }),146 }),
96 );147 );
97 }148 }
98 return Effect.succeed(partitions.user);149 return Effect.succeed(partition);
99 };150 };
100 151
101 return {152 return {
102 get: (key) =>153 get: (key) =>
103 Effect.gen(function* () {154 Effect.gen(function* () {
104 const namespaces = readNamespaces();155 const hits = yield* store.getMany(readNamespaces, key);
105 const hits = yield* store.getMany(namespaces, key);
106 if (hits.size === 0) return null;156 if (hits.size === 0) return null;
107 for (const ns of namespaces) {157 for (const ns of readNamespaces) {
108 const v = hits.get(ns);158 const v = hits.get(ns);
109 if (v !== undefined) return v;159 if (v !== undefined) return v;
110 }160 }
@@ -118,7 +168,7 @@ export const pluginBlobStore = ( · 7 unmodified lines
118 Effect.flatMap(partitionFor(options.owner), (partition) =>168 Effect.flatMap(partitionFor(options.owner), (partition) =>
119 store.delete(nsFor(partition, pluginId), key),169 store.delete(nsFor(partition, pluginId), key),
120 ),170 ),
121 has: (key) => store.getMany(readNamespaces(), key).pipe(Effect.map((hits) => hits.size > 0)),171 has: (key) => store.getMany(readNamespaces, key).pipe(Effect.map((hits) => hits.size > 0)),
122 };172 };
123};173};
124 174
packages/core/sdk/src/connections.test.ts+85−22
@@ -33,6 +33,7 @@ import { definePlugin, type ResolveToolsResult } from "./plugin"; · 32 unmodified lines
33import type { CredentialProvider } from "./provider";33import type { CredentialProvider } from "./provider";
34import { makeTestConfig, makeTestExecutor } from "./testing";34import { makeTestConfig, makeTestExecutor } from "./testing";
35import { ToolResult } from "./tool-result";35import { ToolResult } from "./tool-result";
36import { testAccess } from "@executor-js/product-access/testing";
36 37
37// removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh,38// removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh,
38// SecretProvider, accessToken token-refresh + in-flight dedup tests — the v239// SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2
@@ -163,7 +164,7 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines
163}))();164}))();
164 165
165const setup = () =>166const setup = () =>
166 makeTestExecutor({ plugins: [demoPlugin] as const }).pipe(167 makeTestExecutor({ access: testAccess.member(), plugins: [demoPlugin] as const }).pipe(
167 Effect.tap((executor) => executor.demo.seed()),168 Effect.tap((executor) => executor.demo.seed()),
168 );169 );
169 170
@@ -338,7 +339,10 @@ describe("connections.create", () => { · 168 unmodified lines
338 }),339 }),
339 }),340 }),
340 }))();341 }))();
341 const config = makeTestConfig({ plugins: [gatedPlugin] as const });342 const config = makeTestConfig({
343 access: testAccess.member(),
344 plugins: [gatedPlugin] as const,
345 });
342 const executor = yield* createExecutor(config);346 const executor = yield* createExecutor(config);
343 yield* executor.gated.seed();347 yield* executor.gated.seed();
344 348
@@ -435,7 +439,7 @@ describe("connections.create", () => { · 90 unmodified lines
435 }),439 }),
436 }),440 }),
437 }))();441 }))();
438 const config = makeTestConfig({ plugins: [plugin] as const });442 const config = makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const });
439 const first = yield* createExecutor(config);443 const first = yield* createExecutor(config);
440 yield* first.recoverable.seed();444 yield* first.recoverable.seed();
441 445
@@ -545,7 +549,10 @@ describe("connections.create", () => { · 103 unmodified lines
545 return wrap(db);549 return wrap(db);
546 };550 };
547 551
548 const config = makeTestConfig({ plugins: [demoPlugin] as const });552 const config = makeTestConfig({
553 access: testAccess.member(),
554 plugins: [demoPlugin] as const,
555 });
549 const executor = yield* createExecutor({556 const executor = yield* createExecutor({
550 ...config,557 ...config,
551 db: blindfoldConnectionReads(config.db),558 db: blindfoldConnectionReads(config.db),
@@ -1079,6 +1086,7 @@ describe("connections.create credential-write compensation", () => { · 527 unmodified lines
1079 : Effect.sync(() => void store.set(String(id), value)),1086 : Effect.sync(() => void store.set(String(id), value)),
1080 });1087 });
1081 const executor = yield* makeTestExecutor({1088 const executor = yield* makeTestExecutor({
1089 access: testAccess.member(),
1082 plugins: [durabilityPlugin(provider)] as const,1090 plugins: [durabilityPlugin(provider)] as const,
1083 });1091 });
1084 yield* executor.durable.seed();1092 yield* executor.durable.seed();
@@ -1116,7 +1124,10 @@ describe("connections.create credential-write compensation", () => { · 31 unmodified lines
1116 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))1124 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))
1117 : Effect.sync(() => void store.set(String(id), value)),1125 : Effect.sync(() => void store.set(String(id), value)),
1118 });1126 });
1119 const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const });1127 const executor = yield* makeTestExecutor({
1128 access: testAccess.member(),
1129 plugins: [durabilityPlugin(provider)] as const,
1130 });
1120 yield* executor.durable.seed();1131 yield* executor.durable.seed();
1121 1132
1122 const result = yield* Effect.result(1133 const result = yield* Effect.result(
@@ -1151,7 +1162,10 @@ describe("connections.create credential-write compensation", () => { · 28 unmodified lines
1151 : Effect.sync(() => void store.set(String(id), value)),1162 : Effect.sync(() => void store.set(String(id), value)),
1152 delete: undefined,1163 delete: undefined,
1153 });1164 });
1154 const executor = yield* makeTestExecutor({ plugins: [durabilityPlugin(provider)] as const });1165 const executor = yield* makeTestExecutor({
1166 access: testAccess.member(),
1167 plugins: [durabilityPlugin(provider)] as const,
1168 });
1155 yield* executor.durable.seed();1169 yield* executor.durable.seed();
1156 1170
1157 const warnings: string[] = [];1171 const warnings: string[] = [];
@@ -1202,7 +1216,10 @@ describe("connections.create credential-write compensation", () => { · 44 unmodified lines
1202 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))1216 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))
1203 : Effect.sync(() => void store.set(String(id), value)),1217 : Effect.sync(() => void store.set(String(id), value)),
1204 });1218 });
1205 const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });1219 const config = makeTestConfig({
1220 access: testAccess.member(),
1221 plugins: [durabilityPlugin(provider)] as const,
1222 });
1206 const executor = yield* createExecutor({1223 const executor = yield* createExecutor({
1207 ...config,1224 ...config,
1208 db: failableCompensationRowDelete(config.db, () => failRowDelete),1225 db: failableCompensationRowDelete(config.db, () => failRowDelete),
@@ -1276,6 +1293,7 @@ describe("connections.create credential-write compensation", () => { · 67 unmodified lines
1276 },1293 },
1277 });1294 });
1278 const executor = yield* makeTestExecutor({1295 const executor = yield* makeTestExecutor({
1296 access: testAccess.member(),
1279 plugins: [durabilityPlugin(provider)] as const,1297 plugins: [durabilityPlugin(provider)] as const,
1280 });1298 });
1281 yield* executor.durable.seed();1299 yield* executor.durable.seed();
@@ -1355,7 +1373,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines
1355 },1373 },
1356 });1374 });
1357 const raceState = { armed: false };1375 const raceState = { armed: false };
1358 const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });1376 const config = makeTestConfig({
1377 access: testAccess.member(),
1378 plugins: [durabilityPlugin(provider)] as const,
1379 });
1359 const executor = yield* createExecutor({1380 const executor = yield* createExecutor({
1360 ...config,1381 ...config,
1361 db: staleCompensationRead(config.db, raceState),1382 db: staleCompensationRead(config.db, raceState),
@@ -1435,7 +1456,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines
1435 : Effect.sync(() => void store.set(String(id), value)),1456 : Effect.sync(() => void store.set(String(id), value)),
1436 });1457 });
1437 const readState = { armed: false };1458 const readState = { armed: false };
1438 const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });1459 const config = makeTestConfig({
1460 access: testAccess.member(),
1461 plugins: [durabilityPlugin(provider)] as const,
1462 });
1439 const executor = yield* createExecutor({1463 const executor = yield* createExecutor({
1440 ...config,1464 ...config,
1441 db: failableConfirmationRead(config.db, readState),1465 db: failableConfirmationRead(config.db, readState),
@@ -1510,7 +1534,10 @@ describe("connections.create credential-write compensation", () => { · 68 unmodified lines
1510 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))1534 ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined }))
1511 : Effect.sync(() => void store.set(String(id), value)),1535 : Effect.sync(() => void store.set(String(id), value)),
1512 });1536 });
1513 const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });1537 const config = makeTestConfig({
1538 access: testAccess.member(),
1539 plugins: [durabilityPlugin(provider)] as const,
1540 });
1514 const executor = yield* createExecutor({1541 const executor = yield* createExecutor({
1515 ...config,1542 ...config,
1516 db: failableConnectionDeletes(config.db, () => failRowDelete),1543 db: failableConnectionDeletes(config.db, () => failRowDelete),
@@ -1586,7 +1613,10 @@ describe("connections.create credential-write compensation", () => { · 69 unmodified lines
1586 ? Effect.die("provider crashed")1613 ? Effect.die("provider crashed")
1587 : Effect.sync(() => void store.set(String(id), value)),1614 : Effect.sync(() => void store.set(String(id), value)),
1588 });1615 });
1589 const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });1616 const config = makeTestConfig({
1617 access: testAccess.member(),
1618 plugins: [durabilityPlugin(provider)] as const,
1619 });
1590 const executor = yield* createExecutor({1620 const executor = yield* createExecutor({
1591 ...config,1621 ...config,
1592 db: failableCompensationRowDelete(config.db, () => failRowDelete),1622 db: failableCompensationRowDelete(config.db, () => failRowDelete),
@@ -1646,7 +1676,10 @@ describe("connections.create credential-write compensation", () => { · 53 unmodified lines
1646 ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never))1676 ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never))
1647 : Effect.sync(() => void store.set(String(id), value)),1677 : Effect.sync(() => void store.set(String(id), value)),
1648 });1678 });
1649 const config = makeTestConfig({ plugins: [durabilityPlugin(provider)] as const });1679 const config = makeTestConfig({
1680 access: testAccess.member(),
1681 plugins: [durabilityPlugin(provider)] as const,
1682 });
1650 const executor = yield* createExecutor({1683 const executor = yield* createExecutor({
1651 ...config,1684 ...config,
1652 db: failableCompensationRowDelete(config.db, () => failRowDelete),1685 db: failableCompensationRowDelete(config.db, () => failRowDelete),
@@ -1696,7 +1729,11 @@ describe("connections.create credential-write compensation", () => { · 43 unmodified lines
1696describe("connections.list / get", () => {1729describe("connections.list / get", () => {
1697 it.effect("only includes full health diagnostics in verbose core tool output", () =>1730 it.effect("only includes full health diagnostics in verbose core tool output", () =>
1698 Effect.gen(function* () {1731 Effect.gen(function* () {
1699 const config = makeTestConfig({ plugins: [demoPlugin] as const, coreTools: {} });1732 const config = makeTestConfig({
1733 access: testAccess.member(),
1734 plugins: [demoPlugin] as const,
1735 coreTools: {},
1736 });
1700 const executor = yield* createExecutor(config);1737 const executor = yield* createExecutor(config);
1701 yield* executor.demo.seed();1738 yield* executor.demo.seed();
1702 yield* executor.connections.create({1739 yield* executor.connections.create({
@@ -1876,7 +1913,10 @@ describe("tool catalog sync safety", () => { · 173 unmodified lines
1876 }),1913 }),
1877 }),1914 }),
1878 }))();1915 }))();
1879 const config = makeTestConfig({ plugins: [guardedPlugin] as const });1916 const config = makeTestConfig({
1917 access: testAccess.member(),
1918 plugins: [guardedPlugin] as const,
1919 });
1880 const executor = yield* createExecutor(config);1920 const executor = yield* createExecutor(config);
1881 yield* executor.guarded.seed();1921 yield* executor.guarded.seed();
1882 yield* executor.connections.create({1922 yield* executor.connections.create({
@@ -1942,7 +1982,10 @@ describe("tool catalog sync safety", () => { · 59 unmodified lines
1942 }),1982 }),
1943 }),1983 }),
1944 }))();1984 }))();
1945 const config = makeTestConfig({ plugins: [guardedPlugin] as const });1985 const config = makeTestConfig({
1986 access: testAccess.member(),
1987 plugins: [guardedPlugin] as const,
1988 });
1946 const executor = yield* createExecutor(config);1989 const executor = yield* createExecutor(config);
1947 yield* executor.guarded.seed();1990 yield* executor.guarded.seed();
1948 yield* executor.connections.create({1991 yield* executor.connections.create({
@@ -2005,7 +2048,10 @@ describe("tool catalog sync safety", () => { · 56 unmodified lines
2005 }),2048 }),
2006 }),2049 }),
2007 }))();2050 }))();
2008 const config = makeTestConfig({ plugins: [storedStatePlugin] as const });2051 const config = makeTestConfig({
2052 access: testAccess.member(),
2053 plugins: [storedStatePlugin] as const,
2054 });
2009 const executor = yield* createExecutor(config);2055 const executor = yield* createExecutor(config);
2010 yield* executor["stored-state"].seed();2056 yield* executor["stored-state"].seed();
2011 yield* executor.connections.create({2057 yield* executor.connections.create({
@@ -2064,7 +2110,7 @@ describe("tool catalog sync safety", () => { · 52 unmodified lines
2064 }),2110 }),
2065 }))();2111 }))();
2066 const executor = yield* createExecutor(2112 const executor = yield* createExecutor(
2067 makeTestConfig({ plugins: [guardedPlugin] as const }),2113 makeTestConfig({ access: testAccess.member(), plugins: [guardedPlugin] as const }),
2068 );2114 );
2069 yield* executor.guarded.seed();2115 yield* executor.guarded.seed();
2070 yield* executor.connections.create({2116 yield* executor.connections.create({
@@ -2122,7 +2168,10 @@ describe("tool catalog sync safety", () => { · 51 unmodified lines
2122 }),2168 }),
2123 }),2169 }),
2124 }))();2170 }))();
2125 const config = makeTestConfig({ plugins: [guardedPlugin] as const });2171 const config = makeTestConfig({
2172 access: testAccess.member(),
2173 plugins: [guardedPlugin] as const,
2174 });
2126 const executor = yield* createExecutor(config);2175 const executor = yield* createExecutor(config);
2127 yield* executor.guarded.seed();2176 yield* executor.guarded.seed();
2128 yield* executor.connections.create({2177 yield* executor.connections.create({
@@ -2201,7 +2250,10 @@ describe("tool catalog sync safety", () => { · 72 unmodified lines
2201 }),2250 }),
2202 }),2251 }),
2203 }))();2252 }))();
2204 const config = makeTestConfig({ plugins: [guardedPlugin] as const });2253 const config = makeTestConfig({
2254 access: testAccess.member(),
2255 plugins: [guardedPlugin] as const,
2256 });
2205 const executor = yield* createExecutor(config);2257 const executor = yield* createExecutor(config);
2206 yield* executor.guarded.seed();2258 yield* executor.guarded.seed();
2207 yield* executor.connections.create({2259 yield* executor.connections.create({
@@ -2282,7 +2334,10 @@ describe("tool catalog sync safety", () => { · 74 unmodified lines
2282 }),2334 }),
2283 }),2335 }),
2284 }))();2336 }))();
2285 const config = makeTestConfig({ plugins: [guardedPlugin] as const });2337 const config = makeTestConfig({
2338 access: testAccess.member(),
2339 plugins: [guardedPlugin] as const,
2340 });
2286 const executor = yield* createExecutor(config);2341 const executor = yield* createExecutor(config);
2287 yield* executor.guarded.seed();2342 yield* executor.guarded.seed();
2288 yield* executor.connections.create({2343 yield* executor.connections.create({
@@ -2368,7 +2423,10 @@ describe("tool catalog sync safety", () => { · 79 unmodified lines
2368 }),2423 }),
2369 }))();2424 }))();
2370 2425
2371 const config = makeTestConfig({ plugins: [guardedPlugin] as const });2426 const config = makeTestConfig({
2427 access: testAccess.member(),
2428 plugins: [guardedPlugin] as const,
2429 });
2372 const executor = yield* createExecutor({2430 const executor = yield* createExecutor({
2373 ...config,2431 ...config,
2374 db: instrumentTransactions(config.db, {2432 db: instrumentTransactions(config.db, {
@@ -2457,7 +2515,10 @@ describe("tool catalog sync safety", () => { · 82 unmodified lines
2457 }),2515 }),
2458 }))();2516 }))();
2459 2517
2460 const config = makeTestConfig({ plugins: [guardedPlugin] as const });2518 const config = makeTestConfig({
2519 access: testAccess.member(),
2520 plugins: [guardedPlugin] as const,
2521 });
2461 const executor = yield* createExecutor(config);2522 const executor = yield* createExecutor(config);
2462 yield* executor.guarded.seed();2523 yield* executor.guarded.seed();
2463 for (const name of ["broken", "healthy"]) {2524 for (const name of ["broken", "healthy"]) {
@@ -2676,6 +2737,7 @@ const makeHealthHarness = (options?: { · 212 unmodified lines
2676 2737
2677 return Effect.gen(function* () {2738 return Effect.gen(function* () {
2678 const config = makeTestConfig({2739 const config = makeTestConfig({
2740 access: testAccess.member(),
2679 plugins: [plugin] as const,2741 plugins: [plugin] as const,
2680 coreTools: { webBaseUrl: "http://localhost:3000" },2742 coreTools: { webBaseUrl: "http://localhost:3000" },
2681 });2743 });
@@ -3421,6 +3483,7 @@ describe("health probe gate key integrity", () => { · 739 unmodified lines
3421 // Both executors share ONE root db handle — and therefore one gate map;3483 // Both executors share ONE root db handle — and therefore one gate map;
3422 // only the key separates their probes.3484 // only the key separates their probes.
3423 const configA = makeTestConfig({3485 const configA = makeTestConfig({
3486 access: testAccess.member(),
3424 plugins: [probingPlugin()] as const,3487 plugins: [probingPlugin()] as const,
3425 tenant: "a",3488 tenant: "a",
3426 subject: "user:b",3489 subject: "user:b",
packages/core/sdk/src/core-schema.ts+17−1
@@ -49,7 +49,23 @@ const unscopedExecutorTable = <const TColumns extends UserColumns>( · 48 unmodified lines
49 id: keyColumn("id"),49 id: keyColumn("id"),
50 });50 });
51 out.unique(`${name}_id_uidx`, ["id"]);51 out.unique(`${name}_id_uidx`, ["id"]);
52 return out.policy({ name: executorUnscopedPolicyName });52 // No visibility condition (isolation lives in the namespace string), but
53 // the read-only / delete-only postures still hold for the FumaDB-backed
54 // blob store. External blob backends never see table policies, so the
55 // executor ALSO wraps its base BlobStore when storage writes are denied —
56 // this is defense in depth for the table-backed path, not the sole guard.
57 return out.policy<ExecutorOwnerPolicyContext>({
58 name: executorUnscopedPolicyName,
59 onCreate: ({ context }) => assertReachReadOnly(name, "write", context),
60 onUpdate: ({ context }) => {
61 assertReachReadOnly(name, "write", context);
62 return true;
63 },
64 onDelete: ({ context }) => {
65 assertReachReadOnly(name, "delete", context);
66 return true;
67 },
68 });
53};69};
54 70
55/** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */71/** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */
packages/core/sdk/src/errors.ts+3−4
@@ -149,10 +149,9 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass<I · 148 unmodified lines
149 }149 }
150}150}
151 151
152/** A connection create or workspace-level mutation (an `owner: "org"` row, or152/** A user-intent settings mutation was denied by the product's
153 * the tenant-shared integration catalog) was attempted on an executor bound153 * `ExecutorAccess.settingsWrite` decision for its target.
154 * with `orgWrites: "denied"` — a member who may USE workspace resources but154 * Reads, tool execution, and operational writes (token
155 * not configure them. Reads, tool execution, and operational writes (token
156 * refresh, catalog re-sync) are unaffected; only the user-intent settings155 * refresh, catalog re-sync) are unaffected; only the user-intent settings
157 * surfaces raise this. */156 * surfaces raise this. */
158export class OrgWriteDeniedError157export class OrgWriteDeniedError
packages/core/sdk/src/executor.test.ts+44−7
@@ -20,6 +20,7 @@ import type { CredentialProvider } from "./provider"; · 19 unmodified lines
20import { IntegrationDetectionResult } from "./types";20import { IntegrationDetectionResult } from "./types";
21import { makeTestConfig, makeTestExecutor, memoryCredentialsPlugin } from "./testing";21import { makeTestConfig, makeTestExecutor, memoryCredentialsPlugin } from "./testing";
22import { serveOAuthTestServer } from "./testing/oauth-test-server";22import { serveOAuthTestServer } from "./testing/oauth-test-server";
23import { testAccess } from "@executor-js/product-access/testing";
23 24
24// removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id25// removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id
25// resolution, secrets/sources/scope-stack. The integration coverage below is26// resolution, secrets/sources/scope-stack. The integration coverage below is
@@ -215,6 +216,7 @@ describe("createExecutor", () => { · 189 unmodified lines
215 it.effect("rolls back plugin and core writes from ctx.transaction failures", () =>216 it.effect("rolls back plugin and core writes from ctx.transaction failures", () =>
216 Effect.gen(function* () {217 Effect.gen(function* () {
217 const executor = yield* makeTestExecutor({218 const executor = yield* makeTestExecutor({
219 access: testAccess.member(),
218 plugins: [demoPlugin] as const,220 plugins: [demoPlugin] as const,
219 });221 });
220 const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites());222 const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites());
@@ -237,6 +239,7 @@ describe("createExecutor", () => { · 16 unmodified lines
237 close: () => Effect.sync(() => void (closed = true)),239 close: () => Effect.sync(() => void (closed = true)),
238 }))();240 }))();
239 const executor = yield* makeTestExecutor({241 const executor = yield* makeTestExecutor({
242 access: testAccess.member(),
240 plugins: [closingPlugin] as const,243 plugins: [closingPlugin] as const,
241 });244 });
242 yield* executor.close();245 yield* executor.close();
@@ -248,6 +251,7 @@ describe("createExecutor", () => { · 5 unmodified lines
248 Effect.gen(function* () {251 Effect.gen(function* () {
249 const events: Array<{ kind: string; pluginKey: string; slug: string }> = [];252 const events: Array<{ kind: string; pluginKey: string; slug: string }> = [];
250 const executor = yield* makeTestExecutor({253 const executor = yield* makeTestExecutor({
254 access: testAccess.member(),
251 plugins: [demoPlugin] as const,255 plugins: [demoPlugin] as const,
252 onIntegrationChange: (event) =>256 onIntegrationChange: (event) =>
253 Effect.sync(() => {257 Effect.sync(() => {
@@ -281,6 +285,7 @@ describe("createExecutor", () => { · 27 unmodified lines
281 it.effect("a failing onIntegrationChange observer never fails the operation", () =>285 it.effect("a failing onIntegrationChange observer never fails the operation", () =>
282 Effect.gen(function* () {286 Effect.gen(function* () {
283 const executor = yield* makeTestExecutor({287 const executor = yield* makeTestExecutor({
288 access: testAccess.member(),
284 plugins: [demoPlugin] as const,289 plugins: [demoPlugin] as const,
285 onIntegrationChange: () => Effect.die("observer exploded"),290 onIntegrationChange: () => Effect.die("observer exploded"),
286 });291 });
@@ -294,6 +299,7 @@ describe("createExecutor", () => { · 7 unmodified lines
294 Effect.gen(function* () {299 Effect.gen(function* () {
295 const events: string[] = [];300 const events: string[] = [];
296 const executor = yield* makeTestExecutor({301 const executor = yield* makeTestExecutor({
302 access: testAccess.member(),
297 plugins: [demoPlugin] as const,303 plugins: [demoPlugin] as const,
298 onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))),304 onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))),
299 });305 });
@@ -306,6 +312,7 @@ describe("createExecutor", () => { · 6 unmodified lines
306 it.effect("projects core tools as the built-in Executor integration", () =>312 it.effect("projects core tools as the built-in Executor integration", () =>
307 Effect.gen(function* () {313 Effect.gen(function* () {
308 const executor = yield* makeTestExecutor({314 const executor = yield* makeTestExecutor({
315 access: testAccess.member(),
309 coreTools: { webBaseUrl: "http://localhost:3000" },316 coreTools: { webBaseUrl: "http://localhost:3000" },
310 });317 });
311 const integrations = yield* executor.integrations.list();318 const integrations = yield* executor.integrations.list();
@@ -351,6 +358,7 @@ describe("createExecutor", () => { · 39 unmodified lines
351 it.effect("can omit provider tools from the built-in Executor integration", () =>358 it.effect("can omit provider tools from the built-in Executor integration", () =>
352 Effect.gen(function* () {359 Effect.gen(function* () {
353 const executor = yield* makeTestExecutor({360 const executor = yield* makeTestExecutor({
361 access: testAccess.member(),
354 coreTools: {362 coreTools: {
355 webBaseUrl: "http://localhost:3000",363 webBaseUrl: "http://localhost:3000",
356 includeProviders: false,364 includeProviders: false,
@@ -372,6 +380,7 @@ describe("createExecutor", () => { · 15 unmodified lines
372 it.effect("creates provider-backed connections through the built-in Executor tools", () =>380 it.effect("creates provider-backed connections through the built-in Executor tools", () =>
373 Effect.gen(function* () {381 Effect.gen(function* () {
374 const executor = yield* makeTestExecutor({382 const executor = yield* makeTestExecutor({
383 access: testAccess.member(),
375 plugins: [demoPlugin] as const,384 plugins: [demoPlugin] as const,
376 coreTools: { webBaseUrl: "http://localhost:3000" },385 coreTools: { webBaseUrl: "http://localhost:3000" },
377 });386 });
@@ -414,6 +423,7 @@ describe("createExecutor", () => { · 36 unmodified lines
414 it.effect("creates a provider-backed legacy slug-none connection through the core tool", () =>423 it.effect("creates a provider-backed legacy slug-none connection through the core tool", () =>
415 Effect.gen(function* () {424 Effect.gen(function* () {
416 const executor = yield* makeTestExecutor({425 const executor = yield* makeTestExecutor({
426 access: testAccess.member(),
417 plugins: [demoPlugin] as const,427 plugins: [demoPlugin] as const,
418 coreTools: { webBaseUrl: "http://localhost:3000" },428 coreTools: { webBaseUrl: "http://localhost:3000" },
419 });429 });
@@ -466,6 +476,7 @@ describe("createExecutor", () => { · 46 unmodified lines
466 it.effect("removes catalog integrations through the built-in Executor tools", () =>476 it.effect("removes catalog integrations through the built-in Executor tools", () =>
467 Effect.gen(function* () {477 Effect.gen(function* () {
468 const executor = yield* makeTestExecutor({478 const executor = yield* makeTestExecutor({
479 access: testAccess.member(),
469 plugins: [demoPlugin] as const,480 plugins: [demoPlugin] as const,
470 coreTools: {},481 coreTools: {},
471 });482 });
@@ -526,6 +537,7 @@ describe("createExecutor", () => { · 54 unmodified lines
526 it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () =>537 it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () =>
527 Effect.gen(function* () {538 Effect.gen(function* () {
528 const executor = yield* makeTestExecutor({539 const executor = yield* makeTestExecutor({
540 access: testAccess.member(),
529 plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,541 plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,
530 coreTools: {},542 coreTools: {},
531 });543 });
@@ -604,6 +616,7 @@ describe("createExecutor", () => { · 72 unmodified lines
604 it.effect("preserves actionable health from an incomplete tool catalog", () =>616 it.effect("preserves actionable health from an incomplete tool catalog", () =>
605 Effect.gen(function* () {617 Effect.gen(function* () {
606 const executor = yield* makeTestExecutor({618 const executor = yield* makeTestExecutor({
619 access: testAccess.member(),
607 plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,620 plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,
608 coreTools: {},621 coreTools: {},
609 });622 });
@@ -639,6 +652,7 @@ describe("createExecutor", () => { · 29 unmodified lines
639 it.effect("hands pasted credential entry to the web UI", () =>652 it.effect("hands pasted credential entry to the web UI", () =>
640 Effect.gen(function* () {653 Effect.gen(function* () {
641 const executor = yield* makeTestExecutor({654 const executor = yield* makeTestExecutor({
655 access: testAccess.member(),
642 coreTools: { webBaseUrl: "http://localhost:3000" },656 coreTools: { webBaseUrl: "http://localhost:3000" },
643 });657 });
644 658
@@ -672,6 +686,7 @@ describe("createExecutor", () => { · 27 unmodified lines
672 Effect.gen(function* () {686 Effect.gen(function* () {
673 const server = yield* serveOAuthTestServer({ scopes: ["read"] });687 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
674 const executor = yield* makeTestExecutor({688 const executor = yield* makeTestExecutor({
689 access: testAccess.member(),
675 plugins: [demoPlugin] as const,690 plugins: [demoPlugin] as const,
676 coreTools: { webBaseUrl: "http://localhost:3000" },691 coreTools: { webBaseUrl: "http://localhost:3000" },
677 redirectUri: null,692 redirectUri: null,
@@ -739,7 +754,7 @@ describe("createExecutor", () => { · 61 unmodified lines
739 detector("high-detector", "high"),754 detector("high-detector", "high"),
740 detector("medium-detector", "medium"),755 detector("medium-detector", "medium"),
741 ] as const;756 ] as const;
742 const executor = yield* makeTestExecutor({ plugins });757 const executor = yield* makeTestExecutor({ access: testAccess.member(), plugins });
743 const results = yield* executor.integrations.detect("https://example.com/thing");758 const results = yield* executor.integrations.detect("https://example.com/thing");
744 // Every detector recognizes the URL; the list contains all three.759 // Every detector recognizes the URL; the list contains all three.
745 expect(results.map((r) => r.kind).sort()).toEqual([760 expect(results.map((r) => r.kind).sort()).toEqual([
@@ -753,6 +768,7 @@ describe("createExecutor", () => { · 7 unmodified lines
753 it.effect("tools.schema returns roots with shared reachable definitions", () =>768 it.effect("tools.schema returns roots with shared reachable definitions", () =>
754 Effect.gen(function* () {769 Effect.gen(function* () {
755 const executor = yield* makeTestExecutor({770 const executor = yield* makeTestExecutor({
771 access: testAccess.member(),
756 plugins: [demoPlugin] as const,772 plugins: [demoPlugin] as const,
757 });773 });
758 yield* executor.demo.seed();774 yield* executor.demo.seed();
@@ -778,6 +794,7 @@ describe("createExecutor", () => { · 19 unmodified lines
778 it.effect("execute dispatches a connection-produced tool to the owning plugin", () =>794 it.effect("execute dispatches a connection-produced tool to the owning plugin", () =>
779 Effect.gen(function* () {795 Effect.gen(function* () {
780 const executor = yield* makeTestExecutor({796 const executor = yield* makeTestExecutor({
797 access: testAccess.member(),
781 plugins: [demoPlugin] as const,798 plugins: [demoPlugin] as const,
782 });799 });
783 yield* executor.demo.seed();800 yield* executor.demo.seed();
@@ -800,6 +817,7 @@ describe("createExecutor", () => { · 16 unmodified lines
800 it.effect("execute on a missing address fails with ToolNotFoundError", () =>817 it.effect("execute on a missing address fails with ToolNotFoundError", () =>
801 Effect.gen(function* () {818 Effect.gen(function* () {
802 const executor = yield* makeTestExecutor({819 const executor = yield* makeTestExecutor({
820 access: testAccess.member(),
803 plugins: [demoPlugin] as const,821 plugins: [demoPlugin] as const,
804 });822 });
805 yield* executor.demo.seed();823 yield* executor.demo.seed();
@@ -843,6 +861,7 @@ describe("createExecutor", () => { · 37 unmodified lines
843describe("muscle memory (observed output shapes)", () => {861describe("muscle memory (observed output shapes)", () => {
844 const provisioned = Effect.fn(function* () {862 const provisioned = Effect.fn(function* () {
845 const executor = yield* makeTestExecutor({863 const executor = yield* makeTestExecutor({
864 access: testAccess.member(),
846 plugins: [demoPlugin] as const,865 plugins: [demoPlugin] as const,
847 coreTools: { webBaseUrl: "http://localhost:3000" },866 coreTools: { webBaseUrl: "http://localhost:3000" },
848 });867 });
@@ -1049,7 +1068,7 @@ const seedRunConnection = < · 200 unmodified lines
1049const recordToolRowLaunch = (maxOps?: number) =>1068const recordToolRowLaunch = (maxOps?: number) =>
1050 Effect.gen(function* () {1069 Effect.gen(function* () {
1051 const recorder = makeReadOrderRecorder();1070 const recorder = makeReadOrderRecorder();
1052 const config = makeTestConfig({ plugins: [demoPlugin] as const });1071 const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const });
1053 const executor = yield* createExecutor({1072 const executor = yield* createExecutor({
1054 ...config,1073 ...config,
1055 db: withRecordedReads(config.db, recorder.record),1074 db: withRecordedReads(config.db, recorder.record),
@@ -1073,6 +1092,7 @@ const recordCredentialLaunch = (maxOps?: number) => · 17 unmodified lines
1073 const calls = { count: 0 };1092 const calls = { count: 0 };
1074 const provider = countingProvider(calls, (run) => recorder.record("credential.get", run));1093 const provider = countingProvider(calls, (run) => recorder.record("credential.get", run));
1075 const config = makeTestConfig({1094 const config = makeTestConfig({
1095 access: testAccess.member(),
1076 plugins: [invokeConcurrencyPlugin(provider)] as const,1096 plugins: [invokeConcurrencyPlugin(provider)] as const,
1077 });1097 });
1078 const executor = yield* createExecutor({1098 const executor = yield* createExecutor({
@@ -1199,6 +1219,7 @@ describe("execute read concurrency", () => { · 120 unmodified lines
1199 Effect.gen(function* () {1219 Effect.gen(function* () {
1200 const calls = { count: 0 };1220 const calls = { count: 0 };
1201 const executor = yield* makeTestExecutor({1221 const executor = yield* makeTestExecutor({
1222 access: testAccess.member(),
1202 plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const,1223 plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const,
1203 });1224 });
1204 yield* seedRunConnection(executor);1225 yield* seedRunConnection(executor);
@@ -1227,7 +1248,10 @@ describe("execute read concurrency", () => { · 22 unmodified lines
1227 1248
1228 it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () =>1249 it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () =>
1229 Effect.gen(function* () {1250 Effect.gen(function* () {
1230 const config = makeTestConfig({ plugins: [demoPlugin] as const });1251 const config = makeTestConfig({
1252 access: testAccess.member(),
1253 plugins: [demoPlugin] as const,
1254 });
1231 const executor = yield* createExecutor(config);1255 const executor = yield* createExecutor(config);
1232 yield* seedRunConnection(executor);1256 yield* seedRunConnection(executor);
1233 1257
@@ -1367,7 +1391,10 @@ describe("speculative read abandonment", () => { · 133 unmodified lines
1367 it.effect("unknown-tool error surfaces while the speculative reads hang forever", () =>1391 it.effect("unknown-tool error surfaces while the speculative reads hang forever", () =>
1368 Effect.gen(function* () {1392 Effect.gen(function* () {
1369 const faults = makeReadFaults();1393 const faults = makeReadFaults();
1370 const config = makeTestConfig({ plugins: [demoPlugin] as const });1394 const config = makeTestConfig({
1395 access: testAccess.member(),
1396 plugins: [demoPlugin] as const,
1397 });
1371 const executor = yield* createExecutor({1398 const executor = yield* createExecutor({
1372 ...config,1399 ...config,
1373 db: withFaultedReads(config.db, faults.fault),1400 db: withFaultedReads(config.db, faults.fault),
@@ -1393,7 +1420,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines
1393 it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () =>1420 it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () =>
1394 Effect.gen(function* () {1421 Effect.gen(function* () {
1395 const faults = makeReadFaults();1422 const faults = makeReadFaults();
1396 const config = makeTestConfig({ plugins: [demoPlugin] as const });1423 const config = makeTestConfig({
1424 access: testAccess.member(),
1425 plugins: [demoPlugin] as const,
1426 });
1397 const executor = yield* createExecutor({1427 const executor = yield* createExecutor({
1398 ...config,1428 ...config,
1399 db: withFaultedReads(config.db, faults.fault),1429 db: withFaultedReads(config.db, faults.fault),
@@ -1419,7 +1449,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines
1419 it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () =>1449 it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () =>
1420 Effect.gen(function* () {1450 Effect.gen(function* () {
1421 const faults = makeReadFaults();1451 const faults = makeReadFaults();
1422 const config = makeTestConfig({ plugins: [demoPlugin] as const });1452 const config = makeTestConfig({
1453 access: testAccess.member(),
1454 plugins: [demoPlugin] as const,
1455 });
1423 const executor = yield* createExecutor({1456 const executor = yield* createExecutor({
1424 ...config,1457 ...config,
1425 db: withFaultedReads(config.db, faults.fault),1458 db: withFaultedReads(config.db, faults.fault),
@@ -1452,7 +1485,10 @@ describe("speculative read abandonment", () => { · 26 unmodified lines
1452 () =>1485 () =>
1453 Effect.gen(function* () {1486 Effect.gen(function* () {
1454 const faults = makeReadFaults();1487 const faults = makeReadFaults();
1455 const config = makeTestConfig({ plugins: [demoPlugin] as const });1488 const config = makeTestConfig({
1489 access: testAccess.member(),
1490 plugins: [demoPlugin] as const,
1491 });
1456 const executor = yield* createExecutor({1492 const executor = yield* createExecutor({
1457 ...config,1493 ...config,
1458 db: withFaultedReads(config.db, faults.fault),1494 db: withFaultedReads(config.db, faults.fault),
@@ -1479,6 +1515,7 @@ describe("speculative read abandonment", () => { · 20 unmodified lines
1479 const faults = makeReadFaults();1515 const faults = makeReadFaults();
1480 const armable = armableFailingProvider();1516 const armable = armableFailingProvider();
1481 const config = makeTestConfig({1517 const config = makeTestConfig({
1518 access: testAccess.member(),
1482 plugins: [invokeConcurrencyPlugin(armable.provider)] as const,1519 plugins: [invokeConcurrencyPlugin(armable.provider)] as const,
1483 });1520 });
1484 const executor = yield* createExecutor({1521 const executor = yield* createExecutor({
packages/core/sdk/src/executor.ts+276−220
@@ -62,7 +62,6 @@ import { · 61 unmodified lines
62 type OAuthClientRow,62 type OAuthClientRow,
63 type ToolInvocationRow,63 type ToolInvocationRow,
64 type ToolRow,64 type ToolRow,
65 type ToolPolicyRow,
66} from "./core-schema";65} from "./core-schema";
67import {66import {
68 ElicitationDeclinedError,67 ElicitationDeclinedError,
@@ -73,7 +72,7 @@ import { · 4 unmodified lines
73 type OnElicitation,72 type OnElicitation,
74 type InvokeOptions,73 type InvokeOptions,
75} from "./elicitation";74} from "./elicitation";
76import { currentOrgWriteAccess, type OrgWriteAccess } from "./org-write-access";75import { executorAccessViolation, type ExecutorAccess, type ToolPolicyEvaluator } from "./access";
77import {76import {
78 restoreCredentialSnapshotsWithRecheck,77 restoreCredentialSnapshotsWithRecheck,
79 snapshotCredentialWrites,78 snapshotCredentialWrites,
@@ -155,9 +154,7 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; · 75 unmodified lines
155import {154import {
156 comparePolicyRow,155 comparePolicyRow,
157 isValidPattern,156 isValidPattern,
158 matchPattern,
159 positionForNewPattern,157 positionForNewPattern,
160 resolveEffectivePolicy,
161 rowToToolPolicy,158 rowToToolPolicy,
162 type CreateToolPolicyInput,159 type CreateToolPolicyInput,
163 type EffectivePolicy,160 type EffectivePolicy,
@@ -181,7 +178,6 @@ import type { · 17 unmodified lines
181 StaticToolDecl,178 StaticToolDecl,
182 StorageDeps,179 StorageDeps,
183 ToolPolicyProvider,180 ToolPolicyProvider,
184 ToolPolicyProviderRule,
185 ToolInvocationCredential,181 ToolInvocationCredential,
186} from "./plugin";182} from "./plugin";
187import {183import {
@@ -493,10 +489,11 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines
493 489
494 /**490 /**
495 * The PLATFORM VIEW: read-only, tenant-wide reads across every subject.491 * The PLATFORM VIEW: read-only, tenant-wide reads across every subject.
496 * Present only when the executor was built with `platformView: true`492 * Present only when the executor was built with a product access granting
497 * (default off) — every other surface on this executor stays bound to the493 * `capabilities.adminReads` — every other surface on this executor stays
498 * single `{ tenant, subject }` product view and is unaffected by this one.494 * bound to the single `{ tenant, subject }` product view and is unaffected
499 * Internal admin surface; the public HTTP shape is a separate concern.495 * by this one. Internal admin surface; the public HTTP shape is a separate
496 * concern.
500 */497 */
501 readonly admin?: ExecutorAdmin;498 readonly admin?: ExecutorAdmin;
502 /** Saved generative-UI artifacts, visible to the bound owner scope. */499 /** Saved generative-UI artifacts, visible to the bound owner scope. */
@@ -810,47 +807,27 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 307 unmodified lines
810 */807 */
811 readonly onIntegrationChange?: (event: IntegrationChangeEvent) => Effect.Effect<void>;808 readonly onIntegrationChange?: (event: IntegrationChangeEvent) => Effect.Effect<void>;
812 /**809 /**
813 * Opt into the PLATFORM VIEW: a read-only, tenant-wide `executor.admin`810 * The product's access decisions for this binding — REQUIRED, with no core
814 * surface that reads across every subject in the tenant (see811 * default. The product (host composition root) states which owner
815 * {@link ExecutorAdmin}). Default OFF — `admin` is simply absent, so the812 * partitions this binding reads and in what precedence, whether it may
816 * escape hatch has to be asked for by a host that has authorized an813 * configure workspace-level state (consulted live at every guarded sink),
817 * org-level caller.814 * and its view capabilities (`capabilities.adminReads` exposes the
815 * tenant-observing `admin` surface, whose reads span every subject;
816 * `capabilities.storageWrites` sets the storage posture).
817 * Core validates the shape against the binding, enforces the decisions at
818 * its sinks, and clamps everything with the storage owner policy — tenant
819 * isolation is never product-configurable. See {@link ExecutorAccess} and
820 * `@executor-js/product-access` for the product rule implementations.
818 *821 *
819 * Enabling it makes the WHOLE executor read-only, not just `admin`: the base822 * `capabilities.storageWrites: "denied"` makes the WHOLE executor
820 * owner context carries `writes: "denied"`, so `connections`, `policies`,823 * read-only, not just `admin`: the base owner context carries
821 * `integrations` and `oauth` refuse every create/update/delete at the storage824 * `writes: "denied"`, so `connections`, `policies`, `integrations` and
822 * boundary. An executor built for an org-level caller is an observer, and825 * `oauth` refuse every create/update/delete at the storage boundary. READS
823 * `admin` being its only tenant-wide surface is not the same as it being its826 * still differ by surface: only `admin` is tenant-wide; every other
824 * only guarded one.827 * surface stays bound to `{ tenant, subject }` — widening them would
825 *828 * expose every subject's connection rows, credential item ids included.
826 * READS still differ by surface: only `admin` is tenant-wide. Every other
827 * surface stays bound to `{ tenant, subject }` — widening them would expose
828 * every subject's connection rows, credential item ids included.
829 */
830 readonly platformView?: boolean;
831 /**
832 * Whether this binding may CONFIGURE workspace-level state: `owner: "org"`
833 * rows (shared connections, org tool policies, org OAuth clients) and the
834 * tenant-shared integration catalog. Hosts derive it from the acting
835 * member's role — admins bind `"allowed"`, plain members `"denied"`.
836 * `"request"` reads the fiber-local {@link CurrentOrgWriteAccess} at every
837 * guarded sink. Session hosts bind that reference from the freshly
838 * authenticated request without caching a positive authorization decision
839 * for the session lifetime.
840 * Defaults to `"allowed"` for non-session callers with no role model
841 * (local's single user, the CLI, tests).
842 *
843 * `"denied"` gates only the USER-INTENT settings surfaces (`policies`,
844 * `connections` create/update/remove in Workspace scope, `integrations`
845 * update/remove/healthCheck, OAuth client CRUD and connect flows in Workspace
846 * scope, new-integration registration). Members may still create and manage
847 * Personal connections and OAuth apps. They also USE workspace resources:
848 * reads, tool execution over org connections, and the operational writes
849 * those imply (token refresh, tool-catalog re-sync) are deliberately
850 * untouched — which is why this is a surface gate, not a storage-policy axis
851 * like `platformView`'s blanket `writes: "denied"`.
852 */829 */
853 readonly orgWrites?: OrgWriteAccess | "request";830 readonly access: ExecutorAccess;
854}831}
855 832
856/** Default freshness window for remote-catalog connections (see833/** Default freshness window for remote-catalog connections (see
@@ -1522,9 +1499,11 @@ const makePluginStorageFacade = (input: { · 665 unmodified lines
1522 readonly core: CoreDb;1499 readonly core: CoreDb;
1523 readonly pluginId: string;1500 readonly pluginId: string;
1524 readonly owner: OwnerBinding;1501 readonly owner: OwnerBinding;
1502 /** Product-supplied owner precedence (`ExecutorAccess.owners`):
1503 * which partitions reads see, first entry shadowing later ones. */
1504 readonly readOwners: readonly Owner[];
1525}): PluginStorageFacade => {1505}): PluginStorageFacade => {
1526 // Owner partitions: org always, plus this subject's user partition.1506 const readOwners = input.readOwners;
1527 const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"];
1528 1507
1529 const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => {1508 const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => {
1530 if (owner === "org") return { owner: "org", subject: ORG_SUBJECT };1509 if (owner === "org") return { owner: "org", subject: ORG_SUBJECT };
@@ -1555,7 +1534,12 @@ const makePluginStorageFacade = (input: { · 24 unmodified lines
1555 );1534 );
1556 };1535 };
1557 1536
1558 const ownerRank = (owner: Owner): number => readOwners.indexOf(owner);1537 // A row whose owner is outside the view (only reachable if this facade is
1538 // ever handed a wider db handle) sorts LAST, never shadowing view rows.
1539 const ownerRank = (owner: Owner): number => {
1540 const rank = readOwners.indexOf(owner);
1541 return rank === -1 ? readOwners.length : rank;
1542 };
1559 1543
1560 const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) =>1544 const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) =>
1561 [...rows].sort((left, right) => {1545 [...rows].sort((left, right) => {
@@ -1919,6 +1903,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 357 unmodified lines
1919 const tenant = String(config.tenant);1903 const tenant = String(config.tenant);
1920 const subject = config.subject != null ? String(config.subject) : null;1904 const subject = config.subject != null ? String(config.subject) : null;
1921 1905
1906 // The product's access decisions, validated against the binding before
1907 // anything else is built: a composition root that hands a member-shaped
1908 // access to a subject-less binding (or vice versa) is a programmer error
1909 // that must fail the boot, not silently narrow.
1910 const access = config.access;
1911 const accessViolation = executorAccessViolation(access, subject);
1912 if (accessViolation != null) {
1913 return yield* new StorageError({ message: accessViolation, cause: undefined });
1914 }
1915 // The product's visible/writable partitions in precedence order — stamped
1916 // onto every storage context and threaded to every owner-ordered read.
1917 const accessOwners = access.owners;
1918
1922 const ownerBinding: OwnerBinding = {1919 const ownerBinding: OwnerBinding = {
1923 tenant: config.tenant,1920 tenant: config.tenant,
1924 subject: config.subject ?? null,1921 subject: config.subject ?? null,
@@ -1946,17 +1943,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 21 unmodified lines
1946 )1943 )
1947 : Effect.void;1944 : Effect.void;
1948 1945
1949 // Workspace-settings gate (`ExecutorConfig.orgWrites`). Called at the top1946 // Settings gate (`ExecutorAccess.settingsWrite`). Called at the top of
1950 // of every user-intent workspace-level mutation: with an explicit owner it1947 // every user-intent settings mutation with the TARGET core is about to
1951 // refuses only `"org"` targets; with no owner it guards a tenant-shared1948 // touch — an owner-scoped row, or (with no owner) a tenant-shared
1952 // surface outright. Deliberately NOT wired into the storage owner policy —1949 // workspace surface. WHICH targets a given principal may configure is
1953 // operational org-row writes (token refresh, tool-catalog re-sync) must1950 // entirely the product's rule; core only describes the target, asks
1954 // keep working for a denied member.1951 // live on every call (a request-bound implementation is re-read after
1952 // pauses and on resume; nothing is cached), and enforces a denial as
1953 // `OrgWriteDeniedError`. Deliberately NOT wired into the storage owner
1954 // policy — operational org-row writes (token refresh, tool-catalog
1955 // re-sync) must keep working for a settings-denied member.
1955 const guardOrgWrite = (owner?: Owner): Effect.Effect<void, OrgWriteDeniedError> =>1956 const guardOrgWrite = (owner?: Owner): Effect.Effect<void, OrgWriteDeniedError> =>
1956 Effect.gen(function* () {1957 Effect.gen(function* () {
1957 const access =1958 const decision = yield* access.settingsWrite(
1958 config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites;1959 owner === undefined ? { kind: "workspace" } : { kind: "owner", owner },
1959 if (access === "denied" && (owner === undefined || owner === "org")) {1960 );
1961 if (decision === "denied") {
1960 return yield* new OrgWriteDeniedError();1962 return yield* new OrgWriteDeniedError();
1961 }1963 }
1962 });1964 });
@@ -2010,7 +2012,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 47 unmodified lines
2010 const ownerContext: ExecutorOwnerPolicyContext = {2012 const ownerContext: ExecutorOwnerPolicyContext = {
2011 tenant,2013 tenant,
2012 subject,2014 subject,
2013 ...(config.platformView === true ? { writes: "denied" as const } : {}),2015 // The product's row visibility/write partitions — enforced (never
2016 // chosen) by the storage owner policy, which clamps them to the
2017 // binding's tenant and subject.
2018 owners: accessOwners,
2019 ...(access.capabilities.storageWrites === "denied" ? { writes: "denied" as const } : {}),
2014 };2020 };
2015 const rootDb = withQueryContext(rootDbUntyped, ownerContext);2021 const rootDb = withQueryContext(rootDbUntyped, ownerContext);
2016 // Shared across executors over one database, so the gate key must carry the2022 // Shared across executors over one database, so the gate key must carry the
@@ -2035,7 +2041,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines
2035 } satisfies ExecutorOwnerPolicyContext,2041 } satisfies ExecutorOwnerPolicyContext,
2036 }),2042 }),
2037 );2043 );
2038 const blobs = config.blobs ?? makeFumaBlobStore(fuma);2044 // The blob table sits OUTSIDE the storage owner policy (isolation is the
2045 // namespace string), and a host-supplied backend (`config.blobs`: R2, …)
2046 // never sees the FumaDB table policies at all. So the read-only posture
2047 // is applied HERE, once, on the base store — pending approvals and every
2048 // plugin namespace inherit it. Per-partition visibility is the plugin
2049 // blob store's own `BlobAccess` clamp below.
2050 const rawBlobs = config.blobs ?? makeFumaBlobStore(fuma);
2051 const readOnlyBlobStore = (store: BlobStore): BlobStore => {
2052 const refuse = (operation: string) =>
2053 Effect.fail(
2054 new StorageError({
2055 message: `Blob ${operation} is not allowed: this executor's storage is read-only.`,
2056 cause: undefined,
2057 }),
2058 );
2059 return {
2060 get: (namespace, key) => store.get(namespace, key),
2061 getMany: (namespaces, key) => store.getMany(namespaces, key),
2062 has: (namespace, key) => store.has(namespace, key),
2063 put: () => refuse("write"),
2064 delete: () => refuse("delete"),
2065 };
2066 };
2067 const blobs =
2068 access.capabilities.storageWrites === "denied" ? readOnlyBlobStore(rawBlobs) : rawBlobs;
2039 const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect);2069 const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect);
2040 2070
2041 // Runtime-observed output shapes ("muscle memory"): learned on the2071 // Runtime-observed output shapes ("muscle memory"): learned on the
@@ -2046,6 +2076,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
2046 core,2076 core,
2047 pluginId: SHAPE_MEMORY_PLUGIN_ID,2077 pluginId: SHAPE_MEMORY_PLUGIN_ID,
2048 owner: ownerBinding,2078 owner: ownerBinding,
2079 readOwners: accessOwners,
2049 }),2080 }),
2050 );2081 );
2051 2082
@@ -2062,7 +2093,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 10 unmodified lines
2062 // runtime can recognize and retry a row stranded before its write ran.2093 // runtime can recognize and retry a row stranded before its write ran.
2063 const credentialWriteRuntimeId = crypto.randomUUID();2094 const credentialWriteRuntimeId = crypto.randomUUID();
2064 2095
2065 const staticToolOwner = (): Owner => (subject == null ? "org" : "user");2096 // The partition static tools present under: the product's first-ranked
2097 // owner (a bound member's "user", a subject-less binding's "org").
2098 const staticToolOwner = (): Owner => accessOwners[0]!;
2066 const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName =>2099 const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName =>
2067 ConnectionName.make(integration.id === EXECUTOR_INTEGRATION_ID ? "coreTools" : "static");2100 ConnectionName.make(integration.id === EXECUTOR_INTEGRATION_ID ? "coreTools" : "static");
2068 2101
@@ -3276,11 +3309,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1207 unmodified lines
3276 const existing = yield* findIntegrationRow(input.slug);3309 const existing = yield* findIntegrationRow(input.slug);
3277 const config = input.config === undefined ? null : input.config;3310 const config = input.config === undefined ? null : input.config;
3278 if (existing) {3311 if (existing) {
3279 // Extension methods also run for subjectless boot/system executors,3312 // Replacement of an existing catalog row is the same workspace
3280 // which must be able to converge existing catalog rows. A bound3313 // mutation as creation. WHO may perform it — an end-user admin, or
3281 // subject is an end-user principal, so its replacement is the same3314 // a subject-less boot-convergence service — is entirely the
3282 // workspace mutation as creation and requires the live role guard.3315 // product's `settingsWrite` rule (workspaceServiceAccess allows
3283 if (subject !== null) yield* guardOrgWrite();3316 // it); core keeps no subject-less exemption of its own.
3317 yield* guardOrgWrite();
3284 yield* core.updateMany("integration", {3318 yield* core.updateMany("integration", {
3285 where: (b: AnyCb) => b("slug", "=", String(input.slug)),3319 where: (b: AnyCb) => b("slug", "=", String(input.slug)),
3286 set: {3320 set: {
@@ -5387,103 +5421,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2100 unmodified lines
5387 });5421 });
5388 5422
5389 // ------------------------------------------------------------------5423 // ------------------------------------------------------------------
5390 // Active policy source.5424 // Effective tool policy — the PRODUCT's evaluation, core's enforcement.
5425 //
5426 // Core assembles the rule material (the stored rows as a lazy read, and
5427 // the plugin-registered provider when a toolkit session bound one) and
5428 // hands it to `ExecutorAccess.toolPolicy`, once per surface operation.
5429 // Owner ranking, cross-owner merging, the plugin-default fallback and
5430 // the capability-allowlist default all live in that product hook; core
5431 // only enforces the returned `EffectivePolicy` (block refuses, approval
5432 // gates).
5391 // ------------------------------------------------------------------5433 // ------------------------------------------------------------------
5392 5434
5393 type ActivePolicyRuleSet =5435 const makeToolPolicyEvaluator = (): Effect.Effect<ToolPolicyEvaluator, StorageFailure> =>
5394 | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }5436 access.toolPolicy({
5395 | {5437 policyRows: core.findMany("tool_policy", {}),
5396 readonly kind: "provider";5438 provider: activeToolPolicyProvider,
5397 readonly provider: ToolPolicyProvider;5439 });
5398 readonly rules: readonly ToolPolicyProviderRule[] | null;
5399 }
5400 | {
5401 readonly kind: "prepared";
5402 readonly resolve: (input: {
5403 readonly toolId: string;
5404 readonly defaultRequiresApproval?: boolean;
5405 }) => EffectivePolicy;
5406 };
5407
5408 const compareProviderPolicyRule = (
5409 a: ToolPolicyProviderRule,
5410 b: ToolPolicyProviderRule,
5411 ): number => {
5412 if (a.position < b.position) return -1;
5413 if (a.position > b.position) return 1;
5414 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5415 };
5416
5417 const resolveProviderPolicyFromRules = (
5418 toolId: string,
5419 rules: readonly ToolPolicyProviderRule[],
5420 ): EffectivePolicy => {
5421 for (const rule of [...rules].sort(compareProviderPolicyRule)) {
5422 if (!matchPattern(rule.pattern, toolId)) continue;
5423 return {
5424 action: rule.action,
5425 source: "user",
5426 pattern: rule.pattern,
5427 policyId: rule.id,
5428 };
5429 }
5430 // Toolkit-style providers are capability allowlists. No matching rule
5431 // means the tool is outside the capability boundary.
5432 return {
5433 action: "block",
5434 source: "user",
5435 pattern: "*",
5436 };
5437 };
5438
5439 const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
5440 activeToolPolicyProvider
5441 ? // Batched per-operation resolver: fetch all policy + connection state
5442 // once, then resolve every tool in this operation against that
5443 // snapshot. Avoids the per-tool resolve N+1 on the list surface.
5444 activeToolPolicyProvider.prepare
5445 ? activeToolPolicyProvider.prepare().pipe(
5446 Effect.map((resolve) => ({
5447 kind: "prepared" as const,
5448 resolve,
5449 })),
5450 )
5451 : activeToolPolicyProvider.resolve
5452 ? Effect.succeed({
5453 kind: "provider" as const,
5454 provider: activeToolPolicyProvider,
5455 rules: null,
5456 })
5457 : activeToolPolicyProvider.list().pipe(
5458 Effect.map((rules) => ({
5459 kind: "provider" as const,
5460 provider: activeToolPolicyProvider!,
5461 rules,
5462 })),
5463 )
5464 : core
5465 .findMany("tool_policy", {})
5466 .pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
5467
5468 const resolvePolicyFromRuleSet = (
5469 toolId: string,
5470 ruleSet: ActivePolicyRuleSet,
5471 defaultRequiresApproval?: boolean,
5472 ): Effect.Effect<EffectivePolicy, StorageFailure> =>
5473 ruleSet.kind === "prepared"
5474 ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval }))
5475 : ruleSet.kind === "provider"
5476 ? ruleSet.provider.resolve
5477 ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
5478 : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
5479 : Effect.succeed(
5480 resolveEffectivePolicy(
5481 toolId,
5482 ruleSet.rows,
5483 ownerRankForRow,
5484 defaultRequiresApproval,
5485 ),
5486 );
5487 5440
5488 // ------------------------------------------------------------------5441 // ------------------------------------------------------------------
5489 // Tools (read surface)5442 // Tools (read surface)
@@ -5528,7 +5481,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 38 unmodified lines
5528 // network side effects on a read-only credential. Skip it entirely:5481 // network side effects on a read-only credential. Skip it entirely:
5529 // read-only-ness of the platform read path is a stated invariant here,5482 // read-only-ness of the platform read path is a stated invariant here,
5530 // not an accident of the best-effort catch below.5483 // not an accident of the best-effort catch below.
5531 if (config.platformView === true) return;5484 if (access.capabilities.storageWrites === "denied") return;
5532 const integrations = yield* core.findMany("integration", {});5485 const integrations = yield* core.findMany("integration", {});
5533 if (integrations.length === 0) return;5486 if (integrations.length === 0) return;
5534 const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));5487 const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));
@@ -5692,7 +5645,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 157 unmodified lines
5692 select: TOOL_INVOCATION_COLUMNS,5645 select: TOOL_INVOCATION_COLUMNS,
5693 });5646 });
5694 const includeBlocked = filter?.includeBlocked ?? false;5647 const includeBlocked = filter?.includeBlocked ?? false;
5695 const policyRules = yield* listActivePolicyRuleSet();5648 const policyEvaluator = yield* makeToolPolicyEvaluator();
5696 // Only tools whose integration is still in the catalog. A tool row5649 // Only tools whose integration is still in the catalog. A tool row
5697 // whose integration was removed is an orphan (a removal that could5650 // whose integration was removed is an orphan (a removal that could
5698 // not reach this subject's rows): listing it invites an invoke that5651 // not reach this subject's rows): listing it invites an invoke that
@@ -5704,11 +5657,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
5704 const tool = rowToTool(row);5657 const tool = rowToTool(row);
5705 if (!matchesToolFilter(tool, filter)) continue;5658 if (!matchesToolFilter(tool, filter)) continue;
5706 if (!includeBlocked) {5659 if (!includeBlocked) {
5707 const effective = yield* resolvePolicyFromRuleSet(5660 const effective = yield* policyEvaluator.resolve({
5708 normalizedPolicyId(tool),5661 toolId: normalizedPolicyId(tool),
5709 policyRules,5662 defaultRequiresApproval: tool.annotations?.requiresApproval,
5710 tool.annotations?.requiresApproval,5663 });
5711 );
5712 if (effective.action === "block") continue;5664 if (effective.action === "block") continue;
5713 }5665 }
5714 tools.push(tool);5666 tools.push(tool);
@@ -5717,11 +5669,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
5717 const tool = staticToolToTool(entry);5669 const tool = staticToolToTool(entry);
5718 if (!matchesToolFilter(tool, filter)) continue;5670 if (!matchesToolFilter(tool, filter)) continue;
5719 if (!includeBlocked) {5671 if (!includeBlocked) {
5720 const effective = yield* resolvePolicyFromRuleSet(5672 const effective = yield* policyEvaluator.resolve({
5721 normalizedPolicyId(tool),5673 toolId: normalizedPolicyId(tool),
5722 policyRules,5674 defaultRequiresApproval: tool.annotations?.requiresApproval,
5723 tool.annotations?.requiresApproval,5675 });
5724 );
5725 if (effective.action === "block") continue;5676 if (effective.action === "block") continue;
5726 }5677 }
5727 tools.push(tool);5678 tools.push(tool);
@@ -5733,15 +5684,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
5733 address: ToolAddress,5684 address: ToolAddress,
5734 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>5685 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>
5735 Effect.gen(function* () {5686 Effect.gen(function* () {
5736 const policyRules = yield* listActivePolicyRuleSet();5687 const policyEvaluator = yield* makeToolPolicyEvaluator();
5737 const staticEntry = staticTools.get(String(address));5688 const staticEntry = staticTools.get(String(address));
5738 if (staticEntry) {5689 if (staticEntry) {
5739 const tool = staticToolToTool(staticEntry);5690 const tool = staticToolToTool(staticEntry);
5740 const effective = yield* resolvePolicyFromRuleSet(5691 const effective = yield* policyEvaluator.resolve({
5741 normalizedPolicyId(tool),5692 toolId: normalizedPolicyId(tool),
5742 policyRules,5693 defaultRequiresApproval: tool.annotations?.requiresApproval,
5743 tool.annotations?.requiresApproval,5694 });
5744 );
5745 if (effective.action === "block") return null;5695 if (effective.action === "block") return null;
5746 const preview = yield* Effect.tryPromise({5696 const preview = yield* Effect.tryPromise({
5747 try: () =>5697 try: () =>
@@ -5779,11 +5729,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines
5779 });5729 });
5780 if (!row) return null;5730 if (!row) return null;
5781 const tool = rowToTool(row);5731 const tool = rowToTool(row);
5782 const effective = yield* resolvePolicyFromRuleSet(5732 const effective = yield* policyEvaluator.resolve({
5783 normalizedPolicyId(tool),5733 toolId: normalizedPolicyId(tool),
5784 policyRules,5734 defaultRequiresApproval: tool.annotations?.requiresApproval,
5785 tool.annotations?.requiresApproval,5735 });
5786 );
5787 if (effective.action === "block") return null;5736 if (effective.action === "block") return null;
5788 5737
5789 const runtime = runtimes.get(row.plugin_id);5738 const runtime = runtimes.get(row.plugin_id);
@@ -5941,11 +5890,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 151 unmodified lines
5941 });5890 });
5942 5891
5943 // ------------------------------------------------------------------5892 // ------------------------------------------------------------------
5944 // Policies — owner-ranked (user=0 inner, org=1 outer).5893 // Policies — CRUD over the stored rows. LIST ordering follows the
5894 // product's owner precedence; how rules RESOLVE into an effective
5895 // decision is the product's `ExecutorAccess.toolPolicy` hook.
5945 // ------------------------------------------------------------------5896 // ------------------------------------------------------------------
5946 5897
5947 const ownerRankForRow = (row: { readonly owner: string }): number =>5898 const ownerListRank = (row: { readonly owner: string }): number => {
5948 row.owner === "user" ? 0 : 1;5899 const rank = accessOwners.indexOf(row.owner as Owner);
5900 return rank === -1 ? accessOwners.length : rank;
5901 };
5949 5902
5950 // Tool policies gate by tool identity (`<integration>.<tool>`), independent of5903 // Tool policies gate by tool identity (`<integration>.<tool>`), independent of
5951 // which connection serves it; the org/user split is handled by owner-scoped5904 // which connection serves it; the org/user split is handled by owner-scoped
@@ -5961,7 +5914,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines
5961 .pipe(5914 .pipe(
5962 Effect.map((rows) =>5915 Effect.map((rows) =>
5963 [...rows]5916 [...rows]
5964 .sort((a, b) => ownerRankForRow(a) - ownerRankForRow(b) || comparePolicyRow(a, b))5917 .sort((a, b) => ownerListRank(a) - ownerListRank(b) || comparePolicyRow(a, b))
5965 .map(rowToToolPolicy),5918 .map(rowToToolPolicy),
5966 ),5919 ),
5967 );5920 );
@@ -6089,7 +6042,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 121 unmodified lines
6089 requiresApproval = annotations?.requiresApproval;6042 requiresApproval = annotations?.requiresApproval;
6090 }6043 }
6091 }6044 }
6092 return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);6045 // The stored-rules surface: this deliberately resolves the WORKSPACE
6046 // rules a settings UI edits — a session capability allowlist (the
6047 // plugin provider) is a different question and is not consulted here.
6048 const evaluator = yield* access.toolPolicy({
6049 policyRows: Effect.succeed(policyRows),
6050 provider: null,
6051 });
6052 return yield* evaluator.resolve({ toolId, defaultRequiresApproval: requiresApproval });
6093 });6053 });
6094 6054
6095 // ------------------------------------------------------------------6055 // ------------------------------------------------------------------
@@ -6305,6 +6265,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 209 unmodified lines
6305 }6265 }
6306 });6266 });
6307 6267
6268 // An approval pause can outlive its pre-pause snapshot by minutes or
6269 // days: an admin may rewrite policies while the call waits on a human.
6270 // The granted approval stays granted — it is never asked again — but the
6271 // decision to RUN must be fresh, so after an accepted approval the
6272 // effective policy is re-resolved from the live rule source before any
6273 // credential is resolved or handler executes. A rule that now BLOCKS the
6274 // tool wins over the stale snapshot; a fresh `require_approval` is
6275 // already satisfied by the approval that was just granted. Callers that
6276 // retained storage rows across the pause re-read those themselves.
6277 const recheckPolicyAfterApproval = (
6278 toolId: string,
6279 address: ToolAddress,
6280 defaultRequiresApproval: boolean | undefined,
6281 ): Effect.Effect<void, ToolBlockedError | StorageFailure> =>
6282 Effect.gen(function* () {
6283 const policyEvaluator = yield* makeToolPolicyEvaluator();
6284 const fresh = yield* policyEvaluator.resolve({ toolId, defaultRequiresApproval });
6285 if (fresh.action === "block") {
6286 return yield* new ToolBlockedError({
6287 address,
6288 pattern: fresh.pattern ?? "*",
6289 });
6290 }
6291 });
6292
6308 // ------------------------------------------------------------------6293 // ------------------------------------------------------------------
6309 // execute — the invoke path.6294 // execute — the invoke path.
6310 // ------------------------------------------------------------------6295 // ------------------------------------------------------------------
@@ -6388,12 +6373,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 77 unmodified lines
6388 // not the 5-segment dynamic form.6373 // not the 5-segment dynamic form.
6389 const staticEntry = staticTools.get(String(address));6374 const staticEntry = staticTools.get(String(address));
6390 if (staticEntry) {6375 if (staticEntry) {
6391 const policyRules = yield* listActivePolicyRuleSet();6376 const policyEvaluator = yield* makeToolPolicyEvaluator();
6392 const policy = yield* resolvePolicyFromRuleSet(6377 const policy = yield* policyEvaluator.resolve({
6393 String(address),6378 toolId: String(address),
6394 policyRules,6379 defaultRequiresApproval: staticEntry.tool.annotations?.requiresApproval,
6395 staticEntry.tool.annotations?.requiresApproval,6380 });
6396 );
6397 if (policy.action === "block") {6381 if (policy.action === "block") {
6398 return yield* new ToolBlockedError({6382 return yield* new ToolBlockedError({
6399 address,6383 address,
@@ -6401,6 +6385,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
6401 });6385 });
6402 }6386 }
6403 yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler);6387 yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler);
6388 if (approvalRequired(staticEntry.tool.annotations, policy)) {
6389 yield* recheckPolicyAfterApproval(
6390 String(address),
6391 address,
6392 staticEntry.tool.annotations?.requiresApproval,
6393 );
6394 }
6404 return yield* wrapInvocationError(6395 return yield* wrapInvocationError(
6405 staticEntry.tool.handler({6396 staticEntry.tool.handler({
6406 ctx: staticEntry.ctx,6397 ctx: staticEntry.ctx,
@@ -6472,7 +6463,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines
6472 }),6463 }),
6473 { startImmediately: true },6464 { startImmediately: true },
6474 );6465 );
6475 const policyRulesFiber = yield* Effect.forkChild(listActivePolicyRuleSet());6466 const policyEvaluatorFiber = yield* Effect.forkChild(makeToolPolicyEvaluator());
6476 const connectionRowFiber = yield* Effect.forkChild(6467 const connectionRowFiber = yield* Effect.forkChild(
6477 findConnectionRow({6468 findConnectionRow({
6478 owner: parsed.owner,6469 owner: parsed.owner,
@@ -6508,13 +6499,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
6508 6499
6509 // Resolve policy (owner-ranked).6500 // Resolve policy (owner-ranked).
6510 const toolForPolicy = rowToTool(row);6501 const toolForPolicy = rowToTool(row);
6511 const policyRules = yield* Fiber.join(policyRulesFiber);6502 const policyEvaluator = yield* Fiber.join(policyEvaluatorFiber);
6512 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;6503 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
6513 const policy = yield* resolvePolicyFromRuleSet(6504 const policy = yield* policyEvaluator.resolve({
6514 normalizedPolicyId(toolForPolicy),6505 toolId: normalizedPolicyId(toolForPolicy),
6515 policyRules,6506 defaultRequiresApproval: annotations?.requiresApproval,
6516 annotations?.requiresApproval,6507 });
6517 );
6518 if (policy.action === "block") {6508 if (policy.action === "block") {
6519 return yield* new ToolBlockedError({6509 return yield* new ToolBlockedError({
6520 address,6510 address,
@@ -6571,6 +6561,56 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines
6571 }6561 }
6572 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);6562 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);
6573 6563
6564 // Fresh decisions after the pause (see `recheckPolicyAfterApproval`):
6565 // besides the policy, re-read the rows this call retained across
6566 // the approval. A connection or tool removed while the call waited
6567 // on a human must fail as not-found here — before any credential
6568 // is resolved — not execute against rows that no longer exist.
6569 let approvedToolRow = row;
6570 let approvedConnectionRow = connectionRow;
6571 if (approvalRequired(resolvedAnnotations, policy)) {
6572 yield* recheckPolicyAfterApproval(
6573 normalizedPolicyId(toolForPolicy),
6574 address,
6575 resolvedAnnotations?.requiresApproval,
6576 );
6577 const freshToolRow = yield* core.findFirst("tool", {
6578 where: (b: AnyCb) =>
6579 b.and(
6580 byOwner(parsed.owner)(b),
6581 b("integration", "=", String(parsed.integration)),
6582 b("connection", "=", String(parsed.connection)),
6583 b("name", "=", String(parsed.tool)),
6584 ),
6585 select: TOOL_INVOCATION_COLUMNS,
6586 });
6587 if (!freshToolRow) {
6588 return yield* new ToolNotFoundError({ address });
6589 }
6590 // The plugin runtime and handler were bound to the PRE-pause row.
6591 // If the integration was torn down and re-registered under a
6592 // different plugin while the call waited, the tool the user
6593 // approved no longer exists in that form — fail as not-found
6594 // rather than hand the replacement row to the old plugin.
6595 if (freshToolRow.plugin_id !== row.plugin_id) {
6596 return yield* new ToolNotFoundError({ address });
6597 }
6598 approvedToolRow = freshToolRow;
6599 const freshConnectionRow = yield* findConnectionRow({
6600 owner: parsed.owner,
6601 integration: parsed.integration,
6602 name: parsed.connection,
6603 });
6604 if (!freshConnectionRow) {
6605 return yield* new ConnectionNotFoundError({
6606 owner: parsed.owner,
6607 integration: parsed.integration,
6608 name: parsed.connection,
6609 });
6610 }
6611 approvedConnectionRow = freshConnectionRow;
6612 }
6613
6574 // Resolve every named credential input (`variable → value`); `value` is6614 // Resolve every named credential input (`variable → value`); `value` is
6575 // the primary `token` for single-input + OAuth callers. The6615 // the primary `token` for single-input + OAuth callers. The
6576 // integration-row read is independent of credential resolution, so6616 // integration-row read is independent of credential resolution, so
@@ -6594,9 +6634,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines
6594 // integration read cannot gate the credential error and a failed6634 // integration read cannot gate the credential error and a failed
6595 // one is deliberately abandoned, never silently dropped as an6635 // one is deliberately abandoned, never silently dropped as an
6596 // unobserved value.6636 // unobserved value.
6597 const valuesFiber = yield* Effect.forkChild(resolveConnectionValues(connectionRow), {6637 const valuesFiber = yield* Effect.forkChild(
6598 startImmediately: true,6638 resolveConnectionValues(approvedConnectionRow),
6599 });6639 {
6640 startImmediately: true,
6641 },
6642 );
6600 const integrationRowFiber = yield* Effect.forkChild(6643 const integrationRowFiber = yield* Effect.forkChild(
6601 findIntegrationRow(parsed.integration),6644 findIntegrationRow(parsed.integration),
6602 );6645 );
@@ -6611,7 +6654,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
6611 if (!integrationRow) {6654 if (!integrationRow) {
6612 return yield* new IntegrationNotFoundError({ slug: parsed.integration });6655 return yield* new IntegrationNotFoundError({ slug: parsed.integration });
6613 }6656 }
6614 const grantedScopes = grantedScopesFromRow(connectionRow);6657 const grantedScopes = grantedScopesFromRow(approvedConnectionRow);
6615 const invokeTool = runtime.plugin.invokeTool;6658 const invokeTool = runtime.plugin.invokeTool;
6616 const invokeWith = (6659 const invokeWith = (
6617 resolved: Record<string, string | null>,6660 resolved: Record<string, string | null>,
@@ -6620,7 +6663,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
6620 owner: parsed.owner,6663 owner: parsed.owner,
6621 integration: parsed.integration,6664 integration: parsed.integration,
6622 connection: parsed.connection,6665 connection: parsed.connection,
6623 template: AuthTemplateSlug.make(connectionRow.template),6666 template: AuthTemplateSlug.make(approvedConnectionRow.template),
6624 value: resolved[PRIMARY_INPUT_VARIABLE] ?? null,6667 value: resolved[PRIMARY_INPUT_VARIABLE] ?? null,
6625 values: resolved,6668 values: resolved,
6626 config: decodeJsonColumn(integrationRow.config),6669 config: decodeJsonColumn(integrationRow.config),
@@ -6629,7 +6672,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
6629 return wrapInvocationError(6672 return wrapInvocationError(
6630 invokeTool({6673 invokeTool({
6631 ctx: runtime.ctx,6674 ctx: runtime.ctx,
6632 toolRow: row,6675 toolRow: approvedToolRow,
6633 credential,6676 credential,
6634 args,6677 args,
6635 elicit: buildElicit(address, args, handler),6678 elicit: buildElicit(address, args, handler),
@@ -6656,7 +6699,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
6656 // guidance rather than a masked one.6699 // guidance rather than a masked one.
6657 const { result, usedValues } = yield* Effect.gen(function* () {6700 const { result, usedValues } = yield* Effect.gen(function* () {
6658 if (!isUnauthorizedToolFailure(first)) return { result: first, usedValues: values };6701 if (!isUnauthorizedToolFailure(first)) return { result: first, usedValues: values };
6659 const refreshed = yield* forceRefreshConnectionValues(connectionRow).pipe(6702 const refreshed = yield* forceRefreshConnectionValues(approvedConnectionRow).pipe(
6660 // A failed re-mint is not this call's failure to report: the upstream6703 // A failed re-mint is not this call's failure to report: the upstream
6661 // already produced an auth failure with recovery guidance, which is6704 // already produced an auth failure with recovery guidance, which is
6662 // strictly more actionable than a refresh-plumbing error. Keep it.6705 // strictly more actionable than a refresh-plumbing error. Keep it.
@@ -6671,7 +6714,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
6671 usedValues: refreshed,6714 usedValues: refreshed,
6672 };6715 };
6673 });6716 });
6674 yield* healPersistedHealthOnUse(connectionRow, result, usedValues);6717 yield* healPersistedHealthOnUse(approvedConnectionRow, result, usedValues);
6675 return result;6718 return result;
6676 });6719 });
6677 // Interrupting an already-completed (or already-joined) fiber is a6720 // Interrupting an already-completed (or already-joined) fiber is a
@@ -6684,7 +6727,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6 unmodified lines
6684 // so an interruption that lands before the join reaches it promptly.6727 // so an interruption that lands before the join reaches it promptly.
6685 return yield* Effect.ensuring(6728 return yield* Effect.ensuring(
6686 invokeDynamicTool,6729 invokeDynamicTool,
6687 Fiber.interruptAll([toolRowFiber, policyRulesFiber, connectionRowFiber]),6730 Fiber.interruptAll([toolRowFiber, policyEvaluatorFiber, connectionRowFiber]),
6688 );6731 );
6689 }).pipe(6732 }).pipe(
6690 // Expected tool failures (`ToolResult.fail`) resolve through the6733 // Expected tool failures (`ToolResult.fail`) resolve through the
@@ -6788,16 +6831,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 97 unmodified lines
6788 // register credential providers.6831 // register credential providers.
6789 // ------------------------------------------------------------------6832 // ------------------------------------------------------------------
6790 6833
6834 // IDENTITY partitions, deliberately NOT narrowed by `access.owners`:
6835 // these name where the binding's identity lives, while WHICH of them a
6836 // plugin blob store may read or write is the separate product decision
6837 // (`BlobAccess`) applied per store below. Identity-bound consumers keep
6838 // the full shape — see the pending-approval store.
6791 const blobPartitions: OwnerPartitions = {6839 const blobPartitions: OwnerPartitions = {
6792 org: `o:${tenant}`,6840 org: `o:${tenant}`,
6793 user: subject != null ? `u:${tenant}:${subject}` : null,6841 user: subject != null ? `u:${tenant}:${subject}` : null,
6794 };6842 };
6795 6843
6796 // Pending approvals file under the narrowest partition this executor has:6844 // Pending approvals file under the narrowest IDENTITY partition this
6797 // a subject-bound executor keeps them private to that member, and a pure-org6845 // executor has: a subject-bound executor keeps them private to that
6798 // executor (no subject) files them at the org. Either way the partition IS6846 // member, and a pure-org executor (no subject) files them at the org.
6799 // the ownership check — another caller's executor reads a different6847 // The partition IS the ownership check — another caller's executor reads
6800 // namespace and simply does not see the record.6848 // a different namespace and simply does not see the record. CONTINUATION
6849 // IDENTITY, not resource visibility: this routing must never follow the
6850 // product's narrowed `access.owners` view — rerouting an org-only
6851 // member's approvals into the SHARED org namespace would let any
6852 // coworker's binding consume them.
6801 const pendingApprovals = makePendingApprovalStore(6853 const pendingApprovals = makePendingApprovalStore(
6802 blobs,6854 blobs,
6803 blobPartitions.user ?? blobPartitions.org,6855 blobPartitions.user ?? blobPartitions.org,
@@ -6815,10 +6867,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 11 unmodified lines
6815 core,6867 core,
6816 pluginId: plugin.id,6868 pluginId: plugin.id,
6817 owner: ownerBinding,6869 owner: ownerBinding,
6870 readOwners: accessOwners,
6818 });6871 });
6819 const storageDeps: StorageDeps = {6872 const storageDeps: StorageDeps = {
6820 owner: ownerBinding,6873 owner: ownerBinding,
6821 blobs: pluginBlobStore(blobs, blobPartitions, plugin.id),6874 blobs: pluginBlobStore(blobs, blobPartitions, plugin.id, {
6875 owners: accessOwners,
6876 storageWrites: access.capabilities.storageWrites,
6877 }),
6822 pluginStorage,6878 pluginStorage,
6823 };6879 };
6824 const storage = plugin.storage(storageDeps);6880 const storage = plugin.storage(storageDeps);
@@ -7140,7 +7196,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 315 unmodified lines
7140 7196
7141 // Default OFF: without the opt-in there is no `admin` key at all, so the7197 // Default OFF: without the opt-in there is no `admin` key at all, so the
7142 // tenant-wide handle is never even constructed.7198 // tenant-wide handle is never even constructed.
7143 const admin = config.platformView === true ? makeAdmin() : undefined;7199 const admin = access.capabilities.adminReads ? makeAdmin() : undefined;
7144 7200
7145 // ------------------------------------------------------------------7201 // ------------------------------------------------------------------
7146 // close7202 // close
packages/core/sdk/src/index.ts+12−3
@@ -188,11 +188,12 @@ export { · 187 unmodified lines
188 ownerForItemId,188 ownerForItemId,
189} from "./provider-item-owner";189} from "./provider-item-owner";
190 190
191// Tool policies.191// Tool policies — vocabulary + matching/placement utilities. Resolution
192// semantics live in `@executor-js/product-access/policy`.
192export {193export {
193 matchPattern,194 matchPattern,
194 isValidPattern,195 isValidPattern,
195 effectivePolicyFromSorted,196 comparePolicyRow,
196 ToolPolicyActionSchema,197 ToolPolicyActionSchema,
197 type ToolPolicy,198 type ToolPolicy,
198 type CreateToolPolicyInput,199 type CreateToolPolicyInput,
@@ -446,10 +447,18 @@ export { · 247 unmodified lines
446export {447export {
447 CurrentOrgWriteAccess,448 CurrentOrgWriteAccess,
448 currentOrgWriteAccess,449 currentOrgWriteAccess,
450 executorAccessViolation,
449 makeOrgWriteAccessState,451 makeOrgWriteAccessState,
452 type AccessDecision,
453 type ExecutorAccess,
454 type ExecutorAccessCapabilities,
450 type OrgWriteAccess,455 type OrgWriteAccess,
451 type OrgWriteAccessState,456 type OrgWriteAccessState,
452} from "./org-write-access";457 type SettingsWriteTarget,
458 type ToolPolicyEvaluationInput,
459 type ToolPolicyEvaluator,
460 type ToolPolicySources,
461} from "./access";
453 462
454// CLI / runtime config.463// CLI / runtime config.
455export {464export {
packages/core/sdk/src/integration-removal-cascade.test.ts+9−3
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16import { definePlugin } from "./plugin";16import { definePlugin } from "./plugin";
17import type { CredentialProvider } from "./provider";17import type { CredentialProvider } from "./provider";
18import { makeTestConfig } from "./testing";18import { makeTestConfig } from "./testing";
19import { testAccess } from "@executor-js/product-access/testing";
19 20
20// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
21// Removing an integration must cascade to EVERY member's rows under it, not22// Removing an integration must cascade to EVERY member's rows under it, not
@@ -62,7 +63,11 @@ const demoPlugin = definePlugin(() => ({ · 40 unmodified lines
62 63
63const setup = () =>64const setup = () =>
64 Effect.gen(function* () {65 Effect.gen(function* () {
65 const config = makeTestConfig({ plugins: [demoPlugin] as const, subject: ALICE });66 const config = makeTestConfig({
67 access: testAccess.member(),
68 plugins: [demoPlugin] as const,
69 subject: ALICE,
70 });
66 const alice = yield* createExecutor(config);71 const alice = yield* createExecutor(config);
67 const bob = yield* createExecutor({72 const bob = yield* createExecutor({
68 ...config,73 ...config,
@@ -119,7 +124,7 @@ describe("integrations.remove cascade", () => { · 50 unmodified lines
119 it.effect("a member (org writes denied) still cannot remove", () =>124 it.effect("a member (org writes denied) still cannot remove", () =>
120 Effect.gen(function* () {125 Effect.gen(function* () {
121 const { config } = yield* setup();126 const { config } = yield* setup();
122 const member = yield* createExecutor({ ...config, orgWrites: "denied" });127 const member = yield* createExecutor({ ...config, access: testAccess.member("denied") });
123 yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore));128 yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore));
124 const error = yield* Effect.flip(member.integrations.remove(INTEG));129 const error = yield* Effect.flip(member.integrations.remove(INTEG));
125 expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true);130 expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true);
@@ -129,7 +134,7 @@ describe("integrations.remove cascade", () => { · 3 unmodified lines
129 it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () =>134 it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () =>
130 Effect.gen(function* () {135 Effect.gen(function* () {
131 const { config } = yield* setup();136 const { config } = yield* setup();
132 const platform = yield* createExecutor({ ...config, platformView: true });137 const platform = yield* createExecutor({ ...config, access: testAccess.platform() });
133 yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore));138 yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore));
134 yield* platform.integrations.remove(INTEG).pipe(139 yield* platform.integrations.remove(INTEG).pipe(
135 Effect.flatMap(() => Effect.die("expected the platform view to refuse the removal")),140 Effect.flatMap(() => Effect.die("expected the platform view to refuse the removal")),
@@ -170,6 +175,7 @@ describe("orphaned rows are not served", () => { · 34 unmodified lines
170 withQueryContext(config.testDb.db, {175 withQueryContext(config.testDb.db, {
171 tenant: String(config.tenant),176 tenant: String(config.tenant),
172 subject: BOB,177 subject: BOB,
178 owners: ["user", "org"],
173 }).findMany("tool", {}),179 }).findMany("tool", {}),
174 );180 );
175 expect(stored.map((row) => String(row.integration)).sort()).toEqual([181 expect(stored.map((row) => String(row.integration)).sort()).toEqual([
packages/core/sdk/src/oauth-ema-lifecycle.test.ts+25−6
@@ -24,6 +24,7 @@ import type { OAuthService } from "./oauth-client"; · 23 unmodified lines
24import { definePlugin } from "./plugin";24import { definePlugin } from "./plugin";
25import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";25import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
26import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";26import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";
27import { testAccess } from "@executor-js/product-access/testing";
27 28
28const INTEG = IntegrationSlug.make("acme");29const INTEG = IntegrationSlug.make("acme");
29const TEMPLATE = AuthTemplateSlug.make("oauth");30const TEMPLATE = AuthTemplateSlug.make("oauth");
@@ -157,7 +158,10 @@ describe("enterprise-managed connections", () => { · 127 unmodified lines
157 Effect.scoped(158 Effect.scoped(
158 Effect.gen(function* () {159 Effect.gen(function* () {
159 const servers = yield* enterpriseServers({});160 const servers = yield* enterpriseServers({});
160 const { executor } = yield* makeTestWorkspaceHarness({ plugins });161 const { executor } = yield* makeTestWorkspaceHarness({
162 access: testAccess.member(),
163 plugins,
164 });
161 yield* executor.acme.seed();165 yield* executor.acme.seed();
162 yield* registerClients(executor.oauth.createClient, servers);166 yield* registerClients(executor.oauth.createClient, servers);
163 167
@@ -182,7 +186,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines
182 Effect.scoped(186 Effect.scoped(
183 Effect.gen(function* () {187 Effect.gen(function* () {
184 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });188 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
185 const { executor } = yield* makeTestWorkspaceHarness({ plugins });189 const { executor } = yield* makeTestWorkspaceHarness({
190 access: testAccess.member(),
191 plugins,
192 });
186 yield* executor.acme.seed();193 yield* executor.acme.seed();
187 yield* registerClients(executor.oauth.createClient, servers);194 yield* registerClients(executor.oauth.createClient, servers);
188 yield* executor.oauth.start(startEnterpriseConnect(servers));195 yield* executor.oauth.start(startEnterpriseConnect(servers));
@@ -207,7 +214,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines
207 Effect.scoped(214 Effect.scoped(
208 Effect.gen(function* () {215 Effect.gen(function* () {
209 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });216 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
210 const { executor } = yield* makeTestWorkspaceHarness({ plugins });217 const { executor } = yield* makeTestWorkspaceHarness({
218 access: testAccess.member(),
219 plugins,
220 });
211 yield* executor.acme.seed();221 yield* executor.acme.seed();
212 yield* registerClients(executor.oauth.createClient, servers);222 yield* registerClients(executor.oauth.createClient, servers);
213 yield* executor.oauth.start(startEnterpriseConnect(servers));223 yield* executor.oauth.start(startEnterpriseConnect(servers));
@@ -241,7 +251,10 @@ describe("enterprise-managed connections", () => { · 27 unmodified lines
241 Effect.scoped(251 Effect.scoped(
242 Effect.gen(function* () {252 Effect.gen(function* () {
243 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });253 const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
244 const { executor } = yield* makeTestWorkspaceHarness({ plugins });254 const { executor } = yield* makeTestWorkspaceHarness({
255 access: testAccess.member(),
256 plugins,
257 });
245 yield* executor.acme.seed();258 yield* executor.acme.seed();
246 yield* registerClients(executor.oauth.createClient, servers);259 yield* registerClients(executor.oauth.createClient, servers);
247 yield* executor.oauth.start(startEnterpriseConnect(servers));260 yield* executor.oauth.start(startEnterpriseConnect(servers));
@@ -287,7 +300,10 @@ describe("enterprise-managed connections", () => { · 39 unmodified lines
287 errorDescription: "This client is not approved for the requested MCP server.",300 errorDescription: "This client is not approved for the requested MCP server.",
288 },301 },
289 });302 });
290 const { executor } = yield* makeTestWorkspaceHarness({ plugins });303 const { executor } = yield* makeTestWorkspaceHarness({
304 access: testAccess.member(),
305 plugins,
306 });
291 yield* executor.acme.seed();307 yield* executor.acme.seed();
292 yield* registerClients(executor.oauth.createClient, servers);308 yield* registerClients(executor.oauth.createClient, servers);
293 309
@@ -316,7 +332,10 @@ describe("enterprise-managed connections", () => { · 22 unmodified lines
316 Effect.scoped(332 Effect.scoped(
317 Effect.gen(function* () {333 Effect.gen(function* () {
318 const servers = yield* enterpriseServers({ advertiseProfile: false });334 const servers = yield* enterpriseServers({ advertiseProfile: false });
319 const { executor } = yield* makeTestWorkspaceHarness({ plugins });335 const { executor } = yield* makeTestWorkspaceHarness({
336 access: testAccess.member(),
337 plugins,
338 });
320 yield* executor.acme.seed();339 yield* executor.acme.seed();
321 yield* registerClients(executor.oauth.createClient, servers);340 yield* registerClients(executor.oauth.createClient, servers);
322 341
packages/core/sdk/src/oauth-ema-rollout.test.ts+2−0
@@ -34,6 +34,7 @@ import type { · 33 unmodified lines
34import { definePlugin } from "./plugin";34import { definePlugin } from "./plugin";
35import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";35import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
36import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";36import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";
37import { testAccess } from "@executor-js/product-access/testing";
37 38
38const INTEG = IntegrationSlug.make("acme");39const INTEG = IntegrationSlug.make("acme");
39const TEMPLATE = AuthTemplateSlug.make("oauth");40const TEMPLATE = AuthTemplateSlug.make("oauth");
@@ -233,6 +234,7 @@ const tokenExchangeCount = (servers: EnterpriseServers) => · 193 unmodified lines
233 234
234const harness = (rollout: EnterpriseManagedRollout | undefined) =>235const harness = (rollout: EnterpriseManagedRollout | undefined) =>
235 makeTestWorkspaceHarness({236 makeTestWorkspaceHarness({
237 access: testAccess.member(),
236 plugins,238 plugins,
237 tenant: TENANT,239 tenant: TENANT,
238 subject: SUBJECT,240 subject: SUBJECT,
packages/core/sdk/src/oauth-first-party.test.ts+15−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18import { definePlugin } from "./plugin";18import { definePlugin } from "./plugin";
19import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";19import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
20import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";20import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
21import { testAccess } from "@executor-js/product-access/testing";
21 22
22// First-party OAuth clients: host-operated apps declared in executor config23// First-party OAuth clients: host-operated apps declared in executor config
23// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from24// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from
@@ -119,6 +120,7 @@ describe("first-party oauth clients", () => { · 95 unmodified lines
119 Effect.gen(function* () {120 Effect.gen(function* () {
120 const server = yield* serveOAuthTestServer({ scopes: ["read"] });121 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
121 const { executor } = yield* makeTestWorkspaceHarness({122 const { executor } = yield* makeTestWorkspaceHarness({
123 access: testAccess.member(),
122 plugins,124 plugins,
123 firstPartyOAuthClients: [firstPartyClientFor(server)],125 firstPartyOAuthClients: [firstPartyClientFor(server)],
124 });126 });
@@ -170,6 +172,7 @@ describe("first-party oauth clients", () => { · 45 unmodified lines
170 Effect.gen(function* () {172 Effect.gen(function* () {
171 const server = yield* serveOAuthTestServer({ scopes: ["repo"] });173 const server = yield* serveOAuthTestServer({ scopes: ["repo"] });
172 const { executor } = yield* makeTestWorkspaceHarness({174 const { executor } = yield* makeTestWorkspaceHarness({
175 access: testAccess.member(),
173 plugins,176 plugins,
174 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }],177 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }],
175 });178 });
@@ -204,6 +207,7 @@ describe("first-party oauth clients", () => { · 28 unmodified lines
204 Effect.gen(function* () {207 Effect.gen(function* () {
205 const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] });208 const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] });
206 const { executor } = yield* makeTestWorkspaceHarness({209 const { executor } = yield* makeTestWorkspaceHarness({
210 access: testAccess.member(),
207 plugins,211 plugins,
208 firstPartyOAuthClients: [212 firstPartyOAuthClients: [
209 {213 {
@@ -240,6 +244,7 @@ describe("first-party oauth clients", () => { · 30 unmodified lines
240 Effect.gen(function* () {244 Effect.gen(function* () {
241 const server = yield* serveOAuthTestServer({ scopes: ["read"] });245 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
242 const harness = yield* makeTestWorkspaceHarness({246 const harness = yield* makeTestWorkspaceHarness({
247 access: testAccess.member(),
243 plugins,248 plugins,
244 firstPartyOAuthClients: [firstPartyClientFor(server)],249 firstPartyOAuthClients: [firstPartyClientFor(server)],
245 });250 });
@@ -288,6 +293,7 @@ describe("first-party oauth clients", () => { · 42 unmodified lines
288 Effect.gen(function* () {293 Effect.gen(function* () {
289 const server = yield* serveOAuthTestServer({ scopes: ["read"] });294 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
290 const { executor } = yield* makeTestWorkspaceHarness({295 const { executor } = yield* makeTestWorkspaceHarness({
296 access: testAccess.member(),
291 plugins,297 plugins,
292 firstPartyOAuthClients: [298 firstPartyOAuthClients: [
293 {299 {
@@ -330,6 +336,7 @@ describe("first-party oauth clients", () => { · 36 unmodified lines
330 const server = yield* serveOAuthTestServer({ scopes: ["read"] });336 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
331 let enabled = true;337 let enabled = true;
332 const { executor } = yield* makeTestWorkspaceHarness({338 const { executor } = yield* makeTestWorkspaceHarness({
339 access: testAccess.member(),
333 plugins,340 plugins,
334 subject: "review-user",341 subject: "review-user",
335 tenant: "review-org",342 tenant: "review-org",
@@ -368,6 +375,7 @@ describe("first-party oauth clients", () => { · 32 unmodified lines
368 Effect.gen(function* () {375 Effect.gen(function* () {
369 const server = yield* serveOAuthTestServer({ scopes: ["read"] });376 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
370 const harness = yield* makeTestWorkspaceHarness({377 const harness = yield* makeTestWorkspaceHarness({
378 access: testAccess.member(),
371 plugins,379 plugins,
372 firstPartyOAuthClients: [380 firstPartyOAuthClients: [
373 {381 {
@@ -439,6 +447,7 @@ describe("first-party oauth clients", () => { · 65 unmodified lines
439 Effect.gen(function* () {447 Effect.gen(function* () {
440 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });448 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
441 const { executor } = yield* makeTestWorkspaceHarness({449 const { executor } = yield* makeTestWorkspaceHarness({
450 access: testAccess.member(),
442 plugins,451 plugins,
443 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }],452 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }],
444 });453 });
@@ -467,6 +476,7 @@ describe("first-party oauth clients", () => { · 22 unmodified lines
467 Effect.gen(function* () {476 Effect.gen(function* () {
468 const server = yield* serveOAuthTestServer({ scopes: ["read"] });477 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
469 const { executor } = yield* makeTestWorkspaceHarness({478 const { executor } = yield* makeTestWorkspaceHarness({
479 access: testAccess.member(),
470 plugins,480 plugins,
471 firstPartyOAuthClients: [firstPartyClientFor(server)],481 firstPartyOAuthClients: [firstPartyClientFor(server)],
472 });482 });
@@ -496,7 +506,10 @@ describe("first-party oauth clients", () => { · 23 unmodified lines
496 Effect.scoped(506 Effect.scoped(
497 Effect.gen(function* () {507 Effect.gen(function* () {
498 yield* serveOAuthTestServer({ scopes: ["read"] });508 yield* serveOAuthTestServer({ scopes: ["read"] });
499 const { executor } = yield* makeTestWorkspaceHarness({ plugins });509 const { executor } = yield* makeTestWorkspaceHarness({
510 access: testAccess.member(),
511 plugins,
512 });
500 yield* executor.acme.seed();513 yield* executor.acme.seed();
501 514
502 const error = yield* executor.oauth515 const error = yield* executor.oauth
@@ -523,6 +536,7 @@ describe("first-party oauth clients", () => { · 20 unmodified lines
523 Effect.gen(function* () {536 Effect.gen(function* () {
524 const server = yield* serveOAuthTestServer({ scopes: ["read"] });537 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
525 const { executor } = yield* makeTestWorkspaceHarness({538 const { executor } = yield* makeTestWorkspaceHarness({
539 access: testAccess.member(),
526 plugins,540 plugins,
527 firstPartyOAuthClients: [firstPartyClientFor(server)],541 firstPartyOAuthClients: [firstPartyClientFor(server)],
528 });542 });
packages/core/sdk/src/oauth-flow.test.ts+116−30
@@ -24,6 +24,7 @@ import type { CredentialProvider } from "./provider"; · 23 unmodified lines
24import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";24import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
25import { ToolResult } from "./tool-result";25import { ToolResult } from "./tool-result";
26import { serveOAuthTestServer } from "./testing/oauth-test-server";26import { serveOAuthTestServer } from "./testing/oauth-test-server";
27import { testAccess } from "@executor-js/product-access/testing";
27 28
28// Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow29// Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow
29// and OAuth access-token refresh end to end against the test authorization30// and OAuth access-token refresh end to end against the test authorization
@@ -197,7 +198,10 @@ describe("oauth.start / oauth.complete", () => { · 167 unmodified lines
197 Effect.scoped(198 Effect.scoped(
198 Effect.gen(function* () {199 Effect.gen(function* () {
199 const server = yield* serveOAuthTestServer({ scopes: ["read"] });200 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
200 const { executor } = yield* makeTestWorkspaceHarness({ plugins });201 const { executor } = yield* makeTestWorkspaceHarness({
202 access: testAccess.member(),
203 plugins,
204 });
201 yield* executor.acme.seed();205 yield* executor.acme.seed();
202 206
203 yield* executor.oauth.createClient({207 yield* executor.oauth.createClient({
@@ -302,6 +306,7 @@ describe("oauth.start / oauth.complete", () => { · 98 unmodified lines
302 }))();306 }))();
303 const server = yield* serveOAuthTestServer({ scopes: ["read"] });307 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
304 const { executor } = yield* makeTestWorkspaceHarness({308 const { executor } = yield* makeTestWorkspaceHarness({
309 access: testAccess.member(),
305 plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const,310 plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const,
306 waitUntil: (promise) => keptAlive.push(promise),311 waitUntil: (promise) => keptAlive.push(promise),
307 });312 });
@@ -363,7 +368,10 @@ describe("oauth.start / oauth.complete", () => { · 55 unmodified lines
363 scopes: ["read"],368 scopes: ["read"],
364 defaultTokenEndpointAuthMethod: "client_secret_basic",369 defaultTokenEndpointAuthMethod: "client_secret_basic",
365 });370 });
366 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });371 const { executor, config } = yield* makeTestWorkspaceHarness({
372 access: testAccess.member(),
373 plugins,
374 });
367 yield* executor.acme.seed();375 yield* executor.acme.seed();
368 376
369 yield* executor.oauth.createClient({377 yield* executor.oauth.createClient({
@@ -428,7 +436,10 @@ describe("oauth.start / oauth.complete", () => { · 58 unmodified lines
428 scopes: ["read"],436 scopes: ["read"],
429 defaultTokenEndpointAuthMethod: "client_secret_basic",437 defaultTokenEndpointAuthMethod: "client_secret_basic",
430 });438 });
431 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });439 const { executor, config } = yield* makeTestWorkspaceHarness({
440 access: testAccess.member(),
441 plugins,
442 });
432 yield* executor.acme.seed();443 yield* executor.acme.seed();
433 444
434 yield* executor.oauth.createClient({445 yield* executor.oauth.createClient({
@@ -486,6 +497,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines
486 Effect.gen(function* () {497 Effect.gen(function* () {
487 const server = yield* serveOAuthTestServer({ scopes: ["read"] });498 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
488 const { executor } = yield* makeTestWorkspaceHarness({499 const { executor } = yield* makeTestWorkspaceHarness({
500 access: testAccess.member(),
489 plugins,501 plugins,
490 oauthCallbackStateOrgSlug: "acme",502 oauthCallbackStateOrgSlug: "acme",
491 });503 });
@@ -546,7 +558,10 @@ describe("oauth.start / oauth.complete", () => { · 54 unmodified lines
546 scopes: ["offline_access", "read"],558 scopes: ["offline_access", "read"],
547 omitTokenResponseScopes: ["offline_access"],559 omitTokenResponseScopes: ["offline_access"],
548 });560 });
549 const { executor } = yield* makeTestWorkspaceHarness({ plugins });561 const { executor } = yield* makeTestWorkspaceHarness({
562 access: testAccess.member(),
563 plugins,
564 });
550 yield* executor.acme.seed(["offline_access", "read"]);565 yield* executor.acme.seed(["offline_access", "read"]);
551 566
552 yield* executor.oauth.createClient({567 yield* executor.oauth.createClient({
@@ -590,7 +605,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines
590 scopes: ["openid", "email", "profile", "read"],605 scopes: ["openid", "email", "profile", "read"],
591 idTokenClaims: { email: "alice@example.com", sub: "user-1" },606 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
592 });607 });
593 const { executor } = yield* makeTestWorkspaceHarness({ plugins });608 const { executor } = yield* makeTestWorkspaceHarness({
609 access: testAccess.member(),
610 plugins,
611 });
594 yield* executor.acme.seed(["openid", "email", "profile", "read"]);612 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
595 613
596 yield* executor.oauth.createClient({614 yield* executor.oauth.createClient({
@@ -634,7 +652,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines
634 scopes: ["openid", "email", "profile", "read"],652 scopes: ["openid", "email", "profile", "read"],
635 idTokenClaims: { email: "alice@example.com", sub: "user-1" },653 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
636 });654 });
637 const { executor } = yield* makeTestWorkspaceHarness({ plugins });655 const { executor } = yield* makeTestWorkspaceHarness({
656 access: testAccess.member(),
657 plugins,
658 });
638 yield* executor.acme.seed(["openid", "email", "profile", "read"]);659 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
639 660
640 yield* executor.oauth.createClient({661 yield* executor.oauth.createClient({
@@ -679,7 +700,10 @@ describe("oauth.start / oauth.complete", () => { · 38 unmodified lines
679 scopes: ["openid", "email", "profile", "read"],700 scopes: ["openid", "email", "profile", "read"],
680 idTokenClaims: { email: "alice@example.com", sub: "user-1" },701 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
681 });702 });
682 const { executor } = yield* makeTestWorkspaceHarness({ plugins });703 const { executor } = yield* makeTestWorkspaceHarness({
704 access: testAccess.member(),
705 plugins,
706 });
683 yield* executor.acme.seed(["openid", "email", "profile", "read"]);707 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
684 708
685 yield* executor.oauth.createClient({709 yield* executor.oauth.createClient({
@@ -739,7 +763,10 @@ describe("oauth.start / oauth.complete", () => { · 53 unmodified lines
739 scopes: ["openid", "email", "profile", "read"],763 scopes: ["openid", "email", "profile", "read"],
740 idTokenClaims: { email: "alice@example.com", sub: "user-1" },764 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
741 });765 });
742 const { executor } = yield* makeTestWorkspaceHarness({ plugins });766 const { executor } = yield* makeTestWorkspaceHarness({
767 access: testAccess.member(),
768 plugins,
769 });
743 yield* executor.acme.seed(["openid", "email", "profile", "read"]);770 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
744 771
745 yield* executor.oauth.createClient({772 yield* executor.oauth.createClient({
@@ -797,7 +824,10 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines
797 scopes: ["openid", "email", "profile", "read"],824 scopes: ["openid", "email", "profile", "read"],
798 idTokenClaims: { email: "alice@example.com", sub: "user-1" },825 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
799 });826 });
800 const { executor } = yield* makeTestWorkspaceHarness({ plugins });827 const { executor } = yield* makeTestWorkspaceHarness({
828 access: testAccess.member(),
829 plugins,
830 });
801 yield* executor.acme.seed(["openid", "email", "profile", "read"]);831 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
802 832
803 yield* executor.oauth.createClient({833 yield* executor.oauth.createClient({
@@ -852,7 +882,7 @@ describe("oauth.start / oauth.complete", () => { · 48 unmodified lines
852 idTokenClaims: { email: "alice@example.com", sub: "user-1" },882 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
853 refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" },883 refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" },
854 });884 });
855 const harness = yield* makeTestWorkspaceHarness({ plugins });885 const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
856 const { executor, config } = harness;886 const { executor, config } = harness;
857 yield* executor.acme.seed(["openid", "email", "profile", "read"]);887 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
858 888
@@ -910,6 +940,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines
910 // is no silent localhost default. The redirect flow must fail loudly940 // is no silent localhost default. The redirect flow must fail loudly
911 // rather than handing the provider a wrong `http://127.0.0.1/callback`.941 // rather than handing the provider a wrong `http://127.0.0.1/callback`.
912 const { executor } = yield* makeTestWorkspaceHarness({942 const { executor } = yield* makeTestWorkspaceHarness({
943 access: testAccess.member(),
913 plugins,944 plugins,
914 redirectUri: null,945 redirectUri: null,
915 });946 });
@@ -951,6 +982,7 @@ describe("oauth.start / oauth.complete", () => { · 35 unmodified lines
951 // No redirectUri configured, but client_credentials never redirects —982 // No redirectUri configured, but client_credentials never redirects —
952 // it must still mint the connection inline.983 // it must still mint the connection inline.
953 const { executor } = yield* makeTestWorkspaceHarness({984 const { executor } = yield* makeTestWorkspaceHarness({
985 access: testAccess.member(),
954 plugins,986 plugins,
955 redirectUri: null,987 redirectUri: null,
956 });988 });
@@ -983,7 +1015,10 @@ describe("oauth.start / oauth.complete", () => { · 26 unmodified lines
983 Effect.scoped(1015 Effect.scoped(
984 Effect.gen(function* () {1016 Effect.gen(function* () {
985 const server = yield* serveOAuthTestServer();1017 const server = yield* serveOAuthTestServer();
986 const { executor } = yield* makeTestWorkspaceHarness({ plugins });1018 const { executor } = yield* makeTestWorkspaceHarness({
1019 access: testAccess.member(),
1020 plugins,
1021 });
987 yield* executor.acme.seed();1022 yield* executor.acme.seed();
988 yield* executor.oauth.createClient({1023 yield* executor.oauth.createClient({
989 owner: "org",1024 owner: "org",
@@ -1011,7 +1046,10 @@ describe("oauth.start / oauth.complete", () => { · 21 unmodified lines
1011 Effect.scoped(1046 Effect.scoped(
1012 Effect.gen(function* () {1047 Effect.gen(function* () {
1013 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1048 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1014 const { executor } = yield* makeTestWorkspaceHarness({ plugins });1049 const { executor } = yield* makeTestWorkspaceHarness({
1050 access: testAccess.member(),
1051 plugins,
1052 });
1015 yield* executor.acme.seed();1053 yield* executor.acme.seed();
1016 1054
1017 // The app is registered under the WORKSPACE (org) — "shared with1055 // The app is registered under the WORKSPACE (org) — "shared with
@@ -1062,7 +1100,10 @@ describe("oauth.start / oauth.complete", () => { · 44 unmodified lines
1062 Effect.scoped(1100 Effect.scoped(
1063 Effect.gen(function* () {1101 Effect.gen(function* () {
1064 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1102 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1065 const { executor } = yield* makeTestWorkspaceHarness({ plugins });1103 const { executor } = yield* makeTestWorkspaceHarness({
1104 access: testAccess.member(),
1105 plugins,
1106 });
1066 yield* executor.acme.seed();1107 yield* executor.acme.seed();
1067 1108
1068 // A PRIVATE app owned by the member.1109 // A PRIVATE app owned by the member.
@@ -1100,7 +1141,10 @@ describe("oauth.start / oauth.complete", () => { · 31 unmodified lines
1100 Effect.scoped(1141 Effect.scoped(
1101 Effect.gen(function* () {1142 Effect.gen(function* () {
1102 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1143 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1103 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });1144 const { executor, config } = yield* makeTestWorkspaceHarness({
1145 access: testAccess.member(),
1146 plugins,
1147 });
1104 // Deliberately NOT seeded: the slug names nothing in the catalog — the1148 // Deliberately NOT seeded: the slug names nothing in the catalog — the
1105 // shape of a reconnect against a connection whose integration was1149 // shape of a reconnect against a connection whose integration was
1106 // removed, or an agent replaying a stale slug.1150 // removed, or an agent replaying a stale slug.
@@ -1141,7 +1185,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 34 unmodified lines
1141 Effect.scoped(1185 Effect.scoped(
1142 Effect.gen(function* () {1186 Effect.gen(function* () {
1143 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1187 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1144 const harness = yield* makeTestWorkspaceHarness({ plugins });1188 const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
1145 const { executor, config } = harness;1189 const { executor, config } = harness;
1146 yield* executor.acme.seed();1190 yield* executor.acme.seed();
1147 1191
@@ -1225,7 +1269,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 77 unmodified lines
1225 1269
1226 // One database handle and one credential store under two execution1270 // One database handle and one credential store under two execution
1227 // stacks — what a self-host holds while two MCP sessions are open.1271 // stacks — what a self-host holds while two MCP sessions are open.
1228 const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };1272 const config = {
1273 ...makeTestConfig({ access: testAccess.member(), plugins }),
1274 fetch: park.fetch,
1275 };
1229 const sessionA = yield* createExecutor(config);1276 const sessionA = yield* createExecutor(config);
1230 const sessionB = yield* createExecutor(config);1277 const sessionB = yield* createExecutor(config);
1231 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));1278 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
@@ -1322,7 +1369,12 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 90 unmodified lines
1322 // the store layer and mask the gate-key collision this test is about.1369 // the store layer and mask the gate-key collision this test is about.
1323 const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const;1370 const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const;
1324 const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const;1371 const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const;
1325 const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" });1372 const shared = makeTestConfig({
1373 access: testAccess.member(),
1374 plugins: pluginsA,
1375 tenant: "a",
1376 subject: "user:b",
1377 });
1326 const configA = { ...shared, fetch: parkA.fetch };1378 const configA = { ...shared, fetch: parkA.fetch };
1327 const configB = {1379 const configB = {
1328 ...shared,1380 ...shared,
@@ -1379,7 +1431,11 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines
1379 1431
1380 // Expire BOTH rows so both tenants must refresh. `shared.db` is bound1432 // Expire BOTH rows so both tenants must refresh. `shared.db` is bound
1381 // to tenant A; tenant B's partition needs its own scoped handle.1433 // to tenant A; tenant B's partition needs its own scoped handle.
1382 const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" });1434 const dbB = withQueryContext(shared.testDb.db, {
1435 tenant: "a:user",
1436 subject: "b",
1437 owners: ["user", "org"],
1438 });
1383 yield* Effect.promise(() =>1439 yield* Effect.promise(() =>
1384 shared.db.updateMany("connection", {1440 shared.db.updateMany("connection", {
1385 where: (b) => b("name", "=", "mine"),1441 where: (b) => b("name", "=", "mine"),
@@ -1452,7 +1508,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 66 unmodified lines
1452 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1508 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1453 const park = makeTokenRequestPark();1509 const park = makeTokenRequestPark();
1454 1510
1455 const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };1511 const config = {
1512 ...makeTestConfig({ access: testAccess.member(), plugins }),
1513 fetch: park.fetch,
1514 };
1456 const sessionA = yield* createExecutor(config);1515 const sessionA = yield* createExecutor(config);
1457 const sessionB = yield* createExecutor(config);1516 const sessionB = yield* createExecutor(config);
1458 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));1517 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
@@ -1598,6 +1657,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 139 unmodified lines
1598 // handle, which is what the extra `withQueryContext` wrapper is.1657 // handle, which is what the extra `withQueryContext` wrapper is.
1599 const config = {1658 const config = {
1600 ...makeTestConfig({1659 ...makeTestConfig({
1660 access: testAccess.member(),
1601 plugins: [oauthPlugin] as const,1661 plugins: [oauthPlugin] as const,
1602 tenant: SHARED_STORE_TENANT,1662 tenant: SHARED_STORE_TENANT,
1603 subject: SHARED_STORE_SUBJECT,1663 subject: SHARED_STORE_SUBJECT,
@@ -1722,7 +1782,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 118 unmodified lines
1722 Effect.scoped(1782 Effect.scoped(
1723 Effect.gen(function* () {1783 Effect.gen(function* () {
1724 const server = yield* serveOAuthTestServer({ scopes: ["read"] });1784 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1725 const harness = yield* makeTestWorkspaceHarness({ plugins });1785 const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
1726 const { executor, config } = harness;1786 const { executor, config } = harness;
1727 yield* executor.acme.seed();1787 yield* executor.acme.seed();
1728 1788
@@ -1790,7 +1850,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 61 unmodified lines
1790 tokenExpiresInSeconds: 0,1850 tokenExpiresInSeconds: 0,
1791 invalidRefreshTokenDescription: "Grant not found",1851 invalidRefreshTokenDescription: "Grant not found",
1792 });1852 });
1793 const harness = yield* makeTestWorkspaceHarness({ plugins });1853 const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
1794 const { executor, config } = harness;1854 const { executor, config } = harness;
1795 yield* executor.acme.seed();1855 yield* executor.acme.seed();
1796 1856
@@ -1876,7 +1936,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 79 unmodified lines
1876 tokenExpiresInSeconds: 0,1936 tokenExpiresInSeconds: 0,
1877 invalidRefreshTokenDescription: "Grant revoked",1937 invalidRefreshTokenDescription: "Grant revoked",
1878 });1938 });
1879 const harness = yield* makeTestWorkspaceHarness({ plugins });1939 const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
1880 const { executor, config } = harness;1940 const { executor, config } = harness;
1881 yield* executor.acme.seed();1941 yield* executor.acme.seed();
1882 1942
@@ -1983,7 +2043,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 100 unmodified lines
1983 Effect.scoped(2043 Effect.scoped(
1984 Effect.gen(function* () {2044 Effect.gen(function* () {
1985 const server = yield* serveOAuthTestServer({ scopes: ["read"] });2045 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1986 const { executor } = yield* makeTestWorkspaceHarness({ plugins });2046 const { executor } = yield* makeTestWorkspaceHarness({
2047 access: testAccess.member(),
2048 plugins,
2049 });
1987 yield* executor.acme.seed();2050 yield* executor.acme.seed();
1988 2051
1989 yield* executor.oauth.createClient({2052 yield* executor.oauth.createClient({
@@ -2040,7 +2103,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines
2040 scopes: ["openid", "email", "profile", "offline_access", "read", "write"],2103 scopes: ["openid", "email", "profile", "offline_access", "read", "write"],
2041 omitTokenResponseScopes: ["email", "profile", "write"],2104 omitTokenResponseScopes: ["email", "profile", "write"],
2042 });2105 });
2043 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });2106 const { executor, config } = yield* makeTestWorkspaceHarness({
2107 access: testAccess.member(),
2108 plugins,
2109 });
2044 yield* executor.acme.seed([2110 yield* executor.acme.seed([
2045 "openid",2111 "openid",
2046 "email",2112 "email",
@@ -2098,7 +2164,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 51 unmodified lines
2098 Effect.scoped(2164 Effect.scoped(
2099 Effect.gen(function* () {2165 Effect.gen(function* () {
2100 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });2166 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
2101 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });2167 const { executor, config } = yield* makeTestWorkspaceHarness({
2168 access: testAccess.member(),
2169 plugins,
2170 });
2102 yield* executor.acme.seed(["read", "write"]);2171 yield* executor.acme.seed(["read", "write"]);
2103 2172
2104 yield* executor.oauth.createClient({2173 yield* executor.oauth.createClient({
@@ -2159,7 +2228,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 54 unmodified lines
2159 Effect.scoped(2228 Effect.scoped(
2160 Effect.gen(function* () {2229 Effect.gen(function* () {
2161 const server = yield* serveOAuthTestServer({ scopes: ["read"] });2230 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2162 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });2231 const { executor, config } = yield* makeTestWorkspaceHarness({
2232 access: testAccess.member(),
2233 plugins,
2234 });
2163 yield* executor.acme.seed();2235 yield* executor.acme.seed();
2164 2236
2165 // Reroute the https regional/advertised hosts back to the loopback test2237 // Reroute the https regional/advertised hosts back to the loopback test
@@ -2248,7 +2320,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 82 unmodified lines
2248 Effect.scoped(2320 Effect.scoped(
2249 Effect.gen(function* () {2321 Effect.gen(function* () {
2250 const server = yield* serveOAuthTestServer({ scopes: ["read"] });2322 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2251 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });2323 const { executor, config } = yield* makeTestWorkspaceHarness({
2324 access: testAccess.member(),
2325 plugins,
2326 });
2252 yield* executor.acme.seed();2327 yield* executor.acme.seed();
2253 2328
2254 const tokenCalls: TokenEndpointCall[] = [];2329 const tokenCalls: TokenEndpointCall[] = [];
@@ -2423,6 +2498,7 @@ const connectRejecting = (options?: { readonly tokenExpiresInSeconds?: number }) · 168 unmodified lines
2423 return issued.length > 0 ? issued[issued.length - 1]! : null;2498 return issued.length > 0 ? issued[issued.length - 1]! : null;
2424 });2499 });
2425 const harness = yield* makeTestWorkspaceHarness({2500 const harness = yield* makeTestWorkspaceHarness({
2501 access: testAccess.member(),
2426 plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const,2502 plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const,
2427 });2503 });
2428 const { executor, config } = harness;2504 const { executor, config } = harness;
@@ -2612,6 +2688,7 @@ describe("reactive OAuth refresh on upstream 401", () => { · 183 unmodified lines
2612 }))();2688 }))();
2613 2689
2614 const { executor } = yield* makeTestWorkspaceHarness({2690 const { executor } = yield* makeTestWorkspaceHarness({
2691 access: testAccess.member(),
2615 plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const,2692 plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const,
2616 });2693 });
2617 yield* executor.acme.seed();2694 yield* executor.acme.seed();
@@ -2692,7 +2769,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 74 unmodified lines
2692 Effect.scoped(2769 Effect.scoped(
2693 Effect.gen(function* () {2770 Effect.gen(function* () {
2694 const server = yield* serveOAuthTestServer({ scopes: ["read"] });2771 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2695 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });2772 const { executor, config } = yield* makeTestWorkspaceHarness({
2773 access: testAccess.member(),
2774 plugins,
2775 });
2696 yield* executor.acme.seed();2776 yield* executor.acme.seed();
2697 2777
2698 // `resource: null` — explicitly none, not merely unset.2778 // `resource: null` — explicitly none, not merely unset.
@@ -2761,7 +2841,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 62 unmodified lines
2761 Effect.scoped(2841 Effect.scoped(
2762 Effect.gen(function* () {2842 Effect.gen(function* () {
2763 const server = yield* serveOAuthTestServer({ scopes: ["read"] });2843 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2764 const { executor } = yield* makeTestWorkspaceHarness({ plugins });2844 const { executor } = yield* makeTestWorkspaceHarness({
2845 access: testAccess.member(),
2846 plugins,
2847 });
2765 yield* executor.acme.seed();2848 yield* executor.acme.seed();
2766 2849
2767 yield* executor.oauth.createClient({2850 yield* executor.oauth.createClient({
@@ -2799,7 +2882,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 31 unmodified lines
2799 Effect.scoped(2882 Effect.scoped(
2800 Effect.gen(function* () {2883 Effect.gen(function* () {
2801 const server = yield* serveOAuthTestServer({ scopes: ["read"] });2884 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2802 const { executor } = yield* makeTestWorkspaceHarness({ plugins });2885 const { executor } = yield* makeTestWorkspaceHarness({
2886 access: testAccess.member(),
2887 plugins,
2888 });
2803 yield* executor.acme.seed();2889 yield* executor.acme.seed();
2804 2890
2805 yield* executor.oauth.createClient({2891 yield* executor.oauth.createClient({
packages/core/sdk/src/oauth-list-clients.test.ts+19−4
@@ -7,6 +7,7 @@ import { Effect, Predicate } from "effect"; · 6 unmodified lines
7 7
8import { OAuthClientSlug } from "./ids";8import { OAuthClientSlug } from "./ids";
9import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";9import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
10import { testAccess } from "@executor-js/product-access/testing";
10 11
11// listClients returns metadata-only summaries of the clients visible to the12// listClients returns metadata-only summaries of the clients visible to the
12// caller — the tenant's org clients plus the caller's own user clients — and13// caller — the tenant's org clients plus the caller's own user clients — and
@@ -21,7 +22,10 @@ describe("oauth.listClients", () => { · 8 unmodified lines
21 it.effect("rejects metadata URLs persisted as authorization URLs", () =>22 it.effect("rejects metadata URLs persisted as authorization URLs", () =>
22 Effect.scoped(23 Effect.scoped(
23 Effect.gen(function* () {24 Effect.gen(function* () {
24 const { executor } = yield* makeTestWorkspaceHarness({ plugins });25 const { executor } = yield* makeTestWorkspaceHarness({
26 access: testAccess.member(),
27 plugins,
28 });
25 29
26 const error = yield* Effect.flip(30 const error = yield* Effect.flip(
27 executor.oauth.createClient({31 executor.oauth.createClient({
@@ -50,7 +54,10 @@ describe("oauth.listClients", () => { · 22 unmodified lines
50 it.effect("returns owner-visible clients as summaries without the secret", () =>54 it.effect("returns owner-visible clients as summaries without the secret", () =>
51 Effect.scoped(55 Effect.scoped(
52 Effect.gen(function* () {56 Effect.gen(function* () {
53 const { executor } = yield* makeTestWorkspaceHarness({ plugins });57 const { executor } = yield* makeTestWorkspaceHarness({
58 access: testAccess.member(),
59 plugins,
60 });
54 61
55 yield* executor.oauth.createClient({62 yield* executor.oauth.createClient({
56 owner: "org",63 owner: "org",
@@ -113,7 +120,10 @@ describe("oauth.listClients", () => { · 56 unmodified lines
113 it.effect("rejects HTTP Basic authentication without a client secret", () =>120 it.effect("rejects HTTP Basic authentication without a client secret", () =>
114 Effect.scoped(121 Effect.scoped(
115 Effect.gen(function* () {122 Effect.gen(function* () {
116 const { executor } = yield* makeTestWorkspaceHarness({ plugins });123 const { executor } = yield* makeTestWorkspaceHarness({
124 access: testAccess.member(),
125 plugins,
126 });
117 127
118 const error = yield* Effect.flip(128 const error = yield* Effect.flip(
119 executor.oauth.createClient({129 executor.oauth.createClient({
@@ -147,6 +157,7 @@ describe("oauth.listClients", () => { · 27 unmodified lines
147 const tenant = "shared-tenant";157 const tenant = "shared-tenant";
148 158
149 const a = yield* makeTestWorkspaceHarness({159 const a = yield* makeTestWorkspaceHarness({
160 access: testAccess.member(),
150 plugins,161 plugins,
151 tenant,162 tenant,
152 subject: "subject-a",163 subject: "subject-a",
@@ -172,6 +183,7 @@ describe("oauth.listClients", () => { · 19 unmodified lines
172 });183 });
173 184
174 const b = yield* makeTestWorkspaceHarness({185 const b = yield* makeTestWorkspaceHarness({
186 access: testAccess.member(),
175 plugins,187 plugins,
176 tenant,188 tenant,
177 subject: "subject-b",189 subject: "subject-b",
@@ -190,7 +202,10 @@ describe("oauth.listClients", () => { · 12 unmodified lines
190 it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () =>202 it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () =>
191 Effect.scoped(203 Effect.scoped(
192 Effect.gen(function* () {204 Effect.gen(function* () {
193 const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });205 const { config, executor } = yield* makeTestWorkspaceHarness({
206 access: testAccess.member(),
207 plugins,
208 });
194 209
195 yield* executor.oauth.createClient({210 yield* executor.oauth.createClient({
196 owner: "org",211 owner: "org",
packages/core/sdk/src/oauth-register-dynamic.test.ts+62−16
@@ -12,6 +12,7 @@ import { OAuthRegisterDynamicError } from "./oauth-client"; · 11 unmodified lines
12import { definePlugin } from "./plugin";12import { definePlugin } from "./plugin";
13import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";13import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
14import { serveOAuthTestServer } from "./testing/oauth-test-server";14import { serveOAuthTestServer } from "./testing/oauth-test-server";
15import { testAccess } from "@executor-js/product-access/testing";
15 16
16// RFC 7591 Dynamic Client Registration, end to end:17// RFC 7591 Dynamic Client Registration, end to end:
17// probe → registerDynamicClient (no pasted client id/secret) → listClients18// probe → registerDynamicClient (no pasted client id/secret) → listClients
@@ -64,7 +65,7 @@ describe("oauth.registerDynamicClient", () => { · 46 unmodified lines
64 const server = yield* serveOAuthTestServer({ scopes: ["read"] });65 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
65 const { executor } = yield* makeTestWorkspaceHarness({66 const { executor } = yield* makeTestWorkspaceHarness({
66 plugins,67 plugins,
67 orgWrites: "denied",68 access: testAccess.member("denied"),
68 });69 });
69 70
70 const error = yield* executor.oauth71 const error = yield* executor.oauth
@@ -94,7 +95,10 @@ describe("oauth.registerDynamicClient", () => { · 23 unmodified lines
94 Effect.scoped(95 Effect.scoped(
95 Effect.gen(function* () {96 Effect.gen(function* () {
96 const server = yield* serveOAuthTestServer({ scopes: ["read"] });97 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
97 const { executor } = yield* makeTestWorkspaceHarness({ plugins });98 const { executor } = yield* makeTestWorkspaceHarness({
99 access: testAccess.member(),
100 plugins,
101 });
98 yield* executor.acme.seed();102 yield* executor.acme.seed();
99 103
100 // Probe surfaces the registration endpoint + advertised auth methods so104 // Probe surfaces the registration endpoint + advertised auth methods so
@@ -197,7 +201,10 @@ describe("oauth.registerDynamicClient", () => { · 96 unmodified lines
197 Effect.scoped(201 Effect.scoped(
198 Effect.gen(function* () {202 Effect.gen(function* () {
199 const server = yield* serveOAuthTestServer({ scopes: ["read"] });203 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
200 const { executor } = yield* makeTestWorkspaceHarness({ plugins });204 const { executor } = yield* makeTestWorkspaceHarness({
205 access: testAccess.member(),
206 plugins,
207 });
201 yield* executor.acme.seed();208 yield* executor.acme.seed();
202 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });209 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
203 210
@@ -230,7 +237,10 @@ describe("oauth.registerDynamicClient", () => { · 26 unmodified lines
230 Effect.scoped(237 Effect.scoped(
231 Effect.gen(function* () {238 Effect.gen(function* () {
232 const server = yield* serveOAuthTestServer({ scopes: ["read"] });239 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
233 const { executor } = yield* makeTestWorkspaceHarness({ plugins });240 const { executor } = yield* makeTestWorkspaceHarness({
241 access: testAccess.member(),
242 plugins,
243 });
234 yield* executor.acme.seed();244 yield* executor.acme.seed();
235 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });245 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
236 246
@@ -276,7 +286,10 @@ describe("oauth.registerDynamicClient", () => { · 39 unmodified lines
276 Effect.scoped(286 Effect.scoped(
277 Effect.gen(function* () {287 Effect.gen(function* () {
278 const server = yield* serveOAuthTestServer({ scopes: ["read"] });288 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
279 const { executor } = yield* makeTestWorkspaceHarness({ plugins });289 const { executor } = yield* makeTestWorkspaceHarness({
290 access: testAccess.member(),
291 plugins,
292 });
280 yield* executor.acme.seed();293 yield* executor.acme.seed();
281 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });294 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
282 295
@@ -327,7 +340,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines
327 // reused, so a fresh DCR registration happens. The GC migration then340 // reused, so a fresh DCR registration happens. The GC migration then
328 // backfills/GCs any duplicate this transient window mints.341 // backfills/GCs any duplicate this transient window mints.
329 const server = yield* serveOAuthTestServer({ scopes: ["read"] });342 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
330 const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });343 const { config, executor } = yield* makeTestWorkspaceHarness({
344 access: testAccess.member(),
345 plugins,
346 });
331 yield* executor.acme.seed();347 yield* executor.acme.seed();
332 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });348 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
333 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");349 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
@@ -379,7 +395,10 @@ describe("oauth.registerDynamicClient", () => { · 45 unmodified lines
379 const server = yield* serveOAuthTestServer({395 const server = yield* serveOAuthTestServer({
380 scopes: ["openid", "offline_access"],396 scopes: ["openid", "offline_access"],
381 });397 });
382 const { executor } = yield* makeTestWorkspaceHarness({ plugins });398 const { executor } = yield* makeTestWorkspaceHarness({
399 access: testAccess.member(),
400 plugins,
401 });
383 yield* executor.acme.seed();402 yield* executor.acme.seed();
384 403
385 yield* executor.oauth.registerDynamicClient({404 yield* executor.oauth.registerDynamicClient({
@@ -413,7 +432,10 @@ describe("oauth.registerDynamicClient", () => { · 27 unmodified lines
413 // row's origin_issuer, the reuse lookup keys on it and mints no432 // row's origin_issuer, the reuse lookup keys on it and mints no
414 // duplicate. This is the steady state the migration establishes.433 // duplicate. This is the steady state the migration establishes.
415 const server = yield* serveOAuthTestServer({ scopes: ["read"] });434 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
416 const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });435 const { config, executor } = yield* makeTestWorkspaceHarness({
436 access: testAccess.member(),
437 plugins,
438 });
417 yield* executor.acme.seed();439 yield* executor.acme.seed();
418 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });440 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
419 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");441 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
@@ -467,7 +489,10 @@ describe("oauth.registerDynamicClient", () => { · 47 unmodified lines
467 Effect.scoped(489 Effect.scoped(
468 Effect.gen(function* () {490 Effect.gen(function* () {
469 const server = yield* serveOAuthTestServer({ scopes: ["read"] });491 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
470 const { executor } = yield* makeTestWorkspaceHarness({ plugins });492 const { executor } = yield* makeTestWorkspaceHarness({
493 access: testAccess.member(),
494 plugins,
495 });
471 yield* executor.acme.seed();496 yield* executor.acme.seed();
472 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });497 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
473 const resourceA = `${server.issuerUrl}/mcp/a`;498 const resourceA = `${server.issuerUrl}/mcp/a`;
@@ -533,7 +558,10 @@ describe("oauth.registerDynamicClient", () => { · 59 unmodified lines
533 Effect.scoped(558 Effect.scoped(
534 Effect.gen(function* () {559 Effect.gen(function* () {
535 const server = yield* serveOAuthTestServer({ scopes: ["read"] });560 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
536 const { executor } = yield* makeTestWorkspaceHarness({ plugins });561 const { executor } = yield* makeTestWorkspaceHarness({
562 access: testAccess.member(),
563 plugins,
564 });
537 yield* executor.acme.seed();565 yield* executor.acme.seed();
538 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });566 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
539 const resourceA = `${server.issuerUrl}/mcp/a`;567 const resourceA = `${server.issuerUrl}/mcp/a`;
@@ -638,7 +666,10 @@ describe("oauth.registerDynamicClient", () => { · 98 unmodified lines
638 Effect.scoped(666 Effect.scoped(
639 Effect.gen(function* () {667 Effect.gen(function* () {
640 const server = yield* serveOAuthTestServer({ scopes: ["read"] });668 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
641 const { executor } = yield* makeTestWorkspaceHarness({ plugins });669 const { executor } = yield* makeTestWorkspaceHarness({
670 access: testAccess.member(),
671 plugins,
672 });
642 yield* executor.acme.seed();673 yield* executor.acme.seed();
643 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });674 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
644 675
@@ -733,7 +764,10 @@ describe("oauth.registerDynamicClient", () => { · 88 unmodified lines
733 Effect.scoped(764 Effect.scoped(
734 Effect.gen(function* () {765 Effect.gen(function* () {
735 const server = yield* serveOAuthTestServer({ scopes: ["read"] });766 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
736 const { executor } = yield* makeTestWorkspaceHarness({ plugins });767 const { executor } = yield* makeTestWorkspaceHarness({
768 access: testAccess.member(),
769 plugins,
770 });
737 yield* executor.acme.seed();771 yield* executor.acme.seed();
738 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });772 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
739 773
@@ -784,7 +818,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines
784 // Mirror Mercury: reject any client_name containing the brand.818 // Mirror Mercury: reject any client_name containing the brand.
785 approveClientName: (name) => !name.includes("Acme"),819 approveClientName: (name) => !name.includes("Acme"),
786 });820 });
787 const { executor } = yield* makeTestWorkspaceHarness({ plugins });821 const { executor } = yield* makeTestWorkspaceHarness({
822 access: testAccess.member(),
823 plugins,
824 });
788 yield* executor.acme.seed();825 yield* executor.acme.seed();
789 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });826 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
790 827
@@ -821,7 +858,10 @@ describe("oauth.registerDynamicClient", () => { · 30 unmodified lines
821 scopes: ["read"],858 scopes: ["read"],
822 approveClientName: () => false,859 approveClientName: () => false,
823 });860 });
824 const { executor } = yield* makeTestWorkspaceHarness({ plugins });861 const { executor } = yield* makeTestWorkspaceHarness({
862 access: testAccess.member(),
863 plugins,
864 });
825 yield* executor.acme.seed();865 yield* executor.acme.seed();
826 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });866 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
827 867
@@ -866,7 +906,10 @@ describe("oauth.registerDynamicClient", () => { · 38 unmodified lines
866 approveRedirectUri: (uri) =>906 approveRedirectUri: (uri) =>
867 uri.startsWith("http://localhost") || uri.startsWith("http://127."),907 uri.startsWith("http://localhost") || uri.startsWith("http://127."),
868 });908 });
869 const { executor } = yield* makeTestWorkspaceHarness({ plugins });909 const { executor } = yield* makeTestWorkspaceHarness({
910 access: testAccess.member(),
911 plugins,
912 });
870 yield* executor.acme.seed();913 yield* executor.acme.seed();
871 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });914 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
872 915
@@ -910,7 +953,10 @@ describe("oauth.registerDynamicClient", () => { · 37 unmodified lines
910 scopes: ["read"],953 scopes: ["read"],
911 approveRedirectUri: () => false, // reject every redirect URI, even loopback954 approveRedirectUri: () => false, // reject every redirect URI, even loopback
912 });955 });
913 const { executor } = yield* makeTestWorkspaceHarness({ plugins });956 const { executor } = yield* makeTestWorkspaceHarness({
957 access: testAccess.member(),
958 plugins,
959 });
914 yield* executor.acme.seed();960 yield* executor.acme.seed();
915 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });961 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
916 962
packages/core/sdk/src/oauth-remove-client.test.ts+24−3
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18import { definePlugin } from "./plugin";18import { definePlugin } from "./plugin";
19import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";19import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
20import { serveOAuthTestServer } from "./testing/oauth-test-server";20import { serveOAuthTestServer } from "./testing/oauth-test-server";
21import { testAccess } from "@executor-js/product-access/testing";
21 22
22// removeClient permanently deletes an owner-scoped oauth_client row, keyed by23// removeClient permanently deletes an owner-scoped oauth_client row, keyed by
23// (owner, slug). The owner policy on `oauth_client` prevents removing another24// (owner, slug). The owner policy on `oauth_client` prevents removing another
@@ -32,7 +33,10 @@ describe("oauth.removeClient", () => { · 8 unmodified lines
32 it.effect("removes a client so it no longer lists", () =>33 it.effect("removes a client so it no longer lists", () =>
33 Effect.scoped(34 Effect.scoped(
34 Effect.gen(function* () {35 Effect.gen(function* () {
35 const { executor } = yield* makeTestWorkspaceHarness({ plugins });36 const { executor } = yield* makeTestWorkspaceHarness({
37 access: testAccess.member(),
38 plugins,
39 });
36 40
37 yield* executor.oauth.createClient({41 yield* executor.oauth.createClient({
38 owner: "user",42 owner: "user",
@@ -58,7 +62,10 @@ describe("oauth.removeClient", () => { · 19 unmodified lines
58 it.effect("is idempotent — removing a non-existent client succeeds", () =>62 it.effect("is idempotent — removing a non-existent client succeeds", () =>
59 Effect.scoped(63 Effect.scoped(
60 Effect.gen(function* () {64 Effect.gen(function* () {
61 const { executor } = yield* makeTestWorkspaceHarness({ plugins });65 const { executor } = yield* makeTestWorkspaceHarness({
66 access: testAccess.member(),
67 plugins,
68 });
62 69
63 // No client was ever created; removing it must not error.70 // No client was ever created; removing it must not error.
64 yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed"));71 yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed"));
@@ -72,7 +79,10 @@ describe("oauth.removeClient", () => { · 7 unmodified lines
72 it.effect("removing an org client leaves a user client intact (and vice versa)", () =>79 it.effect("removing an org client leaves a user client intact (and vice versa)", () =>
73 Effect.scoped(80 Effect.scoped(
74 Effect.gen(function* () {81 Effect.gen(function* () {
75 const { executor } = yield* makeTestWorkspaceHarness({ plugins });82 const { executor } = yield* makeTestWorkspaceHarness({
83 access: testAccess.member(),
84 plugins,
85 });
76 86
77 yield* executor.oauth.createClient({87 yield* executor.oauth.createClient({
78 owner: "org",88 owner: "org",
@@ -115,6 +125,7 @@ describe("oauth.removeClient", () => { · 36 unmodified lines
115 const tenant = "shared-tenant";125 const tenant = "shared-tenant";
116 126
117 const a = yield* makeTestWorkspaceHarness({127 const a = yield* makeTestWorkspaceHarness({
128 access: testAccess.member(),
118 plugins,129 plugins,
119 tenant,130 tenant,
120 subject: "subject-a",131 subject: "subject-a",
@@ -131,6 +142,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines
131 });142 });
132 143
133 const b = yield* makeTestWorkspaceHarness({144 const b = yield* makeTestWorkspaceHarness({
145 access: testAccess.member(),
134 plugins,146 plugins,
135 tenant,147 tenant,
136 subject: "subject-b",148 subject: "subject-b",
@@ -155,6 +167,7 @@ describe("oauth.removeClient", () => { · 18 unmodified lines
155 const tenant = "shared-tenant";167 const tenant = "shared-tenant";
156 168
157 const a = yield* makeTestWorkspaceHarness({169 const a = yield* makeTestWorkspaceHarness({
170 access: testAccess.member(),
158 plugins,171 plugins,
159 tenant,172 tenant,
160 subject: "subject-a",173 subject: "subject-a",
@@ -171,6 +184,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines
171 });184 });
172 185
173 const b = yield* makeTestWorkspaceHarness({186 const b = yield* makeTestWorkspaceHarness({
187 access: testAccess.member(),
174 plugins,188 plugins,
175 tenant,189 tenant,
176 subject: "subject-b",190 subject: "subject-b",
@@ -194,6 +208,7 @@ describe("oauth.removeClient", () => { · 17 unmodified lines
194 Effect.scoped(208 Effect.scoped(
195 Effect.gen(function* () {209 Effect.gen(function* () {
196 const { executor } = yield* makeTestWorkspaceHarness({210 const { executor } = yield* makeTestWorkspaceHarness({
211 access: testAccess.member(),
197 plugins,212 plugins,
198 coreTools: {},213 coreTools: {},
199 });214 });
@@ -306,6 +321,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 106 unmodified lines
306 Effect.gen(function* () {321 Effect.gen(function* () {
307 const store = new Map<string, string>();322 const store = new Map<string, string>();
308 const { executor } = yield* makeTestWorkspaceHarness({323 const { executor } = yield* makeTestWorkspaceHarness({
324 access: testAccess.member(),
309 plugins: [txPlugin(store)] as const,325 plugins: [txPlugin(store)] as const,
310 });326 });
311 yield* executor.oauth.createClient(userClient("user-secret"));327 yield* executor.oauth.createClient(userClient("user-secret"));
@@ -339,6 +355,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 27 unmodified lines
339 Effect.gen(function* () {355 Effect.gen(function* () {
340 const store = new Map<string, string>();356 const store = new Map<string, string>();
341 const { executor } = yield* makeTestWorkspaceHarness({357 const { executor } = yield* makeTestWorkspaceHarness({
358 access: testAccess.member(),
342 plugins: [txPlugin(store)] as const,359 plugins: [txPlugin(store)] as const,
343 });360 });
344 yield* executor.oauth.createClient(userClient("user-secret"));361 yield* executor.oauth.createClient(userClient("user-secret"));
@@ -360,6 +377,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 15 unmodified lines
360 Effect.gen(function* () {377 Effect.gen(function* () {
361 const store = new Map<string, string>();378 const store = new Map<string, string>();
362 const { executor } = yield* makeTestWorkspaceHarness({379 const { executor } = yield* makeTestWorkspaceHarness({
380 access: testAccess.member(),
363 plugins: [txPlugin(store)] as const,381 plugins: [txPlugin(store)] as const,
364 });382 });
365 yield* executor.oauth.createClient(userClient("user-secret"));383 yield* executor.oauth.createClient(userClient("user-secret"));
@@ -385,6 +403,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 19 unmodified lines
385 const tenant = "shared-tenant";403 const tenant = "shared-tenant";
386 404
387 const a = yield* makeTestWorkspaceHarness({405 const a = yield* makeTestWorkspaceHarness({
406 access: testAccess.member(),
388 plugins,407 plugins,
389 tenant,408 tenant,
390 subject: "subject-a",409 subject: "subject-a",
@@ -394,6 +413,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 3 unmodified lines
394 expect(secretValue(store)).toBe("a-secret");413 expect(secretValue(store)).toBe("a-secret");
395 414
396 const b = yield* makeTestWorkspaceHarness({415 const b = yield* makeTestWorkspaceHarness({
416 access: testAccess.member(),
397 plugins,417 plugins,
398 tenant,418 tenant,
399 subject: "subject-b",419 subject: "subject-b",
@@ -454,6 +474,7 @@ describe("removing a client does not delete a recreated client's secret", () => · 54 unmodified lines
454 });474 });
455 const store = new Map<string, string>();475 const store = new Map<string, string>();
456 const { executor } = yield* makeTestWorkspaceHarness({476 const { executor } = yield* makeTestWorkspaceHarness({
477 access: testAccess.member(),
457 plugins: [txPlugin(store), integrationPlugin] as const,478 plugins: [txPlugin(store), integrationPlugin] as const,
458 redirectUri: null,479 redirectUri: null,
459 });480 });
packages/core/sdk/src/oauth-scope-union.test.ts+35−9
@@ -16,6 +16,7 @@ import { definePlugin, type IntegrationRecord } from "./plugin"; · 15 unmodified lines
16import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";16import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
17import { serveTestHttpApp } from "./testing";17import { serveTestHttpApp } from "./testing";
18import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";18import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
19import { testAccess } from "@executor-js/product-access/testing";
19 20
20// Integration-driven scopes: at connect, `oauth.start` requests the integration's21// Integration-driven scopes: at connect, `oauth.start` requests the integration's
21// scopes — its DECLARED oauth scopes when it has any, otherwise the scopes22// scopes — its DECLARED oauth scopes when it has any, otherwise the scopes
@@ -187,7 +188,7 @@ const setupMcpScopeClient = ( · 165 unmodified lines
187) =>188) =>
188 Effect.gen(function* () {189 Effect.gen(function* () {
189 const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const;190 const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const;
190 const { executor } = yield* makeTestWorkspaceHarness({ plugins });191 const { executor } = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins });
191 yield* executor.mcp.seed();192 yield* executor.mcp.seed();
192 yield* executor.oauth.createClient({193 yield* executor.oauth.createClient({
193 owner: "org",194 owner: "org",
@@ -213,7 +214,10 @@ describe("oauth.start integration-driven scopes", () => { · 19 unmodified lines
213 memoryCredentialsPlugin(),214 memoryCredentialsPlugin(),
214 makeScopePlugin({ scopes: DECLARED_SCOPES }),215 makeScopePlugin({ scopes: DECLARED_SCOPES }),
215 ] as const;216 ] as const;
216 const { executor } = yield* makeTestWorkspaceHarness({ plugins });217 const { executor } = yield* makeTestWorkspaceHarness({
218 access: testAccess.member(),
219 plugins,
220 });
217 yield* executor.acme.seed();221 yield* executor.acme.seed();
218 222
219 // The app is pure identity — no scope set.223 // The app is pure identity — no scope set.
@@ -265,7 +269,10 @@ describe("oauth.start integration-driven scopes", () => { · 45 unmodified lines
265 authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`,269 authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`,
266 }),270 }),
267 ] as const;271 ] as const;
268 const { executor } = yield* makeTestWorkspaceHarness({ plugins });272 const { executor } = yield* makeTestWorkspaceHarness({
273 access: testAccess.member(),
274 plugins,
275 });
269 yield* executor.acme.seed();276 yield* executor.acme.seed();
270 yield* executor.oauth.createClient({277 yield* executor.oauth.createClient({
271 owner: "org",278 owner: "org",
@@ -303,7 +310,10 @@ describe("oauth.start integration-driven scopes", () => { · 31 unmodified lines
303 memoryCredentialsPlugin(),310 memoryCredentialsPlugin(),
304 makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }),311 makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }),
305 ] as const;312 ] as const;
306 const { executor } = yield* makeTestWorkspaceHarness({ plugins });313 const { executor } = yield* makeTestWorkspaceHarness({
314 access: testAccess.member(),
315 plugins,
316 });
307 yield* executor.acme.seed();317 yield* executor.acme.seed();
308 318
309 yield* executor.oauth.createClient({319 yield* executor.oauth.createClient({
@@ -340,7 +350,10 @@ describe("oauth.start integration-driven scopes", () => { · 30 unmodified lines
340 // `scopes: null` ⇒ the integration declares an oauth method with no350 // `scopes: null` ⇒ the integration declares an oauth method with no
341 // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested.351 // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested.
342 const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const;352 const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const;
343 const { executor } = yield* makeTestWorkspaceHarness({ plugins });353 const { executor } = yield* makeTestWorkspaceHarness({
354 access: testAccess.member(),
355 plugins,
356 });
344 yield* executor.acme.seed();357 yield* executor.acme.seed();
345 358
346 yield* executor.oauth.createClient({359 yield* executor.oauth.createClient({
@@ -411,7 +424,10 @@ describe("oauth.start integration-driven scopes", () => { · 64 unmodified lines
411 memoryCredentialsPlugin(),424 memoryCredentialsPlugin(),
412 makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }),425 makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }),
413 ] as const;426 ] as const;
414 const { executor } = yield* makeTestWorkspaceHarness({ plugins });427 const { executor } = yield* makeTestWorkspaceHarness({
428 access: testAccess.member(),
429 plugins,
430 });
415 yield* executor.acme.seed();431 yield* executor.acme.seed();
416 432
417 yield* executor.oauth.createClient({433 yield* executor.oauth.createClient({
@@ -635,7 +651,10 @@ describe("oauth.start integration-driven scopes", () => { · 217 unmodified lines
635 { discoversScopes: true, discoveryUrl: server.mcpResourceUrl },651 { discoversScopes: true, discoveryUrl: server.mcpResourceUrl },
636 ),652 ),
637 ] as const;653 ] as const;
638 const { executor } = yield* makeTestWorkspaceHarness({ plugins });654 const { executor } = yield* makeTestWorkspaceHarness({
655 access: testAccess.member(),
656 plugins,
657 });
639 yield* executor.mcp.seed();658 yield* executor.mcp.seed();
640 659
641 // No `resource` on the client — the wire parameter is absent by660 // No `resource` on the client — the wire parameter is absent by
@@ -681,6 +700,7 @@ describe("oauth.start integration-driven scopes", () => { · 39 unmodified lines
681 makeMcpScopePlugin({ scopes: null }),700 makeMcpScopePlugin({ scopes: null }),
682 ] as const;701 ] as const;
683 const { executor } = yield* makeTestWorkspaceHarness({702 const { executor } = yield* makeTestWorkspaceHarness({
703 access: testAccess.member(),
684 plugins,704 plugins,
685 firstPartyOAuthClients: [705 firstPartyOAuthClients: [
686 {706 {
@@ -835,7 +855,10 @@ describe("oauth.start recorded scope fallback", () => { · 148 unmodified lines
835 memoryCredentialsPlugin(),855 memoryCredentialsPlugin(),
836 makeScopePlugin({ scopes: DECLARED_SCOPES }),856 makeScopePlugin({ scopes: DECLARED_SCOPES }),
837 ] as const;857 ] as const;
838 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });858 const { executor, config } = yield* makeTestWorkspaceHarness({
859 access: testAccess.member(),
860 plugins,
861 });
839 yield* executor.acme.seed();862 yield* executor.acme.seed();
840 863
841 // A client_credentials client so `start` mints inline (no redirect),864 // A client_credentials client so `start` mints inline (no redirect),
@@ -889,7 +912,10 @@ describe("oauth.start recorded scope fallback", () => { · 47 unmodified lines
889 memoryCredentialsPlugin(),912 memoryCredentialsPlugin(),
890 makeMcpScopePlugin({ scopes: null }),913 makeMcpScopePlugin({ scopes: null }),
891 ] as const;914 ] as const;
892 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });915 const { executor, config } = yield* makeTestWorkspaceHarness({
916 access: testAccess.member(),
917 plugins,
918 });
893 yield* executor.mcp.seed();919 yield* executor.mcp.seed();
894 920
895 yield* executor.oauth.createClient({921 yield* executor.oauth.createClient({
packages/core/sdk/src/oauth-service.ts+3−3
@@ -232,9 +232,9 @@ export interface OAuthServiceDeps { · 231 unmodified lines
232 readonly owner: Owner;232 readonly owner: Owner;
233 readonly subject: string;233 readonly subject: string;
234 };234 };
235 /** Workspace-settings gate from the executor binding235 /** Settings gate from the executor binding (`ExecutorAccess.settingsWrite`):
236 * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the236 * enforces the product's decision for the owner targeted by a user-intent
237 * user-intent client/connect surfaces. */237 * client/connect operation. */
238 readonly guardOrgWrite: (owner: Owner) => Effect.Effect<void, OrgWriteDeniedError>;238 readonly guardOrgWrite: (owner: Owner) => Effect.Effect<void, OrgWriteDeniedError>;
239 readonly defaultWritableProvider: () => CredentialProvider | null;239 readonly defaultWritableProvider: () => CredentialProvider | null;
240 /** Write the connection row with OAuth lifecycle fields + produce its tools. */240 /** Write the connection row with OAuth lifecycle fields + produce its tools. */
packages/core/sdk/src/oauth-session-cleanup.test.ts+6−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
20import { definePlugin } from "./plugin";20import { definePlugin } from "./plugin";
21import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config";21import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config";
22import { serveOAuthTestServer } from "./testing/oauth-test-server";22import { serveOAuthTestServer } from "./testing/oauth-test-server";
23import { testAccess } from "@executor-js/product-access/testing";
23 24
24// An in-flight authorization flow parks its PKCE verifier in `oauth_session` in25// An in-flight authorization flow parks its PKCE verifier in `oauth_session` in
25// plaintext, which is fine while the flow can still spend it. What is not fine is26// plaintext, which is fine while the flow can still spend it. What is not fine is
@@ -127,6 +128,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 101 unmodified lines
127 Effect.gen(function* () {128 Effect.gen(function* () {
128 const server = yield* serveOAuthTestServer({});129 const server = yield* serveOAuthTestServer({});
129 const { executor, config } = yield* makeTestWorkspaceHarness({130 const { executor, config } = yield* makeTestWorkspaceHarness({
131 access: testAccess.member(),
130 plugins: [memoryCredentialsPlugin, acmePlugin] as const,132 plugins: [memoryCredentialsPlugin, acmePlugin] as const,
131 });133 });
132 yield* executor.acme.seed();134 yield* executor.acme.seed();
@@ -205,6 +207,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 72 unmodified lines
205 Effect.gen(function* () {207 Effect.gen(function* () {
206 const server = yield* serveOAuthTestServer({});208 const server = yield* serveOAuthTestServer({});
207 const { executor, config } = yield* makeTestWorkspaceHarness({209 const { executor, config } = yield* makeTestWorkspaceHarness({
210 access: testAccess.member(),
208 plugins: [memoryCredentialsPlugin, acmePlugin] as const,211 plugins: [memoryCredentialsPlugin, acmePlugin] as const,
209 });212 });
210 yield* executor.acme.seed();213 yield* executor.acme.seed();
@@ -280,6 +283,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 69 unmodified lines
280 const plugins = [memoryCredentialsPlugin, acmePlugin] as const;283 const plugins = [memoryCredentialsPlugin, acmePlugin] as const;
281 284
282 const a = yield* makeTestWorkspaceHarness({285 const a = yield* makeTestWorkspaceHarness({
286 access: testAccess.member(),
283 plugins,287 plugins,
284 tenant,288 tenant,
285 subject: "subject-a",289 subject: "subject-a",
@@ -298,6 +302,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 12 unmodified lines
298 302
299 // A second member of the same tenant, on the same database.303 // A second member of the same tenant, on the same database.
300 const b = yield* makeTestWorkspaceHarness({304 const b = yield* makeTestWorkspaceHarness({
305 access: testAccess.member(),
301 plugins,306 plugins,
302 tenant,307 tenant,
303 subject: "subject-b",308 subject: "subject-b",
@@ -363,6 +368,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 59 unmodified lines
363 Effect.gen(function* () {368 Effect.gen(function* () {
364 const server = yield* serveOAuthTestServer({});369 const server = yield* serveOAuthTestServer({});
365 const base = makeTestConfig({370 const base = makeTestConfig({
371 access: testAccess.member(),
366 plugins: [memoryCredentialsPlugin, acmePlugin] as const,372 plugins: [memoryCredentialsPlugin, acmePlugin] as const,
367 });373 });
368 const executor = yield* createExecutor({374 const executor = yield* createExecutor({
packages/core/sdk/src/org-write-access.ts+0−33
@@ -1,33 +0,0 @@
1import { Context, Effect, Ref } from "effect";
2
3/** Workspace-settings authorization bound to the currently executing request. */
4export 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 */
13export 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. */
19export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({
20 current: Ref.makeUnsafe(access),
21});
22
23/** Request-local workspace-write authorization inherited by child fibers. */
24export 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. */
30export 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
12 ToolName,12 ToolName,
13} from "./ids";13} from "./ids";
14import { createExecutor } from "./executor";14import { createExecutor } from "./executor";
15import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./org-write-access";15import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./access";
16import { definePlugin } from "./plugin";16import { definePlugin } from "./plugin";
17import type { CredentialProvider } from "./provider";17import type { CredentialProvider } from "./provider";
18import { makeTestConfig } from "./testing";18import { makeTestConfig } from "./testing";
19import { serveOAuthTestServer } from "./testing/oauth-test-server";19import { serveOAuthTestServer } from "./testing/oauth-test-server";
20import { testAccess } from "@executor-js/product-access/testing";
20 21
21// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
22// `ExecutorConfig.orgWrites` — the workspace-settings gate.23// `ExecutorAccess.workspaceWrites` — the workspace-settings gate.
23//24//
24// A `"denied"` binding (a plain member) may USE workspace resources — read25// A `"denied"` binding (a plain member) may USE workspace resources — read
25// them, execute tools over org connections — but every user-intent26// them, execute tools over org connections — but every user-intent
@@ -29,8 +30,8 @@ import { serveOAuthTestServer } from "./testing/oauth-test-server"; · 3 unmodified lines
29// `"allowed"` (admins, and hosts with no role model) behaves exactly as before.30// `"allowed"` (admins, and hosts with no role model) behaves exactly as before.
30//31//
31// The fixtures build TWO executors over ONE test database: an admin32// The fixtures build TWO executors over ONE test database: an admin
32// (default `orgWrites`) that seeds the workspace, and a member33// (workspace writes allowed) that seeds the workspace, and a member
33// (`orgWrites: "denied"`) that the assertions run against.34// (workspace writes denied) that the assertions run against.
34// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
35 36
36const memoryProvider = (): CredentialProvider => {37const memoryProvider = (): CredentialProvider => {
@@ -83,9 +84,9 @@ const demoPlugin = definePlugin(() => ({ · 46 unmodified lines
83 84
84const setup = () =>85const setup = () =>
85 Effect.gen(function* () {86 Effect.gen(function* () {
86 const config = makeTestConfig({ plugins: [demoPlugin] as const });87 const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const });
87 const admin = yield* createExecutor(config);88 const admin = yield* createExecutor(config);
88 const member = yield* createExecutor({ ...config, orgWrites: "denied" });89 const member = yield* createExecutor({ ...config, access: testAccess.member("denied") });
89 yield* Effect.addFinalizer(() =>90 yield* Effect.addFinalizer(() =>
90 admin.close().pipe(Effect.andThen(member.close()), Effect.ignore),91 admin.close().pipe(Effect.andThen(member.close()), Effect.ignore),
91 );92 );
@@ -101,13 +102,16 @@ const expectOrgWriteDenied = <A, R>(effect: Effect.Effect<A, unknown, R>) => · 9 unmodified lines
101 }),102 }),
102 );103 );
103 104
104describe("orgWrites: denied", () => {105describe("workspace writes denied", () => {
105 it.effect("reads a live session binding at every workspace-write sink", () =>106 it.effect("reads a live session binding at every workspace-write sink", () =>
106 Effect.gen(function* () {107 Effect.gen(function* () {
107 const config = makeTestConfig({ plugins: [demoPlugin] as const });108 const config = makeTestConfig({
109 access: testAccess.member(),
110 plugins: [demoPlugin] as const,
111 });
108 const executor = yield* createExecutor({112 const executor = yield* createExecutor({
109 ...config,113 ...config,
110 orgWrites: "request",114 access: testAccess.requestBound(),
111 });115 });
112 yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore));116 yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore));
113 const policy = yield* executor.demo.seed().pipe(117 const policy = yield* executor.demo.seed().pipe(
@@ -249,12 +253,18 @@ describe("orgWrites: denied", () => { · 135 unmodified lines
249 253
250 it.effect("allows subjectless system re-registration during boot convergence", () =>254 it.effect("allows subjectless system re-registration during boot convergence", () =>
251 Effect.gen(function* () {255 Effect.gen(function* () {
252 const config = makeTestConfig({ plugins: [demoPlugin] as const });256 const config = makeTestConfig({
257 access: testAccess.member(),
258 plugins: [demoPlugin] as const,
259 });
253 const admin = yield* createExecutor(config);260 const admin = yield* createExecutor(config);
254 const { subject: _subject, ...systemConfig } = config;261 const { subject: _subject, ...systemConfig } = config;
262 // Boot convergence is the PRODUCT's rule now: the workspace-service
263 // posture allows it. A subject-less binding whose product DENIES
264 // settings writes is refused instead — see settings-authority.test.ts.
255 const system = yield* createExecutor({265 const system = yield* createExecutor({
256 ...systemConfig,266 ...systemConfig,
257 orgWrites: "denied",267 access: testAccess.org(),
258 });268 });
259 yield* Effect.addFinalizer(() =>269 yield* Effect.addFinalizer(() =>
260 admin.close().pipe(Effect.andThen(system.close()), Effect.ignore),270 admin.close().pipe(Effect.andThen(system.close()), Effect.ignore),
@@ -357,7 +367,7 @@ describe("orgWrites: denied", () => { · 96 unmodified lines
357 );367 );
358});368});
359 369
360describe("orgWrites: default (allowed)", () => {370describe("workspace writes allowed", () => {
361 it.effect("admin bindings mutate workspace-level state as before", () =>371 it.effect("admin bindings mutate workspace-level state as before", () =>
362 Effect.gen(function* () {372 Effect.gen(function* () {
363 const { admin } = yield* setup();373 const { admin } = yield* setup();
packages/core/sdk/src/owner-policy-reach.test.ts+24−3
@@ -95,7 +95,11 @@ describe("owner policy reach", () => { · 94 unmodified lines
95 withDb(async (db) => {95 withDb(async (db) => {
96 await seedTwoSubjects(db);96 await seedTwoSubjects(db);
97 97
98 const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });98 const bound = withQueryContext(db.db, {
99 tenant: TENANT,
100 subject: SUBJECT_A,
101 owners: ["user", "org"],
102 });
99 const rows = await bound.findMany("connection", {});103 const rows = await bound.findMany("connection", {});
100 104
101 expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]);105 expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]);
@@ -111,6 +115,7 @@ describe("owner policy reach", () => { · 9 unmodified lines
111 const platform = withQueryContext(db.db, {115 const platform = withQueryContext(db.db, {
112 tenant: TENANT,116 tenant: TENANT,
113 subject: SUBJECT_A,117 subject: SUBJECT_A,
118 owners: ["user", "org"],
114 reach: "tenant",119 reach: "tenant",
115 });120 });
116 const rows = await platform.findMany("connection", {});121 const rows = await platform.findMany("connection", {});
@@ -129,6 +134,7 @@ describe("owner policy reach", () => { · 12 unmodified lines
129 const platform = withQueryContext(db.db, {134 const platform = withQueryContext(db.db, {
130 tenant: TENANT,135 tenant: TENANT,
131 subject: SUBJECT_A,136 subject: SUBJECT_A,
137 owners: ["user", "org"],
132 reach: "tenant",138 reach: "tenant",
133 });139 });
134 const rows = await platform.findMany("connection", {});140 const rows = await platform.findMany("connection", {});
@@ -149,6 +155,7 @@ describe("owner policy reach", () => { · 14 unmodified lines
149 const platform = withQueryContext(db.db, {155 const platform = withQueryContext(db.db, {
150 tenant: TENANT,156 tenant: TENANT,
151 subject: null,157 subject: null,
158 owners: ["org"],
152 reach: "tenant",159 reach: "tenant",
153 });160 });
154 const rows = await platform.findMany("connection", {});161 const rows = await platform.findMany("connection", {});
@@ -168,6 +175,7 @@ describe("owner policy reach", () => { · 13 unmodified lines
168 const platform = withQueryContext(db.db, {175 const platform = withQueryContext(db.db, {
169 tenant: TENANT,176 tenant: TENANT,
170 subject: SUBJECT_A,177 subject: SUBJECT_A,
178 owners: ["user", "org"],
171 reach: "tenant",179 reach: "tenant",
172 });180 });
173 181
@@ -197,6 +205,7 @@ describe("owner policy reach", () => { · 23 unmodified lines
197 const platform = withQueryContext(db.db, {205 const platform = withQueryContext(db.db, {
198 tenant: TENANT,206 tenant: TENANT,
199 subject: SUBJECT_A,207 subject: SUBJECT_A,
208 owners: ["user", "org"],
200 reach: "tenant",209 reach: "tenant",
201 });210 });
202 211
@@ -225,6 +234,7 @@ describe("owner policy reach", () => { · 22 unmodified lines
225 const platform = withQueryContext(db.db, {234 const platform = withQueryContext(db.db, {
226 tenant: TENANT,235 tenant: TENANT,
227 subject: SUBJECT_A,236 subject: SUBJECT_A,
237 owners: ["user", "org"],
228 reach: "tenant",238 reach: "tenant",
229 });239 });
230 240
@@ -249,6 +259,7 @@ describe("owner policy reach", () => { · 18 unmodified lines
249 const platform = withQueryContext(db.db, {259 const platform = withQueryContext(db.db, {
250 tenant: TENANT,260 tenant: TENANT,
251 subject: SUBJECT_A,261 subject: SUBJECT_A,
262 owners: ["user", "org"],
252 reach: "tenant",263 reach: "tenant",
253 });264 });
254 265
@@ -272,12 +283,17 @@ describe("owner policy reach", () => { · 17 unmodified lines
272 withDb(async (db) => {283 withDb(async (db) => {
273 // The guard must not disturb the product view: an ordinary bound284 // The guard must not disturb the product view: an ordinary bound
274 // context writes exactly as before.285 // context writes exactly as before.
275 const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });286 const bound = withQueryContext(db.db, {
287 tenant: TENANT,
288 subject: SUBJECT_A,
289 owners: ["user", "org"],
290 });
276 291
277 await bound.create("connection", {292 await bound.create("connection", {
278 tenant: TENANT,293 tenant: TENANT,
279 owner: "user",294 owner: "user",
280 subject: SUBJECT_A,295 subject: SUBJECT_A,
296 owners: ["user", "org"],
281 integration: "github",297 integration: "github",
282 name: "written-by-bound-view",298 name: "written-by-bound-view",
283 template: "apiKey",299 template: "apiKey",
@@ -296,13 +312,18 @@ describe("owner policy reach", () => { · 12 unmodified lines
296 it.effect("a bound context still cannot write outside its own subject", () =>312 it.effect("a bound context still cannot write outside its own subject", () =>
297 Effect.promise(() =>313 Effect.promise(() =>
298 withDb(async (db) => {314 withDb(async (db) => {
299 const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });315 const bound = withQueryContext(db.db, {
316 tenant: TENANT,
317 subject: SUBJECT_A,
318 owners: ["user", "org"],
319 });
300 320
301 await expect(321 await expect(
302 bound.create("connection", {322 bound.create("connection", {
303 tenant: TENANT,323 tenant: TENANT,
304 owner: "user",324 owner: "user",
305 subject: SUBJECT_B,325 subject: SUBJECT_B,
326 owners: ["user", "org"],
306 integration: "github",327 integration: "github",
307 name: "cross-subject",328 name: "cross-subject",
308 template: "apiKey",329 template: "apiKey",
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
2import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema";2import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema";
3 3
4import { StorageError } from "./fuma-runtime";4import { StorageError } from "./fuma-runtime";
5import type { Owner } from "./ids";
5 6
6/* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned7/* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned
7 * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the8 * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the
@@ -53,6 +54,19 @@ export interface ExecutorOwnerPolicyContext { · 45 unmodified lines
53 /** The acting member, or null for a pure-org executor (no `owner:"user"`54 /** The acting member, or null for a pure-org executor (no `owner:"user"`
54 * reads/writes are allowed when null). */55 * reads/writes are allowed when null). */
55 readonly subject: string | null;56 readonly subject: string | null;
57 /**
58 * The row-visibility/write decision (`ExecutorAccess.owners` for
59 * principal contexts): which owner partitions this context sees, and may
60 * create/patch rows in. REQUIRED — there is no full-view default; a
61 * context that reaches the policy without it fails closed (reads match
62 * nothing, writes are refused). Host-internal contexts that only touch
63 * tenant-scoped tables state `[]` explicitly.
64 *
65 * This NARROWS, never widens: whatever it lists, the tenant clause and the
66 * bound-subject clause on user rows still apply, and `reach: "tenant"`
67 * contexts remain read-only (or delete-only) by construction.
68 */
69 readonly owners: readonly Owner[];
56 /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the70 /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the
57 * platform view: it sees the whole tenant but writes exactly as a bound71 * platform view: it sees the whole tenant but writes exactly as a bound
58 * context does, so it can never mutate another subject's rows. */72 * context does, so it can never mutate another subject's rows. */
@@ -106,17 +120,29 @@ export const ownerVisibilityCondition = ( · 47 unmodified lines
106 // The platform view: partition by tenant alone. Still never cross-tenant —120 // The platform view: partition by tenant alone. Still never cross-tenant —
107 // `tenant` is the one clause that is NEVER relaxed, at any reach.121 // `tenant` is the one clause that is NEVER relaxed, at any reach.
108 if (context.reach === "tenant") return builder("tenant", "=", context.tenant);122 if (context.reach === "tenant") return builder("tenant", "=", context.tenant);
109 const orgClause = builder.and(123 // The product's visible partitions. A malformed context that reaches the
110 builder("tenant", "=", context.tenant),124 // policy without them (only possible past the type system) fails CLOSED:
111 builder("owner", "=", "org"),125 // no partitions means no rows. Core contributes only the clamps inside
112 );126 // each clause — never a partition of its own.
113 if (context.subject == null) return orgClause;127 const owners: readonly Owner[] = Array.isArray(context.owners) ? context.owners : [];
114 const userClause = builder.and(128 const clauses: (Condition | boolean)[] = [];
115 builder("tenant", "=", context.tenant),129 if (owners.includes("org")) {
116 builder("owner", "=", "user"),130 clauses.push(builder.and(builder("tenant", "=", context.tenant), builder("owner", "=", "org")));
117 builder("subject", "=", context.subject),131 }
118 );132 if (owners.includes("user") && context.subject != null) {
119 return builder.or(orgClause, userClause);133 clauses.push(
134 builder.and(
135 builder("tenant", "=", context.tenant),
136 builder("owner", "=", "user"),
137 builder("subject", "=", context.subject),
138 ),
139 );
140 }
141 // A context whose product view lists no partition this binding can carry
142 // (personal-only with no subject) sees nothing — fail closed, not open.
143 if (clauses.length === 0) return false;
144 if (clauses.length === 1) return clauses[0]!;
145 return builder.or(...clauses);
120};146};
121 147
122/**148/**
@@ -172,6 +198,21 @@ export const assertOwnerWritable = ( · 49 unmodified lines
172 if (values.tenant !== ctx.tenant) {198 if (values.tenant !== ctx.tenant) {
173 policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`);199 policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`);
174 }200 }
201 // The product's write partitions: a row may only be created in a partition
202 // the product view lists (update/delete are already filtered by
203 // `ownerVisibilityCondition`). A context missing the decision fails
204 // closed. Purely narrowing — the per-owner clamps below still apply to
205 // whatever the product allows.
206 if (!Array.isArray(ctx.owners)) {
207 policyViolation(
208 `Storage write on table "${tableName}" is missing the context's owner partitions.`,
209 );
210 }
211 if ((values.owner === "org" || values.owner === "user") && !ctx.owners.includes(values.owner)) {
212 policyViolation(
213 `Storage write on table "${tableName}" targets the "${values.owner}" partition, which this product view does not include.`,
214 );
215 }
175 if (values.owner === "org") {216 if (values.owner === "org") {
176 if (values.subject !== ORG_SUBJECT) {217 if (values.subject !== ORG_SUBJECT) {
177 policyViolation(`Storage write on table "${tableName}" set a subject on an org row.`);218 policyViolation(`Storage write on table "${tableName}" set a subject on an org row.`);
@@ -204,6 +245,14 @@ export const assertOwnerPatch = ( · 26 unmodified lines
204 if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) {245 if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) {
205 policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`);246 policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`);
206 }247 }
248 if (
249 (patch.owner === "org" || patch.owner === "user") &&
250 !(Array.isArray(ctx.owners) && ctx.owners.includes(patch.owner))
251 ) {
252 policyViolation(
253 `Storage write on table "${tableName}" cannot move a row into the "${patch.owner}" partition, which this product view does not include.`,
254 );
255 }
207 if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) {256 if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) {
208 policyViolation(257 policyViolation(
209 `Storage write on table "${tableName}" cannot move a row outside the bound subject.`,258 `Storage write on table "${tableName}" cannot move a row outside the bound subject.`,
packages/core/sdk/src/platform-view.test.ts+7−1
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
22} from "./ids";22} from "./ids";
23import { definePlugin } from "./plugin";23import { definePlugin } from "./plugin";
24import { resetSubjectTouchCache, touchSubject } from "./subject-registry";24import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
25import { testAccess } from "@executor-js/product-access/testing";
25 26
26// The platform view: `executor.admin`, an OPT-IN read-only surface that reads27// The platform view: `executor.admin`, an OPT-IN read-only surface that reads
27// across every subject in the tenant. Written against the real SQLite bring-up28// across every subject in the tenant. Written against the real SQLite bring-up
@@ -254,7 +255,12 @@ const makePlatformExecutor = ( · 226 unmodified lines
254 db: db.db,255 db: db.db,
255 plugins: [providerPlugin],256 plugins: [providerPlugin],
256 onElicitation: "accept-all",257 onElicitation: "accept-all",
257 ...(options?.platformView === false ? {} : { platformView: true }),258 access:
259 options?.platformView === false
260 ? options?.subject === null
261 ? testAccess.org()
262 : testAccess.member()
263 : testAccess.platform({ subject: options?.subject !== null }),
258 }).pipe(Effect.orDie);264 }).pipe(Effect.orDie);
259 265
260const requireAdmin = (executor: Executor) => {266const requireAdmin = (executor: Executor) => {
packages/core/sdk/src/plugin-after-commit.test.ts+5−3
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13import { definePlugin } from "./plugin";13import { definePlugin } from "./plugin";
14import { makeTestExecutor } from "./test-config";14import { makeTestExecutor } from "./test-config";
15import { serveOAuthTestServer } from "./testing/oauth-test-server";15import { serveOAuthTestServer } from "./testing/oauth-test-server";
16import { testAccess } from "@executor-js/product-access/testing";
16 17
17// A plugin's `removeConnection` runs INSIDE core's removal transaction, which is18// A plugin's `removeConnection` runs INSIDE core's removal transaction, which is
18// what makes its database work atomic with the row deletions. The same property19// what makes its database work atomic with the row deletions. The same property
@@ -98,9 +99,10 @@ const revokingPlugin = (revoked: string[]) => · 79 unmodified lines
98 })();99 })();
99 100
100const setup = (revoked: string[]) =>101const setup = (revoked: string[]) =>
101 makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe(102 makeTestExecutor({
102 Effect.tap((executor) => executor.demo.seed()),103 access: testAccess.member(),
103 );104 plugins: [revokingPlugin(revoked)] as const,
105 }).pipe(Effect.tap((executor) => executor.demo.seed()));
104 106
105const REF = {107const REF = {
106 owner: "org",108 owner: "org",
packages/core/sdk/src/plugin-storage.test.ts+9−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 type PluginStorageCollectionWhere,12 type PluginStorageCollectionWhere,
13} from "./plugin-storage";13} from "./plugin-storage";
14import { makeTestConfig, makeTestExecutor } from "./testing";14import { makeTestConfig, makeTestExecutor } from "./testing";
15import { testAccess } from "@executor-js/product-access/testing";
15 16
16const ToolCall = Schema.Struct({17const ToolCall = Schema.Struct({
17 runId: Schema.String,18 runId: Schema.String,
@@ -167,6 +168,7 @@ describe("plugin storage collections", () => { · 149 unmodified lines
167 it.effect("queries declared indexes through the executor's SQLite FumaDB target", () =>168 it.effect("queries declared indexes through the executor's SQLite FumaDB target", () =>
168 Effect.gen(function* () {169 Effect.gen(function* () {
169 const executor = yield* makeTestExecutor({170 const executor = yield* makeTestExecutor({
171 access: testAccess.member(),
170 backend: "sqlite",172 backend: "sqlite",
171 plugins: [executionHistoryPlugin] as const,173 plugins: [executionHistoryPlugin] as const,
172 });174 });
@@ -229,6 +231,7 @@ describe("plugin storage collections", () => { · 56 unmodified lines
229 it.effect("bulk puts and removes plugin storage rows", () =>231 it.effect("bulk puts and removes plugin storage rows", () =>
230 Effect.gen(function* () {232 Effect.gen(function* () {
231 const executor = yield* makeTestExecutor({233 const executor = yield* makeTestExecutor({
234 access: testAccess.member(),
232 backend: "sqlite",235 backend: "sqlite",
233 plugins: [executionHistoryPlugin] as const,236 plugins: [executionHistoryPlugin] as const,
234 });237 });
@@ -275,6 +278,7 @@ describe("plugin storage collections", () => { · 40 unmodified lines
275 it.effect("stores and overwrites every row when a bulk write spans multiple batches", () =>278 it.effect("stores and overwrites every row when a bulk write spans multiple batches", () =>
276 Effect.gen(function* () {279 Effect.gen(function* () {
277 const executor = yield* makeTestExecutor({280 const executor = yield* makeTestExecutor({
281 access: testAccess.member(),
278 backend: "sqlite",282 backend: "sqlite",
279 plugins: [executionHistoryPlugin] as const,283 plugins: [executionHistoryPlugin] as const,
280 });284 });
@@ -312,6 +316,7 @@ describe("plugin storage collections", () => { · 31 unmodified lines
312 it.effect("rolls back every plugin storage row when a bulk write fails", () =>316 it.effect("rolls back every plugin storage row when a bulk write fails", () =>
313 Effect.gen(function* () {317 Effect.gen(function* () {
314 const config = makeTestConfig({318 const config = makeTestConfig({
319 access: testAccess.member(),
315 backend: "sqlite",320 backend: "sqlite",
316 plugins: [executionHistoryPlugin] as const,321 plugins: [executionHistoryPlugin] as const,
317 });322 });
@@ -369,6 +374,7 @@ describe("plugin storage collections", () => { · 51 unmodified lines
369 it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () =>374 it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () =>
370 Effect.gen(function* () {375 Effect.gen(function* () {
371 const config = makeTestConfig({376 const config = makeTestConfig({
377 access: testAccess.member(),
372 backend: "sqlite",378 backend: "sqlite",
373 plugins: [executionHistoryPlugin] as const,379 plugins: [executionHistoryPlugin] as const,
374 });380 });
@@ -435,6 +441,7 @@ describe("plugin storage collections", () => { · 60 unmodified lines
435 () =>441 () =>
436 Effect.gen(function* () {442 Effect.gen(function* () {
437 const executor = yield* makeTestExecutor({443 const executor = yield* makeTestExecutor({
444 access: testAccess.member(),
438 backend: "sqlite",445 backend: "sqlite",
439 plugins: [executionHistoryPlugin] as const,446 plugins: [executionHistoryPlugin] as const,
440 });447 });
@@ -463,6 +470,7 @@ describe("plugin storage collections", () => { · 22 unmodified lines
463 // One executor bound to a subject sees both org and user owner rows; a470 // One executor bound to a subject sees both org and user owner rows; a
464 // user-owned row shadows an org-owned row under the same key on read.471 // user-owned row shadows an org-owned row under the same key on read.
465 const executor = yield* makeTestExecutor({472 const executor = yield* makeTestExecutor({
473 access: testAccess.member(),
466 backend: "sqlite",474 backend: "sqlite",
467 plugins: [executionHistoryPlugin] as const,475 plugins: [executionHistoryPlugin] as const,
468 });476 });
@@ -508,6 +516,7 @@ describe("plugin storage collections", () => { · 39 unmodified lines
508 it.effect("rejects runtime queries against undeclared index fields", () =>516 it.effect("rejects runtime queries against undeclared index fields", () =>
509 Effect.gen(function* () {517 Effect.gen(function* () {
510 const executor = yield* makeTestExecutor({518 const executor = yield* makeTestExecutor({
519 access: testAccess.member(),
511 backend: "sqlite",520 backend: "sqlite",
512 plugins: [executionHistoryPlugin] as const,521 plugins: [executionHistoryPlugin] as const,
513 });522 });
packages/core/sdk/src/plugin.ts+3−3
@@ -168,9 +168,9 @@ export interface PluginCtx<TStore = unknown> { · 167 unmodified lines
168 * external work or writes to storage outside the catalog transaction. */168 * external work or writes to storage outside the catalog transaction. */
169 readonly authorizeWrite: () => Effect.Effect<void, OrgWriteDeniedError>;169 readonly authorizeWrite: () => Effect.Effect<void, OrgWriteDeniedError>;
170 /** Register / replace this plugin's integration in the catalog. Both170 /** Register / replace this plugin's integration in the catalog. Both
171 * operations are workspace-level changes gated by the executor's171 * operations are workspace-level changes gated by the product's
172 * `orgWrites` binding for end-user principals. Subjectless system172 * `ExecutorAccess.settingsWrite` decision, including replacement by
173 * executors may re-register an existing row during boot convergence. */173 * subjectless system executors during boot convergence. */
174 readonly register: (174 readonly register: (
175 input: RegisterIntegrationInput,175 input: RegisterIntegrationInput,
176 ) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>;176 ) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>;
packages/core/sdk/src/policies.test.ts+12−8
@@ -15,15 +15,15 @@ import { · 14 unmodified lines
15import { ElicitationResponse, type ElicitationHandler } from "./elicitation";15import { ElicitationResponse, type ElicitationHandler } from "./elicitation";
16import { createExecutor } from "./executor";16import { createExecutor } from "./executor";
17import type { FumaDb } from "./fuma-runtime";17import type { FumaDb } from "./fuma-runtime";
18import {18// Resolution semantics are PRODUCT rules — these tests exercise the real
19 effectivePolicyFromSorted,19// implementations from @executor-js/product-access (via the workspace root
20 isValidPattern,20// devDependency), never a copy.
21 matchPattern,21import { effectivePolicyFromSorted, resolveToolPolicy } from "@executor-js/product-access/policy";
22 resolveToolPolicy,22import { isValidPattern, matchPattern } from "./policies";
23} from "./policies";
24import { definePlugin, tool } from "./plugin";23import { definePlugin, tool } from "./plugin";
25import type { CredentialProvider } from "./provider";24import type { CredentialProvider } from "./provider";
26import { makeTestConfig, makeTestExecutor } from "./testing";25import { makeTestConfig, makeTestExecutor } from "./testing";
26import { testAccess } from "@executor-js/product-access/testing";
27 27
28// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
29// Pure unit tests — pattern matcher + resolution. No executor required.29// Pure unit tests — pattern matcher + resolution. No executor required.
@@ -332,7 +332,7 @@ const addr = (integration: IntegrationSlug, tool: string): ToolAddress => · 302 unmodified lines
332 ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`);332 ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`);
333 333
334const setupExecutor = () =>334const setupExecutor = () =>
335 makeTestExecutor({ plugins: [policyTestPlugin()] as const }).pipe(335 makeTestExecutor({ access: testAccess.member(), plugins: [policyTestPlugin()] as const }).pipe(
336 Effect.tap((executor) =>336 Effect.tap((executor) =>
337 Effect.gen(function* () {337 Effect.gen(function* () {
338 yield* executor.ptest.seed();338 yield* executor.ptest.seed();
@@ -533,7 +533,10 @@ describe("executor.policies", () => { · 194 unmodified lines
533 it.effect("fails when the policy vanishes during update", () =>533 it.effect("fails when the policy vanishes during update", () =>
534 Effect.gen(function* () {534 Effect.gen(function* () {
535 let armed = false;535 let armed = false;
536 const config = makeTestConfig({ plugins: [policyTestPlugin()] as const });536 const config = makeTestConfig({
537 access: testAccess.member(),
538 plugins: [policyTestPlugin()] as const,
539 });
537 const executor = yield* createExecutor({540 const executor = yield* createExecutor({
538 ...config,541 ...config,
539 db: removePolicyAfterUpdate(config.db, () => armed),542 db: removePolicyAfterUpdate(config.db, () => armed),
@@ -685,6 +688,7 @@ describe("active tool-policy provider", () => { · 145 unmodified lines
685 it.effect("uses provider rules as an allowlist for list, schema, and execute", () =>688 it.effect("uses provider rules as an allowlist for list, schema, and execute", () =>
686 Effect.gen(function* () {689 Effect.gen(function* () {
687 const executor = yield* makeTestExecutor({690 const executor = yield* makeTestExecutor({
691 access: testAccess.member(),
688 plugins: [staticPlugin, policyProviderPlugin] as const,692 plugins: [staticPlugin, policyProviderPlugin] as const,
689 });693 });
690 694
packages/core/sdk/src/policies.ts+15−112
@@ -1,15 +1,17 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Tool policies — pattern matcher + policy resolution. Pure functions; the2// Tool policies — the decision VOCABULARY (types, schemas, projections) plus
3// executor stitches them into `tools.list`, `execute`, and the public3// the pure pattern-matching and rule-placement utilities the executor's CRUD
4// `executor.policies` CRUD surface. Plugins consume the same surface.4// surface needs. Policies are owner-scoped (org | user) rows.
5//5//
6// v2: policies are owner-scoped (org | user) instead of scope-stacked. Each6// HOW rules resolve into an effective decision — owner ranking, the
7// owner contributes its first matching rule by local position; the final answer7// most-restrictive merge across owners, the plugin-default fallback and the
8// is the most restrictive matched action across owners, so a user preference8// capability-allowlist default — is a PRODUCT rule: it lives in
9// cannot weaken an org guardrail (org = outer, user = inner).9// `@executor-js/product-access/policy` and reaches core only through the
10// `ExecutorAccess.toolPolicy` hook, whose `EffectivePolicy` answers core
11// enforces at list/schema/invoke/approval-recheck.
10// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
11 13
12import { Match, Schema } from "effect";14import { Schema } from "effect";
13import { generateKeyBetween } from "fractional-indexing";15import { generateKeyBetween } from "fractional-indexing";
14 16
15import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema";17import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema";
@@ -121,10 +123,11 @@ export const isValidPattern = (pattern: string): boolean => { · 105 unmodified lines
121};123};
122 124
123// ---------------------------------------------------------------------------125// ---------------------------------------------------------------------------
124// Resolution — each owner contributes its first matching rule by local126// Ordering / placement utilities. HOW matched rules combine into an
125// position; the most restrictive matched action across owners wins. Caller127// effective decision (owner ranking, most-restrictive merge, fallbacks) is a
126// passes an `ownerRank` so the resolver doesn't need to know which owner is128// PRODUCT rule and lives in `@executor-js/product-access/policy`; core only
127// the outer guardrail.129// consumes the resulting `EffectivePolicy` through
130// `ExecutorAccess.toolPolicy` and enforces it.
128// ---------------------------------------------------------------------------131// ---------------------------------------------------------------------------
129 132
130export const comparePolicyRow = (133export const comparePolicyRow = (
@@ -180,106 +183,6 @@ export const positionForNewPattern = ( · 49 unmodified lines
180 return generateKeyBetween(prev, next);183 return generateKeyBetween(prev, next);
181};184};
182 185
183const 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
191const 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
201export 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
235const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
236 defaultRequiresApproval
237 ? { action: "require_approval", source: "plugin-default" }
238 : { action: "approve", source: "plugin-default" };
239
240const liftUser = (match: PolicyMatch): EffectivePolicy => ({
241 action: match.action,
242 source: "user",
243 pattern: match.pattern,
244 policyId: match.policyId,
245});
246
247export 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
257export const effectivePolicyFromSorted = (
258 toolId: string,
259 sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> &
260 Partial<Pick<ToolPolicy, "owner">>)[],
261 defaultRequiresApproval?: boolean,
262): EffectivePolicy => {
263 const firstMatchByOwner = new Map<string, EffectivePolicy>();
264 for (const p of sortedPolicies) {
265 const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__";
266 if (firstMatchByOwner.has(ownerKey)) continue;
267 if (matchPattern(p.pattern, toolId)) {
268 firstMatchByOwner.set(ownerKey, {
269 action: p.action,
270 source: "user",
271 pattern: p.pattern,
272 policyId: p.id,
273 });
274 }
275 }
276 let selected: EffectivePolicy | undefined;
277 for (const match of firstMatchByOwner.values()) {
278 selected = moreRestrictive(selected, match);
279 }
280 return selected ?? liftPlugin(defaultRequiresApproval);
281};
282
283// ---------------------------------------------------------------------------186// ---------------------------------------------------------------------------
284// Row → public projection.187// Row → public projection.
285// ---------------------------------------------------------------------------188// ---------------------------------------------------------------------------
packages/core/sdk/src/promise-executor.ts+11−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
21 type InvokeOptions as EffectInvokeOptions,21 type InvokeOptions as EffectInvokeOptions,
22 type OnElicitation,22 type OnElicitation,
23} from "./executor";23} from "./executor";
24import type { ExecutorAccess } from "./access";
24import type { ElicitationContext, ElicitationResponse } from "./elicitation";25import type { ElicitationContext, ElicitationResponse } from "./elicitation";
25import type { FumaDb, FumaTables } from "./fuma-runtime";26import type { FumaDb, FumaTables } from "./fuma-runtime";
26import { Subject, Tenant } from "./ids";27import { Subject, Tenant } from "./ids";
@@ -127,6 +128,15 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 100 unmodified lines
127 * an options arg.128 * an options arg.
128 */129 */
129 readonly onElicitation: PromiseOnElicitation;130 readonly onElicitation: PromiseOnElicitation;
131 /**
132 * Product access decisions for the binding — REQUIRED, exactly as on the
133 * Effect SDK's `ExecutorConfig.access`. This façade is core SDK, not a
134 * product: it supplies no posture of its own. Embedders state theirs
135 * explicitly — `@executor-js/product-access` ships the standard ones
136 * (`singleUserAccess()` for a subject-bound single owner,
137 * `workspaceServiceAccess()` for a subject-less workspace binding).
138 */
139 readonly access: ExecutorAccess;
130}140}
131 141
132// ---------------------------------------------------------------------------142// ---------------------------------------------------------------------------
@@ -213,6 +223,7 @@ export const createExecutor = async <const TPlugins extends readonly AnyPlugin[] · 80 unmodified lines
213 plugins,223 plugins,
214 ...(config.providers ? { providers: config.providers } : {}),224 ...(config.providers ? { providers: config.providers } : {}),
215 onElicitation: toEffectOnElicitation(config.onElicitation),225 onElicitation: toEffectOnElicitation(config.onElicitation),
226 access: config.access,
216 ...(db ? { db } : {}),227 ...(db ? { db } : {}),
217 };228 };
218 229
packages/core/sdk/src/promise.test.ts+5−0
@@ -5,6 +5,7 @@ import { definePlugin, tool } from "./plugin"; · 4 unmodified lines
5import type { ToolDef } from "./tool";5import type { ToolDef } from "./tool";
6import { IntegrationSlug, ToolName } from "./ids";6import { IntegrationSlug, ToolName } from "./ids";
7import { Effect, Schema } from "effect";7import { Effect, Schema } from "effect";
8import { testAccess } from "@executor-js/product-access/testing";
8 9
9// A minimal static-tool plugin built on the Effect surface, consumed10// A minimal static-tool plugin built on the Effect surface, consumed
10// through the Promise façade. Exercises the proxy's ability to promisify11// through the Promise façade. Exercises the proxy's ability to promisify
@@ -41,6 +42,7 @@ describe("promise/createExecutor", () => { · 30 unmodified lines
41 it("returns Promise-shaped executor and invokes static tools", async () => {42 it("returns Promise-shaped executor and invokes static tools", async () => {
42 const plugins = [echoPlugin()] as const;43 const plugins = [echoPlugin()] as const;
43 const executor = await createExecutor({44 const executor = await createExecutor({
45 access: testAccess.org(),
44 plugins,46 plugins,
45 onElicitation: "accept-all",47 onElicitation: "accept-all",
46 });48 });
@@ -54,6 +56,7 @@ describe("promise/createExecutor", () => { · 7 unmodified lines
54 it("promisifies plugin extension methods", async () => {56 it("promisifies plugin extension methods", async () => {
55 const plugins = [echoPlugin()] as const;57 const plugins = [echoPlugin()] as const;
56 const executor = await createExecutor({58 const executor = await createExecutor({
59 access: testAccess.org(),
57 plugins,60 plugins,
58 onElicitation: "accept-all",61 onElicitation: "accept-all",
59 });62 });
@@ -94,6 +97,7 @@ describe("promise/createExecutor", () => { · 34 unmodified lines
94 97
95 const plugins = [approvedPlugin()] as const;98 const plugins = [approvedPlugin()] as const;
96 const executor = await createExecutor({99 const executor = await createExecutor({
100 access: testAccess.org(),
97 plugins,101 plugins,
98 onElicitation: "accept-all", // default → auto-approve102 onElicitation: "accept-all", // default → auto-approve
99 });103 });
@@ -157,6 +161,7 @@ describe("promise/createExecutor", () => { · 57 unmodified lines
157 161
158 const plugins = [inventoryPlugin()] as const;162 const plugins = [inventoryPlugin()] as const;
159 const executor = await createExecutor({163 const executor = await createExecutor({
164 access: testAccess.org(),
160 plugins,165 plugins,
161 providers: [memoryProvider],166 providers: [memoryProvider],
162 onElicitation: "accept-all",167 onElicitation: "accept-all",
packages/core/sdk/src/provider-call-timeout.test.ts+10−3
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
29import { definePlugin } from "./plugin";29import { definePlugin } from "./plugin";
30import type { CredentialProvider } from "./provider";30import type { CredentialProvider } from "./provider";
31import { makeTestConfig } from "./test-config";31import { makeTestConfig } from "./test-config";
32import { testAccess } from "@executor-js/product-access/testing";
32 33
33const STORE = ProviderKey.make("remote-store");34const STORE = ProviderKey.make("remote-store");
34const INTEG = IntegrationSlug.make("acme");35const INTEG = IntegrationSlug.make("acme");
@@ -55,7 +56,7 @@ const plugin = (provider: CredentialProvider) => · 20 unmodified lines
55const executorWithConnection = (provider: CredentialProvider) =>56const executorWithConnection = (provider: CredentialProvider) =>
56 Effect.gen(function* () {57 Effect.gen(function* () {
57 const executor = yield* createExecutor(58 const executor = yield* createExecutor(
58 makeTestConfig({ plugins: [plugin(provider)] as const }),59 makeTestConfig({ access: testAccess.member(), plugins: [plugin(provider)] as const }),
59 );60 );
60 yield* executor.acme.seed();61 yield* executor.acme.seed();
61 yield* executor.connections.create({62 yield* executor.connections.create({
@@ -110,7 +111,9 @@ describe("a credential provider that stops answering", () => { · 48 unmodified lines
110 get: (id) => Effect.sync(() => items.get(String(id)) ?? null),111 get: (id) => Effect.sync(() => items.get(String(id)) ?? null),
111 set: (id, value) => Effect.sync(() => void items.set(String(id), value)),112 set: (id, value) => Effect.sync(() => void items.set(String(id), value)),
112 };113 };
113 const executor = yield* createExecutor(makeTestConfig({ plugins: [plugin(lit)] as const }));114 const executor = yield* createExecutor(
115 makeTestConfig({ access: testAccess.member(), plugins: [plugin(lit)] as const }),
116 );
114 yield* executor.acme.seed();117 yield* executor.acme.seed();
115 yield* executor.connections.create({118 yield* executor.connections.create({
116 owner: "org",119 owner: "org",
@@ -144,7 +147,10 @@ describe("a credential provider that stops answering", () => { · 27 unmodified lines
144 }147 }
145 148
146 const executor = yield* createExecutor(149 const executor = yield* createExecutor(
147 makeTestConfig({ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const }),150 makeTestConfig({
151 access: testAccess.member(),
152 plugins: [plugin(new ClassProvider() as CredentialProvider)] as const,
153 }),
148 );154 );
149 yield* executor.acme.seed();155 yield* executor.acme.seed();
150 yield* executor.connections.create({156 yield* executor.connections.create({
@@ -184,6 +190,7 @@ describe("a credential provider that stops answering", () => { · 33 unmodified lines
184 190
185 const executor = yield* createExecutor(191 const executor = yield* createExecutor(
186 makeTestConfig({192 makeTestConfig({
193 access: testAccess.member(),
187 plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const,194 plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const,
188 }),195 }),
189 );196 );
packages/core/sdk/src/settings-authority.test.ts+118−0
@@ -0,0 +1,118 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Result } from "effect";
3import { testAccess } from "@executor-js/product-access/testing";
4
5import type { ExecutorAccess } from "./access";
6import { createExecutor } from "./executor";
7import { IntegrationSlug, Subject } from "./ids";
8import { definePlugin } from "./plugin";
9import { 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
26const SLUG = IntegrationSlug.make("settings-authority");
27const SEEDED_DESCRIPTION = "seeded by the workspace service";
28const REPLACED_DESCRIPTION = "replaced despite a denied settings decision";
29
30const 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. */
41const denyingServiceAccess = (): ExecutorAccess => ({
42 ...testAccess.org(),
43 settingsWrite: () => Effect.succeed("denied" as const),
44});
45
46const 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
66describe("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
88} from "./elicitation";88} from "./elicitation";
89 89
90// Tool-policy helpers + projections (pure functions / Schema).90// Tool-policy helpers + projections (pure functions / Schema).
91// Resolution semantics (owner ranking, most-restrictive merge, fallbacks)
92// live in `@executor-js/product-access/policy`, which builds on these.
91export {93export {
92 matchPattern,94 matchPattern,
93 isValidPattern,95 isValidPattern,
94 effectivePolicyFromSorted,
95 comparePolicyRow,96 comparePolicyRow,
96 patternSpecificity,97 patternSpecificity,
97 positionForNewPattern,98 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
88 });88 });
89 expect(await storageClassOf(db, "c_legacy")).toBe("integer");89 expect(await storageClassOf(db, "c_legacy")).toBe("integer");
90 90
91 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });91 const scoped = withQueryContext(db.db, {
92 tenant: TENANT,
93 subject: SUBJECT,
94 owners: ["user", "org"],
95 });
92 // Not "returns a bad value" — the read THROWS, which is why the gateway96 // Not "returns a bad value" — the read THROWS, which is why the gateway
93 // lost every saved integration rather than one field of one row.97 // lost every saved integration rather than one field of one row.
94 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);98 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
@@ -118,7 +122,11 @@ describe("legacy bigint storage class migration", () => { · 23 unmodified lines
118 expect(await storageClassOf(db, "c_healthy")).toBe("blob");122 expect(await storageClassOf(db, "c_healthy")).toBe("blob");
119 expect(await storageClassOf(db, "c_null")).toBe("null");123 expect(await storageClassOf(db, "c_null")).toBe("null");
120 124
121 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });125 const scoped = withQueryContext(db.db, {
126 tenant: TENANT,
127 subject: SUBJECT,
128 owners: ["user", "org"],
129 });
122 const rows = await scoped.findMany("connection", {});130 const rows = await scoped.findMany("connection", {});
123 expect(131 expect(
124 rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]),132 rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]),
@@ -143,7 +151,11 @@ describe("legacy bigint storage class migration", () => { · 18 unmodified lines
143 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1);151 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1);
144 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0);152 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0);
145 153
146 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });154 const scoped = withQueryContext(db.db, {
155 tenant: TENANT,
156 subject: SUBJECT,
157 owners: ["user", "org"],
158 });
147 const rows = await scoped.findMany("connection", {});159 const rows = await scoped.findMany("connection", {});
148 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);160 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
149 }),161 }),
@@ -169,7 +181,11 @@ describe("legacy bigint storage class migration", () => { · 19 unmodified lines
169 181
170 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2);182 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2);
171 183
172 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });184 const scoped = withQueryContext(db.db, {
185 tenant: TENANT,
186 subject: SUBJECT,
187 owners: ["user", "org"],
188 });
173 const integrations = await scoped.findMany("integration", {});189 const integrations = await scoped.findMany("integration", {});
174 expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([190 expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([
175 LEGACY_EXPIRES_AT,191 LEGACY_EXPIRES_AT,
packages/core/sdk/src/subject-registry.test.ts+6−0
@@ -9,6 +9,7 @@ import { definePlugin } from "./plugin"; · 8 unmodified lines
9import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";9import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
10import { resetSubjectTouchCache, touchSubject } from "./subject-registry";10import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
11import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";11import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
12import { testAccess } from "@executor-js/product-access/testing";
12 13
13// `touchSubject` is the only writer of the `subject` table. Written against the14// `touchSubject` is the only writer of the `subject` table. Written against the
14// real SQLite bring-up (the same statements local/self-host/D1 boot with) so15// real SQLite bring-up (the same statements local/self-host/D1 boot with) so
@@ -32,6 +33,7 @@ const scopedTo = (db: SqliteTestFumaDb, tenant: string) => · 17 unmodified lines
32 withQueryContext(db.db, {33 withQueryContext(db.db, {
33 tenant,34 tenant,
34 subject: null,35 subject: null,
36 owners: [],
35 } satisfies ExecutorOwnerPolicyContext);37 } satisfies ExecutorOwnerPolicyContext);
36 38
37const storedSubjects = (39const storedSubjects = (
@@ -345,6 +347,7 @@ const subjectsOf = ( · 307 unmodified lines
345 const rows = await withQueryContext(harness.config.db as never, {347 const rows = await withQueryContext(harness.config.db as never, {
346 tenant,348 tenant,
347 subject: null,349 subject: null,
350 owners: [],
348 } satisfies ExecutorOwnerPolicyContext).findMany("subject", {});351 } satisfies ExecutorOwnerPolicyContext).findMany("subject", {});
349 return rows.map((row) => String(row.external_id));352 return rows.map((row) => String(row.external_id));
350 });353 });
@@ -354,6 +357,7 @@ describe("connections.create subject sighting", () => { · 3 unmodified lines
354 Effect.scoped(357 Effect.scoped(
355 Effect.gen(function* () {358 Effect.gen(function* () {
356 const harness = yield* makeTestWorkspaceHarness({359 const harness = yield* makeTestWorkspaceHarness({
360 access: testAccess.member(),
357 plugins,361 plugins,
358 tenant: TENANT,362 tenant: TENANT,
359 });363 });
@@ -377,6 +381,7 @@ describe("connections.create subject sighting", () => { · 17 unmodified lines
377 // The row names the acting principal, not the connection's owner tier:381 // The row names the acting principal, not the connection's owner tier:
378 // a member who only ever creates org connections still exists.382 // a member who only ever creates org connections still exists.
379 const harness = yield* makeTestWorkspaceHarness({383 const harness = yield* makeTestWorkspaceHarness({
384 access: testAccess.member(),
380 plugins,385 plugins,
381 tenant: TENANT,386 tenant: TENANT,
382 });387 });
@@ -398,6 +403,7 @@ describe("connections.create subject sighting", () => { · 15 unmodified lines
398 Effect.scoped(403 Effect.scoped(
399 Effect.gen(function* () {404 Effect.gen(function* () {
400 const harness = yield* makeTestWorkspaceHarness({405 const harness = yield* makeTestWorkspaceHarness({
406 access: testAccess.org(),
401 plugins,407 plugins,
402 tenant: TENANT,408 tenant: TENANT,
403 subject: null,409 subject: null,
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
133 withQueryContext(db, {133 withQueryContext(db, {
134 tenant: input.tenant,134 tenant: input.tenant,
135 subject: externalId,135 subject: externalId,
136 // This host-internal writer touches ONLY the tenant-scoped `subject`
137 // table; it holds no owner-scoped view at all, stated explicitly.
138 owners: [],
136 } satisfies ExecutorOwnerPolicyContext),139 } satisfies ExecutorOwnerPolicyContext),
137 );140 );
138 // No `tenant` clause: the tenant policy adds it to every read/update.141 // No `tenant` clause: the tenant policy adds it to every read/update.
packages/core/sdk/src/test-config.ts+18−9
@@ -1,5 +1,6 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2import { withQueryContext } from "@executor-js/fumadb/query";2import { withQueryContext } from "@executor-js/fumadb/query";
3import type { ExecutorAccess } from "./access";
3import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor";4import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor";
4import type { FumaDb } from "./fuma-runtime";5import type { FumaDb } from "./fuma-runtime";
5import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids";6import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids";
@@ -134,15 +135,18 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 128 unmodified lines
134 readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];135 readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];
135 readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];136 readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];
136 readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"];137 readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"];
137 /** Workspace-settings permission for the test binding (see138 /** Product access decisions for the test binding (see
138 * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts139 * `ExecutorConfig.access`). REQUIRED — this fixture is generic and has no
139 * with no role model. */140 * posture of its own. Tests state one explicitly, normally from
140 readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];141 * `@executor-js/product-access/testing` (`testAccess.member()`, …; the
142 * sdk's own tests resolve that package through the workspace ROOT
143 * devDependency, keeping the package graph acyclic). */
144 readonly access: ExecutorAccess;
141 readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"];145 readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"];
142};146};
143 147
144export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(148export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
145 options?: TestConfigOptions<TPlugins>,149 options: TestConfigOptions<TPlugins>,
146): Omit<ExecutorConfig<TPlugins>, "db"> & {150): Omit<ExecutorConfig<TPlugins>, "db"> & {
147 readonly db: FumaDb;151 readonly db: FumaDb;
148 readonly testDb: TestFumaDb;152 readonly testDb: TestFumaDb;
@@ -156,9 +160,14 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
156 backend: options?.backend ?? "sqlite",160 backend: options?.backend ?? "sqlite",
157 dataDir: options?.dataDir,161 dataDir: options?.dataDir,
158 });162 });
163 // The fixture's SEEDING/INSPECTION handle: tests read and write rows
164 // through it directly, so it carries the binding's full identity view,
165 // stated explicitly. The executor built from this config re-binds its own
166 // context from `options.access` and never inherits this one.
159 const db = withQueryContext(testDb.db, {167 const db = withQueryContext(testDb.db, {
160 tenant,168 tenant,
161 subject,169 subject,
170 owners: subject != null ? ["user", "org"] : ["org"],
162 } satisfies ExecutorOwnerPolicyContext);171 } satisfies ExecutorOwnerPolicyContext);
163 172
164 // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow173 // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow
@@ -178,7 +187,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 13 unmodified lines
178 onElicitation: "accept-all",187 onElicitation: "accept-all",
179 onIntegrationChange: options?.onIntegrationChange,188 onIntegrationChange: options?.onIntegrationChange,
180 ...(redirectUri != null ? { redirectUri } : {}),189 ...(redirectUri != null ? { redirectUri } : {}),
181 ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),190 access: options.access,
182 oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,191 oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
183 firstPartyOAuthClients: options?.firstPartyOAuthClients,192 firstPartyOAuthClients: options?.firstPartyOAuthClients,
184 enterpriseManagedRollout: options?.enterpriseManagedRollout,193 enterpriseManagedRollout: options?.enterpriseManagedRollout,
@@ -209,7 +218,7 @@ export class TestWorkspace extends Context.Service<TestWorkspace, TestWorkspaceH · 24 unmodified lines
209}218}
210 219
211export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>(220export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
212 options?: TestConfigOptions<TPlugins>,221 options: TestConfigOptions<TPlugins>,
213) =>222) =>
214 Effect.acquireRelease(223 Effect.acquireRelease(
215 Effect.gen(function* () {224 Effect.gen(function* () {
@@ -233,7 +242,7 @@ export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlug · 17 unmodified lines
233 );242 );
234 243
235export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>(244export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
236 options?: TestConfigOptions<TPlugins>,245 options: TestConfigOptions<TPlugins>,
237) =>246) =>
238 Layer.effect(TestWorkspace)(247 Layer.effect(TestWorkspace)(
239 makeTestWorkspaceHarness(options).pipe(248 makeTestWorkspaceHarness(options).pipe(
@@ -242,7 +251,7 @@ export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin · 2 unmodified lines
242 );251 );
243 252
244export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>(253export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
245 options?: TestConfigOptions<TPlugins>,254 options: TestConfigOptions<TPlugins>,
246) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor));255) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor));
247 256
248/** Built-in in-memory writable credential provider, contributed as a plugin257/** 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
7 OAuthTestServer,7 OAuthTestServer,
8 TestWorkspace,8 TestWorkspace,
9} from "./testing";9} from "./testing";
10import { testAccess } from "@executor-js/product-access/testing";
10 11
11const plugins = [memoryCredentialsPlugin()] as const;12const plugins = [memoryCredentialsPlugin()] as const;
12 13
13const TestLayer = Layer.mergeAll(makeTestWorkspaceLayer({ plugins }), OAuthTestServer.layer());14const TestLayer = Layer.mergeAll(
15 makeTestWorkspaceLayer({ access: testAccess.member(), plugins }),
16 OAuthTestServer.layer(),
17);
14 18
15layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => {19layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => {
16 it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () =>20 it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () =>
packages/core/sdk/src/tool-not-found-empty-catalog.test.ts+4−1
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
30import { definePlugin } from "./plugin";30import { definePlugin } from "./plugin";
31import type { CredentialProvider } from "./provider";31import type { CredentialProvider } from "./provider";
32import { makeTestConfig } from "./test-config";32import { makeTestConfig } from "./test-config";
33import { testAccess } from "@executor-js/product-access/testing";
33 34
34const STORE = ProviderKey.make("memory");35const STORE = ProviderKey.make("memory");
35const INTEG = IntegrationSlug.make("demo");36const INTEG = IntegrationSlug.make("demo");
@@ -65,7 +66,9 @@ const POPULATED = pluginWith([ · 29 unmodified lines
65 66
66const failInvoking = (plugin: ReturnType<typeof pluginWith>, tool: string) =>67const failInvoking = (plugin: ReturnType<typeof pluginWith>, tool: string) =>
67 Effect.gen(function* () {68 Effect.gen(function* () {
68 const executor = yield* createExecutor({ ...makeTestConfig({ plugins: [plugin] as const }) });69 const executor = yield* createExecutor({
70 ...makeTestConfig({ access: testAccess.member(), plugins: [plugin] as const }),
71 });
69 yield* executor.demo.seed();72 yield* executor.demo.seed();
70 yield* executor.connections.create({73 yield* executor.connections.create({
71 owner: "org",74 owner: "org",
packages/core/sdk/src/tools-list-merge.test.ts+5−3
@@ -4,6 +4,7 @@ import { Effect } from "effect"; · 3 unmodified lines
4import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";4import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";
5import { definePlugin } from "./plugin";5import { definePlugin } from "./plugin";
6import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";6import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
7import { testAccess } from "@executor-js/product-access/testing";
7 8
8// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
9// Cross-owner merge regression (the "Axiom shows 0 tools" bug).10// Cross-owner merge regression (the "Axiom shows 0 tools" bug).
@@ -55,9 +56,10 @@ const demoPlugin = definePlugin(() => ({ · 45 unmodified lines
55// Default test executor binds `subject: "test-subject"`, so user-owned56// Default test executor binds `subject: "test-subject"`, so user-owned
56// connections/tools can be created and merge with org rows.57// connections/tools can be created and merge with org rows.
57const setup = () =>58const setup = () =>
58 makeTestExecutor({ plugins: [memoryCredentialsPlugin(), demoPlugin] as const }).pipe(59 makeTestExecutor({
59 Effect.tap((executor) => executor.demo.seed()),60 access: testAccess.member(),
60 );61 plugins: [memoryCredentialsPlugin(), demoPlugin] as const,
62 }).pipe(Effect.tap((executor) => executor.demo.seed()));
61 63
62describe("tools.list cross-owner merge", () => {64describe("tools.list cross-owner merge", () => {
63 it.effect("lists user-owned tools when owner is omitted (the fix)", () =>65 it.effect("lists user-owned tools when owner is omitted (the fix)", () =>
packages/hosts/cloudflare/src/blob-store.test.ts+4−1
@@ -93,7 +93,10 @@ describe("makeR2BlobStore", () => { · 92 unmodified lines
93 Effect.gen(function* () {93 Effect.gen(function* () {
94 const { bucket } = makeFakeBucket();94 const { bucket } = makeFakeBucket();
95 const store = makeR2BlobStore(bucket);95 const store = makeR2BlobStore(bucket);
96 const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi");96 const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi", {
97 owners: ["user", "org"],
98 storageWrites: "allowed",
99 });
97 100
98 yield* plugin.put("spec/h1", "org-spec", { owner: "org" });101 yield* plugin.put("spec/h1", "org-spec", { owner: "org" });
99 expect(yield* plugin.get("spec/h1")).toBe("org-spec");102 expect(yield* plugin.get("spec/h1")).toBe("org-spec");
packages/hosts/mcp-apps-shell/package.json+1−0
@@ -79,6 +79,7 @@ · 78 unmodified lines
79 "@effect/vitest": "catalog:",79 "@effect/vitest": "catalog:",
80 "@executor-js/execution": "workspace:*",80 "@executor-js/execution": "workspace:*",
81 "@executor-js/host-mcp": "workspace:*",81 "@executor-js/host-mcp": "workspace:*",
82 "@executor-js/product-access": "workspace:*",
82 "@executor-js/sdk": "workspace:*",83 "@executor-js/sdk": "workspace:*",
83 "@tailwindcss/browser": "^4.2.2",84 "@tailwindcss/browser": "^4.2.2",
84 "@tailwindcss/vite": "catalog:",85 "@tailwindcss/vite": "catalog:",
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
41import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";41import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
42 42
43import { loadMcpAppsShellHtml } from "../shell-html";43import { loadMcpAppsShellHtml } from "../shell-html";
44import { testAccess } from "@executor-js/product-access/testing";
44 45
45type ShellServer = {46type ShellServer = {
46 readonly url: string;47 readonly url: string;
@@ -1305,7 +1306,10 @@ const startMcpHarness = async (openApi: OpenApiServer): Promise<McpHarness> => { · 1258 unmodified lines
1305 const executor = await Effect.runPromise(1306 const executor = await Effect.runPromise(
1306 Effect.gen(function* () {1307 Effect.gen(function* () {
1307 const built = yield* createExecutor(1308 const built = yield* createExecutor(
1308 makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }),1309 makeTestConfig({
1310 access: testAccess.member(),
1311 plugins: [inventoryPlugin(openApi.postRequests)],
1312 }),
1309 );1313 );
1310 // Tools only exist per connection, so register the integration and open1314 // Tools only exist per connection, so register the integration and open
1311 // one org `main` connection — that is what makes1315 // one org `main` connection — that is what makes
packages/hosts/mcp/package.json+1−0
@@ -46,6 +46,7 @@ · 45 unmodified lines
46 "dependencies": {46 "dependencies": {
47 "@cfworker/json-schema": "^4.1.1",47 "@cfworker/json-schema": "^4.1.1",
48 "@executor-js/execution": "workspace:*",48 "@executor-js/execution": "workspace:*",
49 "@executor-js/product-access": "workspace:*",
49 "@executor-js/sdk": "workspace:*",50 "@executor-js/sdk": "workspace:*",
50 "@modelcontextprotocol/ext-apps": "^1.7.4",51 "@modelcontextprotocol/ext-apps": "^1.7.4",
51 "@modelcontextprotocol/sdk": "^1.29.0",52 "@modelcontextprotocol/sdk": "^1.29.0",
packages/hosts/mcp/src/in-memory-session-store.test.ts+19−5
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13} from "./in-memory-session-store";13} from "./in-memory-session-store";
14import { defaultMcpResource, type Principal } from "./seams";14import { defaultMcpResource, type Principal } from "./seams";
15import { createExecutorMcpServer } from "./tool-server";15import { createExecutorMcpServer } from "./tool-server";
16import { testAccess } from "@executor-js/product-access/testing";
16 17
17const TEST_PRINCIPAL: Principal = {18const TEST_PRINCIPAL: Principal = {
18 accountId: "acct_test",19 accountId: "acct_test",
@@ -161,7 +162,10 @@ const openSession = async ( · 142 unmodified lines
161 162
162it("keeps overlapping warm-session workspace writes bound to their request roles", async () => {163it("keeps overlapping warm-session workspace writes bound to their request roles", async () => {
163 const executor = await Effect.runPromise(164 const executor = await Effect.runPromise(
164 createExecutor({ ...makeTestConfig(), orgWrites: "request" }),165 createExecutor({
166 ...makeTestConfig({ access: testAccess.member() }),
167 access: testAccess.requestBound(),
168 }),
165 );169 );
166 const started = new Map<string, () => void>();170 const started = new Map<string, () => void>();
167 const startedPromises = ["member", "admin"].map(171 const startedPromises = ["member", "admin"].map(
@@ -240,7 +244,10 @@ it("keeps overlapping warm-session workspace writes bound to their request roles · 72 unmodified lines
240 244
241it("binds a paused workspace write to the resuming principal after demotion", async () => {245it("binds a paused workspace write to the resuming principal after demotion", async () => {
242 const executor = await Effect.runPromise(246 const executor = await Effect.runPromise(
243 createExecutor({ ...makeTestConfig(), orgWrites: "request" }),247 createExecutor({
248 ...makeTestConfig({ access: testAccess.member() }),
249 access: testAccess.requestBound(),
250 }),
244 );251 );
245 const executionId = "exec_resume_demotion";252 const executionId = "exec_resume_demotion";
246 const pattern = "paused-resume-demotion.*";253 const pattern = "paused-resume-demotion.*";
@@ -313,7 +320,10 @@ it("binds a paused workspace write to the resuming principal after demotion", as · 66 unmodified lines
313 320
314it("uses the browser approver's demoted role after an admin starts waiting", async () => {321it("uses the browser approver's demoted role after an admin starts waiting", async () => {
315 const executor = await Effect.runPromise(322 const executor = await Effect.runPromise(
316 createExecutor({ ...makeTestConfig({ coreTools: {} }), orgWrites: "request" }),323 createExecutor({
324 ...makeTestConfig({ access: testAccess.member(), coreTools: {} }),
325 access: testAccess.requestBound(),
326 }),
317 );327 );
318 const executionId = "exec_browser_resume_demotion";328 const executionId = "exec_browser_resume_demotion";
319 const pattern = "browser-resume-demotion.*";329 const pattern = "browser-resume-demotion.*";
@@ -695,7 +705,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 375 unmodified lines
695 it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => {705 it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => {
696 let executorClosed = 0;706 let executorClosed = 0;
697 let customClosed = 0;707 let customClosed = 0;
698 const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig()));708 const realExecutor = await Effect.runPromise(
709 createExecutor(makeTestConfig({ access: testAccess.member() })),
710 );
699 const testExecutor = {711 const testExecutor = {
700 ...realExecutor,712 ...realExecutor,
701 close: () =>713 close: () =>
@@ -743,7 +755,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 41 unmodified lines
743 755
744 it("shuts down the scoped executor when sessions.close() is called", async () => {756 it("shuts down the scoped executor when sessions.close() is called", async () => {
745 let executorClosed = 0;757 let executorClosed = 0;
746 const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig()));758 const realExecutor = await Effect.runPromise(
759 createExecutor(makeTestConfig({ access: testAccess.member() })),
760 );
747 const testExecutor = {761 const testExecutor = {
748 ...realExecutor,762 ...realExecutor,
749 close: () =>763 close: () =>
packages/hosts/mcp/src/seams.ts+5−2
@@ -1,6 +1,7 @@
1import { Context, Effect, Layer, Schema } from "effect";1import { Context, Effect, Layer, Schema } from "effect";
2import type { Cause } from "effect";2import type { Cause } from "effect";
3 3
4import { orgWriteAccessForRole } from "@executor-js/product-access";
4import type { OrgWriteAccess } from "@executor-js/sdk";5import type { OrgWriteAccess } from "@executor-js/sdk";
5 6
6// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
@@ -72,9 +73,11 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 65 unmodified lines
72/** Internal header overwritten by a trusted session store before MCP dispatch. */73/** Internal header overwritten by a trusted session store before MCP dispatch. */
73export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access";74export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access";
74 75
75/** Derive the effective workspace-write access for one authenticated request. */76/** Derive the effective workspace-write access for one authenticated request
77 * — the product's role rule (`@executor-js/product-access`), applied here at
78 * the serving boundary that stamps the request-bound carrier. */
76export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess =>79export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess =>
77 principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied";80 orgWriteAccessForRole(principal);
78 81
79/**82/**
80 * Stamp request-bound workspace-write access for the MCP SDK request handler.83 * Stamp request-bound workspace-write access for the MCP SDK request handler.
packages/kernel/runtime-dynamic-worker/package.json+1−0
@@ -25,6 +25,7 @@ · 24 unmodified lines
25 "@executor-js/execution": "workspace:*",25 "@executor-js/execution": "workspace:*",
26 "@executor-js/fumadb": "workspace:*",26 "@executor-js/fumadb": "workspace:*",
27 "@executor-js/plugin-openapi": "workspace:*",27 "@executor-js/plugin-openapi": "workspace:*",
28 "@executor-js/product-access": "workspace:*",
28 "@executor-js/sdk": "workspace:*",29 "@executor-js/sdk": "workspace:*",
29 "@types/node": "catalog:",30 "@types/node": "catalog:",
30 "drizzle-orm": "catalog:",31 "drizzle-orm": "catalog:",
packages/kernel/runtime-dynamic-worker/src/integration.test.ts+2−0
@@ -45,6 +45,7 @@ import { · 44 unmodified lines
45 type FumaDb,45 type FumaDb,
46 type FumaTables,46 type FumaTables,
47} from "@executor-js/sdk";47} from "@executor-js/sdk";
48import { testAccess } from "@executor-js/product-access/testing";
48import { makeExecutorToolInvoker } from "@executor-js/execution";49import { makeExecutorToolInvoker } from "@executor-js/execution";
49import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi";50import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi";
50 51
@@ -231,6 +232,7 @@ const buildSandboxBridge = (spec: string, slug: string, baseUrl = "https://upstr · 180 unmodified lines
231 providers: [memoryProvider()],232 providers: [memoryProvider()],
232 plugins,233 plugins,
233 onElicitation: "accept-all",234 onElicitation: "accept-all",
235 access: testAccess.member(),
234 });236 });
235 // v2: addSpec registers the integration; tools are produced per-connection,237 // v2: addSpec registers the integration; tools are produced per-connection,
236 // so an org `main` connection is required for the operation to be callable.238 // so an org `main` connection is required for the operation to be callable.
packages/plugins/desktop-settings/package.json+1−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
47 "react": "catalog:"47 "react": "catalog:"
48 },48 },
49 "devDependencies": {49 "devDependencies": {
50 "@executor-js/product-access": "workspace:*",
50 "@types/react": "catalog:",51 "@types/react": "catalog:",
51 "tsup": "catalog:",52 "tsup": "catalog:",
52 "typescript": "catalog:"53 "typescript": "catalog:"
packages/plugins/desktop-settings/src/server.test.ts+2−0
@@ -5,11 +5,13 @@ import { ToolAddress } from "@executor-js/sdk"; · 4 unmodified lines
5import { makeTestExecutor } from "@executor-js/sdk/testing";5import { makeTestExecutor } from "@executor-js/sdk/testing";
6 6
7import { desktopSettingsPlugin } from "./server";7import { desktopSettingsPlugin } from "./server";
8import { testAccess } from "@executor-js/product-access/testing";
8 9
9describe("desktopSettingsPlugin", () => {10describe("desktopSettingsPlugin", () => {
10 it.effect("returns a browser handoff URL for Desktop-only settings", () =>11 it.effect("returns a browser handoff URL for Desktop-only settings", () =>
11 Effect.gen(function* () {12 Effect.gen(function* () {
12 const executor = yield* makeTestExecutor({13 const executor = yield* makeTestExecutor({
14 access: testAccess.member(),
13 plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const,15 plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const,
14 });16 });
15 17
packages/plugins/file-secrets/package.json+1−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
47 "@executor-js/sdk": "workspace:*"47 "@executor-js/sdk": "workspace:*"
48 },48 },
49 "devDependencies": {49 "devDependencies": {
50 "@executor-js/product-access": "workspace:*",
50 "@types/node": "catalog:",51 "@types/node": "catalog:",
51 "bun-types": "catalog:",52 "bun-types": "catalog:",
52 "effect": "catalog:",53 "effect": "catalog:",
packages/plugins/file-secrets/src/data-dir.test.ts+11−2
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";14import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
15 15
16import { fileSecretsPlugin } from "./index";16import { fileSecretsPlugin } from "./index";
17import { testAccess } from "@executor-js/product-access/testing";
17 18
18const INTEGRATION = IntegrationSlug.make("durable-secrets");19const INTEGRATION = IntegrationSlug.make("durable-secrets");
19const CONNECTION = ConnectionName.make("main");20const CONNECTION = ConnectionName.make("main");
@@ -69,7 +70,11 @@ describe("file secrets data directory", () => { · 49 unmodified lines
69 vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome);70 vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome);
70 const firstAuthPath = yield* Effect.scoped(71 const firstAuthPath = yield* Effect.scoped(
71 Effect.gen(function* () {72 Effect.gen(function* () {
72 const first = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });73 const first = yield* makeTestWorkspaceHarness({
74 access: testAccess.member(),
75 dataDir,
76 plugins: plugins(),
77 });
73 yield* first.executor.connectionFixture.registerIntegration();78 yield* first.executor.connectionFixture.registerIntegration();
74 const connection = yield* first.executor.connections.create({79 const connection = yield* first.executor.connections.create({
75 owner: "org",80 owner: "org",
@@ -96,7 +101,11 @@ describe("file secrets data directory", () => { · 20 unmodified lines
96 vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome);101 vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome);
97 yield* Effect.scoped(102 yield* Effect.scoped(
98 Effect.gen(function* () {103 Effect.gen(function* () {
99 const recreated = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });104 const recreated = yield* makeTestWorkspaceHarness({
105 access: testAccess.member(),
106 dataDir,
107 plugins: plugins(),
108 });
100 const connections = yield* recreated.executor.connections.list({109 const connections = yield* recreated.executor.connections.list({
101 integration: INTEGRATION,110 integration: INTEGRATION,
102 });111 });
packages/plugins/file-secrets/src/index.test.ts+8−1
@@ -17,13 +17,17 @@ import { ProviderKey } from "@executor-js/sdk"; · 16 unmodified lines
17import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";17import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
18 18
19import { fileSecretsPlugin } from "./index";19import { fileSecretsPlugin } from "./index";
20import { testAccess } from "@executor-js/product-access/testing";
20 21
21const FILE_PROVIDER = ProviderKey.make("file");22const FILE_PROVIDER = ProviderKey.make("file");
22 23
23const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) =>24const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) =>
24 Effect.scoped(25 Effect.scoped(
25 Effect.gen(function* () {26 Effect.gen(function* () {
26 const workspace = yield* makeTestWorkspaceHarness({ plugins: [plugin] as const });27 const workspace = yield* makeTestWorkspaceHarness({
28 access: testAccess.member(),
29 plugins: [plugin] as const,
30 });
27 const items = yield* workspace.executor.providers.items(FILE_PROVIDER);31 const items = yield* workspace.executor.providers.items(FILE_PROVIDER);
28 return {32 return {
29 filePath: workspace.executor.fileSecrets.filePath,33 filePath: workspace.executor.fileSecrets.filePath,
@@ -136,6 +140,7 @@ describe("file secrets auth location", () => { · 106 unmodified lines
136 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);140 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
137 writeAuthFile(legacyFilePath, "not-json");141 writeAuthFile(legacyFilePath, "not-json");
138 const workspace = yield* makeTestWorkspaceHarness({142 const workspace = yield* makeTestWorkspaceHarness({
143 access: testAccess.member(),
139 plugins: [fileSecretsPlugin()] as const,144 plugins: [fileSecretsPlugin()] as const,
140 });145 });
141 146
@@ -158,6 +163,7 @@ describe("file secrets auth location", () => { · 16 unmodified lines
158 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);163 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
159 mkdirSync(legacyFilePath, { recursive: true });164 mkdirSync(legacyFilePath, { recursive: true });
160 const workspace = yield* makeTestWorkspaceHarness({165 const workspace = yield* makeTestWorkspaceHarness({
166 access: testAccess.member(),
161 plugins: [fileSecretsPlugin()] as const,167 plugins: [fileSecretsPlugin()] as const,
162 });168 });
163 169
@@ -185,6 +191,7 @@ describe("file secrets auth location", () => { · 21 unmodified lines
185 const legacyContents = '{"legacy-token":"legacy-secret"}';191 const legacyContents = '{"legacy-token":"legacy-secret"}';
186 writeAuthFile(legacyFilePath, legacyContents);192 writeAuthFile(legacyFilePath, legacyContents);
187 const workspace = yield* makeTestWorkspaceHarness({193 const workspace = yield* makeTestWorkspaceHarness({
194 access: testAccess.member(),
188 plugins: [fileSecretsPlugin()] as const,195 plugins: [fileSecretsPlugin()] as const,
189 });196 });
190 197
packages/plugins/graphql/package.json+1−0
@@ -71,6 +71,7 @@ · 70 unmodified lines
71 "@effect/atom-react": "catalog:",71 "@effect/atom-react": "catalog:",
72 "@effect/vitest": "catalog:",72 "@effect/vitest": "catalog:",
73 "@executor-js/api": "workspace:*",73 "@executor-js/api": "workspace:*",
74 "@executor-js/product-access": "workspace:*",
74 "@executor-js/react": "workspace:*",75 "@executor-js/react": "workspace:*",
75 "@types/node": "catalog:",76 "@types/node": "catalog:",
76 "@types/react": "catalog:",77 "@types/react": "catalog:",
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
21 21
22import { graphqlPlugin } from "./plugin";22import { graphqlPlugin } from "./plugin";
23import type { GraphqlAuthMethod } from "./types";23import type { GraphqlAuthMethod } from "./types";
24import { testAccess } from "@executor-js/product-access/testing";
24 25
25const makeExecutor = () =>26const makeExecutor = () =>
26 createExecutor(27 createExecutor(
27 makeTestConfig({28 makeTestConfig({
29 access: testAccess.member(),
28 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,30 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
29 }),31 }),
30 );32 );
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
16 16
17import { makeGreetingGraphqlSchema } from "../testing";17import { makeGreetingGraphqlSchema } from "../testing";
18import { graphqlPlugin } from "./plugin";18import { graphqlPlugin } from "./plugin";
19import { testAccess } from "@executor-js/product-access/testing";
19 20
20const INVOCATION_TIMEOUT_MS = 100;21const INVOCATION_TIMEOUT_MS = 100;
21 22
@@ -61,6 +62,7 @@ describe("GraphQL invocation timeout", () => { · 39 unmodified lines
61 const server = yield* startHangingResponseServer(closed);62 const server = yield* startHangingResponseServer(closed);
62 const executor = yield* createExecutor(63 const executor = yield* createExecutor(
63 makeTestConfig({64 makeTestConfig({
65 access: testAccess.member(),
64 plugins: [66 plugins: [
65 memoryCredentialsPlugin(),67 memoryCredentialsPlugin(),
66 graphqlPlugin({68 graphqlPlugin({
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
23import { graphqlPlugin } from "./plugin";23import { graphqlPlugin } from "./plugin";
24import { variable } from "@executor-js/sdk/http-auth";24import { variable } from "@executor-js/sdk/http-auth";
25import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";25import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
26import { testAccess } from "@executor-js/product-access/testing";
26 27
27const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });28const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
28 29
29const makeExecutor = () =>30const makeExecutor = () =>
30 createExecutor(31 createExecutor(
31 makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),32 makeTestConfig({
33 access: testAccess.member(),
34 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
35 }),
32 );36 );
33 37
34const toolAddr = (integration: string, connection: string, tool: string): ToolAddress =>38const 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
35 serveGraphqlTestServer,35 serveGraphqlTestServer,
36 waitForRecordedRequests,36 waitForRecordedRequests,
37} from "../testing";37} from "../testing";
38import { testAccess } from "@executor-js/product-access/testing";
38 39
39// removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/40// removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/
40// usagesForConnection, multi-scope shadowing, and `executor.sources.*` /41// usagesForConnection, multi-scope shadowing, and `executor.sources.*` /
@@ -118,7 +119,10 @@ const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphql · 77 unmodified lines
118 119
119const makeExecutor = () =>120const makeExecutor = () =>
120 createExecutor(121 createExecutor(
121 makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),122 makeTestConfig({
123 access: testAccess.member(),
124 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
125 }),
122 );126 );
123 127
124const recordingBlobStore = () => {128const recordingBlobStore = () => {
@@ -160,11 +164,14 @@ describe("graphqlPlugin real protocol server", () => { · 35 unmodified lines
160 it.effect("denies member schema persistence before writing an org blob", () =>164 it.effect("denies member schema persistence before writing an org blob", () =>
161 Effect.gen(function* () {165 Effect.gen(function* () {
162 const blobs = recordingBlobStore();166 const blobs = recordingBlobStore();
163 const config = makeTestConfig({ plugins: [graphqlPlugin()] as const });167 const config = makeTestConfig({
168 access: testAccess.member(),
169 plugins: [graphqlPlugin()] as const,
170 });
164 const member = yield* createExecutor({171 const member = yield* createExecutor({
165 ...config,172 ...config,
166 blobs: blobs.store,173 blobs: blobs.store,
167 orgWrites: "denied",174 access: testAccess.member("denied"),
168 });175 });
169 176
170 const error = yield* member.graphql177 const error = yield* member.graphql
@@ -420,6 +427,7 @@ describe("graphqlPlugin real protocol server", () => { · 249 unmodified lines
420 }),427 }),
421 );428 );
422 const config = makeTestConfig({429 const config = makeTestConfig({
430 access: testAccess.member(),
423 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,431 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
424 });432 });
425 const executor = yield* createExecutor({ ...config, httpClientLayer });433 const executor = yield* createExecutor({ ...config, httpClientLayer });
@@ -1236,6 +1244,7 @@ describe("graphqlPlugin", () => { · 810 unmodified lines
1236 it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () =>1244 it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () =>
1237 Effect.gen(function* () {1245 Effect.gen(function* () {
1238 const config = makeTestConfig({1246 const config = makeTestConfig({
1247 access: testAccess.member(),
1239 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,1248 plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,
1240 });1249 });
1241 const executor = yield* createExecutor(config);1250 const executor = yield* createExecutor(config);
packages/plugins/keychain/package.json+1−0
@@ -49,6 +49,7 @@ · 48 unmodified lines
49 },49 },
50 "devDependencies": {50 "devDependencies": {
51 "@effect/vitest": "catalog:",51 "@effect/vitest": "catalog:",
52 "@executor-js/product-access": "workspace:*",
52 "@types/node": "catalog:",53 "@types/node": "catalog:",
53 "bun-types": "catalog:",54 "bun-types": "catalog:",
54 "effect": "catalog:",55 "effect": "catalog:",
packages/plugins/keychain/src/index.test.ts+4−3
@@ -5,6 +5,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 4 unmodified lines
5import { keychainPlugin } from "./index";5import { keychainPlugin } from "./index";
6import { makeKeychainProvider } from "./provider";6import { makeKeychainProvider } from "./provider";
7import { setPassword, deletePassword } from "./keyring";7import { setPassword, deletePassword } from "./keyring";
8import { testAccess } from "@executor-js/product-access/testing";
8 9
9// removed: v1 tests routed through `executor.secrets.set/get/remove` with10// removed: v1 tests routed through `executor.secrets.set/get/remove` with
10// `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived11// `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived
@@ -33,9 +34,7 @@ describe("keychain plugin", () => { · 22 unmodified lines
33 it.effect("exposes keychain metadata and registers a provider when reachable", () =>34 it.effect("exposes keychain metadata and registers a provider when reachable", () =>
34 Effect.gen(function* () {35 Effect.gen(function* () {
35 const executor = yield* createExecutor(36 const executor = yield* createExecutor(
36 makeTestConfig({37 makeTestConfig({ access: testAccess.member(), plugins: [keychainPlugin()] as const }),
37 plugins: [keychainPlugin()] as const,
38 }),
39 );38 );
40 39
41 expect(executor.keychain.displayName).toBeTypeOf("string");40 expect(executor.keychain.displayName).toBeTypeOf("string");
@@ -60,6 +59,7 @@ describe("keychain plugin", () => { · 18 unmodified lines
60 59
61 const executor = yield* createExecutor(60 const executor = yield* createExecutor(
62 makeTestConfig({61 makeTestConfig({
62 access: testAccess.member(),
63 plugins: [keychainPlugin({ serviceName })] as const,63 plugins: [keychainPlugin({ serviceName })] as const,
64 }),64 }),
65 );65 );
@@ -86,6 +86,7 @@ describe("keychain plugin", () => { · 20 unmodified lines
86 Effect.gen(function* () {86 Effect.gen(function* () {
87 const executor = yield* createExecutor(87 const executor = yield* createExecutor(
88 makeTestConfig({88 makeTestConfig({
89 access: testAccess.member(),
89 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,90 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,
90 }),91 }),
91 );92 );
packages/plugins/mcp/package.json+1−0
@@ -76,6 +76,7 @@ · 75 unmodified lines
76 "@effect/atom-react": "catalog:",76 "@effect/atom-react": "catalog:",
77 "@effect/vitest": "catalog:",77 "@effect/vitest": "catalog:",
78 "@executor-js/api": "workspace:*",78 "@executor-js/api": "workspace:*",
79 "@executor-js/product-access": "workspace:*",
79 "@executor-js/react": "workspace:*",80 "@executor-js/react": "workspace:*",
80 "@modelcontextprotocol/server": "2.0.0",81 "@modelcontextprotocol/server": "2.0.0",
81 "@types/node": "catalog:",82 "@types/node": "catalog:",
packages/plugins/mcp/src/sdk/catalog-sync.test.ts+9−2
@@ -35,6 +35,7 @@ import { mcpPlugin } from "./plugin"; · 34 unmodified lines
35import { createMcpConnector } from "./connection";35import { createMcpConnector } from "./connection";
36import { discoverTools } from "./discover";36import { discoverTools } from "./discover";
37import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing";37import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing";
38import { testAccess } from "@executor-js/product-access/testing";
38 39
39const INTEG = IntegrationSlug.make("catalog_mcp");40const INTEG = IntegrationSlug.make("catalog_mcp");
40const CONNECTION = ConnectionName.make("main");41const CONNECTION = ConnectionName.make("main");
@@ -48,7 +49,10 @@ const makeCatalogTestExecutor = ( · 7 unmodified lines
48 },49 },
49) =>50) =>
50 createExecutor({51 createExecutor({
51 ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),52 ...makeTestConfig({
53 access: testAccess.member(),
54 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
55 }),
52 ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),56 ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),
53 ...(options?.toolsSyncGraceMs === undefined57 ...(options?.toolsSyncGraceMs === undefined
54 ? {}58 ? {}
@@ -357,7 +361,10 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines
357 Effect.gen(function* () {361 Effect.gen(function* () {
358 const fixture = yield* serveLatchedListServer();362 const fixture = yield* serveLatchedListServer();
359 const executor = yield* createExecutor({363 const executor = yield* createExecutor({
360 ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),364 ...makeTestConfig({
365 access: testAccess.member(),
366 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
367 }),
361 // Everything is expired on every read, so a single tools read has the368 // Everything is expired on every read, so a single tools read has the
362 // whole set to rebuild.369 // whole set to rebuild.
363 toolsSyncTtlMs: 0,370 toolsSyncTtlMs: 0,
packages/plugins/mcp/src/sdk/elicitation.test.ts+10−2
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
23 23
24import { mcpPlugin } from "./plugin";24import { mcpPlugin } from "./plugin";
25import { makeElicitationMcpServer, serveMcpServer } from "../testing";25import { makeElicitationMcpServer, serveMcpServer } from "../testing";
26import { testAccess } from "@executor-js/product-access/testing";
26 27
27const isFormElicitation = Schema.is(FormElicitation);28const isFormElicitation = Schema.is(FormElicitation);
28 29
@@ -60,7 +61,12 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 31 unmodified lines
60 61
61const makeTestExecutor = (serverUrl: string) =>62const makeTestExecutor = (serverUrl: string) =>
62 Effect.acquireRelease(63 Effect.acquireRelease(
63 createExecutor(makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const })),64 createExecutor(
65 makeTestConfig({
66 access: testAccess.member(),
67 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
68 }),
69 ),
64 (executor) => executor.close().pipe(Effect.ignore),70 (executor) => executor.close().pipe(Effect.ignore),
65 ).pipe(71 ).pipe(
66 Effect.tap((executor) =>72 Effect.tap((executor) =>
@@ -373,7 +379,9 @@ describe("MCP elicitation (end-to-end)", () => { · 306 unmodified lines
373 it.effect("addServer preserves the configured display name as the integration description", () =>379 it.effect("addServer preserves the configured display name as the integration description", () =>
374 Effect.gen(function* () {380 Effect.gen(function* () {
375 const server = yield* serveElicitationTestServer;381 const server = yield* serveElicitationTestServer;
376 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));382 const executor = yield* createExecutor(
383 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
384 );
377 385
378 yield* executor.mcp.addServer({386 yield* executor.mcp.addServer({
379 name: "Gmail",387 name: "Gmail",
packages/plugins/mcp/src/sdk/image-content.test.ts+5−1
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
17 makeImageMcpServer,17 makeImageMcpServer,
18 serveMcpServer,18 serveMcpServer,
19} from "../testing";19} from "../testing";
20import { testAccess } from "@executor-js/product-access/testing";
20 21
21const INTEGRATION = IntegrationSlug.make("image_mcp");22const INTEGRATION = IntegrationSlug.make("image_mcp");
22const TEMPLATE = AuthTemplateSlug.make("none");23const TEMPLATE = AuthTemplateSlug.make("none");
@@ -32,7 +33,10 @@ describe("MCP image content", () => { · 9 unmodified lines
32 Effect.gen(function* () {33 Effect.gen(function* () {
33 const server = yield* serveMcpServer(makeImageMcpServer);34 const server = yield* serveMcpServer(makeImageMcpServer);
34 const executor = yield* createExecutor(35 const executor = yield* createExecutor(
35 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),36 makeTestConfig({
37 access: testAccess.member(),
38 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
39 }),
36 );40 );
37 41
38 yield* executor.mcp.addServer({42 yield* executor.mcp.addServer({
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
24import { mcpPlugin } from "./plugin";24import { mcpPlugin } from "./plugin";
25import { variable } from "@executor-js/sdk/http-auth";25import { variable } from "@executor-js/sdk/http-auth";
26import { makeEchoMcpServer, serveMcpServer } from "../testing";26import { makeEchoMcpServer, serveMcpServer } from "../testing";
27import { testAccess } from "@executor-js/product-access/testing";
27 28
28const serveRecordingServer = serveMcpServer(() =>29const serveRecordingServer = serveMcpServer(() =>
29 makeEchoMcpServer({30 makeEchoMcpServer({
@@ -40,7 +41,10 @@ describe("MCP multi-placement auth", () => { · 10 unmodified lines
40 Effect.gen(function* () {41 Effect.gen(function* () {
41 const server = yield* serveRecordingServer;42 const server = yield* serveRecordingServer;
42 const executor = yield* createExecutor(43 const executor = yield* createExecutor(
43 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),44 makeTestConfig({
45 access: testAccess.member(),
46 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
47 }),
44 );48 );
45 49
46 yield* executor.mcp.addServer({50 yield* executor.mcp.addServer({
@@ -88,7 +92,10 @@ describe("MCP multi-placement auth", () => { · 41 unmodified lines
88 Effect.gen(function* () {92 Effect.gen(function* () {
89 const server = yield* serveRecordingServer;93 const server = yield* serveRecordingServer;
90 const executor = yield* createExecutor(94 const executor = yield* createExecutor(
91 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),95 makeTestConfig({
96 access: testAccess.member(),
97 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
98 }),
92 );99 );
93 100
94 yield* executor.mcp.addServer({101 yield* executor.mcp.addServer({
@@ -126,7 +133,10 @@ describe("MCP multi-placement auth", () => { · 31 unmodified lines
126 Effect.gen(function* () {133 Effect.gen(function* () {
127 const server = yield* serveRecordingServer;134 const server = yield* serveRecordingServer;
128 const executor = yield* createExecutor(135 const executor = yield* createExecutor(
129 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),136 makeTestConfig({
137 access: testAccess.member(),
138 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
139 }),
130 );140 );
131 141
132 // Two declared methods; each connection picks one by template slug.142 // Two declared methods; each connection picks one by template slug.
@@ -193,7 +203,10 @@ describe("MCP multi-placement auth", () => { · 60 unmodified lines
193 Effect.gen(function* () {203 Effect.gen(function* () {
194 const server = yield* serveRecordingServer;204 const server = yield* serveRecordingServer;
195 const executor = yield* createExecutor(205 const executor = yield* createExecutor(
196 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),206 makeTestConfig({
207 access: testAccess.member(),
208 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
209 }),
197 );210 );
198 211
199 yield* executor.mcp.addServer({212 yield* executor.mcp.addServer({
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
23 23
24import { mcpPlugin } from "./plugin";24import { mcpPlugin } from "./plugin";
25import { makeEchoMcpServer, serveMcpServer } from "../testing";25import { makeEchoMcpServer, serveMcpServer } from "../testing";
26import { testAccess } from "@executor-js/product-access/testing";
26 27
27const INTEG = IntegrationSlug.make("iso_mcp");28const INTEG = IntegrationSlug.make("iso_mcp");
28const TEMPLATE = AuthTemplateSlug.make("bearer");29const TEMPLATE = AuthTemplateSlug.make("bearer");
@@ -43,7 +44,10 @@ describe("MCP owner isolation", () => { · 14 unmodified lines
43 Effect.gen(function* () {44 Effect.gen(function* () {
44 const server = yield* serveAuthRecordingMcpServer;45 const server = yield* serveAuthRecordingMcpServer;
45 const executor = yield* createExecutor(46 const executor = yield* createExecutor(
46 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),47 makeTestConfig({
48 access: testAccess.member(),
49 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
50 }),
47 );51 );
48 52
49 // One integration with a Bearer header auth template; the connection's53 // 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
29import { McpInvocationError } from "./errors";29import { McpInvocationError } from "./errors";
30import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";30import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
31import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";31import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";
32import { testAccess } from "@executor-js/product-access/testing";
32 33
33// removed: the v1 addSource / scopes / secrets / credential-binding / usages /34// removed: the v1 addSource / scopes / secrets / credential-binding / usages /
34// sources.configure / multi-scope shadowing suites. v2 has no scope stack, no35// sources.configure / multi-scope shadowing suites. v2 has no scope stack, no
@@ -261,6 +262,7 @@ const seedCallToolExecutor = (input: { · 226 unmodified lines
261 Effect.gen(function* () {262 Effect.gen(function* () {
262 const server = yield* serveCallToolServer(input.callTool);263 const server = yield* serveCallToolServer(input.callTool);
263 const config = makeTestConfig({264 const config = makeTestConfig({
265 access: testAccess.member(),
264 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,266 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
265 });267 });
266 const executor = yield* createExecutor(config);268 const executor = yield* createExecutor(config);
@@ -590,9 +592,7 @@ describe("mcpPlugin", () => { · 323 unmodified lines
590 it.effect("creates executor with mcp plugin", () =>592 it.effect("creates executor with mcp plugin", () =>
591 Effect.gen(function* () {593 Effect.gen(function* () {
592 const executor = yield* createExecutor(594 const executor = yield* createExecutor(
593 makeTestConfig({595 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
594 plugins: [mcpPlugin()] as const,
595 }),
596 );596 );
597 597
598 expect(executor.mcp).toBeDefined();598 expect(executor.mcp).toBeDefined();
@@ -631,7 +631,9 @@ describe("mcpPlugin", () => { · 32 unmodified lines
631 631
632 it.effect("integration catalog has no configured MCP integrations initially", () =>632 it.effect("integration catalog has no configured MCP integrations initially", () =>
633 Effect.gen(function* () {633 Effect.gen(function* () {
634 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));634 const executor = yield* createExecutor(
635 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
636 );
635 const integrations = yield* executor.integrations.list();637 const integrations = yield* executor.integrations.list();
636 expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0);638 expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0);
637 }),639 }),
@@ -639,7 +641,9 @@ describe("mcpPlugin", () => { · 1 unmodified line
639 641
640 it.effect("projects an MCP server family into the integration catalog", () =>642 it.effect("projects an MCP server family into the integration catalog", () =>
641 Effect.gen(function* () {643 Effect.gen(function* () {
642 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));644 const executor = yield* createExecutor(
645 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
646 );
643 yield* executor.mcp.addServer({647 yield* executor.mcp.addServer({
644 name: "Cloudflare Docs",648 name: "Cloudflare Docs",
645 family: "cloudflare",649 family: "cloudflare",
@@ -656,7 +660,9 @@ describe("mcpPlugin", () => { · 10 unmodified lines
656 660
657 it.effect("connection tools list is empty until a connection is created", () =>661 it.effect("connection tools list is empty until a connection is created", () =>
658 Effect.gen(function* () {662 Effect.gen(function* () {
659 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));663 const executor = yield* createExecutor(
664 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
665 );
660 const tools = yield* executor.tools.list();666 const tools = yield* executor.tools.list();
661 expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0);667 expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0);
662 }),668 }),
@@ -667,7 +673,10 @@ describe("mcpPlugin", () => { · 4 unmodified lines
667 Effect.gen(function* () {673 Effect.gen(function* () {
668 const server = yield* serveOAuthTestServer({ scopes: [] });674 const server = yield* serveOAuthTestServer({ scopes: [] });
669 const executor = yield* createExecutor(675 const executor = yield* createExecutor(
670 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),676 makeTestConfig({
677 access: testAccess.member(),
678 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
679 }),
671 );680 );
672 const attachedClient = OAuthClientSlug.make("axiom-mcp");681 const attachedClient = OAuthClientSlug.make("axiom-mcp");
673 const unrelatedClient = OAuthClientSlug.make("manual-app");682 const unrelatedClient = OAuthClientSlug.make("manual-app");
@@ -726,7 +735,10 @@ describe("mcpPlugin", () => { · 52 unmodified lines
726 Effect.scoped(735 Effect.scoped(
727 Effect.gen(function* () {736 Effect.gen(function* () {
728 const executor = yield* createExecutor(737 const executor = yield* createExecutor(
729 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),738 makeTestConfig({
739 access: testAccess.member(),
740 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
741 }),
730 );742 );
731 743
732 yield* executor.mcp.addServer({744 yield* executor.mcp.addServer({
@@ -769,7 +781,9 @@ describe("mcpPlugin", () => { · 36 unmodified lines
769 // adding an API key to an OAuth server must NOT displace the OAuth method.781 // adding an API key to an OAuth server must NOT displace the OAuth method.
770 it.effect("configureAuth merge-appends a custom method without clobbering oauth", () =>782 it.effect("configureAuth merge-appends a custom method without clobbering oauth", () =>
771 Effect.gen(function* () {783 Effect.gen(function* () {
772 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));784 const executor = yield* createExecutor(
785 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
786 );
773 787
774 yield* executor.mcp.addServer({788 yield* executor.mcp.addServer({
775 name: "OAuth MCP",789 name: "OAuth MCP",
@@ -796,7 +810,9 @@ describe("mcpPlugin", () => { · 20 unmodified lines
796 810
797 it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () =>811 it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () =>
798 Effect.gen(function* () {812 Effect.gen(function* () {
799 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));813 const executor = yield* createExecutor(
814 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
815 );
800 816
801 yield* executor.mcp.addServer({817 yield* executor.mcp.addServer({
802 name: "Open MCP",818 name: "Open MCP",
@@ -826,7 +842,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines
826 scopes: ["channels:history", "users:read"],842 scopes: ["channels:history", "users:read"],
827 });843 });
828 const executor = yield* createExecutor(844 const executor = yield* createExecutor(
829 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),845 makeTestConfig({
846 access: testAccess.member(),
847 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
848 }),
830 );849 );
831 850
832 yield* executor.mcp.addServer({851 yield* executor.mcp.addServer({
@@ -870,7 +889,10 @@ describe("mcpPlugin", () => { · 37 unmodified lines
870 Effect.gen(function* () {889 Effect.gen(function* () {
871 const server = yield* serveOAuthTestServer({ scopes: ["mcp"] });890 const server = yield* serveOAuthTestServer({ scopes: ["mcp"] });
872 const executor = yield* createExecutor(891 const executor = yield* createExecutor(
873 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),892 makeTestConfig({
893 access: testAccess.member(),
894 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
895 }),
874 );896 );
875 897
876 yield* executor.mcp.addServer({898 yield* executor.mcp.addServer({
@@ -911,7 +933,10 @@ describe("mcpPlugin", () => { · 34 unmodified lines
911 it.effect("registers integration + connection with 0 tools when discovery fails", () =>933 it.effect("registers integration + connection with 0 tools when discovery fails", () =>
912 Effect.gen(function* () {934 Effect.gen(function* () {
913 const executor = yield* createExecutor(935 const executor = yield* createExecutor(
914 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),936 makeTestConfig({
937 access: testAccess.member(),
938 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
939 }),
915 );940 );
916 941
917 const slugStr = "broken_source";942 const slugStr = "broken_source";
@@ -941,7 +966,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines
941 966
942 it.effect("static probeEndpoint returns actionable tool failures", () =>967 it.effect("static probeEndpoint returns actionable tool failures", () =>
943 Effect.gen(function* () {968 Effect.gen(function* () {
944 const config = makeTestConfig({ plugins: [mcpPlugin()] as const });969 const config = makeTestConfig({
970 access: testAccess.member(),
971 plugins: [mcpPlugin()] as const,
972 });
945 const executor = yield* createExecutor(config);973 const executor = yield* createExecutor(config);
946 974
947 const result = yield* executor.execute(ToolAddress.make("executor.mcp.probeEndpoint"), {975 const result = yield* executor.execute(ToolAddress.make("executor.mcp.probeEndpoint"), {
@@ -1294,7 +1322,10 @@ describe("mcpPlugin", () => { · 346 unmodified lines
1294 ),1322 ),
1295 ),1323 ),
1296 );1324 );
1297 const config = makeTestConfig({ plugins: [mcpPlugin()] as const });1325 const config = makeTestConfig({
1326 access: testAccess.member(),
1327 plugins: [mcpPlugin()] as const,
1328 });
1298 const executor = yield* createExecutor(config);1329 const executor = yield* createExecutor(config);
1299 1330
1300 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));1331 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
@@ -1330,7 +1361,10 @@ describe("mcpPlugin", () => { · 29 unmodified lines
1330 : HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }),1361 : HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 }),
1331 ),1362 ),
1332 );1363 );
1333 const config = makeTestConfig({ plugins: [mcpPlugin()] as const });1364 const config = makeTestConfig({
1365 access: testAccess.member(),
1366 plugins: [mcpPlugin()] as const,
1367 });
1334 const executor = yield* createExecutor(config);1368 const executor = yield* createExecutor(config);
1335 1369
1336 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));1370 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
@@ -1372,7 +1406,10 @@ describe("mcpPlugin", () => { · 35 unmodified lines
1372 return HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 });1406 return HttpServerResponse.jsonUnsafe({ message: "Unauthorized" }, { status: 401 });
1373 }),1407 }),
1374 );1408 );
1375 const config = makeTestConfig({ plugins: [mcpPlugin()] as const });1409 const config = makeTestConfig({
1410 access: testAccess.member(),
1411 plugins: [mcpPlugin()] as const,
1412 });
1376 const executor = yield* createExecutor(config);1413 const executor = yield* createExecutor(config);
1377 1414
1378 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));1415 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
@@ -1400,7 +1437,10 @@ const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); · 21 unmodified lines
1400 1437
1401const seedAnnotationsExecutor = (serverUrl: string) =>1438const seedAnnotationsExecutor = (serverUrl: string) =>
1402 createExecutor(1439 createExecutor(
1403 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),1440 makeTestConfig({
1441 access: testAccess.member(),
1442 plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const,
1443 }),
1404 ).pipe(1444 ).pipe(
1405 Effect.tap((executor) =>1445 Effect.tap((executor) =>
1406 Effect.gen(function* () {1446 Effect.gen(function* () {
@@ -1508,7 +1548,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 101 unmodified lines
1508 // a candidate.1548 // a candidate.
1509 it.effect("returns low-confidence candidate when path has /mcp segment", () =>1549 it.effect("returns low-confidence candidate when path has /mcp segment", () =>
1510 Effect.gen(function* () {1550 Effect.gen(function* () {
1511 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));1551 const executor = yield* createExecutor(
1552 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1553 );
1512 const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp");1554 const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp");
1513 const mcp = results.find((r) => r.kind === "mcp");1555 const mcp = results.find((r) => r.kind === "mcp");
1514 expect(mcp).toBeDefined();1556 expect(mcp).toBeDefined();
@@ -1518,7 +1560,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines
1518 1560
1519 it.effect("matches mcp on hostname label", () =>1561 it.effect("matches mcp on hostname label", () =>
1520 Effect.gen(function* () {1562 Effect.gen(function* () {
1521 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));1563 const executor = yield* createExecutor(
1564 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1565 );
1522 const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/");1566 const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/");
1523 const mcp = results.find((r) => r.kind === "mcp");1567 const mcp = results.find((r) => r.kind === "mcp");
1524 expect(mcp?.confidence).toBe("low");1568 expect(mcp?.confidence).toBe("low");
@@ -1527,7 +1571,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 2 unmodified lines
1527 1571
1528 it.effect("does not match mcp as a substring", () =>1572 it.effect("does not match mcp as a substring", () =>
1529 Effect.gen(function* () {1573 Effect.gen(function* () {
1530 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));1574 const executor = yield* createExecutor(
1575 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1576 );
1531 // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so1577 // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so
1532 // the URL-token fallback must not fire.1578 // the URL-token fallback must not fire.
1533 const results = yield* executor.integrations.detect("http://127.0.0.1:1/mcpstore");1579 const results = yield* executor.integrations.detect("http://127.0.0.1:1/mcpstore");
@@ -1537,7 +1583,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines
1537 1583
1538 it.effect("returns null when no token match and no wire-shape match", () =>1584 it.effect("returns null when no token match and no wire-shape match", () =>
1539 Effect.gen(function* () {1585 Effect.gen(function* () {
1540 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));1586 const executor = yield* createExecutor(
1587 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1588 );
1541 const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1");1589 const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1");
1542 expect(results.find((r) => r.kind === "mcp")).toBeUndefined();1590 expect(results.find((r) => r.kind === "mcp")).toBeUndefined();
1543 }),1591 }),
@@ -1590,7 +1638,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 46 unmodified lines
1590 it.effect("stamps a sanitized endpoint on the detect span", () =>1638 it.effect("stamps a sanitized endpoint on the detect span", () =>
1591 Effect.gen(function* () {1639 Effect.gen(function* () {
1592 const spans: Array<Tracer.NativeSpan> = [];1640 const spans: Array<Tracer.NativeSpan> = [];
1593 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));1641 const executor = yield* createExecutor(
1642 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1643 );
1594 1644
1595 yield* executor.integrations1645 yield* executor.integrations
1596 .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`)1646 .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`)
@@ -1620,7 +1670,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 23 unmodified lines
1620 it.effect("stamps a sanitized endpoint on the probe_endpoint span", () =>1670 it.effect("stamps a sanitized endpoint on the probe_endpoint span", () =>
1621 Effect.gen(function* () {1671 Effect.gen(function* () {
1622 const spans: Array<Tracer.NativeSpan> = [];1672 const spans: Array<Tracer.NativeSpan> = [];
1623 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));1673 const executor = yield* createExecutor(
1674 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
1675 );
1624 1676
1625 yield* executor.mcp1677 yield* executor.mcp
1626 .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`)1678 .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`)
@@ -1649,6 +1701,7 @@ describe("stdio static env", () => { · 22 unmodified lines
1649 Effect.scoped(1701 Effect.scoped(
1650 Effect.gen(function* () {1702 Effect.gen(function* () {
1651 const config = makeTestConfig({1703 const config = makeTestConfig({
1704 access: testAccess.member(),
1652 plugins: [1705 plugins: [
1653 memoryCredentialsPlugin(),1706 memoryCredentialsPlugin(),
1654 mcpPlugin({ dangerouslyAllowStdioMCP: true }),1707 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
@@ -1725,6 +1778,7 @@ describe("stdio static env", () => { · 70 unmodified lines
1725 Effect.scoped(1778 Effect.scoped(
1726 Effect.gen(function* () {1779 Effect.gen(function* () {
1727 const config = makeTestConfig({1780 const config = makeTestConfig({
1781 access: testAccess.member(),
1728 plugins: [1782 plugins: [
1729 memoryCredentialsPlugin(),1783 memoryCredentialsPlugin(),
1730 mcpPlugin({ dangerouslyAllowStdioMCP: true }),1784 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
@@ -1777,6 +1831,7 @@ describe("stdio static env", () => { · 46 unmodified lines
1777 Effect.scoped(1831 Effect.scoped(
1778 Effect.gen(function* () {1832 Effect.gen(function* () {
1779 const config = makeTestConfig({1833 const config = makeTestConfig({
1834 access: testAccess.member(),
1780 plugins: [1835 plugins: [
1781 memoryCredentialsPlugin(),1836 memoryCredentialsPlugin(),
1782 mcpPlugin({ dangerouslyAllowStdioMCP: true }),1837 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
@@ -1832,6 +1887,7 @@ describe("stdio static env", () => { · 49 unmodified lines
1832 Effect.scoped(1887 Effect.scoped(
1833 Effect.gen(function* () {1888 Effect.gen(function* () {
1834 const config = makeTestConfig({1889 const config = makeTestConfig({
1890 access: testAccess.member(),
1835 plugins: [1891 plugins: [
1836 memoryCredentialsPlugin(),1892 memoryCredentialsPlugin(),
1837 mcpPlugin({ dangerouslyAllowStdioMCP: true }),1893 mcpPlugin({ dangerouslyAllowStdioMCP: true }),
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
29 29
30import { mcpPlugin } from "./plugin";30import { mcpPlugin } from "./plugin";
31import { probeMcpEndpointShape } from "./probe-shape";31import { probeMcpEndpointShape } from "./probe-shape";
32import { testAccess } from "@executor-js/product-access/testing";
32 33
33const MCP_INITIALIZE_BODY = JSON.stringify({34const MCP_INITIALIZE_BODY = JSON.stringify({
34 jsonrpc: "2.0",35 jsonrpc: "2.0",
@@ -176,7 +177,9 @@ const messageFromUnknown = (cause: unknown): string => · 141 unmodified lines
176 177
177const runEndpointProbe = (url: string): Effect.Effect<EndpointProbeOutcome> =>178const runEndpointProbe = (url: string): Effect.Effect<EndpointProbeOutcome> =>
178 Effect.gen(function* () {179 Effect.gen(function* () {
179 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));180 const executor = yield* createExecutor(
181 makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }),
182 );
180 return yield* executor.mcp.probeEndpoint(url).pipe(183 return yield* executor.mcp.probeEndpoint(url).pipe(
181 Effect.map(184 Effect.map(
182 (r) =>185 (r) =>
packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts+2−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";12import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
13 13
14import { mcpPlugin } from "./plugin";14import { mcpPlugin } from "./plugin";
15import { testAccess } from "@executor-js/product-access/testing";
15 16
16const FILE_ID = "F012ABC3456";17const FILE_ID = "F012ABC3456";
17const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);18const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
@@ -128,6 +129,7 @@ describe("Slack Connect file fallback", () => { · 110 unmodified lines
128 Effect.gen(function* () {129 Effect.gen(function* () {
129 const config = {130 const config = {
130 ...makeTestConfig({131 ...makeTestConfig({
132 access: testAccess.member(),
131 plugins: [133 plugins: [
132 memoryCredentialsPlugin(),134 memoryCredentialsPlugin(),
133 mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }),135 mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }),
packages/plugins/onepassword/package.json+1−0
@@ -60,6 +60,7 @@ · 59 unmodified lines
60 "devDependencies": {60 "devDependencies": {
61 "@effect/vitest": "catalog:",61 "@effect/vitest": "catalog:",
62 "@executor-js/api": "workspace:*",62 "@executor-js/api": "workspace:*",
63 "@executor-js/product-access": "workspace:*",
63 "@types/node": "catalog:",64 "@types/node": "catalog:",
64 "@types/react": "catalog:",65 "@types/react": "catalog:",
65 "bun-types": "catalog:",66 "bun-types": "catalog:",
packages/plugins/onepassword/src/sdk/plugin.test.ts+8−6
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
15import type { OnePasswordService } from "./service";15import type { OnePasswordService } from "./service";
16import { OnePasswordError } from "./errors";16import { OnePasswordError } from "./errors";
17import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types";17import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types";
18import { testAccess } from "@executor-js/product-access/testing";
18 19
19// removed: v1 routed configure/removeConfig through an explicit `ScopeId`20// removed: v1 routed configure/removeConfig through an explicit `ScopeId`
20// (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and21// (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and
@@ -63,7 +64,7 @@ describe("onepassword plugin", () => { · 42 unmodified lines
63 it.effect("registers onepassword as a credential provider", () =>64 it.effect("registers onepassword as a credential provider", () =>
64 Effect.gen(function* () {65 Effect.gen(function* () {
65 const executor = yield* createExecutor(66 const executor = yield* createExecutor(
66 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),67 makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
67 );68 );
68 const providers = yield* executor.providers.list();69 const providers = yield* executor.providers.list();
69 expect(providers).toContain(ONEPASSWORD);70 expect(providers).toContain(ONEPASSWORD);
@@ -73,7 +74,7 @@ describe("onepassword plugin", () => { · 3 unmodified lines
73 it.effect("configure upserts accounts by id and removeConfig removes them one by one", () =>74 it.effect("configure upserts accounts by id and removeConfig removes them one by one", () =>
74 Effect.gen(function* () {75 Effect.gen(function* () {
75 const executor = yield* createExecutor(76 const executor = yield* createExecutor(
76 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),77 makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
77 );78 );
78 79
79 const initial = yield* executor.onepassword.getConfig();80 const initial = yield* executor.onepassword.getConfig();
@@ -124,7 +125,7 @@ describe("onepassword plugin", () => { · 44 unmodified lines
124 it.effect("removeConfig without an id removes everything", () =>125 it.effect("removeConfig without an id removes everything", () =>
125 Effect.gen(function* () {126 Effect.gen(function* () {
126 const executor = yield* createExecutor(127 const executor = yield* createExecutor(
127 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),128 makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
128 );129 );
129 yield* executor.onepassword.configure({130 yield* executor.onepassword.configure({
130 name: "Work",131 name: "Work",
@@ -144,7 +145,7 @@ describe("onepassword plugin", () => { · 13 unmodified lines
144 it.effect("getConfig redacts every account's service-account token", () =>145 it.effect("getConfig redacts every account's service-account token", () =>
145 Effect.gen(function* () {146 Effect.gen(function* () {
146 const executor = yield* createExecutor(147 const executor = yield* createExecutor(
147 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),148 makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
148 );149 );
149 150
150 yield* executor.onepassword.configure({151 yield* executor.onepassword.configure({
@@ -171,7 +172,7 @@ describe("onepassword plugin", () => { · 20 unmodified lines
171 it.effect("exposes provider configuration as agent-callable static tools", () =>172 it.effect("exposes provider configuration as agent-callable static tools", () =>
172 Effect.gen(function* () {173 Effect.gen(function* () {
173 const executor = yield* createExecutor(174 const executor = yield* createExecutor(
174 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),175 makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
175 );176 );
176 177
177 const configured = yield* executor.execute(178 const configured = yield* executor.execute(
@@ -225,7 +226,7 @@ describe("onepassword plugin", () => { · 47 unmodified lines
225 it.effect("status reports not-configured before configure", () =>226 it.effect("status reports not-configured before configure", () =>
226 Effect.gen(function* () {227 Effect.gen(function* () {
227 const executor = yield* createExecutor(228 const executor = yield* createExecutor(
228 makeTestConfig({ plugins: [onepasswordPlugin()] as const }),229 makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }),
229 );230 );
230 const status = yield* executor.onepassword.status();231 const status = yield* executor.onepassword.status();
231 expect(status.connected).toBe(false);232 expect(status.connected).toBe(false);
@@ -248,6 +249,7 @@ describe("onepassword store", () => { · 16 unmodified lines
248 makeInMemoryBlobStore(),249 makeInMemoryBlobStore(),
249 { org: "org_test", user: null },250 { org: "org_test", user: null },
250 "onepassword",251 "onepassword",
252 { owners: ["org"], storageWrites: "allowed" },
251 );253 );
252 return { blobs, store: makeOnePasswordStore(blobs) };254 return { blobs, store: makeOnePasswordStore(blobs) };
253 };255 };
packages/plugins/openapi/package.json+1−0
@@ -86,6 +86,7 @@ · 85 unmodified lines
86 "@effect/atom-react": "catalog:",86 "@effect/atom-react": "catalog:",
87 "@effect/vitest": "catalog:",87 "@effect/vitest": "catalog:",
88 "@executor-js/api": "workspace:*",88 "@executor-js/api": "workspace:*",
89 "@executor-js/product-access": "workspace:*",
89 "@executor-js/react": "workspace:*",90 "@executor-js/react": "workspace:*",
90 "@types/js-yaml": "4.0.9",91 "@types/js-yaml": "4.0.9",
91 "@types/node": "catalog:",92 "@types/node": "catalog:",
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
9 9
10import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";10import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
11import { googleCatalog } from "./presets";11import { googleCatalog } from "./presets";
12import { testAccess } from "@executor-js/product-access/testing";
12 13
13const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";14const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
14const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";15const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
@@ -179,6 +180,7 @@ it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived i · 164 unmodified lines
179 Effect.gen(function* () {180 Effect.gen(function* () {
180 const executor = yield* createExecutor(181 const executor = yield* createExecutor(
181 makeTestConfig({182 makeTestConfig({
183 access: testAccess.member(),
182 plugins: [184 plugins: [
183 openApiPlugin({185 openApiPlugin({
184 httpClientLayer: discoveryHttpClientLayer,186 httpClientLayer: discoveryHttpClientLayer,
@@ -211,6 +213,7 @@ it.effect( · 26 unmodified lines
211 );213 );
212 const executor = yield* createExecutor(214 const executor = yield* createExecutor(
213 makeTestConfig({215 makeTestConfig({
216 access: testAccess.member(),
214 plugins: [217 plugins: [
215 openApiPlugin({218 openApiPlugin({
216 httpClientLayer: discoveryHttpClientLayer,219 httpClientLayer: discoveryHttpClientLayer,
@@ -257,6 +260,7 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 40 unmodified lines
257 );260 );
258 const executor = yield* createExecutor(261 const executor = yield* createExecutor(
259 makeTestConfig({262 makeTestConfig({
263 access: testAccess.member(),
260 plugins: [264 plugins: [
261 openApiPlugin({265 openApiPlugin({
262 httpClientLayer: gmailDiscoveryHttpClientLayer,266 httpClientLayer: gmailDiscoveryHttpClientLayer,
packages/plugins/openapi/src/sdk/configure.test.ts+25−8
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
35 serveOpenApiHttpApiTestServer,35 serveOpenApiHttpApiTestServer,
36 unwrapInvocation,36 unwrapInvocation,
37} from "../testing";37} from "../testing";
38import { testAccess } from "@executor-js/product-access/testing";
38 39
39const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>40const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
40 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;41 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -90,7 +91,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 49 unmodified lines
90 it.effect("adds a custom apiKey method and getConfig reflects it", () =>91 it.effect("adds a custom apiKey method and getConfig reflects it", () =>
91 Effect.scoped(92 Effect.scoped(
92 Effect.gen(function* () {93 Effect.gen(function* () {
93 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));94 const executor = yield* createExecutor(
95 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
96 );
94 97
95 yield* executor.openapi.addSpec({98 yield* executor.openapi.addSpec({
96 spec: { kind: "blob", value: specText() },99 spec: { kind: "blob", value: specText() },
@@ -119,7 +122,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 22 unmodified lines
119 it.effect("appends to an existing spec-derived template without dropping entries", () =>122 it.effect("appends to an existing spec-derived template without dropping entries", () =>
120 Effect.scoped(123 Effect.scoped(
121 Effect.gen(function* () {124 Effect.gen(function* () {
122 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));125 const executor = yield* createExecutor(
126 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
127 );
123 128
124 const seedTemplate: AuthenticationInput = {129 const seedTemplate: AuthenticationInput = {
125 slug: "seed",130 slug: "seed",
@@ -145,7 +150,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 19 unmodified lines
145 it.effect("generates a custom_<id> slug for a method submitted without one", () =>150 it.effect("generates a custom_<id> slug for a method submitted without one", () =>
146 Effect.scoped(151 Effect.scoped(
147 Effect.gen(function* () {152 Effect.gen(function* () {
148 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));153 const executor = yield* createExecutor(
154 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
155 );
149 156
150 yield* executor.openapi.addSpec({157 yield* executor.openapi.addSpec({
151 spec: { kind: "blob", value: specText() },158 spec: { kind: "blob", value: specText() },
@@ -167,7 +174,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 15 unmodified lines
167 it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () =>174 it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () =>
168 Effect.scoped(175 Effect.scoped(
169 Effect.gen(function* () {176 Effect.gen(function* () {
170 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));177 const executor = yield* createExecutor(
178 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
179 );
171 180
172 yield* executor.openapi.addSpec({181 yield* executor.openapi.addSpec({
173 spec: { kind: "blob", value: specText() },182 spec: { kind: "blob", value: specText() },
@@ -208,7 +217,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 34 unmodified lines
208 it.effect("replace mode overwrites the auth template array", () =>217 it.effect("replace mode overwrites the auth template array", () =>
209 Effect.scoped(218 Effect.scoped(
210 Effect.gen(function* () {219 Effect.gen(function* () {
211 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));220 const executor = yield* createExecutor(
221 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
222 );
212 223
213 const seedTemplate: AuthenticationInput = {224 const seedTemplate: AuthenticationInput = {
214 slug: "seed",225 slug: "seed",
@@ -236,7 +247,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 21 unmodified lines
236 Effect.scoped(247 Effect.scoped(
237 Effect.gen(function* () {248 Effect.gen(function* () {
238 const server = yield* servePluginTestApi();249 const server = yield* servePluginTestApi();
239 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));250 const executor = yield* createExecutor(
251 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
252 );
240 253
241 yield* executor.openapi.addSpec({254 yield* executor.openapi.addSpec({
242 spec: { kind: "blob", value: server.specJson },255 spec: { kind: "blob", value: server.specJson },
@@ -272,7 +285,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 29 unmodified lines
272 Effect.scoped(285 Effect.scoped(
273 Effect.gen(function* () {286 Effect.gen(function* () {
274 const server = yield* servePluginTestApi();287 const server = yield* servePluginTestApi();
275 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));288 const executor = yield* createExecutor(
289 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
290 );
276 291
277 yield* executor.openapi.addSpec({292 yield* executor.openapi.addSpec({
278 spec: { kind: "blob", value: server.specJson },293 spec: { kind: "blob", value: server.specJson },
@@ -320,7 +335,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 41 unmodified lines
320 it.effect("getConfig returns null for an unknown integration", () =>335 it.effect("getConfig returns null for an unknown integration", () =>
321 Effect.scoped(336 Effect.scoped(
322 Effect.gen(function* () {337 Effect.gen(function* () {
323 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));338 const executor = yield* createExecutor(
339 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
340 );
324 expect(yield* executor.openapi.getConfig("nope")).toBeNull();341 expect(yield* executor.openapi.getConfig("nope")).toBeNull();
325 }),342 }),
326 ),343 ),
packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts+22−23
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
30} from "@executor-js/plugin-openapi/testing";30} from "@executor-js/plugin-openapi/testing";
31 31
32import { openApiPlugin } from "./plugin";32import { openApiPlugin } from "./plugin";
33import { testAccess } from "@executor-js/product-access/testing";
33 34
34type Captured = {35type Captured = {
35 contentType: string;36 contentType: string;
@@ -76,30 +77,28 @@ const plugins = [ · 40 unmodified lines
76 memoryCredentialsPlugin(),77 memoryCredentialsPlugin(),
77] as const;78] as const;
78 79
79layer(80layer(makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), { timeout: "15 seconds" })(
80 makeTestWorkspaceLayer({81 "OpenAPI non-JSON request body serialization",
81 plugins,82 (it) => {
82 }),83 it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () =>
83 { timeout: "15 seconds" },84 Effect.gen(function* () {
84)("OpenAPI non-JSON request body serialization", (it) => {85 const { server, captured } = yield* startEchoServer();
85 it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () =>86 const { config } = yield* TestWorkspace;
86 Effect.gen(function* () {87 const executor = yield* createExecutor({ ...config, plugins });
87 const { server, captured } = yield* startEchoServer();
88 const { config } = yield* TestWorkspace;
89 const executor = yield* createExecutor({ ...config, plugins });
90 88
91 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" });89 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "form" });
92 90
93 yield* executor.execute(conn.address("forms.submit"), {91 yield* executor.execute(conn.address("forms.submit"), {
94 body: { name: "Acme", email: "a@b.com" },92 body: { name: "Acme", email: "a@b.com" },
95 });93 });
96 94
97 expect(captured.contentType).toBe("application/x-www-form-urlencoded");95 expect(captured.contentType).toBe("application/x-www-form-urlencoded");
98 expect(captured.body).not.toBe("[object Object]");96 expect(captured.body).not.toBe("[object Object]");
99 97
100 const parsed = new URLSearchParams(captured.body);98 const parsed = new URLSearchParams(captured.body);
101 expect(parsed.get("name")).toBe("Acme");99 expect(parsed.get("name")).toBe("Acme");
102 expect(parsed.get("email")).toBe("a@b.com");100 expect(parsed.get("email")).toBe("a@b.com");
103 }),101 }),
104 );102 );
105});103 },
104);
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
34 34
35import { serveOpenApiHttpApiTestServer } from "../testing";35import { serveOpenApiHttpApiTestServer } from "../testing";
36import { openApiPlugin } from "./plugin";36import { openApiPlugin } from "./plugin";
37import { testAccess } from "@executor-js/product-access/testing";
37 38
38/** The credential the connection authenticates with. Longer than the sample's39/** The credential the connection authenticates with. Longer than the sample's
39 * 120-char value cap on purpose: truncating before scrubbing would leave a40 * 120-char value cap on purpose: truncating before scrubbing would leave a
@@ -84,6 +85,7 @@ const withProbe = <A, E, R>( · 44 unmodified lines
84 });85 });
85 const executor = yield* createExecutor(86 const executor = yield* createExecutor(
86 makeTestConfig({87 makeTestConfig({
88 access: testAccess.member(),
87 plugins: [89 plugins: [
88 openApiPlugin({ httpClientLayer: FetchHttpClient.layer }),90 openApiPlugin({ httpClientLayer: FetchHttpClient.layer }),
89 memoryCredentialsPlugin(),91 memoryCredentialsPlugin(),
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+4−1
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
37 37
38import { openApiPlugin } from "./plugin";38import { openApiPlugin } from "./plugin";
39import { type AuthenticationInput } from "./types";39import { type AuthenticationInput } from "./types";
40import { testAccess } from "@executor-js/product-access/testing";
40 41
41// ---------------------------------------------------------------------------42// ---------------------------------------------------------------------------
42// Test API — a single endpoint that echoes the Authorization header so the43// Test API — a single endpoint that echoes the Authorization header so the
@@ -86,7 +87,9 @@ describe("OpenAPI owner-isolated bearer", () => { · 43 unmodified lines
86 Effect.scoped(87 Effect.scoped(
87 Effect.gen(function* () {88 Effect.gen(function* () {
88 const server = yield* serveVercel();89 const server = yield* serveVercel();
89 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));90 const executor = yield* createExecutor(
91 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
92 );
90 93
91 // Admin uploads the spec once; the integration declares a bearer94 // Admin uploads the spec once; the integration declares a bearer
92 // template but no token value.95 // 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
32} from "@executor-js/plugin-openapi/testing";32} from "@executor-js/plugin-openapi/testing";
33 33
34import { openApiPlugin } from "./plugin";34import { openApiPlugin } from "./plugin";
35import { testAccess } from "@executor-js/product-access/testing";
35 36
36const JsonNameBody = Schema.fromJsonString(37const JsonNameBody = Schema.fromJsonString(
37 Schema.Struct({38 Schema.Struct({
@@ -172,7 +173,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 134 unmodified lines
172 payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),173 payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),
173 });174 });
174 175
175 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));176 const executor = yield* createExecutor(
177 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
178 );
176 179
177 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" });180 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" });
178 181
@@ -222,7 +225,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 43 unmodified lines
222 ),225 ),
223 });226 });
224 227
225 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));228 const executor = yield* createExecutor(
229 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
230 );
226 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" });231 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" });
227 232
228 const schema = yield* executor.tools.schema(conn.address("body.upload"));233 const schema = yield* executor.tools.schema(conn.address("body.upload"));
@@ -298,7 +303,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 69 unmodified lines
298 ),303 ),
299 });304 });
300 305
301 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));306 const executor = yield* createExecutor(
307 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
308 );
302 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" });309 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" });
303 310
304 const schema = yield* executor.tools.schema(conn.address("body.uploadPages"));311 const schema = yield* executor.tools.schema(conn.address("body.uploadPages"));
@@ -378,7 +385,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 73 unmodified lines
378 }),385 }),
379 });386 });
380 387
381 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));388 const executor = yield* createExecutor(
389 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
390 );
382 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" });391 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" });
383 392
384 const exit = yield* executor393 const exit = yield* executor
@@ -424,7 +433,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines
424 }),433 }),
425 });434 });
426 435
427 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));436 const executor = yield* createExecutor(
437 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
438 );
428 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" });439 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" });
429 440
430 // The rewrite is scoped to multipart bodies: a JSON body keeps its441 // The rewrite is scoped to multipart bodies: a JSON body keeps its
@@ -465,7 +476,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 34 unmodified lines
465 }),476 }),
466 });477 });
467 478
468 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));479 const executor = yield* createExecutor(
480 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
481 );
469 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" });482 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" });
470 483
471 yield* executor.execute(conn.address("body.createNote"), {484 yield* executor.execute(conn.address("body.createNote"), {
@@ -511,7 +524,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines
511 }),524 }),
512 });525 });
513 526
514 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));527 const executor = yield* createExecutor(
528 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
529 );
515 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" });530 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" });
516 531
517 const schema = yield* executor.tools.schema(conn.address("body.uploadMixed"));532 const schema = yield* executor.tools.schema(conn.address("body.uploadMixed"));
@@ -564,7 +579,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 46 unmodified lines
564 },579 },
565 });580 });
566 581
567 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));582 const executor = yield* createExecutor(
583 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
584 );
568 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" });585 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" });
569 586
570 // Documented limitation: component schemas are carried through587 // Documented limitation: component schemas are carried through
@@ -584,7 +601,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines
584 payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })),601 payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })),
585 });602 });
586 603
587 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));604 const executor = yield* createExecutor(
605 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
606 );
588 607
589 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "xml" });608 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "xml" });
590 609
@@ -603,7 +622,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
603 transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")),622 transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")),
604 });623 });
605 624
606 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));625 const executor = yield* createExecutor(
626 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
627 );
607 628
608 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tx" });629 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tx" });
609 630
@@ -622,7 +643,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
622 payload: Schema.String.pipe(HttpApiSchema.asText()),643 payload: Schema.String.pipe(HttpApiSchema.asText()),
623 });644 });
624 645
625 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));646 const executor = yield* createExecutor(
647 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
648 );
626 649
627 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tp" });650 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "tp" });
628 651
@@ -639,7 +662,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 10 unmodified lines
639 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),662 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
640 });663 });
641 664
642 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));665 const executor = yield* createExecutor(
666 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
667 );
643 668
644 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin" });669 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin" });
645 670
@@ -658,7 +683,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
658 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),683 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
659 });684 });
660 685
661 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));686 const executor = yield* createExecutor(
687 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
688 );
662 689
663 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" });690 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" });
664 691
@@ -677,7 +704,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 12 unmodified lines
677 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),704 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
678 });705 });
679 706
680 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));707 const executor = yield* createExecutor(
708 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
709 );
681 710
682 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" });711 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" });
683 712
@@ -699,7 +728,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 15 unmodified lines
699 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),728 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
700 });729 });
701 730
702 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));731 const executor = yield* createExecutor(
732 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
733 );
703 734
704 const conn = yield* addOpenApiTestConnection(executor, server, {735 const conn = yield* addOpenApiTestConnection(executor, server, {
705 slug: "bin_nested_b64_bad",736 slug: "bin_nested_b64_bad",
@@ -723,7 +754,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines
723 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),754 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
724 });755 });
725 756
726 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));757 const executor = yield* createExecutor(
758 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
759 );
727 760
728 const conn = yield* addOpenApiTestConnection(executor, server, {761 const conn = yield* addOpenApiTestConnection(executor, server, {
729 slug: "bin_b64_missing",762 slug: "bin_b64_missing",
@@ -743,7 +776,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines
743 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),776 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
744 });777 });
745 778
746 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));779 const executor = yield* createExecutor(
780 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
781 );
747 782
748 const conn = yield* addOpenApiTestConnection(executor, server, {783 const conn = yield* addOpenApiTestConnection(executor, server, {
749 slug: "bin_object_body",784 slug: "bin_object_body",
@@ -767,7 +802,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines
767 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),802 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
768 });803 });
769 804
770 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));805 const executor = yield* createExecutor(
806 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
807 );
771 808
772 const conn = yield* addOpenApiTestConnection(executor, server, {809 const conn = yield* addOpenApiTestConnection(executor, server, {
773 slug: "bin_string_body",810 slug: "bin_string_body",
@@ -837,7 +874,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 63 unmodified lines
837 }),874 }),
838 });875 });
839 876
840 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));877 const executor = yield* createExecutor(
878 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
879 );
841 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" });880 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" });
842 881
843 const schema = yield* executor.tools.schema(conn.address("gmail.getAttachment"));882 const schema = yield* executor.tools.schema(conn.address("gmail.getAttachment"));
@@ -921,7 +960,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 77 unmodified lines
921 }),960 }),
922 });961 });
923 962
924 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));963 const executor = yield* createExecutor(
964 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
965 );
925 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" });966 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" });
926 967
927 const result = yield* executor.execute(conn.address("gmailText.getAttachment"), {968 const result = yield* executor.execute(conn.address("gmailText.getAttachment"), {
@@ -989,7 +1030,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 61 unmodified lines
989 }),1030 }),
990 });1031 });
991 1032
992 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1033 const executor = yield* createExecutor(
1034 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1035 );
993 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" });1036 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" });
994 1037
995 const result = yield* executor.execute(conn.address("zipAttachment.getAttachment"), {1038 const result = yield* executor.execute(conn.address("zipAttachment.getAttachment"), {
@@ -1107,7 +1150,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 111 unmodified lines
1107 ),1150 ),
1108 });1151 });
1109 1152
1110 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1153 const executor = yield* createExecutor(
1154 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1155 );
1111 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" });1156 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" });
1112 1157
1113 const result = yield* executor.execute(conn.address("gmailMeta.getAttachment"), {1158 const result = yield* executor.execute(conn.address("gmailMeta.getAttachment"), {
@@ -1156,7 +1201,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 42 unmodified lines
1156 }),1201 }),
1157 });1202 });
1158 1203
1159 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1204 const executor = yield* createExecutor(
1205 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1206 );
1160 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" });1207 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" });
1161 1208
1162 const schema = yield* executor.tools.schema(conn.address("files.download"));1209 const schema = yield* executor.tools.schema(conn.address("files.download"));
@@ -1210,7 +1257,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 47 unmodified lines
1210 }),1257 }),
1211 });1258 });
1212 1259
1213 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1260 const executor = yield* createExecutor(
1261 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1262 );
1214 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" });1263 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" });
1215 1264
1216 const result = yield* executor.execute(conn.address("files.download"), {1265 const result = yield* executor.execute(conn.address("files.download"), {
@@ -1247,7 +1296,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 30 unmodified lines
1247 payload: multiContentPayload,1296 payload: multiContentPayload,
1248 });1297 });
1249 1298
1250 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1299 const executor = yield* createExecutor(
1300 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1301 );
1251 1302
1252 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc" });1303 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc" });
1253 1304
@@ -1265,7 +1316,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 11 unmodified lines
1265 payload: multiContentPayload,1316 payload: multiContentPayload,
1266 });1317 });
1267 1318
1268 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1319 const executor = yield* createExecutor(
1320 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1321 );
1269 1322
1270 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc2" });1323 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc2" });
1271 1324
@@ -1295,7 +1348,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 23 unmodified lines
1295 }),1348 }),
1296 });1349 });
1297 1350
1298 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1351 const executor = yield* createExecutor(
1352 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1353 );
1299 1354
1300 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" });1355 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" });
1301 1356
@@ -1322,7 +1377,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 20 unmodified lines
1322 }),1377 }),
1323 });1378 });
1324 1379
1325 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1380 const executor = yield* createExecutor(
1381 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1382 );
1326 1383
1327 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" });1384 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" });
1328 1385
@@ -1352,7 +1409,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 23 unmodified lines
1352 },1409 },
1353 }),1410 }),
1354 });1411 });
1355 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1412 const executor = yield* createExecutor(
1413 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1414 );
1356 1415
1357 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" });1416 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" });
1358 1417
@@ -1375,7 +1434,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 16 unmodified lines
1375 const { server } = yield* startEchoServer({1434 const { server } = yield* startEchoServer({
1376 payload: multiContentPayload,1435 payload: multiContentPayload,
1377 });1436 });
1378 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1437 const executor = yield* createExecutor(
1438 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1439 );
1379 1440
1380 const conn = yield* addOpenApiTestConnection(executor, server, {1441 const conn = yield* addOpenApiTestConnection(executor, server, {
1381 slug: "mc3",1442 slug: "mc3",
@@ -1404,7 +1465,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 22 unmodified lines
1404 const { server } = yield* startEchoServer({1465 const { server } = yield* startEchoServer({
1405 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),1466 payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
1406 });1467 });
1407 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1468 const executor = yield* createExecutor(
1469 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1470 );
1408 1471
1409 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" });1472 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" });
1410 1473
@@ -1450,7 +1513,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines
1450 ),1513 ),
1451 });1514 });
1452 1515
1453 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1516 const executor = yield* createExecutor(
1517 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1518 );
1454 1519
1455 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mpe" });1520 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mpe" });
1456 1521
@@ -1492,7 +1557,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 35 unmodified lines
1492 ),1557 ),
1493 });1558 });
1494 1559
1495 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1560 const executor = yield* createExecutor(
1561 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1562 );
1496 1563
1497 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fe" });1564 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fe" });
1498 1565
@@ -1523,7 +1590,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 24 unmodified lines
1523 ),1590 ),
1524 });1591 });
1525 1592
1526 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1593 const executor = yield* createExecutor(
1594 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1595 );
1527 1596
1528 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fd" });1597 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fd" });
1529 1598
@@ -1550,7 +1619,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 20 unmodified lines
1550 ),1619 ),
1551 });1620 });
1552 1621
1553 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1622 const executor = yield* createExecutor(
1623 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1624 );
1554 1625
1555 // No encoding → OAS3 defaults: style=form, explode=true.1626 // No encoding → OAS3 defaults: style=form, explode=true.
1556 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" });1627 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" });
packages/plugins/openapi/src/sdk/plugin.test.ts+104−35
@@ -49,6 +49,7 @@ import { · 48 unmodified lines
49 serveOpenApiHttpApiTestServer,49 serveOpenApiHttpApiTestServer,
50 unwrapInvocation,50 unwrapInvocation,
51} from "../testing";51} from "../testing";
52import { testAccess } from "@executor-js/product-access/testing";
52 53
53const TOOL_ERROR_TYPESCRIPT =54const TOOL_ERROR_TYPESCRIPT =
54 "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }";55 "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }";
@@ -393,7 +394,9 @@ describe("OpenAPI Plugin", () => { · 338 unmodified lines
393 Effect.gen(function* () {394 Effect.gen(function* () {
394 const server = yield* servePluginTestApi();395 const server = yield* servePluginTestApi();
395 396
396 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));397 const executor = yield* createExecutor(
398 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
399 );
397 400
398 const preview = yield* executor.openapi.previewSpec(server.specJson);401 const preview = yield* executor.openapi.previewSpec(server.specJson);
399 402
@@ -408,7 +411,10 @@ describe("OpenAPI Plugin", () => { · 8 unmodified lines
408 Effect.gen(function* () {411 Effect.gen(function* () {
409 const server = yield* serveOAuthDiscoverableOpenApiSpec();412 const server = yield* serveOAuthDiscoverableOpenApiSpec();
410 const executor = yield* createExecutor(413 const executor = yield* createExecutor(
411 makeTestConfig({ plugins: testPlugins(server.httpClientLayer) }),414 makeTestConfig({
415 access: testAccess.member(),
416 plugins: testPlugins(server.httpClientLayer),
417 }),
412 );418 );
413 419
414 const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/"));420 const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/"));
@@ -456,7 +462,9 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines
456 462
457 it.effect("exposes static openapi executor control tools via execute", () =>463 it.effect("exposes static openapi executor control tools via execute", () =>
458 Effect.gen(function* () {464 Effect.gen(function* () {
459 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));465 const executor = yield* createExecutor(
466 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
467 );
460 468
461 // v2: static control tools are NOT part of `tools.list()` (that's the469 // v2: static control tools are NOT part of `tools.list()` (that's the
462 // persisted per-connection catalog) and aren't `tools.schema()`-resolvable;470 // persisted per-connection catalog) and aren't `tools.schema()`-resolvable;
@@ -473,7 +481,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines
473 481
474 it.effect("invokes static previewSpec through executor.execute", () =>482 it.effect("invokes static previewSpec through executor.execute", () =>
475 Effect.gen(function* () {483 Effect.gen(function* () {
476 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));484 const executor = yield* createExecutor(
485 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
486 );
477 487
478 const preview = unwrapInvocation(488 const preview = unwrapInvocation(
479 yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {489 yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {
@@ -495,7 +505,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines
495 505
496 it.effect("invokes static addSpec through executor.execute", () =>506 it.effect("invokes static addSpec through executor.execute", () =>
497 Effect.gen(function* () {507 Effect.gen(function* () {
498 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));508 const executor = yield* createExecutor(
509 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
510 );
499 511
500 const result = unwrapInvocation(512 const result = unwrapInvocation(
501 yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), {513 yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), {
@@ -517,7 +529,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines
517 Effect.scoped(529 Effect.scoped(
518 Effect.gen(function* () {530 Effect.gen(function* () {
519 const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });531 const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
520 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));532 const executor = yield* createExecutor(
533 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
534 );
521 535
522 yield* executor.openapi.addSpec({536 yield* executor.openapi.addSpec({
523 spec: { kind: "url", url: specServer.specUrl },537 spec: { kind: "url", url: specServer.specUrl },
@@ -565,7 +579,10 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines
565 579
566 it.effect("static previewSpec returns actionable tool failures", () =>580 it.effect("static previewSpec returns actionable tool failures", () =>
567 Effect.gen(function* () {581 Effect.gen(function* () {
568 const config = makeTestConfig({ plugins: [openApiPlugin()] as const });582 const config = makeTestConfig({
583 access: testAccess.member(),
584 plugins: [openApiPlugin()] as const,
585 });
569 const executor = yield* createExecutor(config);586 const executor = yield* createExecutor(config);
570 587
571 const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {588 const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {
@@ -587,7 +604,7 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines
587 it.effect("requires approval before adding an integration through the runtime tool", () =>604 it.effect("requires approval before adding an integration through the runtime tool", () =>
588 Effect.gen(function* () {605 Effect.gen(function* () {
589 const executor = yield* createExecutor(606 const executor = yield* createExecutor(
590 makeTestConfig({ plugins: [openApiPlugin()] as const }),607 makeTestConfig({ access: testAccess.member(), plugins: [openApiPlugin()] as const }),
591 );608 );
592 609
593 const declined = yield* executor610 const declined = yield* executor
@@ -607,7 +624,9 @@ describe("OpenAPI Plugin", () => { · 13 unmodified lines
607 Effect.scoped(624 Effect.scoped(
608 Effect.gen(function* () {625 Effect.gen(function* () {
609 const server = yield* servePluginTestApi();626 const server = yield* servePluginTestApi();
610 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));627 const executor = yield* createExecutor(
628 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
629 );
611 630
612 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });631 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
613 632
@@ -625,7 +644,9 @@ describe("OpenAPI Plugin", () => { · 11 unmodified lines
625 Effect.scoped(644 Effect.scoped(
626 Effect.gen(function* () {645 Effect.gen(function* () {
627 const server = yield* servePluginTestApi();646 const server = yield* servePluginTestApi();
628 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));647 const executor = yield* createExecutor(
648 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
649 );
629 650
630 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });651 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
631 652
@@ -642,7 +663,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines
642 Effect.scoped(663 Effect.scoped(
643 Effect.gen(function* () {664 Effect.gen(function* () {
644 const server = yield* servePluginTestApi();665 const server = yield* servePluginTestApi();
645 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));666 const executor = yield* createExecutor(
667 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
668 );
646 669
647 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });670 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
648 const calls = { count: 0 };671 const calls = { count: 0 };
@@ -671,7 +694,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines
671 Effect.scoped(694 Effect.scoped(
672 Effect.gen(function* () {695 Effect.gen(function* () {
673 const server = yield* servePluginTestApi();696 const server = yield* servePluginTestApi();
674 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));697 const executor = yield* createExecutor(
698 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
699 );
675 700
676 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });701 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
677 const calls = { count: 0 };702 const calls = { count: 0 };
@@ -705,7 +730,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines
705 Effect.scoped(730 Effect.scoped(
706 Effect.gen(function* () {731 Effect.gen(function* () {
707 const server = yield* servePluginTestApi();732 const server = yield* servePluginTestApi();
708 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));733 const executor = yield* createExecutor(
734 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
735 );
709 736
710 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });737 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
711 const calls = { count: 0 };738 const calls = { count: 0 };
@@ -738,7 +765,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines
738 Effect.scoped(765 Effect.scoped(
739 Effect.gen(function* () {766 Effect.gen(function* () {
740 const server = yield* servePluginTestApi();767 const server = yield* servePluginTestApi();
741 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));768 const executor = yield* createExecutor(
769 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
770 );
742 771
743 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });772 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
744 const calls = { count: 0 };773 const calls = { count: 0 };
@@ -771,7 +800,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines
771 Effect.scoped(800 Effect.scoped(
772 Effect.gen(function* () {801 Effect.gen(function* () {
773 const server = yield* servePluginTestApi();802 const server = yield* servePluginTestApi();
774 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));803 const executor = yield* createExecutor(
804 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
805 );
775 806
776 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });807 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
777 808
@@ -812,7 +843,9 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines
812 Effect.scoped(843 Effect.scoped(
813 Effect.gen(function* () {844 Effect.gen(function* () {
814 const server = yield* servePluginTestApi();845 const server = yield* servePluginTestApi();
815 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));846 const executor = yield* createExecutor(
847 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
848 );
816 849
817 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });850 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
818 851
@@ -829,7 +862,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines
829 Effect.scoped(862 Effect.scoped(
830 Effect.gen(function* () {863 Effect.gen(function* () {
831 const server = yield* servePluginTestApi();864 const server = yield* servePluginTestApi();
832 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));865 const executor = yield* createExecutor(
866 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
867 );
833 868
834 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });869 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
835 const failure = yield* executor870 const failure = yield* executor
@@ -853,7 +888,9 @@ describe("OpenAPI Plugin", () => { · 17 unmodified lines
853 Effect.scoped(888 Effect.scoped(
854 Effect.gen(function* () {889 Effect.gen(function* () {
855 const server = yield* servePluginTestApi();890 const server = yield* servePluginTestApi();
856 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));891 const executor = yield* createExecutor(
892 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
893 );
857 const multiServer = {894 const multiServer = {
858 ...server,895 ...server,
859 specJson: makeOpenApiTestSpecJson(TestApi, {896 specJson: makeOpenApiTestSpecJson(TestApi, {
@@ -889,7 +926,9 @@ describe("OpenAPI Plugin", () => { · 29 unmodified lines
889 const server = yield* serveOpenApiEchoTestServer({926 const server = yield* serveOpenApiEchoTestServer({
890 transformSpec: withEchoCookieParameter,927 transformSpec: withEchoCookieParameter,
891 });928 });
892 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));929 const executor = yield* createExecutor(
930 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
931 );
893 const conn = yield* addOpenApiTestConnection(executor, server, {932 const conn = yield* addOpenApiTestConnection(executor, server, {
894 slug: "cookie_params",933 slug: "cookie_params",
895 headers: { Cookie: "configured=yes" },934 headers: { Cookie: "configured=yes" },
@@ -923,7 +962,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines
923 const server = yield* serveOpenApiEchoTestServer({962 const server = yield* serveOpenApiEchoTestServer({
924 transformSpec: withEchoCookieParameter,963 transformSpec: withEchoCookieParameter,
925 });964 });
926 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));965 const executor = yield* createExecutor(
966 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
967 );
927 const conn = yield* addOpenApiTestConnection(executor, server, {968 const conn = yield* addOpenApiTestConnection(executor, server, {
928 slug: "cookie_unknown",969 slug: "cookie_unknown",
929 });970 });
@@ -951,7 +992,9 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines
951 Effect.scoped(992 Effect.scoped(
952 Effect.gen(function* () {993 Effect.gen(function* () {
953 const server = yield* servePluginTestApi();994 const server = yield* servePluginTestApi();
954 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));995 const executor = yield* createExecutor(
996 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
997 );
955 998
956 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" });999 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" });
957 1000
@@ -980,7 +1023,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines
980 Effect.scoped(1023 Effect.scoped(
981 Effect.gen(function* () {1024 Effect.gen(function* () {
982 const server = yield* servePluginTestApi();1025 const server = yield* servePluginTestApi();
983 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1026 const executor = yield* createExecutor(
1027 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1028 );
984 1029
985 yield* executor.openapi.addSpec({1030 yield* executor.openapi.addSpec({
986 spec: { kind: "blob", value: server.specJson },1031 spec: { kind: "blob", value: server.specJson },
@@ -1012,7 +1057,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines
1012 Effect.scoped(1057 Effect.scoped(
1013 Effect.gen(function* () {1058 Effect.gen(function* () {
1014 const server = yield* servePluginTestApi();1059 const server = yield* servePluginTestApi();
1015 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1060 const executor = yield* createExecutor(
1061 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1062 );
1016 1063
1017 yield* executor.openapi.addSpec({1064 yield* executor.openapi.addSpec({
1018 spec: { kind: "blob", value: server.specJson },1065 spec: { kind: "blob", value: server.specJson },
@@ -1044,7 +1091,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines
1044 Effect.scoped(1091 Effect.scoped(
1045 Effect.gen(function* () {1092 Effect.gen(function* () {
1046 const server = yield* servePluginTestApi();1093 const server = yield* servePluginTestApi();
1047 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1094 const executor = yield* createExecutor(
1095 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1096 );
1048 1097
1049 // The spec declares bearer auth; the caller passes NO template — the1098 // The spec declares bearer auth; the caller passes NO template — the
1050 // agentic add path (MCP/API) does exactly this. Without server-side1099 // agentic add path (MCP/API) does exactly this. Without server-side
@@ -1097,7 +1146,9 @@ describe("OpenAPI Plugin", () => { · 46 unmodified lines
1097 Effect.scoped(1146 Effect.scoped(
1098 Effect.gen(function* () {1147 Effect.gen(function* () {
1099 const server = yield* servePluginTestApi();1148 const server = yield* servePluginTestApi();
1100 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1149 const executor = yield* createExecutor(
1150 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1151 );
1101 1152
1102 // No baseUrl override: the spec declares `servers`, so the host is1153 // No baseUrl override: the spec declares `servers`, so the host is
1103 // resolved per call from the operation's servers rather than baked into1154 // resolved per call from the operation's servers rather than baked into
@@ -1127,7 +1178,9 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines
1127 Effect.scoped(1178 Effect.scoped(
1128 Effect.gen(function* () {1179 Effect.gen(function* () {
1129 const server = yield* servePluginTestApi();1180 const server = yield* servePluginTestApi();
1130 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1181 const executor = yield* createExecutor(
1182 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1183 );
1131 1184
1132 // The add page sends [] when the user deletes every detected method.1185 // The add page sends [] when the user deletes every detected method.
1133 // That intent must survive — deriving methods back from the spec here1186 // That intent must survive — deriving methods back from the spec here
@@ -1158,7 +1211,9 @@ describe("OpenAPI Plugin", () => { · 24 unmodified lines
1158 1211
1159 it.effect("addSpec accepts Graph-sized OpenAPI blobs", () =>1212 it.effect("addSpec accepts Graph-sized OpenAPI blobs", () =>
1160 Effect.gen(function* () {1213 Effect.gen(function* () {
1161 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1214 const executor = yield* createExecutor(
1215 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1216 );
1162 const largeDescription = "x".repeat(36 * 1024 * 1024);1217 const largeDescription = "x".repeat(36 * 1024 * 1024);
1163 1218
1164 const added = yield* executor.openapi.addSpec({1219 const added = yield* executor.openapi.addSpec({
@@ -1191,7 +1246,9 @@ paths: · 26 unmodified lines
1191 "addSpec accepts Microsoft Graph-scale operation catalogs from one spec",1246 "addSpec accepts Microsoft Graph-scale operation catalogs from one spec",
1192 () =>1247 () =>
1193 Effect.gen(function* () {1248 Effect.gen(function* () {
1194 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1249 const executor = yield* createExecutor(
1250 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1251 );
1195 1252
1196 const added = yield* executor.openapi.addSpec({1253 const added = yield* executor.openapi.addSpec({
1197 spec: { kind: "blob", value: microsoftGraphScaleSpecText() },1254 spec: { kind: "blob", value: microsoftGraphScaleSpecText() },
@@ -1208,7 +1265,9 @@ paths: · 10 unmodified lines
1208 Effect.scoped(1265 Effect.scoped(
1209 Effect.gen(function* () {1266 Effect.gen(function* () {
1210 const server = yield* servePluginTestApi();1267 const server = yield* servePluginTestApi();
1211 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1268 const executor = yield* createExecutor(
1269 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1270 );
1212 1271
1213 yield* addOpenApiTestConnection(executor, server, { slug: "removable" });1272 yield* addOpenApiTestConnection(executor, server, { slug: "removable" });
1214 expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain(1273 expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain(
@@ -1232,7 +1291,9 @@ paths: · 17 unmodified lines
1232 Effect.scoped(1291 Effect.scoped(
1233 Effect.gen(function* () {1292 Effect.gen(function* () {
1234 const server = yield* servePluginTestApi();1293 const server = yield* servePluginTestApi();
1235 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1294 const executor = yield* createExecutor(
1295 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1296 );
1236 1297
1237 // First add carries an apiKey auth template + a distinctive description.1298 // First add carries an apiKey auth template + a distinctive description.
1238 // A silent upsert on re-add would clobber both.1299 // A silent upsert on re-add would clobber both.
@@ -1294,7 +1355,9 @@ paths: · 55 unmodified lines
1294 // The mutable spec server is a real 127.0.0.1 listener — reach it over1355 // The mutable spec server is a real 127.0.0.1 listener — reach it over
1295 // the default fetch-based client, like production would.1356 // the default fetch-based client, like production would.
1296 const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });1357 const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
1297 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1358 const executor = yield* createExecutor(
1359 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1360 );
1298 1361
1299 const added = yield* executor.openapi.addSpec({1362 const added = yield* executor.openapi.addSpec({
1300 spec: { kind: "url", url: specServer.specUrl },1363 spec: { kind: "url", url: specServer.specUrl },
@@ -1370,7 +1433,7 @@ paths: · 69 unmodified lines
1370 it.effect("denies member updateSpec before writing an org blob", () =>1433 it.effect("denies member updateSpec before writing an org blob", () =>
1371 Effect.gen(function* () {1434 Effect.gen(function* () {
1372 const blobs = recordingBlobStore();1435 const blobs = recordingBlobStore();
1373 const config = makeTestConfig({ plugins: testPlugins() });1436 const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() });
1374 const admin = yield* createExecutor({ ...config, blobs: blobs.store });1437 const admin = yield* createExecutor({ ...config, blobs: blobs.store });
1375 yield* admin.openapi.addSpec({1438 yield* admin.openapi.addSpec({
1376 spec: { kind: "blob", value: testApiSpecText() },1439 spec: { kind: "blob", value: testApiSpecText() },
@@ -1380,7 +1443,7 @@ paths: · 3 unmodified lines
1380 const member = yield* createExecutor({1443 const member = yield* createExecutor({
1381 ...config,1444 ...config,
1382 blobs: blobs.store,1445 blobs: blobs.store,
1383 orgWrites: "denied",1446 access: testAccess.member("denied"),
1384 });1447 });
1385 1448
1386 const error = yield* member.openapi1449 const error = yield* member.openapi
@@ -1398,7 +1461,9 @@ paths: · 11 unmodified lines
1398 Effect.scoped(1461 Effect.scoped(
1399 Effect.gen(function* () {1462 Effect.gen(function* () {
1400 const server = yield* servePluginTestApi();1463 const server = yield* servePluginTestApi();
1401 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1464 const executor = yield* createExecutor(
1465 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1466 );
1402 yield* addOpenApiTestConnection(executor, server, { slug: "pasted" });1467 yield* addOpenApiTestConnection(executor, server, { slug: "pasted" });
1403 1468
1404 // A pasted-blob integration has no source URL — re-fetch must say so.1469 // A pasted-blob integration has no source URL — re-fetch must say so.
@@ -1450,6 +1515,7 @@ paths: · 45 unmodified lines
1450 const plugins = testPlugins();1515 const plugins = testPlugins();
1451 1516
1452 const alice = yield* makeTestWorkspaceHarness({1517 const alice = yield* makeTestWorkspaceHarness({
1518 access: testAccess.member(),
1453 plugins,1519 plugins,
1454 tenant,1520 tenant,
1455 subject: "alice",1521 subject: "alice",
@@ -1470,6 +1536,7 @@ paths: · 14 unmodified lines
1470 });1536 });
1471 1537
1472 const bob = yield* makeTestWorkspaceHarness({1538 const bob = yield* makeTestWorkspaceHarness({
1539 access: testAccess.member(),
1473 plugins,1540 plugins,
1474 tenant,1541 tenant,
1475 subject: "bob",1542 subject: "bob",
@@ -1513,7 +1580,9 @@ paths: · 37 unmodified lines
1513 1580
1514 it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () =>1581 it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () =>
1515 Effect.gen(function* () {1582 Effect.gen(function* () {
1516 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));1583 const executor = yield* createExecutor(
1584 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
1585 );
1517 const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip);1586 const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip);
1518 expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true);1587 expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true);
1519 }),1588 }),
packages/plugins/openapi/src/sdk/real-specs.test.ts+2−0
@@ -28,6 +28,7 @@ import { openApiPlugin } from "./plugin"; · 27 unmodified lines
28import { deriveAuthenticationTemplateFromPreview } from "./derive-auth";28import { deriveAuthenticationTemplateFromPreview } from "./derive-auth";
29import { previewSpec as previewSpecRaw } from "./preview";29import { previewSpec as previewSpecRaw } from "./preview";
30import type { ExtractionResult } from "./types";30import type { ExtractionResult } from "./types";
31import { testAccess } from "@executor-js/product-access/testing";
31 32
32const previewSpec = (input: string) =>33const previewSpec = (input: string) =>
33 previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer));34 previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer));
@@ -91,6 +92,7 @@ const getRegisteredToolSchema = (slug: string, specText: string, toolName: strin · 57 unmodified lines
91 92
92 const executor = yield* createExecutor(93 const executor = yield* createExecutor(
93 makeTestConfig({94 makeTestConfig({
95 access: testAccess.member(),
94 plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const,96 plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const,
95 }),97 }),
96 );98 );
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
16import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing";16import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing";
17 17
18import { openApiPlugin } from "./plugin";18import { openApiPlugin } from "./plugin";
19import { testAccess } from "@executor-js/product-access/testing";
19 20
20const BODY_READ_DEADLINE_MS = 15_000;21const BODY_READ_DEADLINE_MS = 15_000;
21const RESPONSE_BODY_TIMEOUT_MS = 100;22const RESPONSE_BODY_TIMEOUT_MS = 100;
@@ -42,7 +43,9 @@ const testPlugins = () => · 20 unmodified lines
42 43
43const buildExecutor = (baseUrl: string) =>44const buildExecutor = (baseUrl: string) =>
44 Effect.gen(function* () {45 Effect.gen(function* () {
45 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));46 const executor = yield* createExecutor(
47 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
48 );
46 yield* executor.openapi.addSpec(49 yield* executor.openapi.addSpec(
47 makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, {50 makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, {
48 slug: "body_timeout",51 slug: "body_timeout",
packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts+4−1
@@ -18,6 +18,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 17 unmodified lines
18import { invokeWithLayer } from "./invoke";18import { invokeWithLayer } from "./invoke";
19import { openApiPlugin } from "./plugin";19import { openApiPlugin } from "./plugin";
20import type { OperationBinding } from "./types";20import type { OperationBinding } from "./types";
21import { testAccess } from "@executor-js/product-access/testing";
21 22
22const RESPONSE_HEADERS_TIMEOUT_MS = 100;23const RESPONSE_HEADERS_TIMEOUT_MS = 100;
23const STREAM_TOOL = "logs.getLogs";24const STREAM_TOOL = "logs.getLogs";
@@ -44,7 +45,9 @@ const testPlugins = () => · 20 unmodified lines
44 45
45const buildExecutor = (baseUrl: string) =>46const buildExecutor = (baseUrl: string) =>
46 Effect.gen(function* () {47 Effect.gen(function* () {
47 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));48 const executor = yield* createExecutor(
49 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
50 );
48 yield* executor.openapi.addSpec(51 yield* executor.openapi.addSpec(
49 makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }),52 makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }),
50 );53 );
packages/plugins/openapi/src/sdk/spec-blob.test.ts+15−6
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
38 serveOpenApiHttpApiTestServer,38 serveOpenApiHttpApiTestServer,
39 unwrapInvocation,39 unwrapInvocation,
40} from "../testing";40} from "../testing";
41import { testAccess } from "@executor-js/product-access/testing";
41 42
42const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>43const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
43 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;44 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -113,7 +114,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 69 unmodified lines
113 it.effect("addSpec stores a content pointer, not the inline spec text", () =>114 it.effect("addSpec stores a content pointer, not the inline spec text", () =>
114 Effect.scoped(115 Effect.scoped(
115 Effect.gen(function* () {116 Effect.gen(function* () {
116 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));117 const executor = yield* createExecutor(
118 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
119 );
117 const text = specText();120 const text = specText();
118 121
119 yield* executor.openapi.addSpec({122 yield* executor.openapi.addSpec({
@@ -135,7 +138,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 15 unmodified lines
135 api: TestApi,138 api: TestApi,
136 handlersLayer: EchoGroupLive,139 handlersLayer: EchoGroupLive,
137 });140 });
138 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));141 const executor = yield* createExecutor(
142 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
143 );
139 144
140 yield* executor.openapi.addSpec({145 yield* executor.openapi.addSpec({
141 spec: { kind: "blob", value: server.specJson },146 spec: { kind: "blob", value: server.specJson },
@@ -168,7 +173,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 26 unmodified lines
168 () =>173 () =>
169 Effect.scoped(174 Effect.scoped(
170 Effect.gen(function* () {175 Effect.gen(function* () {
171 const config = makeTestConfig({ plugins: testPlugins() });176 const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() });
172 const executor = yield* createExecutor(config);177 const executor = yield* createExecutor(config);
173 const text = specTextWithDefinition();178 const text = specTextWithDefinition();
174 const hash = yield* sha256Hex(text);179 const hash = yield* sha256Hex(text);
@@ -245,7 +250,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 70 unmodified lines
245 it.effect("stale sync preserves tools and definitions when the spec blob is missing", () =>250 it.effect("stale sync preserves tools and definitions when the spec blob is missing", () =>
246 Effect.scoped(251 Effect.scoped(
247 Effect.gen(function* () {252 Effect.gen(function* () {
248 const config = makeTestConfig({ plugins: testPlugins() });253 const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() });
249 const executor = yield* createExecutor(config);254 const executor = yield* createExecutor(config);
250 const text = specTextWithDefinition();255 const text = specTextWithDefinition();
251 const hash = yield* sha256Hex(text);256 const hash = yield* sha256Hex(text);
@@ -318,7 +323,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 66 unmodified lines
318 it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () =>323 it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () =>
319 Effect.scoped(324 Effect.scoped(
320 Effect.gen(function* () {325 Effect.gen(function* () {
321 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));326 const executor = yield* createExecutor(
327 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
328 );
322 const emptySpec = JSON.stringify({329 const emptySpec = JSON.stringify({
323 openapi: "3.1.0",330 openapi: "3.1.0",
324 info: { title: "Empty", version: "1.0.0" },331 info: { title: "Empty", version: "1.0.0" },
@@ -409,7 +416,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 84 unmodified lines
409 it.effect("remove + re-add of the same spec is idempotent over the shared blob", () =>416 it.effect("remove + re-add of the same spec is idempotent over the shared blob", () =>
410 Effect.scoped(417 Effect.scoped(
411 Effect.gen(function* () {418 Effect.gen(function* () {
412 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));419 const executor = yield* createExecutor(
420 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
421 );
413 const text = specText();422 const text = specText();
414 423
415 yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" });424 yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" });
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
9import { openApiPlugin } from "./plugin";9import { openApiPlugin } from "./plugin";
10import { applySpecOverrides, type SpecOverrides } from "./spec-overrides";10import { applySpecOverrides, type SpecOverrides } from "./spec-overrides";
11import { serveMutableOpenApiSpecTestServer } from "../testing";11import { serveMutableOpenApiSpecTestServer } from "../testing";
12import { testAccess } from "@executor-js/product-access/testing";
12 13
13const testPlugins = () =>14const testPlugins = () =>
14 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;15 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;
@@ -60,7 +61,9 @@ describe("OpenAPI spec override lifecycle", () => { · 45 unmodified lines
60 it.effect("applies scope overrides to preview and persists raw and patched hashes", () =>61 it.effect("applies scope overrides to preview and persists raw and patched hashes", () =>
61 Effect.scoped(62 Effect.scoped(
62 Effect.gen(function* () {63 Effect.gen(function* () {
63 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));64 const executor = yield* createExecutor(
65 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
66 );
64 const sourceText = encodeJsonText(oauthSpec);67 const sourceText = encodeJsonText(oauthSpec);
65 68
66 const preview = yield* executor.openapi.previewSpec({69 const preview = yield* executor.openapi.previewSpec({
@@ -93,7 +96,9 @@ describe("OpenAPI spec override lifecycle", () => { · 26 unmodified lines
93 it.effect("changes and clears overrides for an inline spec without repasting the source", () =>96 it.effect("changes and clears overrides for an inline spec without repasting the source", () =>
94 Effect.scoped(97 Effect.scoped(
95 Effect.gen(function* () {98 Effect.gen(function* () {
96 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));99 const executor = yield* createExecutor(
100 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
101 );
97 const sourceText = encodeJsonText(oauthSpec);102 const sourceText = encodeJsonText(oauthSpec);
98 yield* executor.openapi.addSpec({103 yield* executor.openapi.addSpec({
99 spec: { kind: "blob", value: sourceText },104 spec: { kind: "blob", value: sourceText },
@@ -130,7 +135,9 @@ describe("OpenAPI spec override lifecycle", () => { · 30 unmodified lines
130 .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String }));135 .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String }));
131 const InitialApi = HttpApi.make("initial").add(Items);136 const InitialApi = HttpApi.make("initial").add(Items);
132 const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi });137 const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi });
133 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));138 const executor = yield* createExecutor(
139 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
140 );
134 const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }];141 const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }];
135 142
136 yield* executor.openapi.addSpec({143 yield* executor.openapi.addSpec({
packages/plugins/openapi/src/sdk/streaming-response.test.ts+7−2
@@ -17,6 +17,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 16 unmodified lines
17 17
18import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke";18import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke";
19import { openApiPlugin } from "./plugin";19import { openApiPlugin } from "./plugin";
20import { testAccess } from "@executor-js/product-access/testing";
20 21
21const testPlugins = () =>22const testPlugins = () =>
22 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;23 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;
@@ -35,7 +36,9 @@ const StreamingApi = HttpApi.make("streamingResponseTest") · 12 unmodified lines
35 36
36const buildExecutor = (baseUrl: string) =>37const buildExecutor = (baseUrl: string) =>
37 Effect.gen(function* () {38 Effect.gen(function* () {
38 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));39 const executor = yield* createExecutor(
40 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
41 );
39 yield* executor.openapi.addSpec(42 yield* executor.openapi.addSpec(
40 makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }),43 makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }),
41 );44 );
@@ -293,7 +296,9 @@ describe("OpenAPI streaming responses", () => { · 251 unmodified lines
293 );296 );
294 });297 });
295 298
296 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));299 const executor = yield* createExecutor(
300 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
301 );
297 yield* executor.openapi.addSpec({302 yield* executor.openapi.addSpec({
298 spec: {303 spec: {
299 kind: "blob",304 kind: "blob",
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
25 25
26import { openApiPlugin } from "./plugin";26import { openApiPlugin } from "./plugin";
27import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing";27import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing";
28import { testAccess } from "@executor-js/product-access/testing";
28 29
29const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>30const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
30 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;31 [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -58,7 +59,9 @@ const setup = Effect.gen(function* () { · 27 unmodified lines
58 api: TestApi,59 api: TestApi,
59 handlersLayer: EchoGroupLive,60 handlersLayer: EchoGroupLive,
60 });61 });
61 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));62 const executor = yield* createExecutor(
63 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
64 );
62 yield* executor.openapi.addSpec({65 yield* executor.openapi.addSpec({
63 spec: { kind: "blob", value: server.specJson },66 spec: { kind: "blob", value: server.specJson },
64 slug: "proj_api",67 slug: "proj_api",
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+8−3
@@ -48,6 +48,7 @@ import { · 47 unmodified lines
48} from "../testing";48} from "../testing";
49 49
50import { openApiPlugin } from "./plugin";50import { openApiPlugin } from "./plugin";
51import { testAccess } from "@executor-js/product-access/testing";
51 52
52class AdapterDefect extends Data.TaggedError("AdapterDefect") {}53class AdapterDefect extends Data.TaggedError("AdapterDefect") {}
53 54
@@ -149,7 +150,7 @@ const FailureApi = HttpApi.make("failuresTest") · 95 unmodified lines
149const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) =>150const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) =>
150 Effect.gen(function* () {151 Effect.gen(function* () {
151 const executor = yield* createExecutor(152 const executor = yield* createExecutor(
152 makeTestConfig({ plugins: testPlugins(httpClientLayer) }),153 makeTestConfig({ access: testAccess.member(), plugins: testPlugins(httpClientLayer) }),
153 );154 );
154 yield* executor.openapi.addSpec(155 yield* executor.openapi.addSpec(
155 makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }),156 makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }),
@@ -167,7 +168,9 @@ const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) · 11 unmodified lines
167 168
168const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) =>169const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) =>
169 Effect.gen(function* () {170 Effect.gen(function* () {
170 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));171 const executor = yield* createExecutor(
172 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
173 );
171 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" });174 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" });
172 return { executor, address: conn.address(LIST_THINGS) };175 return { executor, address: conn.address(LIST_THINGS) };
173 });176 });
@@ -349,7 +352,9 @@ describe("OpenAPI upstream failure modes", () => { · 175 unmodified lines
349 headers: { "content-type": "application/json" },352 headers: { "content-type": "application/json" },
350 body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}',353 body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}',
351 }));354 }));
352 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));355 const executor = yield* createExecutor(
356 makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }),
357 );
353 // Declare the operation's scope in the spec blob before registering it,358 // Declare the operation's scope in the spec blob before registering it,
354 // so the extracted binding carries requiredScopes.359 // so the extracted binding carries requiredScopes.
355 const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));360 const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
packages/plugins/toolkits/package.json+1−0
@@ -56,6 +56,7 @@ · 55 unmodified lines
56 "@effect/atom-react": "catalog:",56 "@effect/atom-react": "catalog:",
57 "@effect/vitest": "catalog:",57 "@effect/vitest": "catalog:",
58 "@executor-js/api": "workspace:*",58 "@executor-js/api": "workspace:*",
59 "@executor-js/product-access": "workspace:*",
59 "@executor-js/react": "workspace:*",60 "@executor-js/react": "workspace:*",
60 "@tanstack/react-router": "catalog:",61 "@tanstack/react-router": "catalog:",
61 "@types/node": "catalog:",62 "@types/node": "catalog:",
packages/plugins/toolkits/src/server.test.ts+6−0
@@ -3,11 +3,13 @@ import { Effect, Predicate, Result } from "effect"; · 2 unmodified lines
3import { makeTestExecutor } from "@executor-js/sdk/testing";3import { makeTestExecutor } from "@executor-js/sdk/testing";
4 4
5import { toolkitsPlugin } from "./server";5import { toolkitsPlugin } from "./server";
6import { testAccess } from "@executor-js/product-access/testing";
6 7
7describe("toolkitsPlugin", () => {8describe("toolkitsPlugin", () => {
8 it.effect("creates toolkits and manages ordered policy rules", () =>9 it.effect("creates toolkits and manages ordered policy rules", () =>
9 Effect.gen(function* () {10 Effect.gen(function* () {
10 const executor = yield* makeTestExecutor({11 const executor = yield* makeTestExecutor({
12 access: testAccess.member(),
11 plugins: [toolkitsPlugin()] as const,13 plugins: [toolkitsPlugin()] as const,
12 });14 });
13 15
@@ -51,6 +53,7 @@ describe("toolkitsPlugin", () => { · 37 unmodified lines
51 it.effect("rejects duplicate visible slugs", () =>53 it.effect("rejects duplicate visible slugs", () =>
52 Effect.gen(function* () {54 Effect.gen(function* () {
53 const executor = yield* makeTestExecutor({55 const executor = yield* makeTestExecutor({
56 access: testAccess.member(),
54 plugins: [toolkitsPlugin()] as const,57 plugins: [toolkitsPlugin()] as const,
55 });58 });
56 yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" });59 yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" });
@@ -67,6 +70,7 @@ describe("toolkitsPlugin", () => { · 10 unmodified lines
67 it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () =>70 it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () =>
68 Effect.gen(function* () {71 Effect.gen(function* () {
69 const executor = yield* makeTestExecutor({72 const executor = yield* makeTestExecutor({
73 access: testAccess.member(),
70 plugins: [toolkitsPlugin()] as const,74 plugins: [toolkitsPlugin()] as const,
71 });75 });
72 76
@@ -135,6 +139,7 @@ describe("toolkitsPlugin", () => { · 62 unmodified lines
135 it.effect("treats a persisted connection-root approve as an access policy", () =>139 it.effect("treats a persisted connection-root approve as an access policy", () =>
136 Effect.gen(function* () {140 Effect.gen(function* () {
137 const executor = yield* makeTestExecutor({141 const executor = yield* makeTestExecutor({
142 access: testAccess.member(),
138 plugins: [toolkitsPlugin()] as const,143 plugins: [toolkitsPlugin()] as const,
139 });144 });
140 145
@@ -169,6 +174,7 @@ describe("toolkitsPlugin", () => { · 28 unmodified lines
169 it.effect("applies a broad approve policy over a narrower connection", () =>174 it.effect("applies a broad approve policy over a narrower connection", () =>
170 Effect.gen(function* () {175 Effect.gen(function* () {
171 const executor = yield* makeTestExecutor({176 const executor = yield* makeTestExecutor({
177 access: testAccess.member(),
172 plugins: [toolkitsPlugin()] as const,178 plugins: [toolkitsPlugin()] as const,
173 });179 });
174 180
packages/react/package.json+1−0
@@ -36,6 +36,7 @@ · 35 unmodified lines
36 "@base-ui/react": "^1.3.0",36 "@base-ui/react": "^1.3.0",
37 "@effect/atom-react": "catalog:",37 "@effect/atom-react": "catalog:",
38 "@executor-js/api": "workspace:*",38 "@executor-js/api": "workspace:*",
39 "@executor-js/product-access": "workspace:*",
39 "@executor-js/sdk": "workspace:*",40 "@executor-js/sdk": "workspace:*",
40 "@hookform/resolvers": "^5.2.2",41 "@hookform/resolvers": "^5.2.2",
41 "@lobehub/icons": "^5.4.0",42 "@lobehub/icons": "^5.4.0",
packages/react/src/pages/integration-detail.tsx+1−1
@@ -10,10 +10,10 @@ import { · 9 unmodified lines
10 AuthTemplateSlug,10 AuthTemplateSlug,
11 IntegrationSlug,11 IntegrationSlug,
12 ToolAddress,12 ToolAddress,
13 effectivePolicyFromSorted,
14 type Connection,13 type Connection,
15 type Owner,14 type Owner,
16} from "@executor-js/sdk/shared";15} from "@executor-js/sdk/shared";
16import { effectivePolicyFromSorted } from "@executor-js/product-access/policy";
17import {17import {
18 checkConnectionHealth,18 checkConnectionHealth,
19 connectionsAllAtom,19 connectionsAllAtom,
packages/react/src/pages/tools.tsx+2−1
@@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; · 1 unmodified line
2import { Link } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared";5import { ToolAddress } from "@executor-js/sdk/shared";
6import { effectivePolicyFromSorted } from "@executor-js/product-access/policy";
6 7
7import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms";8import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms";
8import { usePolicyActions } from "../hooks/use-policy-actions";9import { usePolicyActions } from "../hooks/use-policy-actions";
scripts/publish-packages.ts+1−0
@@ -31,6 +31,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 30 unmodified lines
31 "packages/kernel/core",31 "packages/kernel/core",
32 "packages/kernel/runtime-quickjs",32 "packages/kernel/runtime-quickjs",
33 "packages/core/sdk",33 "packages/core/sdk",
34 "packages/core/product-access",
34 "packages/core/config",35 "packages/core/config",
35 "packages/core/execution",36 "packages/core/execution",
36 "packages/core/cli",37 "packages/core/cli",
scripts/smoke-docs-install.ts+40−5
@@ -7,6 +7,7 @@ · 6 unmodified lines
7 * name (`@executor-js/sdk`) instead of relying on workspace resolution.7 * name (`@executor-js/sdk`) instead of relying on workspace resolution.
8 */8 */
9import { $ } from "bun";9import { $ } from "bun";
10import { Schema } from "effect";
10import { existsSync, readdirSync } from "node:fs";11import { existsSync, readdirSync } from "node:fs";
11import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";12import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12import { tmpdir } from "node:os";13import { tmpdir } from "node:os";
@@ -14,10 +15,15 @@ import { dirname, join, resolve } from "node:path"; · 1 unmodified line
14import { fileURLToPath } from "node:url";15import { fileURLToPath } from "node:url";
15 16
16const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");17const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17const documentedPackages = ["@executor-js/sdk", "@executor-js/plugin-openapi"] as const;18const documentedPackages = [
19 "@executor-js/sdk",
20 "@executor-js/product-access",
21 "@executor-js/plugin-openapi",
22] as const;
18const publicPackageDirs = [23const publicPackageDirs = [
19 "packages/core/fumadb",24 "packages/core/fumadb",
20 "packages/core/sdk",25 "packages/core/sdk",
26 "packages/core/product-access",
21 "packages/core/config",27 "packages/core/config",
22 "packages/plugins/openapi",28 "packages/plugins/openapi",
23] as const;29] as const;
@@ -27,6 +33,21 @@ const readPackageName = async (pkgDir: string): Promise<string> => { · 3 unmodified lines
27 return (JSON.parse(raw) as { name: string }).name;33 return (JSON.parse(raw) as { name: string }).name;
28};34};
29 35
36// `effect` is the SDK's (and product-access's) required peer, and the docs
37// install command tells consumers to add it themselves. `npm install
38// --legacy-peer-deps` below skips auto-installing peers, so the fixture must
39// declare it like a real consumer would — pinned from the workspace catalog
40// so it matches what the packed manifests resolved `catalog:` to.
41const decodeCatalog = Schema.decodeUnknownSync(
42 Schema.Struct({ catalog: Schema.Struct({ effect: Schema.NonEmptyString }) }),
43);
44
45const 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
30const findTarball = (pkgDir: string, packageName: string): string => {51const findTarball = (pkgDir: string, packageName: string): string => {
31 const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz"));52 const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz"));
32 if (!tarball) {53 if (!tarball) {
@@ -48,7 +69,7 @@ for (const relDir of publicPackageDirs) { · 15 unmodified lines
48const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-"));69const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-"));
49 70
50try {71try {
51 const dependencies: Record<string, string> = {};72 const dependencies: Record<string, string> = { effect: await readCatalogEffectVersion() };
52 const overrides: Record<string, string> = {};73 const overrides: Record<string, string> = {};
53 for (const [name, tarball] of tarballs) {74 for (const [name, tarball] of tarballs) {
54 overrides[name] = `file:${tarball}`;75 overrides[name] = `file:${tarball}`;
@@ -89,9 +110,23 @@ try { · 34 unmodified lines
89 }110 }
90 111
91 console.log(`[docs-smoke] import documented SDK packages`);112 console.log(`[docs-smoke] import documented SDK packages`);
92 await $`node --input-type=module --eval ${`const sdk = await import("@executor-js/sdk"); const openapi = await import("@executor-js/plugin-openapi"); if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor"); if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`}`.cwd(113 // The documented minimal embed: the SDK root export is the Promise façade
93 tmp,114 // and ships no access posture of its own, so the probe constructs a
94 );115 // subject-less workspace-service executor with the posture from
116 // `@executor-js/product-access` (ephemeral in-memory backend, nothing
117 // invoked over the network) and closes it.
118 const importProbe = [
119 `const sdk = await import("@executor-js/sdk");`,
120 `const access = await import("@executor-js/product-access");`,
121 `const openapi = await import("@executor-js/plugin-openapi");`,
122 `if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor");`,
123 `if (typeof access.workspaceServiceAccess !== "function") throw new Error("missing workspaceServiceAccess");`,
124 `if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`,
125 `const executor = await sdk.createExecutor({ access: access.workspaceServiceAccess(), onElicitation: "accept-all" });`,
126 `if (typeof executor.close !== "function") throw new Error("missing executor.close");`,
127 `await executor.close();`,
128 ].join("\n");
129 await $`node --input-type=module --eval ${importProbe}`.cwd(tmp);
95} finally {130} finally {
96 await rm(tmp, { recursive: true, force: true });131 await rm(tmp, { recursive: true, force: true });
97}132}
scripts/smoke-test-packed.ts+1−0
@@ -37,6 +37,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 36 unmodified lines
37 "packages/kernel/core",37 "packages/kernel/core",
38 "packages/kernel/runtime-quickjs",38 "packages/kernel/runtime-quickjs",
39 "packages/core/sdk",39 "packages/core/sdk",
40 "packages/core/product-access",
40 "packages/core/config",41 "packages/core/config",
41 "packages/core/execution",42 "packages/core/execution",
42 "packages/core/cli",43 "packages/core/cli",