Unified · Split
| @@ -5,6 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | "fixed": [ |
| 6 | 6 | [ |
| 7 | 7 | "@executor-js/sdk", |
| 8 | + "@executor-js/product-access", | |
| 8 | 9 | "@executor-js/codemode-core", |
| 9 | 10 | "@executor-js/runtime-quickjs", |
| 10 | 11 | "@executor-js/execution", |
| @@ -0,0 +1,21 @@ | ||
| 1 | +--- | |
| 2 | +"executor": minor | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Extract personal/organization access rules from the core SDK into the new | |
| 6 | +`@executor-js/product-access` package. BREAKING for SDK embedders: | |
| 7 | +`createExecutor` (Effect and Promise APIs alike) now REQUIRES | |
| 8 | +`access: ExecutorAccess` — the product's decisions for row visibility and | |
| 9 | +write partitions (`owners`), user-intent settings authorization | |
| 10 | +(`settingsWrite`), view capabilities (`adminReads` / `storageWrites`), and | |
| 11 | +effective tool-policy evaluation (`toolPolicy`). The former `orgWrites` and | |
| 12 | +`platformView` options are removed; select a posture from | |
| 13 | +`@executor-js/product-access` (`singleUserAccess()`, | |
| 14 | +`workspaceServiceAccess()`, `memberAccessForRole(...)`, | |
| 15 | +`requestBoundMemberAccess()`, `platformObserverAccess()`) or supply your own. | |
| 16 | +Core keeps enforcing tenant isolation, the storage owner policy clamps, and | |
| 17 | +approval mechanics; it no longer decides any product rule. Test helpers | |
| 18 | +changed too: `makeTestConfig` and the workspace harness require `access` | |
| 19 | +(postures in `@executor-js/product-access/testing`). No stored schema or data | |
| 20 | +migration — persisted rows, tenants, owners, subjects and addresses are | |
| 21 | +unchanged. | |
| @@ -149,6 +149,7 @@ jobs: · 148 unmodified lines | ||
| 149 | 149 | './apps/cli/dist/executor' |
| 150 | 150 | './packages/core/storage-core' |
| 151 | 151 | './packages/core/sdk' |
| 152 | + './packages/core/product-access' | |
| 152 | 153 | './packages/core/config' |
| 153 | 154 | './packages/core/execution' |
| 154 | 155 | './packages/core/cli' |
| @@ -139,10 +139,18 @@ Embed Executor with the TypeScript SDK (a Promise API; an Effect-native API is · 138 unmodified lines | ||
| 139 | 139 | also available): |
| 140 | 140 | |
| 141 | 141 | ```ts |
| 142 | −import { createExecutor } from "@executor-js/sdk/promise"; | |
| 143 | −import { openApiPlugin } from "@executor-js/plugin-openapi/promise"; | |
| 144 | − | |
| 145 | −const executor = await createExecutor({ plugins: [openApiPlugin()] }); | |
| 142 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 143 | +import { createExecutor } from "@executor-js/sdk"; | |
| 144 | +import { openApiPlugin } from "@executor-js/plugin-openapi"; | |
| 145 | + | |
| 146 | +const executor = await createExecutor({ | |
| 147 | + plugins: [openApiPlugin()], | |
| 148 | + onElicitation: "accept-all", | |
| 149 | + // Product access decisions are REQUIRED and stated explicitly — the SDK | |
| 150 | + // ships no default posture. `@executor-js/product-access` has the standard | |
| 151 | + // ones (this is the subject-less single-workspace posture). | |
| 152 | + access: workspaceServiceAccess(), | |
| 153 | +}); | |
| 146 | 154 | |
| 147 | 155 | // add an integration, create a connection, then list and call tools |
| 148 | 156 | const tools = await executor.tools.list({ integration: "inventory" }); |
| @@ -55,6 +55,7 @@ · 54 unmodified lines | ||
| 55 | 55 | "@executor-js/plugin-openapi": "workspace:*", |
| 56 | 56 | "@executor-js/plugin-toolkits": "workspace:*", |
| 57 | 57 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 58 | + "@executor-js/product-access": "workspace:*", | |
| 58 | 59 | "@executor-js/react": "workspace:*", |
| 59 | 60 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 60 | 61 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -17,7 +17,7 @@ · 16 unmodified lines | ||
| 17 | 17 | // acting member, and this plane deliberately serves the whole tenant. |
| 18 | 18 | // |
| 19 | 19 | // The executor is built by `makePlatformExecutor` — `{ tenant, subject: |
| 20 | −// undefined, platformView: true }` — so the reads are tenant-wide and read-only | |
| 20 | +// undefined, access: platformObserverAccess() }` — so the reads are tenant-wide and read-only | |
| 21 | 21 | // by storage policy, and no `subject` row is minted for the caller. |
| 22 | 22 | // |
| 23 | 23 | // Cross-tenant isolation is structural, not a check in this file: the tenant is |
| @@ -174,7 +174,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 173 unmodified lines | ||
| 174 | 174 | * `subject: null` + the read-only tenant reach rather than inventing a subject. |
| 175 | 175 | * |
| 176 | 176 | * The `/admin/*` mount turns this into an executor with `{ tenant: |
| 177 | − * organizationId, subject: undefined, platformView: true }`. | |
| 177 | + * organizationId, subject: undefined, access: platformObserverAccess() }`. | |
| 178 | 178 | */ |
| 179 | 179 | export interface PlatformAuth { |
| 180 | 180 | readonly kind: "platform"; |
| @@ -39,6 +39,7 @@ import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/test · 38 unmodified lines | ||
| 39 | 39 | import executorConfig from "../executor.config"; |
| 40 | 40 | import { DbService } from "./db/db"; |
| 41 | 41 | import { createDrizzleFumaDb } from "./db/fuma"; |
| 42 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 42 | 43 | |
| 43 | 44 | // --------------------------------------------------------------------------- |
| 44 | 45 | // Test-only plugin: exposes one in-memory tool that elicits once. Lets the |
| @@ -124,6 +125,7 @@ const buildScopedExecutor = ( · 79 unmodified lines | ||
| 124 | 125 | plugins, |
| 125 | 126 | httpClientLayer: FetchHttpClient.layer, |
| 126 | 127 | onElicitation: "accept-all", |
| 128 | + access: testAccess.member(), | |
| 127 | 129 | }); |
| 128 | 130 | }); |
| 129 | 131 | |
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 27 | 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 28 | 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 29 | +import { requestBoundMemberAccess } from "@executor-js/product-access"; | |
| 29 | 30 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| 30 | 31 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 31 | 32 | import { |
| @@ -349,7 +350,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 317 unmodified lines | ||
| 349 | 350 | sessionMeta.organizationName, |
| 350 | 351 | { |
| 351 | 352 | mcpResource: sessionMeta.resource, |
| 352 | − orgWrites: "request", | |
| 353 | + access: requestBoundMemberAccess(), | |
| 353 | 354 | }, |
| 354 | 355 | ).pipe( |
| 355 | 356 | // The metered stack tracks each execution to Autumn. It requires |
| @@ -32,6 +32,7 @@ import { createExecutor } from "@executor-js/sdk"; · 31 unmodified lines | ||
| 32 | 32 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 33 | 33 | |
| 34 | 34 | import { UrlRedactingSpanProcessor } from "./redact-span-urls"; |
| 35 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 35 | 36 | |
| 36 | 37 | // Synthetic placeholders — never a real authorization code or CSRF state. |
| 37 | 38 | const CODE = "synthetic-authorization-code-9f2c"; |
| @@ -55,7 +56,7 @@ describe("oauth callback telemetry", () => { · 17 unmodified lines | ||
| 55 | 56 | it.effect("exports no span attribute containing the authorization code or state", () => |
| 56 | 57 | Effect.gen(function* () { |
| 57 | 58 | const { exporter, provider, tracerLayer } = makeTracing(); |
| 58 | − const executor = yield* createExecutor(makeTestConfig({})); | |
| 59 | + const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() })); | |
| 59 | 60 | |
| 60 | 61 | const web = yield* Effect.acquireRelease( |
| 61 | 62 | Effect.sync(() => |
| @@ -30,6 +30,7 @@ · 29 unmodified lines | ||
| 30 | 30 | "@executor-js/plugin-openapi": "workspace:*", |
| 31 | 31 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 32 | 32 | "@executor-js/plugin-toolkits": "workspace:*", |
| 33 | + "@executor-js/product-access": "workspace:*", | |
| 33 | 34 | "@executor-js/react": "workspace:*", |
| 34 | 35 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 35 | 36 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -9,6 +9,7 @@ import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; · 8 unmodified lines | ||
| 9 | 9 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| 10 | 10 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 11 | 11 | import type { ExecutorDbHandle } from "@executor-js/api/server"; |
| 12 | +import { requestBoundMemberAccess } from "@executor-js/product-access"; | |
| 12 | 13 | import { |
| 13 | 14 | McpAgentSessionDOBase, |
| 14 | 15 | type BuiltMcpServer, |
| @@ -151,7 +152,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 136 unmodified lines | ||
| 151 | 152 | sessionMeta.userId, |
| 152 | 153 | sessionMeta.organizationId, |
| 153 | 154 | sessionMeta.organizationName, |
| 154 | − { mcpResource: sessionMeta.resource, orgWrites: "request" }, | |
| 155 | + { mcpResource: sessionMeta.resource, access: requestBoundMemberAccess() }, | |
| 155 | 156 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); |
| 156 | 157 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 157 | 158 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| @@ -35,6 +35,7 @@ · 34 unmodified lines | ||
| 35 | 35 | "@executor-js/plugin-openapi": "workspace:*", |
| 36 | 36 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 37 | 37 | "@executor-js/plugin-toolkits": "workspace:*", |
| 38 | + "@executor-js/product-access": "workspace:*", | |
| 38 | 39 | "@executor-js/react": "workspace:*", |
| 39 | 40 | "@executor-js/runtime-quickjs": "workspace:*", |
| 40 | 41 | "@executor-js/sdk": "workspace:*", |
| @@ -12,6 +12,7 @@ import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; · 11 unmodified lines | ||
| 12 | 12 | import { mintInviteCode } from "./testing/mint-invite"; |
| 13 | 13 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 14 | 14 | import type { SelfHostPlugins } from "./plugins"; |
| 15 | +import { memberAccess } from "@executor-js/product-access"; | |
| 15 | 16 | |
| 16 | 17 | // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, |
| 17 | 18 | // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, |
| @@ -22,9 +23,9 @@ const createScopedExecutor = ( · 4 unmodified lines | ||
| 22 | 23 | organizationId: string, |
| 23 | 24 | organizationName: string, |
| 24 | 25 | ) => |
| 25 | − makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe( | |
| 26 | − Effect.provide(SelfHostScopedExecutorSeams), | |
| 27 | − ); | |
| 26 | + makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, { | |
| 27 | + access: memberAccess("allowed"), | |
| 28 | + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); | |
| 28 | 29 | |
| 29 | 30 | // End-to-end: an org-owned connection's tools are reachable from a user's MCP |
| 30 | 31 | // `execute` sandbox. |
| @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 10 unmodified lines | ||
| 11 | 11 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 12 | 12 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 13 | 13 | import type { SelfHostPlugins } from "./plugins"; |
| 14 | +import { memberAccess } from "@executor-js/product-access"; | |
| 14 | 15 | |
| 15 | 16 | // The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, |
| 16 | 17 | // fresh per-request plugins, host config) over the shared `makeScopedExecutor`, |
| @@ -21,9 +22,9 @@ const createScopedExecutor = ( · 4 unmodified lines | ||
| 21 | 22 | organizationId: string, |
| 22 | 23 | organizationName: string, |
| 23 | 24 | ) => |
| 24 | − makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe( | |
| 25 | − Effect.provide(SelfHostScopedExecutorSeams), | |
| 26 | − ); | |
| 25 | + makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, { | |
| 26 | + access: memberAccess("allowed"), | |
| 27 | + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); | |
| 27 | 28 | |
| 28 | 29 | const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); |
| 29 | 30 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -12,6 +12,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 11 unmodified lines | ||
| 12 | 12 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 13 | 13 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 14 | 14 | import type { SelfHostPlugins } from "./plugins"; |
| 15 | +import { memberAccess } from "@executor-js/product-access"; | |
| 15 | 16 | |
| 16 | 17 | // In v2 a connection IS the credential: its inline `value` is written through the |
| 17 | 18 | // default writable provider — here the encrypted-secrets provider, which stores |
| @@ -28,9 +29,9 @@ const createScopedExecutor = ( · 10 unmodified lines | ||
| 28 | 29 | organizationId: string, |
| 29 | 30 | organizationName: string, |
| 30 | 31 | ) => |
| 31 | − makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe( | |
| 32 | − Effect.provide(SelfHostScopedExecutorSeams), | |
| 33 | − ); | |
| 32 | + makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, { | |
| 33 | + access: memberAccess("allowed"), | |
| 34 | + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); | |
| 34 | 35 | |
| 35 | 36 | let dbLayer!: Layer.Layer<SelfHostDb>; |
| 36 | 37 | let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; |
| @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server"; · 10 unmodified lines | ||
| 11 | 11 | import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 12 | 12 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 13 | 13 | import type { SelfHostPlugins } from "./plugins"; |
| 14 | +import { memberAccess } from "@executor-js/product-access"; | |
| 14 | 15 | |
| 15 | 16 | // The `subject` table is populated at the request seam: `makeScopedExecutor` is |
| 16 | 17 | // what every HTTP request and MCP session on every host passes through, so |
| @@ -19,9 +20,9 @@ import type { SelfHostPlugins } from "./plugins"; · 2 unmodified lines | ||
| 19 | 20 | // the point is that the production wiring reaches the writer at all. |
| 20 | 21 | |
| 21 | 22 | const createScopedExecutor = (accountId: string, organizationId: string) => |
| 22 | − makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe( | |
| 23 | − Effect.provide(SelfHostScopedExecutorSeams), | |
| 24 | − ); | |
| 23 | + makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default", { | |
| 24 | + access: memberAccess("allowed"), | |
| 25 | + }).pipe(Effect.provide(SelfHostScopedExecutorSeams)); | |
| 25 | 26 | |
| 26 | 27 | const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); |
| 27 | 28 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -41,6 +41,7 @@ · 40 unmodified lines | ||
| 41 | 41 | "@executor-js/plugin-openapi": "workspace:*", |
| 42 | 42 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 43 | 43 | "@executor-js/plugin-toolkits": "workspace:*", |
| 44 | + "@executor-js/product-access": "workspace:*", | |
| 44 | 45 | "@executor-js/react": "workspace:*", |
| 45 | 46 | "@executor-js/runtime-quickjs": "workspace:*", |
| 46 | 47 | "@executor-js/sdk": "workspace:*", |
| @@ -61,6 +61,7 @@ import { · 60 unmodified lines | ||
| 61 | 61 | createExecutor, |
| 62 | 62 | } from "@executor-js/sdk"; |
| 63 | 63 | import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 64 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 64 | 65 | |
| 65 | 66 | import { ErrorCaptureLive } from "./observability"; |
| 66 | 67 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; |
| @@ -109,6 +110,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 42 unmodified lines | ||
| 109 | 110 | db: sqlite.db, |
| 110 | 111 | plugins, |
| 111 | 112 | onElicitation: "accept-all", |
| 113 | + access: testAccess.member(), | |
| 112 | 114 | }), |
| 113 | 115 | ); |
| 114 | 116 | |
| @@ -17,13 +17,21 @@ import { Effect } from "effect"; · 16 unmodified lines | ||
| 17 | 17 | import { withQueryContext } from "@executor-js/fumadb/query"; |
| 18 | 18 | |
| 19 | 19 | import { collectTables } from "@executor-js/api/server"; |
| 20 | −import { runSqliteDataMigrations } from "@executor-js/sdk"; | |
| 20 | +import { runSqliteDataMigrations, type ExecutorOwnerPolicyContext } from "@executor-js/sdk"; | |
| 21 | 21 | |
| 22 | 22 | import { localDataMigrations } from "./data-migrations"; |
| 23 | 23 | import { createSqliteFumaDb } from "./sqlite-fumadb"; |
| 24 | 24 | |
| 25 | 25 | const TENANT = "executor-workspace-1771"; |
| 26 | 26 | const SUBJECT = "local"; |
| 27 | +// The fixture's inspection context: the local single-user binding's full | |
| 28 | +// identity view, with its owner partitions stated explicitly — the owner | |
| 29 | +// policy has no full-view default, and the seeded row is a `user` row. | |
| 30 | +const FIXTURE_CONTEXT: ExecutorOwnerPolicyContext = { | |
| 31 | + tenant: TENANT, | |
| 32 | + subject: SUBJECT, | |
| 33 | + owners: ["user", "org"], | |
| 34 | +}; | |
| 27 | 35 | // Epoch millis, the shape an OAuth token expiry takes. |
| 28 | 36 | const LEGACY_EXPIRES_AT = 1787321623456; |
| 29 | 37 | |
| @@ -71,7 +79,7 @@ describe("local boot over a legacy bigint database", () => { · 41 unmodified lines | ||
| 71 | 79 | await seedLegacyConnection(dbPath); |
| 72 | 80 | |
| 73 | 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 | 83 | // The reported symptom: not a wrong value, a throw — so the gateway lost |
| 76 | 84 | // every saved integration at once. |
| 77 | 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 | 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 | 100 | const rows = await scoped.findMany("connection", {}); |
| 93 | 101 | expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([ |
| 94 | 102 | ["default", LEGACY_EXPIRES_AT], |
| @@ -112,7 +120,7 @@ describe("local boot over a legacy bigint database", () => { · 17 unmodified lines | ||
| 112 | 120 | ); |
| 113 | 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 | 124 | const rows = await scoped.findMany("connection", {}); |
| 117 | 125 | expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); |
| 118 | 126 | await second.close(); |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | ||
| 36 | 36 | runSqliteDataMigrations, |
| 37 | 37 | } from "@executor-js/sdk"; |
| 38 | 38 | import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 39 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 39 | 40 | import { openApiPlugin } from "@executor-js/plugin-openapi"; |
| 40 | 41 | import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing"; |
| 41 | 42 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| @@ -123,6 +124,7 @@ const bootRealStack = async (dbPath: string) => { · 81 unmodified lines | ||
| 123 | 124 | memoryCredentialsPlugin(), |
| 124 | 125 | ] as const, |
| 125 | 126 | onElicitation: "accept-all", |
| 127 | + access: testAccess.member(), | |
| 126 | 128 | }), |
| 127 | 129 | ); |
| 128 | 130 | return { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | type Executor, |
| 14 | 14 | } from "@executor-js/sdk"; |
| 15 | 15 | import { collectTables } from "@executor-js/api/server"; |
| 16 | +import { singleUserAccess } from "@executor-js/product-access"; | |
| 16 | 17 | import { loadPluginsFromJsonc } from "@executor-js/config"; |
| 17 | 18 | import type { McpPluginExtension } from "@executor-js/plugin-mcp"; |
| 18 | 19 | |
| @@ -220,6 +221,9 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 201 unmodified lines | ||
| 220 | 221 | { plugin_key: event.pluginKey }, |
| 221 | 222 | ), |
| 222 | 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 | 227 | oauthEndpointUrlPolicy: { allowHttp: true }, |
| 224 | 228 | // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` |
| 225 | 229 | // route on the same origin as the web UI. Derived from `webBaseUrl` |
| @@ -34,6 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | |
| 35 | 35 | import { createMcpRequestHandler } from "./mcp"; |
| 36 | 36 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; |
| 37 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 37 | 38 | |
| 38 | 39 | const TEST_BASE_URL = "http://local.test"; |
| 39 | 40 | |
| @@ -90,6 +91,7 @@ const makeExecutor = async (tmpDir: string): Promise<Executor> => { · 50 unmodified lines | ||
| 90 | 91 | db: sqlite.db, |
| 91 | 92 | plugins, |
| 92 | 93 | onElicitation: "accept-all", |
| 94 | + access: testAccess.member(), | |
| 93 | 95 | }), |
| 94 | 96 | ); |
| 95 | 97 | |
| @@ -49,6 +49,7 @@ import { · 48 unmodified lines | ||
| 49 | 49 | createExecutor, |
| 50 | 50 | } from "@executor-js/sdk"; |
| 51 | 51 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; |
| 52 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 52 | 53 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 53 | 54 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| 54 | 55 | import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; |
| @@ -98,6 +99,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 43 unmodified lines | ||
| 98 | 99 | db: sqlite.db, |
| 99 | 100 | plugins, |
| 100 | 101 | onElicitation: "accept-all", |
| 102 | + access: testAccess.member(), | |
| 101 | 103 | oauthEndpointUrlPolicy: { allowHttp: true }, |
| 102 | 104 | // EXPLICIT OAuth callback — required now that the localhost default is |
| 103 | 105 | // gone; the local daemon serves `/api/oauth/callback` on the web origin. |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | "@effect/tsgo": "^0.5.2", |
| 12 | 12 | "@effect/vitest": "catalog:", |
| 13 | 13 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 14 | + "@executor-js/product-access": "workspace:*", | |
| 14 | 15 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 15 | 16 | "@vitest/expect": "catalog:", |
| 16 | 17 | "@vitest/mocker": "catalog:", |
| @@ -76,6 +77,7 @@ · 59 unmodified lines | ||
| 76 | 77 | "@executor-js/plugin-openapi": "workspace:*", |
| 77 | 78 | "@executor-js/plugin-toolkits": "workspace:*", |
| 78 | 79 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 80 | + "@executor-js/product-access": "workspace:*", | |
| 79 | 81 | "@executor-js/react": "workspace:*", |
| 80 | 82 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 81 | 83 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -186,6 +188,7 @@ · 104 unmodified lines | ||
| 186 | 188 | "@executor-js/plugin-openapi": "workspace:*", |
| 187 | 189 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 188 | 190 | "@executor-js/plugin-toolkits": "workspace:*", |
| 191 | + "@executor-js/product-access": "workspace:*", | |
| 189 | 192 | "@executor-js/react": "workspace:*", |
| 190 | 193 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| 191 | 194 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -239,6 +242,7 @@ · 47 unmodified lines | ||
| 239 | 242 | "@executor-js/plugin-openapi": "workspace:*", |
| 240 | 243 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 241 | 244 | "@executor-js/plugin-toolkits": "workspace:*", |
| 245 | + "@executor-js/product-access": "workspace:*", | |
| 242 | 246 | "@executor-js/react": "workspace:*", |
| 243 | 247 | "@executor-js/runtime-quickjs": "workspace:*", |
| 244 | 248 | "@executor-js/sdk": "workspace:*", |
| @@ -296,6 +300,7 @@ · 51 unmodified lines | ||
| 296 | 300 | "@executor-js/plugin-openapi": "workspace:*", |
| 297 | 301 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 298 | 302 | "@executor-js/plugin-toolkits": "workspace:*", |
| 303 | + "@executor-js/product-access": "workspace:*", | |
| 299 | 304 | "@executor-js/react": "workspace:*", |
| 300 | 305 | "@executor-js/runtime-quickjs": "workspace:*", |
| 301 | 306 | "@executor-js/sdk": "workspace:*", |
| @@ -397,6 +402,7 @@ · 95 unmodified lines | ||
| 397 | 402 | "@executor-js/plugin-onepassword": "workspace:*", |
| 398 | 403 | "@executor-js/plugin-openapi": "workspace:*", |
| 399 | 404 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 405 | + "@executor-js/product-access": "workspace:*", | |
| 400 | 406 | "@executor-js/sdk": "workspace:*", |
| 401 | 407 | "effect": "catalog:", |
| 402 | 408 | }, |
| @@ -410,6 +416,7 @@ · 7 unmodified lines | ||
| 410 | 416 | "version": "0.0.54", |
| 411 | 417 | "dependencies": { |
| 412 | 418 | "@executor-js/plugin-openapi": "workspace:*", |
| 419 | + "@executor-js/product-access": "workspace:*", | |
| 413 | 420 | "@executor-js/sdk": "workspace:*", |
| 414 | 421 | "effect": "catalog:", |
| 415 | 422 | }, |
| @@ -425,6 +432,7 @@ · 9 unmodified lines | ||
| 425 | 432 | "@executor-js/plugin-graphql": "workspace:*", |
| 426 | 433 | "@executor-js/plugin-mcp": "workspace:*", |
| 427 | 434 | "@executor-js/plugin-openapi": "workspace:*", |
| 435 | + "@executor-js/product-access": "workspace:*", | |
| 428 | 436 | "@executor-js/sdk": "workspace:*", |
| 429 | 437 | }, |
| 430 | 438 | "devDependencies": { |
| @@ -484,6 +492,7 @@ · 53 unmodified lines | ||
| 484 | 492 | "dependencies": { |
| 485 | 493 | "@executor-js/execution": "workspace:*", |
| 486 | 494 | "@executor-js/host-mcp": "workspace:*", |
| 495 | + "@executor-js/product-access": "workspace:*", | |
| 487 | 496 | "@executor-js/sdk": "workspace:*", |
| 488 | 497 | "effect": "catalog:", |
| 489 | 498 | }, |
| @@ -546,6 +555,7 @@ · 56 unmodified lines | ||
| 546 | 555 | }, |
| 547 | 556 | "devDependencies": { |
| 548 | 557 | "@effect/vitest": "catalog:", |
| 558 | + "@executor-js/product-access": "workspace:*", | |
| 549 | 559 | "@executor-js/runtime-quickjs": "workspace:*", |
| 550 | 560 | "@types/node": "catalog:", |
| 551 | 561 | "bun-types": "catalog:", |
| @@ -603,6 +613,25 @@ · 51 unmodified lines | ||
| 603 | 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 | 635 | "packages/core/sdk": { |
| 607 | 636 | "name": "@executor-js/sdk", |
| 608 | 637 | "version": "1.6.8", |
| @@ -704,6 +733,7 @@ · 95 unmodified lines | ||
| 704 | 733 | "dependencies": { |
| 705 | 734 | "@cfworker/json-schema": "^4.1.1", |
| 706 | 735 | "@executor-js/execution": "workspace:*", |
| 736 | + "@executor-js/product-access": "workspace:*", | |
| 707 | 737 | "@executor-js/sdk": "workspace:*", |
| 708 | 738 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 709 | 739 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -734,6 +764,7 @@ · 24 unmodified lines | ||
| 734 | 764 | "@effect/vitest": "catalog:", |
| 735 | 765 | "@executor-js/execution": "workspace:*", |
| 736 | 766 | "@executor-js/host-mcp": "workspace:*", |
| 767 | + "@executor-js/product-access": "workspace:*", | |
| 737 | 768 | "@executor-js/sdk": "workspace:*", |
| 738 | 769 | "@tailwindcss/browser": "^4.2.2", |
| 739 | 770 | "@tailwindcss/vite": "catalog:", |
| @@ -820,6 +851,7 @@ · 80 unmodified lines | ||
| 820 | 851 | "@executor-js/execution": "workspace:*", |
| 821 | 852 | "@executor-js/fumadb": "workspace:*", |
| 822 | 853 | "@executor-js/plugin-openapi": "workspace:*", |
| 854 | + "@executor-js/product-access": "workspace:*", | |
| 823 | 855 | "@executor-js/sdk": "workspace:*", |
| 824 | 856 | "@types/node": "catalog:", |
| 825 | 857 | "drizzle-orm": "catalog:", |
| @@ -895,6 +927,7 @@ · 69 unmodified lines | ||
| 895 | 927 | "react": "catalog:", |
| 896 | 928 | }, |
| 897 | 929 | "devDependencies": { |
| 930 | + "@executor-js/product-access": "workspace:*", | |
| 898 | 931 | "@types/react": "catalog:", |
| 899 | 932 | "tsup": "catalog:", |
| 900 | 933 | "typescript": "catalog:", |
| @@ -945,6 +978,7 @@ · 44 unmodified lines | ||
| 945 | 978 | "@executor-js/sdk": "workspace:*", |
| 946 | 979 | }, |
| 947 | 980 | "devDependencies": { |
| 981 | + "@executor-js/product-access": "workspace:*", | |
| 948 | 982 | "@types/node": "catalog:", |
| 949 | 983 | "bun-types": "catalog:", |
| 950 | 984 | "effect": "catalog:", |
| @@ -969,6 +1003,7 @@ · 18 unmodified lines | ||
| 969 | 1003 | "@effect/atom-react": "catalog:", |
| 970 | 1004 | "@effect/vitest": "catalog:", |
| 971 | 1005 | "@executor-js/api": "workspace:*", |
| 1006 | + "@executor-js/product-access": "workspace:*", | |
| 972 | 1007 | "@executor-js/react": "workspace:*", |
| 973 | 1008 | "@types/node": "catalog:", |
| 974 | 1009 | "@types/react": "catalog:", |
| @@ -1003,6 +1038,7 @@ · 28 unmodified lines | ||
| 1003 | 1038 | }, |
| 1004 | 1039 | "devDependencies": { |
| 1005 | 1040 | "@effect/vitest": "catalog:", |
| 1041 | + "@executor-js/product-access": "workspace:*", | |
| 1006 | 1042 | "@types/node": "catalog:", |
| 1007 | 1043 | "bun-types": "catalog:", |
| 1008 | 1044 | "effect": "catalog:", |
| @@ -1031,6 +1067,7 @@ · 22 unmodified lines | ||
| 1031 | 1067 | "@effect/atom-react": "catalog:", |
| 1032 | 1068 | "@effect/vitest": "catalog:", |
| 1033 | 1069 | "@executor-js/api": "workspace:*", |
| 1070 | + "@executor-js/product-access": "workspace:*", | |
| 1034 | 1071 | "@executor-js/react": "workspace:*", |
| 1035 | 1072 | "@modelcontextprotocol/server": "2.0.0", |
| 1036 | 1073 | "@types/node": "catalog:", |
| @@ -1068,6 +1105,7 @@ · 31 unmodified lines | ||
| 1068 | 1105 | "devDependencies": { |
| 1069 | 1106 | "@effect/vitest": "catalog:", |
| 1070 | 1107 | "@executor-js/api": "workspace:*", |
| 1108 | + "@executor-js/product-access": "workspace:*", | |
| 1071 | 1109 | "@types/node": "catalog:", |
| 1072 | 1110 | "@types/react": "catalog:", |
| 1073 | 1111 | "bun-types": "catalog:", |
| @@ -1105,6 +1143,7 @@ · 31 unmodified lines | ||
| 1105 | 1143 | "@effect/atom-react": "catalog:", |
| 1106 | 1144 | "@effect/vitest": "catalog:", |
| 1107 | 1145 | "@executor-js/api": "workspace:*", |
| 1146 | + "@executor-js/product-access": "workspace:*", | |
| 1108 | 1147 | "@executor-js/react": "workspace:*", |
| 1109 | 1148 | "@types/js-yaml": "4.0.9", |
| 1110 | 1149 | "@types/node": "catalog:", |
| @@ -1158,6 +1197,7 @@ · 47 unmodified lines | ||
| 1158 | 1197 | "@effect/atom-react": "catalog:", |
| 1159 | 1198 | "@effect/vitest": "catalog:", |
| 1160 | 1199 | "@executor-js/api": "workspace:*", |
| 1200 | + "@executor-js/product-access": "workspace:*", | |
| 1161 | 1201 | "@executor-js/react": "workspace:*", |
| 1162 | 1202 | "@tanstack/react-router": "catalog:", |
| 1163 | 1203 | "@types/node": "catalog:", |
| @@ -1224,6 +1264,7 @@ · 60 unmodified lines | ||
| 1224 | 1264 | "@base-ui/react": "^1.3.0", |
| 1225 | 1265 | "@effect/atom-react": "catalog:", |
| 1226 | 1266 | "@executor-js/api": "workspace:*", |
| 1267 | + "@executor-js/product-access": "workspace:*", | |
| 1227 | 1268 | "@executor-js/sdk": "workspace:*", |
| 1228 | 1269 | "@hookform/resolvers": "^5.2.2", |
| 1229 | 1270 | "@lobehub/icons": "^5.4.0", |
| @@ -1869,6 +1910,8 @@ · 639 unmodified lines | ||
| 1869 | 1910 | |
| 1870 | 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 | 1915 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], |
| 1873 | 1916 | |
| 1874 | 1917 | "@executor-js/runtime-deno-subprocess": ["@executor-js/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"], |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | "@executor-js/plugin-onepassword": "workspace:*", |
| 17 | 17 | "@executor-js/plugin-openapi": "workspace:*", |
| 18 | 18 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 19 | + "@executor-js/product-access": "workspace:*", | |
| 19 | 20 | "@executor-js/sdk": "workspace:*", |
| 20 | 21 | "effect": "catalog:" |
| 21 | 22 | }, |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | ||
| 31 | 31 | ToolAddress, |
| 32 | 32 | type CredentialProvider, |
| 33 | 33 | } from "@executor-js/sdk"; |
| 34 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 34 | 35 | |
| 35 | 36 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 36 | 37 | import { |
| @@ -192,6 +193,8 @@ const program = Effect.gen(function* () { · 155 unmodified lines | ||
| 192 | 193 | |
| 193 | 194 | const executor = yield* createExecutor({ |
| 194 | 195 | tenant: Tenant.make("example-tenant"), |
| 196 | + // Subject-less single-workspace example: org partition, writes allowed. | |
| 197 | + access: workspaceServiceAccess(), | |
| 195 | 198 | plugins, |
| 196 | 199 | providers: [memoryProvider], |
| 197 | 200 | onElicitation: "accept-all" as const, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "@executor-js/plugin-openapi": "workspace:*", |
| 13 | + "@executor-js/product-access": "workspace:*", | |
| 13 | 14 | "@executor-js/sdk": "workspace:*", |
| 14 | 15 | "effect": "catalog:" |
| 15 | 16 | }, |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type CredentialProvider, |
| 9 | 9 | } from "@executor-js/sdk/promise"; |
| 10 | 10 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; |
| 11 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 11 | 12 | |
| 12 | 13 | const inventoryApi = { |
| 13 | 14 | openapi: "3.0.0", |
| @@ -92,6 +93,9 @@ const memoryProvider: CredentialProvider = { · 78 unmodified lines | ||
| 92 | 93 | }; |
| 93 | 94 | |
| 94 | 95 | const executor = await createExecutor({ |
| 96 | + // Subject-less single-workspace embedder: the product posture is stated | |
| 97 | + // explicitly — the SDK ships no default. | |
| 98 | + access: workspaceServiceAccess(), | |
| 95 | 99 | plugins: [openApiPlugin()], |
| 96 | 100 | providers: [memoryProvider], |
| 97 | 101 | onElicitation: "accept-all", |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | "@executor-js/plugin-graphql": "workspace:*", |
| 13 | 13 | "@executor-js/plugin-mcp": "workspace:*", |
| 14 | 14 | "@executor-js/plugin-openapi": "workspace:*", |
| 15 | + "@executor-js/product-access": "workspace:*", | |
| 15 | 16 | "@executor-js/sdk": "workspace:*" |
| 16 | 17 | }, |
| 17 | 18 | "devDependencies": { |
| @@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines | ||
| 20 | 20 | import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; |
| 21 | 21 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; |
| 22 | 22 | import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; |
| 23 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 23 | 24 | |
| 24 | 25 | // --------------------------------------------------------------------------- |
| 25 | 26 | // 1. Create the executor with all plugins |
| @@ -44,6 +45,9 @@ const memoryProvider: CredentialProvider = { · 18 unmodified lines | ||
| 44 | 45 | }; |
| 45 | 46 | |
| 46 | 47 | const executor = await createExecutor({ |
| 48 | + // Subject-less single-workspace embedder: the product posture is stated | |
| 49 | + // explicitly — the SDK ships no default. | |
| 50 | + access: workspaceServiceAccess(), | |
| 47 | 51 | plugins, |
| 48 | 52 | providers: [memoryProvider], |
| 49 | 53 | onElicitation: "accept-all", |
| @@ -39,7 +39,7 @@ · 38 unmodified lines | ||
| 39 | 39 | "test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}", |
| 40 | 40 | "test:e2e": "bun run --cwd e2e test", |
| 41 | 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 | 43 | "typecheck": "turbo run typecheck", |
| 44 | 44 | "typecheck:slow": "turbo run typecheck:slow", |
| 45 | 45 | "ci": "bun run lint && bun run typecheck && bun run test", |
| @@ -78,6 +78,7 @@ · 32 unmodified lines | ||
| 78 | 78 | "@effect/tsgo": "^0.5.2", |
| 79 | 79 | "@effect/vitest": "catalog:", |
| 80 | 80 | "@executor-js/plugin-provider-service-split": "workspace:*", |
| 81 | + "@executor-js/product-access": "workspace:*", | |
| 81 | 82 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 82 | 83 | "@vitest/expect": "catalog:", |
| 83 | 84 | "@vitest/mocker": "catalog:", |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | "dependencies": { |
| 17 | 17 | "@executor-js/execution": "workspace:*", |
| 18 | 18 | "@executor-js/host-mcp": "workspace:*", |
| 19 | + "@executor-js/product-access": "workspace:*", | |
| 19 | 20 | "@executor-js/sdk": "workspace:*", |
| 20 | 21 | "effect": "catalog:" |
| 21 | 22 | }, |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | type ExecutorAdmin, |
| 14 | 14 | } from "@executor-js/sdk"; |
| 15 | 15 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; |
| 16 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 16 | 17 | import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal"; |
| 17 | 18 | |
| 18 | 19 | import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api"; |
| @@ -204,7 +205,7 @@ const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effec · 185 unmodified lines | ||
| 204 | 205 | tenant: Tenant.make(tenant), |
| 205 | 206 | db: db.db, |
| 206 | 207 | onElicitation: "accept-all", |
| 207 | − platformView: true, | |
| 208 | + access: testAccess.platform({ subject: false }), | |
| 208 | 209 | }).pipe(Effect.orDie); |
| 209 | 210 | |
| 210 | 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 | 215 | subject: Subject.make(USER_A1), |
| 215 | 216 | db: db.db, |
| 216 | 217 | onElicitation: "accept-all", |
| 218 | + access: testAccess.member(), | |
| 217 | 219 | }).pipe(Effect.orDie); |
| 218 | 220 | |
| 219 | 221 | /** |
| @@ -639,7 +641,7 @@ describe("admin users API", () => { · 419 unmodified lines | ||
| 639 | 641 | withDb((db) => |
| 640 | 642 | Effect.gen(function* () { |
| 641 | 643 | yield* seed(db); |
| 642 | − // A host that forgot `platformView: true` must not silently report an | |
| 644 | + // A host that forgot the platform access posture must not silently report an | |
| 643 | 645 | // empty tenant — that would read as "this owner has no users". |
| 644 | 646 | const web = yield* webHandlerFor( |
| 645 | 647 | stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize), |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | // The product plane (`/api/*`) answers "what can I, this member, reach". This |
| 5 | 5 | // one answers the OWNER's question: "who are my users, and what have they |
| 6 | 6 | // connected". It reads the SDK's platform view (`executor.admin`, opt-in via |
| 7 | −// `platformView: true`), which is read-only by construction — the owner policy | |
| 7 | +// `platformObserverAccess()`), which is read-only by construction — the owner policy | |
| 8 | 8 | // rejects writes at `reach: "tenant"` — so every endpoint here is a GET. |
| 9 | 9 | // |
| 10 | 10 | // VOCABULARY: this is the translation seam. Internal code says subject / tenant |
| @@ -35,7 +35,7 @@ import type { AdminUsersListOptions } from "./service"; · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | 37 | * Narrow an executor to its platform view. `admin` is present only when the |
| 38 | − * executor was built with `platformView: true`; a host that reaches these reads | |
| 38 | + * executor was built with the platform access posture; a host that reaches these reads | |
| 39 | 39 | * with a product-view executor is a wiring bug, so it fails loudly as a 500 |
| 40 | 40 | * rather than silently returning an empty tenant (which would read as "this |
| 41 | 41 | * owner has no users"). |
| @@ -16,6 +16,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 15 unmodified lines | ||
| 16 | 16 | import { ExecutorApi } from "../api"; |
| 17 | 17 | import { observabilityMiddleware } from "../observability"; |
| 18 | 18 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; |
| 19 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 19 | 20 | |
| 20 | 21 | // --------------------------------------------------------------------------- |
| 21 | 22 | // The catalog response surfaces each plugin's DECLARED auth methods (projected |
| @@ -109,7 +110,9 @@ interface IntegrationResponseBody { · 87 unmodified lines | ||
| 109 | 110 | describe("catalog surfaces declared auth methods", () => { |
| 110 | 111 | it.effect("an OAuth integration with zero connections advertises an oauth method", () => |
| 111 | 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 | 116 | const slug = IntegrationSlug.make("oauth-server"); |
| 114 | 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 | 137 | it.effect("an apikey integration advertises an apikey method", () => |
| 135 | 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 | 142 | const slug = IntegrationSlug.make("apikey-server"); |
| 138 | 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 | 162 | it.effect("list surfaces authMethods and a plugin with no projector yields []", () => |
| 158 | 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 | 167 | yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]); |
| 161 | 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 | 188 | it.effect("surfaces plugin-derived display URLs without exposing config", () => |
| 182 | 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 | 193 | const slug = IntegrationSlug.make("autumn"); |
| 185 | 194 | yield* executor.declaring.seed(slug, [], "https://api.useautumn.com"); |
| 186 | 195 | |
| @@ -17,6 +17,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 16 unmodified lines | ||
| 17 | 17 | import { ExecutorApi } from "./api"; |
| 18 | 18 | import { observabilityMiddleware } from "./observability"; |
| 19 | 19 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; |
| 20 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 20 | 21 | |
| 21 | 22 | // --------------------------------------------------------------------------- |
| 22 | 23 | // v2 owner-scoped API behaviour. |
| @@ -88,7 +89,7 @@ const vercelPlugin = definePlugin(() => ({ · 65 unmodified lines | ||
| 88 | 89 | describe("core API owner-scoped writes (v2)", () => { |
| 89 | 90 | it.effect("policy create + update target an explicit owner", () => |
| 90 | 91 | Effect.gen(function* () { |
| 91 | − const executor = yield* createExecutor(makeTestConfig({})); | |
| 92 | + const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() })); | |
| 92 | 93 | const web = yield* webHandlerFor(executor); |
| 93 | 94 | const context = handlerContextFor(executor); |
| 94 | 95 | |
| @@ -135,6 +136,7 @@ describe("core API owner-scoped writes (v2)", () => { · 40 unmodified lines | ||
| 135 | 136 | it.effect("connection remove deletes the named owner row, not the other owner", () => |
| 136 | 137 | Effect.gen(function* () { |
| 137 | 138 | const config = makeTestConfig({ |
| 139 | + access: testAccess.member(), | |
| 138 | 140 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, |
| 139 | 141 | }); |
| 140 | 142 | const executor = yield* createExecutor(config); |
| @@ -179,6 +181,7 @@ describe("core API owner-scoped writes (v2)", () => { · 38 unmodified lines | ||
| 179 | 181 | it.effect("connection create accepts pasted values payloads", () => |
| 180 | 182 | Effect.gen(function* () { |
| 181 | 183 | const config = makeTestConfig({ |
| 184 | + access: testAccess.member(), | |
| 182 | 185 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, |
| 183 | 186 | }); |
| 184 | 187 | const executor = yield* createExecutor(config); |
| @@ -220,6 +223,7 @@ describe("core API owner-scoped writes (v2)", () => { · 35 unmodified lines | ||
| 220 | 223 | it.effect("connection list returns both owners' rows under one integration", () => |
| 221 | 224 | Effect.gen(function* () { |
| 222 | 225 | const config = makeTestConfig({ |
| 226 | + access: testAccess.member(), | |
| 223 | 227 | plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, |
| 224 | 228 | }); |
| 225 | 229 | const executor = yield* createExecutor(config); |
| @@ -38,6 +38,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab · 37 unmodified lines | ||
| 38 | 38 | import { Context, Data, Effect, Layer } from "effect"; |
| 39 | 39 | import type * as Cause from "effect/Cause"; |
| 40 | 40 | |
| 41 | +import { memberAccessForRole } from "@executor-js/product-access"; | |
| 41 | 42 | import type { AnyPlugin } from "@executor-js/sdk"; |
| 42 | 43 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 43 | 44 | |
| @@ -247,10 +248,9 @@ export const makeExecutionStackMiddleware = < · 203 unmodified lines | ||
| 247 | 248 | resolved.organizationId, |
| 248 | 249 | resolved.organizationName, |
| 249 | 250 | { |
| 250 | − orgWrites: | |
| 251 | − resolved.orgRoleModel === "none" || resolved.orgRole === "admin" | |
| 252 | − ? "allowed" | |
| 253 | − : "denied", | |
| 251 | + // The product's role rule, decided from the freshly resolved | |
| 252 | + // principal for exactly this request's stack. | |
| 253 | + access: memberAccessForRole(resolved), | |
| 254 | 254 | }, |
| 255 | 255 | ).pipe( |
| 256 | 256 | Effect.provide(options.stackLayer, { local: true }), |
| @@ -27,7 +27,7 @@ import { Context, Effect, Layer } from "effect"; · 26 unmodified lines | ||
| 27 | 27 | import type * as Cause from "effect/Cause"; |
| 28 | 28 | |
| 29 | 29 | import type { McpResource } from "@executor-js/host-mcp"; |
| 30 | −import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk"; | |
| 30 | +import type { AnyPlugin, Executor, ExecutorAccess, StorageFailure } from "@executor-js/sdk"; | |
| 31 | 31 | import { |
| 32 | 32 | createExecutionEngine, |
| 33 | 33 | type ExecutionEngine, |
| @@ -112,11 +112,12 @@ export const makeExecutionStack = < · 78 unmodified lines | ||
| 112 | 112 | accountId: string, |
| 113 | 113 | organizationId: string, |
| 114 | 114 | organizationName: string, |
| 115 | − options?: { | |
| 115 | + options: { | |
| 116 | 116 | readonly mcpResource?: McpResource; |
| 117 | − /** Workspace-settings permission for this binding (see | |
| 118 | − * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ | |
| 119 | − readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"]; | |
| 117 | + /** The product's access decisions for this binding (see | |
| 118 | + * `ExecutorConfig.access`), selected by the host — role-derived on the | |
| 119 | + * HTTP plane, request-bound on MCP session stacks. */ | |
| 120 | + readonly access: ExecutorAccess; | |
| 120 | 121 | }, |
| 121 | 122 | ): Effect.Effect< |
| 122 | 123 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, |
| @@ -129,8 +130,8 @@ export const makeExecutionStack = < · 6 unmodified lines | ||
| 129 | 130 | organizationId, |
| 130 | 131 | organizationName, |
| 131 | 132 | { |
| 132 | − plugins: { mcpResource: options?.mcpResource }, | |
| 133 | − ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), | |
| 133 | + plugins: { mcpResource: options.mcpResource }, | |
| 134 | + access: options.access, | |
| 134 | 135 | }, |
| 135 | 136 | ).pipe(Effect.withSpan("executor.stack.scoped_executor")); |
| 136 | 137 | const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( |
| @@ -147,7 +148,7 @@ export const makeExecutionStack = < · 10 unmodified lines | ||
| 147 | 148 | organizationId, |
| 148 | 149 | organizationName, |
| 149 | 150 | }, |
| 150 | − { mcpResource: options?.mcpResource }, | |
| 151 | + { mcpResource: options.mcpResource }, | |
| 151 | 152 | ), |
| 152 | 153 | ); |
| 153 | 154 | return { executor, engine }; |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; |
| 4 | +import { requestBoundMemberAccess } from "@executor-js/product-access"; | |
| 4 | 5 | import { |
| 5 | 6 | McpEngineBuildError, |
| 6 | 7 | type McpBuildServer, |
| @@ -50,7 +51,10 @@ export const makeMcpBuildServer = · 43 unmodified lines | ||
| 50 | 51 | principal.organizationName, |
| 51 | 52 | { |
| 52 | 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 | 59 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); |
| 56 | 60 | // Read inside the provided boundary: `webBaseUrl` is a host seam, and |
| @@ -34,12 +34,14 @@ · 33 unmodified lines | ||
| 34 | 34 | import { Context, Effect, Option } from "effect"; |
| 35 | 35 | |
| 36 | 36 | import type { McpResource } from "@executor-js/host-mcp"; |
| 37 | +import { platformObserverAccess } from "@executor-js/product-access"; | |
| 37 | 38 | import { |
| 38 | 39 | createExecutor, |
| 39 | 40 | Subject, |
| 40 | 41 | Tenant, |
| 41 | 42 | type AnyPlugin, |
| 42 | 43 | type Executor, |
| 44 | + type ExecutorAccess, | |
| 43 | 45 | type ExecutorConfig, |
| 44 | 46 | type FirstPartyOAuthClientConfig, |
| 45 | 47 | type StorageFailure, |
| @@ -258,12 +260,13 @@ export const makeScopedExecutor = < · 212 unmodified lines | ||
| 258 | 260 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the |
| 259 | 261 | // v2 executor binding, which is `{ tenant, subject }` only. |
| 260 | 262 | _organizationName: string, |
| 261 | − options?: { | |
| 263 | + options: { | |
| 262 | 264 | readonly plugins?: PluginsProviderContext; |
| 263 | − /** Workspace-settings permission for this binding (see | |
| 264 | − * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's | |
| 265 | − * role; omitted -> allowed (hosts with no role model). */ | |
| 266 | − readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"]; | |
| 265 | + /** The product's access decisions for this binding (see | |
| 266 | + * `ExecutorConfig.access`) — REQUIRED, selected by the host from | |
| 267 | + * `@executor-js/product-access` (role-derived on the HTTP plane, | |
| 268 | + * request-bound on session stacks). */ | |
| 269 | + readonly access: ExecutorAccess; | |
| 267 | 270 | }, |
| 268 | 271 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => |
| 269 | 272 | Effect.gen(function* () { |
| @@ -336,7 +339,7 @@ export const makeScopedExecutor = < · 66 unmodified lines | ||
| 336 | 339 | ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), |
| 337 | 340 | ...(waitUntil !== undefined ? { waitUntil } : {}), |
| 338 | 341 | onElicitation: "accept-all", |
| 339 | − ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), | |
| 342 | + access: options.access, | |
| 340 | 343 | redirectUri, |
| 341 | 344 | oauthCallbackStateOrgSlug: orgSlug, |
| 342 | 345 | firstPartyOAuthClients: config.firstPartyOAuthClients, |
| @@ -370,9 +373,9 @@ export const makeScopedExecutor = < · 27 unmodified lines | ||
| 370 | 373 | // An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on |
| 371 | 374 | // the whole workspace) has NO acting member, so there is no honest subject to |
| 372 | 375 | // bind. This builds the executor that shape implies: `{ tenant, subject: |
| 373 | −// undefined, platformView: true }`. | |
| 376 | +// undefined, access: platformObserverAccess() }`. | |
| 374 | 377 | // |
| 375 | −// WHAT "READ-ONLY" MEANS HERE, PRECISELY. `platformView: true` puts | |
| 378 | +// WHAT "READ-ONLY" MEANS HERE, PRECISELY. The platform access posture puts | |
| 376 | 379 | // `writes: "denied"` on the executor's base owner-policy context and |
| 377 | 380 | // `reach: "tenant"` on the `admin` handle's. So: |
| 378 | 381 | // - EVERY surface on this executor — `admin`, and the ordinary `connections` |
| @@ -429,6 +432,6 @@ export const makePlatformExecutor = ( · 50 unmodified lines | ||
| 429 | 432 | httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions), |
| 430 | 433 | fetch: makeHostedFetch(hostedHttpOptions), |
| 431 | 434 | onElicitation: "accept-all", |
| 432 | − platformView: true, | |
| 435 | + access: platformObserverAccess(), | |
| 433 | 436 | }).pipe(Effect.withSpan("executor.platform.create_executor")); |
| 434 | 437 | }); |
| @@ -17,12 +17,14 @@ npm install @executor-js/sdk @executor-js/execution @executor-js/runtime-quickjs · 16 unmodified lines | ||
| 17 | 17 | ## Usage |
| 18 | 18 | |
| 19 | 19 | ```ts |
| 20 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 20 | 21 | import { createExecutor } from "@executor-js/sdk"; |
| 21 | 22 | import { createExecutionEngine } from "@executor-js/execution"; |
| 22 | 23 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 23 | 24 | |
| 24 | 25 | const executor = await createExecutor({ |
| 25 | 26 | onElicitation: "accept-all", |
| 27 | + access: workspaceServiceAccess(), | |
| 26 | 28 | }); |
| 27 | 29 | |
| 28 | 30 | const engine = createExecutionEngine({ |
| @@ -48,6 +48,7 @@ · 47 unmodified lines | ||
| 48 | 48 | }, |
| 49 | 49 | "devDependencies": { |
| 50 | 50 | "@effect/vitest": "catalog:", |
| 51 | + "@executor-js/product-access": "workspace:*", | |
| 51 | 52 | "@executor-js/runtime-quickjs": "workspace:*", |
| 52 | 53 | "@types/node": "catalog:", |
| 53 | 54 | "bun-types": "catalog:", |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 15 | 15 | |
| 16 | 16 | import { buildExecuteDescription, parseIntegrationInventory } from "./description"; |
| 17 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 17 | 18 | |
| 18 | 19 | const memoryProvider = (): CredentialProvider => { |
| 19 | 20 | const store = new Map<string, string>(); |
| @@ -74,7 +75,10 @@ describe("buildExecuteDescription", () => { · 54 unmodified lines | ||
| 74 | 75 | it.effect("lists the connected integrations, not the connection prefixes", () => |
| 75 | 76 | Effect.gen(function* () { |
| 76 | 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 | 83 | yield* executor["slack-plugin"].seed(); |
| 80 | 84 | yield* executor["github-plugin"].seed(); |
| @@ -121,7 +125,10 @@ describe("buildExecuteDescription", () => { · 40 unmodified lines | ||
| 121 | 125 | it.effect("lists integration names only, with no descriptions", () => |
| 122 | 126 | Effect.gen(function* () { |
| 123 | 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 | 133 | yield* executor["slack-plugin"].seed(); |
| 127 | 134 | yield* executor["github-plugin"].seed(); |
| @@ -154,7 +161,9 @@ describe("buildExecuteDescription", () => { · 26 unmodified lines | ||
| 154 | 161 | |
| 155 | 162 | it.effect("dedupes many connections of one integration into a single line", () => |
| 156 | 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 | 167 | yield* executor["github-plugin"].seed(); |
| 159 | 168 | yield* executor.connections.create({ |
| 160 | 169 | owner: "org", |
| @@ -181,7 +190,9 @@ describe("buildExecuteDescription", () => { · 20 unmodified lines | ||
| 181 | 190 | |
| 182 | 191 | it.effect("omits the Available integrations section when no connections exist", () => |
| 183 | 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 | 197 | const description = yield* buildExecuteDescription(executor); |
| 187 | 198 | |
| @@ -195,7 +206,10 @@ describe("parseIntegrationInventory", () => { · 7 unmodified lines | ||
| 195 | 206 | it.effect("round-trips the slugs a built description lists", () => |
| 196 | 207 | Effect.gen(function* () { |
| 197 | 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 | 214 | yield* executor["slack-plugin"].seed(); |
| 201 | 215 | yield* executor["github-plugin"].seed(); |
| @@ -14,6 +14,7 @@ import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine"; |
| 16 | 16 | import { FormElicitation } from "@executor-js/sdk/core"; |
| 17 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 17 | 18 | |
| 18 | 19 | // Regression for the hang reported as the executor-MCP "180s timeout" against |
| 19 | 20 | // Cowork (Claude web). Cowork goes down the `executeWithPause` branch because |
| @@ -42,7 +43,10 @@ const emptyPlugin = definePlugin(() => ({ · 22 unmodified lines | ||
| 42 | 43 | staticIntegrations: () => [], |
| 43 | 44 | })); |
| 44 | 45 | |
| 45 | −const makeExecutor = () => createExecutor(makeTestConfig({ plugins: [emptyPlugin()] as const })); | |
| 46 | +const makeExecutor = () => | |
| 47 | + createExecutor( | |
| 48 | + makeTestConfig({ access: testAccess.member(), plugins: [emptyPlugin()] as const }), | |
| 49 | + ); | |
| 46 | 50 | |
| 47 | 51 | describe("executeWithPause failure propagation", () => { |
| 48 | 52 | it.effect("surfaces a fast codeExecutor failure as an Exit.Failure", () => |
| @@ -98,7 +102,7 @@ describe("paused execution authorization", () => { · 49 unmodified lines | ||
| 98 | 102 | it.effect("uses the resumer's current org-write access after approval", () => |
| 99 | 103 | Effect.gen(function* () { |
| 100 | 104 | const executor = yield* createExecutor( |
| 101 | − makeTestConfig({ coreTools: {}, orgWrites: "request" }), | |
| 105 | + makeTestConfig({ coreTools: {}, access: testAccess.requestBound() }), | |
| 102 | 106 | ); |
| 103 | 107 | const engine = createExecutionEngine({ |
| 104 | 108 | executor, |
| @@ -170,7 +174,7 @@ describe("paused execution authorization", () => { · 65 unmodified lines | ||
| 170 | 174 | const executor = yield* createExecutor( |
| 171 | 175 | makeTestConfig({ |
| 172 | 176 | coreTools: {}, |
| 173 | − orgWrites: "request", | |
| 177 | + access: testAccess.requestBound(), | |
| 174 | 178 | plugins: [joinPlugin()] as const, |
| 175 | 179 | }), |
| 176 | 180 | ); |
| @@ -5,6 +5,7 @@ import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/ · 4 unmodified lines | ||
| 5 | 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 6 | 6 | import { ExecutionToolError } from "./errors"; |
| 7 | 7 | import { makeExecutorToolInvoker } from "./tool-invoker"; |
| 8 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 8 | 9 | |
| 9 | 10 | const EmptyInputSchema = Schema.toStandardSchemaV1( |
| 10 | 11 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), |
| @@ -138,7 +139,9 @@ const leakyPlugin = definePlugin(() => ({ · 127 unmodified lines | ||
| 138 | 139 | describe("internal-error leak audit (opaque defects)", () => { |
| 139 | 140 | it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () => |
| 140 | 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 | 145 | const invoker = makeExecutorToolInvoker(executor, { |
| 143 | 146 | invokeOptions: { onElicitation: acceptAll }, |
| 144 | 147 | }); |
| @@ -160,7 +163,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 15 unmodified lines | ||
| 160 | 163 | |
| 161 | 164 | it.effect("plain Error with stack: stack and message do NOT escape", () => |
| 162 | 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 | 169 | const invoker = makeExecutorToolInvoker(executor, { |
| 165 | 170 | invokeOptions: { onElicitation: acceptAll }, |
| 166 | 171 | }); |
| @@ -179,7 +184,9 @@ describe("internal-error leak audit (opaque defects)", () => { · 12 unmodified lines | ||
| 179 | 184 | describe("openapi pre-flight invocation errors (expected failures)", () => { |
| 180 | 185 | it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () => |
| 181 | 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 | 190 | const invoker = makeExecutorToolInvoker(executor, { |
| 184 | 191 | invokeOptions: { onElicitation: acceptAll }, |
| 185 | 192 | }); |
| @@ -197,7 +204,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines | ||
| 197 | 204 | |
| 198 | 205 | it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () => |
| 199 | 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 | 210 | const invoker = makeExecutorToolInvoker(executor, { |
| 202 | 211 | invokeOptions: { onElicitation: acceptAll }, |
| 203 | 212 | }); |
| @@ -215,7 +224,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 11 unmodified lines | ||
| 215 | 224 | |
| 216 | 225 | it.effect("transport failure (statusCode None but cause present) stays opaque", () => |
| 217 | 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 | 230 | const invoker = makeExecutorToolInvoker(executor, { |
| 220 | 231 | invokeOptions: { onElicitation: acceptAll }, |
| 221 | 232 | }); |
| @@ -235,7 +246,9 @@ describe("openapi pre-flight invocation errors (expected failures)", () => { · 13 unmodified lines | ||
| 235 | 246 | |
| 236 | 247 | it.effect("post-response failure (statusCode Some) stays opaque", () => |
| 237 | 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 | 252 | const invoker = makeExecutorToolInvoker(executor, { |
| 240 | 253 | invokeOptions: { onElicitation: acceptAll }, |
| 241 | 254 | }); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | ||
| 38 | 38 | searchTools, |
| 39 | 39 | type ToolDiscoveryProvider, |
| 40 | 40 | } from "./tool-invoker"; |
| 41 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 41 | 42 | |
| 42 | 43 | // --------------------------------------------------------------------------- |
| 43 | 44 | // v2 port. The v1 suite modelled namespaces as `staticSources` whose tools |
| @@ -477,7 +478,7 @@ const provision = ( · 433 unmodified lines | ||
| 477 | 478 | }); |
| 478 | 479 | |
| 479 | 480 | const makeExecutorWith = <const TPlugins extends readonly AnyPlugin[]>(plugins: TPlugins) => |
| 480 | − createExecutor(makeTestConfig({ plugins })); | |
| 481 | + createExecutor(makeTestConfig({ access: testAccess.member(), plugins })); | |
| 481 | 482 | |
| 482 | 483 | const makeSearchExecutor = () => |
| 483 | 484 | Effect.gen(function* () { |
| @@ -1250,6 +1251,7 @@ describe("tool discovery", () => { · 766 unmodified lines | ||
| 1250 | 1251 | }); |
| 1251 | 1252 | const executor = yield* createExecutor( |
| 1252 | 1253 | makeTestConfig({ |
| 1254 | + access: testAccess.member(), | |
| 1253 | 1255 | plugins: [memoryCredentialsPlugin()] as const, |
| 1254 | 1256 | coreTools: {}, |
| 1255 | 1257 | }), |
| @@ -1303,6 +1305,7 @@ describe("tool discovery", () => { · 47 unmodified lines | ||
| 1303 | 1305 | Effect.gen(function* () { |
| 1304 | 1306 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1305 | 1307 | const config = makeTestConfig({ |
| 1308 | + access: testAccess.member(), | |
| 1306 | 1309 | plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, |
| 1307 | 1310 | }); |
| 1308 | 1311 | const executor = yield* createExecutor(config); |
| @@ -1386,6 +1389,7 @@ describe("tool discovery", () => { · 77 unmodified lines | ||
| 1386 | 1389 | invalidRefreshTokenDescription: "Refresh token expired", |
| 1387 | 1390 | }); |
| 1388 | 1391 | const config = makeTestConfig({ |
| 1392 | + access: testAccess.member(), | |
| 1389 | 1393 | plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, |
| 1390 | 1394 | }); |
| 1391 | 1395 | const executor = yield* createExecutor(config); |
| @@ -0,0 +1 @@ | ||
| 1 | +# @executor-js/product-access | |
| @@ -0,0 +1,79 @@ | ||
| 1 | +# @executor-js/product-access | |
| 2 | + | |
| 3 | +The product's personal/organization access rules for | |
| 4 | +[`@executor-js/sdk`](../sdk). The SDK executes tools and enforces tenant | |
| 5 | +isolation; it deliberately owns no product rule. Every `createExecutor` call | |
| 6 | +requires an `ExecutorAccess` — the product's decisions for: | |
| 7 | + | |
| 8 | +- **`owners`** — which owner partitions (`"user"`, `"org"`) the binding sees | |
| 9 | + and may write, in precedence order. Really filters storage CRUD, tool | |
| 10 | + list/schema/invoke, plugin storage and plugin blobs. | |
| 11 | +- **`settingsWrite(target)`** — whether a user-intent settings mutation | |
| 12 | + (connections, policies, OAuth clients, the integration catalog) is allowed, | |
| 13 | + asked live at every sink. | |
| 14 | +- **`capabilities`** — `adminReads` (the tenant-wide read-only admin surface) | |
| 15 | + and `storageWrites` (`"denied"` = whole-executor read-only). | |
| 16 | +- **`toolPolicy(sources)`** — how stored policy rows and toolkit capability | |
| 17 | + rules resolve into an effective allow/approve/block decision. | |
| 18 | + | |
| 19 | +This package ships the standard rules. Core never falls back to them — a | |
| 20 | +composition root states its posture explicitly. | |
| 21 | + | |
| 22 | +## Effect SDK | |
| 23 | + | |
| 24 | +```ts | |
| 25 | +import { Effect } from "effect"; | |
| 26 | +import { createExecutor } from "@executor-js/sdk/core"; | |
| 27 | +import { singleUserAccess } from "@executor-js/product-access"; | |
| 28 | + | |
| 29 | +const program = Effect.gen(function* () { | |
| 30 | + const executor = yield* createExecutor({ | |
| 31 | + tenant, | |
| 32 | + subject, | |
| 33 | + onElicitation: "accept-all", | |
| 34 | + access: singleUserAccess(), | |
| 35 | + }); | |
| 36 | + // Execution stays on the executor namespaces: executor.tools, executor.execute, … | |
| 37 | +}); | |
| 38 | +``` | |
| 39 | + | |
| 40 | +## Promise SDK | |
| 41 | + | |
| 42 | +```ts | |
| 43 | +import { createExecutor } from "@executor-js/sdk"; // published root = Promise API | |
| 44 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 45 | + | |
| 46 | +const executor = await createExecutor({ | |
| 47 | + onElicitation: "accept-all", | |
| 48 | + // Subject-less single-workspace embedder posture. | |
| 49 | + access: workspaceServiceAccess(), | |
| 50 | +}); | |
| 51 | +``` | |
| 52 | + | |
| 53 | +## Postures | |
| 54 | + | |
| 55 | +| Constructor | Binding | Used by | | |
| 56 | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------- | | |
| 57 | +| `singleUserAccess()` | bound subject, everything allowed | local daemon, CLI, desktop | | |
| 58 | +| `memberAccessForRole({ orgRoleModel, orgRole })` | bound member; admins configure, members use | HTTP API plane | | |
| 59 | +| `requestBoundMemberAccess()` | bound member; decision re-read per request via `CurrentOrgWriteAccess`, re-stamped on approval resume | MCP session stacks | | |
| 60 | +| `workspaceServiceAccess()` | subject-less org service; boot convergence | seeding/system executors, embedders | | |
| 61 | +| `platformObserverAccess()` | subject-less, storage read-only, tenant-wide `admin` reads | `/admin/*` org-credential plane | | |
| 62 | + | |
| 63 | +`orgWriteAccessForRole` is the single home of the admin/member rule. | |
| 64 | +`./policy` exports the resolution semantics (owner ranking, most-restrictive | |
| 65 | +merge, plugin-default fallback, toolkit allowlist) — browser-safe, built on | |
| 66 | +`@executor-js/sdk/shared`. `./testing` exports `testAccess` postures for | |
| 67 | +tests (`makeTestConfig({ access: testAccess.member() })`). | |
| 68 | + | |
| 69 | +## Breaking changes (from the pre-split SDK) | |
| 70 | + | |
| 71 | +- `ExecutorConfig.orgWrites` and `platformView` are gone; `access` is | |
| 72 | + required on both the Effect and Promise `createExecutor`. | |
| 73 | +- `makeTestConfig` / `makeTestWorkspaceHarness` / `makeTestExecutor` / | |
| 74 | + `makeTestWorkspaceLayer` require `access`. | |
| 75 | +- Policy resolution functions moved here from `@executor-js/sdk` | |
| 76 | + (`resolveEffectivePolicy`, `effectivePolicyFromSorted`, …); pattern | |
| 77 | + matching (`matchPattern`, `isValidPattern`) stays in the SDK. | |
| 78 | +- No stored schema or data migration: rows, tenants, owners, subjects and | |
| 79 | + tool addresses are unchanged. | |
| @@ -0,0 +1,67 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor-js/product-access", | |
| 3 | + "version": "1.6.8", | |
| 4 | + "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/product-access", | |
| 5 | + "bugs": { | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | + }, | |
| 8 | + "license": "MIT", | |
| 9 | + "repository": { | |
| 10 | + "type": "git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | + "directory": "packages/core/product-access" | |
| 13 | + }, | |
| 14 | + "files": [ | |
| 15 | + "dist" | |
| 16 | + ], | |
| 17 | + "type": "module", | |
| 18 | + "exports": { | |
| 19 | + ".": "./src/index.ts", | |
| 20 | + "./policy": "./src/policy.ts", | |
| 21 | + "./testing": "./src/testing.ts" | |
| 22 | + }, | |
| 23 | + "publishConfig": { | |
| 24 | + "access": "public", | |
| 25 | + "exports": { | |
| 26 | + ".": { | |
| 27 | + "import": { | |
| 28 | + "types": "./dist/index.d.ts", | |
| 29 | + "default": "./dist/index.js" | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "./policy": { | |
| 33 | + "import": { | |
| 34 | + "types": "./dist/policy.d.ts", | |
| 35 | + "default": "./dist/policy.js" | |
| 36 | + } | |
| 37 | + }, | |
| 38 | + "./testing": { | |
| 39 | + "import": { | |
| 40 | + "types": "./dist/testing.d.ts", | |
| 41 | + "default": "./dist/testing.js" | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | + }, | |
| 46 | + "scripts": { | |
| 47 | + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", | |
| 48 | + "typecheck": "tsgo --noEmit", | |
| 49 | + "test": "vitest run", | |
| 50 | + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" | |
| 51 | + }, | |
| 52 | + "dependencies": { | |
| 53 | + "@executor-js/sdk": "workspace:*" | |
| 54 | + }, | |
| 55 | + "devDependencies": { | |
| 56 | + "@effect/vitest": "catalog:", | |
| 57 | + "@types/node": "catalog:", | |
| 58 | + "bun-types": "catalog:", | |
| 59 | + "effect": "catalog:", | |
| 60 | + "tsup": "catalog:", | |
| 61 | + "typescript": "catalog:", | |
| 62 | + "vitest": "catalog:" | |
| 63 | + }, | |
| 64 | + "peerDependencies": { | |
| 65 | + "effect": "catalog:" | |
| 66 | + } | |
| 67 | +} | |
| @@ -0,0 +1,185 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Ref } from "effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + CurrentOrgWriteAccess, | |
| 6 | + makeOrgWriteAccessState, | |
| 7 | + type ToolPolicyRow, | |
| 8 | +} from "@executor-js/sdk/core"; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + memberAccess, | |
| 12 | + memberAccessForRole, | |
| 13 | + orgWriteAccessForRole, | |
| 14 | + platformObserverAccess, | |
| 15 | + requestBoundMemberAccess, | |
| 16 | + singleUserAccess, | |
| 17 | + workspaceServiceAccess, | |
| 18 | +} from "./index"; | |
| 19 | +import { resolveProviderPolicyFromRules, standardToolPolicy } from "./policy"; | |
| 20 | + | |
| 21 | +describe("orgWriteAccessForRole", () => { | |
| 22 | + it("trusts the single user of a deployment without a role model", () => { | |
| 23 | + expect(orgWriteAccessForRole({ orgRoleModel: "none" })).toBe("allowed"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("lets admins configure the workspace under a role model", () => { | |
| 27 | + expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "admin" })).toBe( | |
| 28 | + "allowed", | |
| 29 | + ); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("denies plain members", () => { | |
| 33 | + expect(orgWriteAccessForRole({ orgRoleModel: "organization", orgRole: "member" })).toBe( | |
| 34 | + "denied", | |
| 35 | + ); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("fails closed when a role model is present but the role is missing", () => { | |
| 39 | + expect(orgWriteAccessForRole({ orgRoleModel: "organization" })).toBe("denied"); | |
| 40 | + }); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe("settings-write rules", () => { | |
| 44 | + it.effect("the role decision gates workspace targets; personal stays open", () => | |
| 45 | + Effect.gen(function* () { | |
| 46 | + const member = memberAccessForRole({ orgRoleModel: "organization", orgRole: "member" }); | |
| 47 | + const admin = memberAccessForRole({ orgRoleModel: "organization", orgRole: "admin" }); | |
| 48 | + expect(yield* member.settingsWrite({ kind: "workspace" })).toBe("denied"); | |
| 49 | + expect(yield* member.settingsWrite({ kind: "owner", owner: "org" })).toBe("denied"); | |
| 50 | + expect(yield* member.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed"); | |
| 51 | + expect(yield* admin.settingsWrite({ kind: "workspace" })).toBe("allowed"); | |
| 52 | + expect(admin.owners).toEqual(["user", "org"]); | |
| 53 | + expect(admin.capabilities).toEqual({ adminReads: false, storageWrites: "allowed" }); | |
| 54 | + }), | |
| 55 | + ); | |
| 56 | + | |
| 57 | + it.effect("requestBoundMemberAccess fails closed without a request binding", () => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const access = requestBoundMemberAccess(); | |
| 60 | + expect(yield* access.settingsWrite({ kind: "workspace" })).toBe("denied"); | |
| 61 | + // Personal targets never consult the request decision. | |
| 62 | + expect(yield* access.settingsWrite({ kind: "owner", owner: "user" })).toBe("allowed"); | |
| 63 | + }), | |
| 64 | + ); | |
| 65 | + | |
| 66 | + it.effect("requestBoundMemberAccess reads the live request binding at every ask", () => | |
| 67 | + Effect.gen(function* () { | |
| 68 | + const access = requestBoundMemberAccess(); | |
| 69 | + const state = makeOrgWriteAccessState("allowed"); | |
| 70 | + const ask = access | |
| 71 | + .settingsWrite({ kind: "owner", owner: "org" }) | |
| 72 | + .pipe(Effect.provideService(CurrentOrgWriteAccess, state)); | |
| 73 | + expect(yield* ask).toBe("allowed"); | |
| 74 | + // The engine re-stamps this Ref from the resuming principal; the SAME | |
| 75 | + // access value must observe the new decision, not a snapshot. | |
| 76 | + yield* Ref.set(state.current, "denied"); | |
| 77 | + expect(yield* ask).toBe("denied"); | |
| 78 | + }), | |
| 79 | + ); | |
| 80 | + | |
| 81 | + it.effect("single-user and workspace-service postures allow workspace writes", () => | |
| 82 | + Effect.gen(function* () { | |
| 83 | + const single = singleUserAccess(); | |
| 84 | + const service = workspaceServiceAccess(); | |
| 85 | + expect(yield* single.settingsWrite({ kind: "workspace" })).toBe("allowed"); | |
| 86 | + expect(single.owners).toEqual(["user", "org"]); | |
| 87 | + expect(yield* service.settingsWrite({ kind: "workspace" })).toBe("allowed"); | |
| 88 | + expect(service.owners).toEqual(["org"]); | |
| 89 | + expect(yield* memberAccess("denied").settingsWrite({ kind: "workspace" })).toBe("denied"); | |
| 90 | + }), | |
| 91 | + ); | |
| 92 | + | |
| 93 | + it("the platform observer is a subject-less read-only view with admin reads", () => { | |
| 94 | + const access = platformObserverAccess(); | |
| 95 | + expect(access.owners).toEqual(["org"]); | |
| 96 | + expect(access.capabilities).toEqual({ adminReads: true, storageWrites: "denied" }); | |
| 97 | + }); | |
| 98 | +}); | |
| 99 | + | |
| 100 | +describe("standardToolPolicy", () => { | |
| 101 | + const row = ( | |
| 102 | + id: string, | |
| 103 | + owner: "org" | "user", | |
| 104 | + pattern: string, | |
| 105 | + action: "approve" | "require_approval" | "block", | |
| 106 | + ): ToolPolicyRow => | |
| 107 | + ({ | |
| 108 | + id, | |
| 109 | + owner, | |
| 110 | + subject: owner === "org" ? "" : "u", | |
| 111 | + pattern, | |
| 112 | + action, | |
| 113 | + position: "a0", | |
| 114 | + created_at: new Date(0), | |
| 115 | + updated_at: new Date(0), | |
| 116 | + }) as ToolPolicyRow; | |
| 117 | + | |
| 118 | + const evaluate = ( | |
| 119 | + rows: readonly ReturnType<typeof row>[], | |
| 120 | + toolId: string, | |
| 121 | + defaultRequiresApproval?: boolean, | |
| 122 | + ) => | |
| 123 | + Effect.gen(function* () { | |
| 124 | + const evaluator = yield* standardToolPolicy(["user", "org"])({ | |
| 125 | + policyRows: Effect.succeed(rows), | |
| 126 | + provider: null, | |
| 127 | + }); | |
| 128 | + return yield* evaluator.resolve({ toolId, defaultRequiresApproval }); | |
| 129 | + }); | |
| 130 | + | |
| 131 | + it.effect("an org guardrail beats a user preference", () => | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const effective = yield* evaluate( | |
| 134 | + [ | |
| 135 | + row("outer", "org", "vercel.*", "block"), | |
| 136 | + row("inner", "user", "vercel.dns.create", "approve"), | |
| 137 | + ], | |
| 138 | + "vercel.dns.create", | |
| 139 | + ); | |
| 140 | + expect(effective).toMatchObject({ action: "block", policyId: "outer" }); | |
| 141 | + }), | |
| 142 | + ); | |
| 143 | + | |
| 144 | + it.effect("no authored match falls back to the plugin default", () => | |
| 145 | + Effect.gen(function* () { | |
| 146 | + expect(yield* evaluate([], "vercel.dns.create", true)).toMatchObject({ | |
| 147 | + action: "require_approval", | |
| 148 | + source: "plugin-default", | |
| 149 | + }); | |
| 150 | + expect(yield* evaluate([], "vercel.dns.create")).toMatchObject({ | |
| 151 | + action: "approve", | |
| 152 | + source: "plugin-default", | |
| 153 | + }); | |
| 154 | + }), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + it.effect("a list-only provider is an allowlist: unmatched tools block", () => | |
| 158 | + Effect.gen(function* () { | |
| 159 | + const evaluator = yield* standardToolPolicy(["user", "org"])({ | |
| 160 | + policyRows: Effect.succeed([]), | |
| 161 | + provider: { | |
| 162 | + list: () => | |
| 163 | + Effect.succeed([ | |
| 164 | + { id: "r1", pattern: "github.*", action: "approve" as const, position: "a0" }, | |
| 165 | + ]), | |
| 166 | + }, | |
| 167 | + }); | |
| 168 | + expect(yield* evaluator.resolve({ toolId: "github.org.acme.repos.list" })).toMatchObject({ | |
| 169 | + action: "approve", | |
| 170 | + }); | |
| 171 | + expect(yield* evaluator.resolve({ toolId: "vercel.org.acme.deploy" })).toMatchObject({ | |
| 172 | + action: "block", | |
| 173 | + pattern: "*", | |
| 174 | + }); | |
| 175 | + }), | |
| 176 | + ); | |
| 177 | + | |
| 178 | + it("resolveProviderPolicyFromRules picks the first match by position", () => { | |
| 179 | + const rules = [ | |
| 180 | + { id: "b", pattern: "github.*", action: "block" as const, position: "a1" }, | |
| 181 | + { id: "a", pattern: "github.*", action: "approve" as const, position: "a0" }, | |
| 182 | + ]; | |
| 183 | + expect(resolveProviderPolicyFromRules("github.x", rules)).toMatchObject({ policyId: "a" }); | |
| 184 | + }); | |
| 185 | +}); | |
| @@ -0,0 +1,159 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// @executor-js/product-access — the PRODUCT's personal/organization access | |
| 3 | +// rules, stated once and selected explicitly by every composition root. | |
| 4 | +// | |
| 5 | +// The SDK owns none of these. `createExecutor` requires an `ExecutorAccess` | |
| 6 | +// and enforces its decisions at core's sinks — storage row visibility and | |
| 7 | +// write partitions (`owners`), the user-intent settings gate | |
| 8 | +// (`settingsWrite`), view capabilities (`capabilities`), and effective | |
| 9 | +// tool-policy evaluation (`toolPolicy`) — while the storage owner policy | |
| 10 | +// keeps clamping everything to the binding's tenant/subject partition. | |
| 11 | +// This package is where "what may this principal touch" is decided: | |
| 12 | +// - the role rule (admins configure the workspace, members use it), | |
| 13 | +// - the personal-stays-open settings rule (a denied member still manages | |
| 14 | +// Personal-scope resources), | |
| 15 | +// - the request-bound session posture (MCP sessions re-decide per request | |
| 16 | +// and re-stamp on approval resume), | |
| 17 | +// - the single-user posture (local daemon / CLI / examples), | |
| 18 | +// - the subject-less workspace-service and platform-observer postures, | |
| 19 | +// - how authored policy rows and toolkit capability rules resolve | |
| 20 | +// (`./policy`). | |
| 21 | +// Non-HTTP hosts consume it without pulling `@executor-js/api`. | |
| 22 | +// | |
| 23 | +// Imports come from the PUBLISHED sdk entries: `@executor-js/sdk/core` (the | |
| 24 | +// Effect SDK; the sdk ROOT export is the Promise façade) and the | |
| 25 | +// browser-safe `@executor-js/sdk/shared` inside `./policy`. | |
| 26 | +// --------------------------------------------------------------------------- | |
| 27 | + | |
| 28 | +import { Effect } from "effect"; | |
| 29 | + | |
| 30 | +import { | |
| 31 | + currentOrgWriteAccess, | |
| 32 | + type AccessDecision, | |
| 33 | + type ExecutorAccess, | |
| 34 | + type OrgWriteAccess, | |
| 35 | + type Owner, | |
| 36 | + type SettingsWriteTarget, | |
| 37 | +} from "@executor-js/sdk/core"; | |
| 38 | + | |
| 39 | +import { standardToolPolicy } from "./policy"; | |
| 40 | + | |
| 41 | +export { | |
| 42 | + effectivePolicyFromSorted, | |
| 43 | + resolveEffectivePolicy, | |
| 44 | + resolveProviderPolicyFromRules, | |
| 45 | + resolveToolPolicy, | |
| 46 | + standardToolPolicy, | |
| 47 | + type ToolPolicyRuleLike, | |
| 48 | +} from "./policy"; | |
| 49 | + | |
| 50 | +/** The role shape the org-write rule reads — structural, so both the API's | |
| 51 | + * `Principal` and host-mcp's schema'd copy satisfy it without translation. */ | |
| 52 | +export interface OrgRoleInput { | |
| 53 | + /** `"organization"` when the deployment has an org role model, `"none"` | |
| 54 | + * when it does not (self-host single-org, local). */ | |
| 55 | + readonly orgRoleModel: "organization" | "none"; | |
| 56 | + /** The acting member's role under an `"organization"` model. Missing at a | |
| 57 | + * legacy boundary fails closed (treated as a plain member). */ | |
| 58 | + readonly orgRole?: "admin" | "member" | undefined; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * THE product rule for workspace-settings writes: a deployment without a | |
| 63 | + * role model trusts its user; under a role model only admins configure the | |
| 64 | + * workspace, and an absent role fails closed. Previously duplicated by the | |
| 65 | + * HTTP execution-stack middleware and the MCP serving seams — this is now | |
| 66 | + * the single home. | |
| 67 | + */ | |
| 68 | +export const orgWriteAccessForRole = (input: OrgRoleInput): OrgWriteAccess => | |
| 69 | + input.orgRoleModel === "none" || input.orgRole === "admin" ? "allowed" : "denied"; | |
| 70 | + | |
| 71 | +/** A bound member's partitions, personal shadowing shared. */ | |
| 72 | +const MEMBER_OWNERS: readonly Owner[] = ["user", "org"]; | |
| 73 | +/** A subject-less binding's single partition. */ | |
| 74 | +const ORG_OWNERS: readonly Owner[] = ["org"]; | |
| 75 | + | |
| 76 | +/** | |
| 77 | + * The settings rule every current product shares: Personal-scope targets | |
| 78 | + * are always the member's own to configure; workspace-level targets (org | |
| 79 | + * rows and tenant-shared surfaces) follow the workspace decision, evaluated | |
| 80 | + * LIVE at each sink so a request-bound decision is re-read after pauses. | |
| 81 | + */ | |
| 82 | +const personalOpenSettingsWrite = | |
| 83 | + (workspace: Effect.Effect<AccessDecision>) => | |
| 84 | + (target: SettingsWriteTarget): Effect.Effect<AccessDecision> => | |
| 85 | + target.kind === "owner" && target.owner === "user" | |
| 86 | + ? Effect.succeed("allowed" as const) | |
| 87 | + : workspace; | |
| 88 | + | |
| 89 | +const memberCapabilities = { adminReads: false, storageWrites: "allowed" } as const; | |
| 90 | + | |
| 91 | +const memberPosture = (workspace: Effect.Effect<AccessDecision>): ExecutorAccess => ({ | |
| 92 | + owners: MEMBER_OWNERS, | |
| 93 | + settingsWrite: personalOpenSettingsWrite(workspace), | |
| 94 | + capabilities: memberCapabilities, | |
| 95 | + toolPolicy: standardToolPolicy(MEMBER_OWNERS), | |
| 96 | +}); | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * A bound member whose workspace-settings decision was made from the freshly | |
| 100 | + * authenticated request and holds for its lifetime — the HTTP API plane, | |
| 101 | + * where every request re-authenticates and builds a new stack. | |
| 102 | + */ | |
| 103 | +export const memberAccess = (workspaceWrites: OrgWriteAccess): ExecutorAccess => | |
| 104 | + memberPosture(Effect.succeed(workspaceWrites)); | |
| 105 | + | |
| 106 | +/** {@link memberAccess} with the decision derived by {@link orgWriteAccessForRole}. */ | |
| 107 | +export const memberAccessForRole = (input: OrgRoleInput): ExecutorAccess => | |
| 108 | + memberAccess(orgWriteAccessForRole(input)); | |
| 109 | + | |
| 110 | +/** | |
| 111 | + * A bound member on a session-lifetime stack (MCP sessions): the executor | |
| 112 | + * outlives any one request, so the workspace-settings decision is read from | |
| 113 | + * the fiber-local `CurrentOrgWriteAccess` at EVERY guarded sink. The session | |
| 114 | + * host stamps that reference from each freshly authenticated request, the | |
| 115 | + * engine re-stamps it from the resuming principal on approval resume, and a | |
| 116 | + * missing binding fails closed (denied) — a positive authorization is never | |
| 117 | + * cached for the session lifetime. | |
| 118 | + */ | |
| 119 | +export const requestBoundMemberAccess = (): ExecutorAccess => memberPosture(currentOrgWriteAccess); | |
| 120 | + | |
| 121 | +/** | |
| 122 | + * The single-user products (local daemon, CLI, desktop, examples): one human | |
| 123 | + * owns the whole deployment, there is no role model, and the bound subject | |
| 124 | + * may configure everything. | |
| 125 | + */ | |
| 126 | +export const singleUserAccess = (): ExecutorAccess => memberPosture(Effect.succeed("allowed")); | |
| 127 | + | |
| 128 | +/** | |
| 129 | + * A subject-less workspace-service binding — boot/system executors that | |
| 130 | + * converge the tenant's shared state (catalog registration, seeding) with no | |
| 131 | + * acting member. Sees and writes only the org partition; workspace writes | |
| 132 | + * are the whole point. | |
| 133 | + */ | |
| 134 | +export const workspaceServiceAccess = (): ExecutorAccess => ({ | |
| 135 | + owners: ORG_OWNERS, | |
| 136 | + settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")), | |
| 137 | + capabilities: memberCapabilities, | |
| 138 | + toolPolicy: standardToolPolicy(ORG_OWNERS), | |
| 139 | +}); | |
| 140 | + | |
| 141 | +/** | |
| 142 | + * The platform observer an org-level credential gets (the `/admin/*` plane): | |
| 143 | + * subject-less, the WHOLE executor read-only at the storage boundary | |
| 144 | + * (`storageWrites: "denied"`), and only the `admin` surface reading | |
| 145 | + * tenant-wide (`adminReads: true`). | |
| 146 | + * | |
| 147 | + * `settingsWrite` stays `"allowed"` ON PURPOSE: the platform posture's | |
| 148 | + * enforcement point is the storage owner policy, which refuses every | |
| 149 | + * mutation regardless of the surface gate. Deciding `"denied"` here would | |
| 150 | + * merely swap the failure the (already unreachable-over-HTTP) mutation | |
| 151 | + * surfaces raise from the storage denial to `OrgWriteDeniedError`; the | |
| 152 | + * observer's read-only-ness never depends on it. | |
| 153 | + */ | |
| 154 | +export const platformObserverAccess = (): ExecutorAccess => ({ | |
| 155 | + owners: ORG_OWNERS, | |
| 156 | + settingsWrite: personalOpenSettingsWrite(Effect.succeed("allowed")), | |
| 157 | + capabilities: { adminReads: true, storageWrites: "denied" }, | |
| 158 | + toolPolicy: standardToolPolicy(ORG_OWNERS), | |
| 159 | +}); | |
| @@ -0,0 +1,263 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// The PRODUCT's tool-policy resolution rules — how authored rows and toolkit | |
| 3 | +// capability rules combine into one `EffectivePolicy`: | |
| 4 | +// | |
| 5 | +// - owner-ranked, per-owner first-match by local position; | |
| 6 | +// - the MOST RESTRICTIVE matched action across owners wins, so a user | |
| 7 | +// preference cannot weaken an org guardrail (org = outer, user = inner); | |
| 8 | +// - no authored match falls back to the tool's own `requiresApproval` | |
| 9 | +// annotation (the plugin default); | |
| 10 | +// - a list-only toolkit provider is a capability ALLOWLIST: no matching | |
| 11 | +// rule means the tool is outside the capability boundary and blocks. | |
| 12 | +// | |
| 13 | +// Core (`@executor-js/sdk`) never applies these rules itself: it hands the | |
| 14 | +// rule material to `ExecutorAccess.toolPolicy` and enforces whatever | |
| 15 | +// `EffectivePolicy` comes back. This module deliberately imports only the | |
| 16 | +// browser-safe `@executor-js/sdk/shared` utilities (pattern matching, row | |
| 17 | +// ordering) at runtime, so UI surfaces can preview the same resolution | |
| 18 | +// without pulling the server SDK. | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +import { Effect, Match } from "effect"; | |
| 22 | + | |
| 23 | +import { | |
| 24 | + comparePolicyRow, | |
| 25 | + matchPattern, | |
| 26 | + type EffectivePolicy, | |
| 27 | + type PolicyMatch, | |
| 28 | + type ToolPolicy, | |
| 29 | +} from "@executor-js/sdk/shared"; | |
| 30 | +import type { | |
| 31 | + StorageFailure, | |
| 32 | + ToolPolicyEvaluationInput, | |
| 33 | + ToolPolicyEvaluator, | |
| 34 | + ToolPolicyProvider, | |
| 35 | + ToolPolicyProviderRule, | |
| 36 | + ToolPolicySources, | |
| 37 | +} from "@executor-js/sdk/core"; | |
| 38 | + | |
| 39 | +/** The stored-row shape resolution reads — structural, so both core's | |
| 40 | + * persisted `ToolPolicyRow` and UI projections satisfy it. */ | |
| 41 | +export interface ToolPolicyRuleLike { | |
| 42 | + readonly id: string; | |
| 43 | + readonly owner: string; | |
| 44 | + readonly pattern: string; | |
| 45 | + /** Persisted rows carry a plain string; resolution narrows it. */ | |
| 46 | + readonly action: string; | |
| 47 | + readonly position: string; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const actionRestrictionRank = (action: ToolPolicy["action"]): number => | |
| 51 | + Match.value(action).pipe( | |
| 52 | + Match.when("block", () => 3), | |
| 53 | + Match.when("require_approval", () => 2), | |
| 54 | + Match.when("approve", () => 1), | |
| 55 | + Match.exhaustive, | |
| 56 | + ); | |
| 57 | + | |
| 58 | +const moreRestrictive = <T extends { readonly action: ToolPolicy["action"] }>( | |
| 59 | + current: T | undefined, | |
| 60 | + candidate: T, | |
| 61 | +): T => { | |
| 62 | + if (!current) return candidate; | |
| 63 | + return actionRestrictionRank(candidate.action) > actionRestrictionRank(current.action) | |
| 64 | + ? candidate | |
| 65 | + : current; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => | |
| 69 | + defaultRequiresApproval | |
| 70 | + ? { action: "require_approval", source: "plugin-default" } | |
| 71 | + : { action: "approve", source: "plugin-default" }; | |
| 72 | + | |
| 73 | +const liftUser = (match: PolicyMatch): EffectivePolicy => ({ | |
| 74 | + action: match.action, | |
| 75 | + source: "user", | |
| 76 | + pattern: match.pattern, | |
| 77 | + policyId: match.policyId, | |
| 78 | +}); | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * Resolve the matching authored rule for one tool id: each owner contributes | |
| 82 | + * its first matching rule by local position, then the most restrictive | |
| 83 | + * matched action across owners wins. `ownerRank` orders owners for the | |
| 84 | + * per-owner scan (the product's precedence); it never weakens the merge. | |
| 85 | + * Returns undefined when no rule matches. | |
| 86 | + */ | |
| 87 | +export const resolveToolPolicy = ( | |
| 88 | + toolId: string, | |
| 89 | + policies: readonly ToolPolicyRuleLike[], | |
| 90 | + ownerRank: (row: { readonly owner: string }) => number, | |
| 91 | +): PolicyMatch | undefined => { | |
| 92 | + if (policies.length === 0) return undefined; | |
| 93 | + const sorted = [...policies].sort((a, b) => { | |
| 94 | + const sa = ownerRank(a); | |
| 95 | + const sb = ownerRank(b); | |
| 96 | + if (sa !== sb) return sa - sb; | |
| 97 | + return comparePolicyRow(a, b); | |
| 98 | + }); | |
| 99 | + const firstMatchByOwner = new Map<string, PolicyMatch>(); | |
| 100 | + for (const row of sorted) { | |
| 101 | + if (firstMatchByOwner.has(row.owner)) continue; | |
| 102 | + if (matchPattern(row.pattern, toolId)) { | |
| 103 | + firstMatchByOwner.set(row.owner, { | |
| 104 | + action: row.action as ToolPolicy["action"], | |
| 105 | + pattern: row.pattern, | |
| 106 | + policyId: row.id, | |
| 107 | + }); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + let selected: PolicyMatch | undefined; | |
| 111 | + for (const match of firstMatchByOwner.values()) { | |
| 112 | + selected = moreRestrictive(selected, match); | |
| 113 | + } | |
| 114 | + return selected; | |
| 115 | +}; | |
| 116 | + | |
| 117 | +/** | |
| 118 | + * {@link resolveToolPolicy} lifted to an {@link EffectivePolicy}: an authored | |
| 119 | + * match wins, else the plugin default (`requiresApproval` → | |
| 120 | + * `require_approval`, otherwise `approve`). | |
| 121 | + */ | |
| 122 | +export const resolveEffectivePolicy = ( | |
| 123 | + toolId: string, | |
| 124 | + policies: readonly ToolPolicyRuleLike[], | |
| 125 | + ownerRank: (row: { readonly owner: string }) => number, | |
| 126 | + defaultRequiresApproval?: boolean, | |
| 127 | +): EffectivePolicy => { | |
| 128 | + const match = resolveToolPolicy(toolId, policies, ownerRank); | |
| 129 | + return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); | |
| 130 | +}; | |
| 131 | + | |
| 132 | +/** | |
| 133 | + * UI-preview variant over an ALREADY-SORTED rule list (a settings page's | |
| 134 | + * loaded policy list): same per-owner first-match + most-restrictive merge, | |
| 135 | + * treating rows without an owner as one flat list. | |
| 136 | + */ | |
| 137 | +export const effectivePolicyFromSorted = ( | |
| 138 | + toolId: string, | |
| 139 | + sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> & | |
| 140 | + Partial<Pick<ToolPolicy, "owner">>)[], | |
| 141 | + defaultRequiresApproval?: boolean, | |
| 142 | +): EffectivePolicy => { | |
| 143 | + const firstMatchByOwner = new Map<string, EffectivePolicy>(); | |
| 144 | + for (const p of sortedPolicies) { | |
| 145 | + const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__"; | |
| 146 | + if (firstMatchByOwner.has(ownerKey)) continue; | |
| 147 | + if (matchPattern(p.pattern, toolId)) { | |
| 148 | + firstMatchByOwner.set(ownerKey, { | |
| 149 | + action: p.action, | |
| 150 | + source: "user", | |
| 151 | + pattern: p.pattern, | |
| 152 | + policyId: p.id, | |
| 153 | + }); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + let selected: EffectivePolicy | undefined; | |
| 157 | + for (const match of firstMatchByOwner.values()) { | |
| 158 | + selected = moreRestrictive(selected, match); | |
| 159 | + } | |
| 160 | + return selected ?? liftPlugin(defaultRequiresApproval); | |
| 161 | +}; | |
| 162 | + | |
| 163 | +const compareProviderPolicyRule = ( | |
| 164 | + a: ToolPolicyProviderRule, | |
| 165 | + b: ToolPolicyProviderRule, | |
| 166 | +): number => { | |
| 167 | + if (a.position < b.position) return -1; | |
| 168 | + if (a.position > b.position) return 1; | |
| 169 | + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | |
| 170 | +}; | |
| 171 | + | |
| 172 | +/** | |
| 173 | + * Resolution for a list-only toolkit provider: first matching rule by | |
| 174 | + * position wins; NO matching rule blocks — the provider is a capability | |
| 175 | + * allowlist, and a tool it does not name is outside the boundary. | |
| 176 | + */ | |
| 177 | +export const resolveProviderPolicyFromRules = ( | |
| 178 | + toolId: string, | |
| 179 | + rules: readonly ToolPolicyProviderRule[], | |
| 180 | +): EffectivePolicy => { | |
| 181 | + for (const rule of [...rules].sort(compareProviderPolicyRule)) { | |
| 182 | + if (!matchPattern(rule.pattern, toolId)) continue; | |
| 183 | + return { | |
| 184 | + action: rule.action, | |
| 185 | + source: "user", | |
| 186 | + pattern: rule.pattern, | |
| 187 | + policyId: rule.id, | |
| 188 | + }; | |
| 189 | + } | |
| 190 | + return { | |
| 191 | + action: "block", | |
| 192 | + source: "user", | |
| 193 | + pattern: "*", | |
| 194 | + }; | |
| 195 | +}; | |
| 196 | + | |
| 197 | +/** | |
| 198 | + * The standard product implementation of `ExecutorAccess.toolPolicy`. | |
| 199 | + * | |
| 200 | + * With a plugin-registered provider (a toolkit session), the provider's own | |
| 201 | + * batched `prepare` / per-tool `resolve` / plain `list` allowlist governs. | |
| 202 | + * Otherwise the stored owner-scoped rows resolve under the binding's owner | |
| 203 | + * precedence with the most-restrictive merge and plugin-default fallback. | |
| 204 | + */ | |
| 205 | +export const standardToolPolicy = | |
| 206 | + (owners: readonly string[]) => | |
| 207 | + (sources: ToolPolicySources): Effect.Effect<ToolPolicyEvaluator, StorageFailure> => { | |
| 208 | + const provider: ToolPolicyProvider | null = sources.provider; | |
| 209 | + if (provider) { | |
| 210 | + if (provider.prepare) { | |
| 211 | + // Batched per-operation resolver: fetch all policy + connection | |
| 212 | + // state once, resolve every tool in the operation from the snapshot. | |
| 213 | + return provider.prepare().pipe( | |
| 214 | + Effect.map( | |
| 215 | + (resolve): ToolPolicyEvaluator => ({ | |
| 216 | + resolve: (input: ToolPolicyEvaluationInput) => | |
| 217 | + Effect.succeed( | |
| 218 | + resolve({ | |
| 219 | + toolId: input.toolId, | |
| 220 | + ...(input.defaultRequiresApproval === undefined | |
| 221 | + ? {} | |
| 222 | + : { defaultRequiresApproval: input.defaultRequiresApproval }), | |
| 223 | + }), | |
| 224 | + ), | |
| 225 | + }), | |
| 226 | + ), | |
| 227 | + ); | |
| 228 | + } | |
| 229 | + const perTool = provider.resolve; | |
| 230 | + if (perTool) { | |
| 231 | + return Effect.succeed({ | |
| 232 | + resolve: (input: ToolPolicyEvaluationInput) => | |
| 233 | + perTool({ | |
| 234 | + toolId: input.toolId, | |
| 235 | + ...(input.defaultRequiresApproval === undefined | |
| 236 | + ? {} | |
| 237 | + : { defaultRequiresApproval: input.defaultRequiresApproval }), | |
| 238 | + }), | |
| 239 | + }); | |
| 240 | + } | |
| 241 | + return provider.list().pipe( | |
| 242 | + Effect.map( | |
| 243 | + (rules): ToolPolicyEvaluator => ({ | |
| 244 | + resolve: (input) => Effect.succeed(resolveProviderPolicyFromRules(input.toolId, rules)), | |
| 245 | + }), | |
| 246 | + ), | |
| 247 | + ); | |
| 248 | + } | |
| 249 | + const ownerRank = (row: { readonly owner: string }): number => { | |
| 250 | + const rank = owners.indexOf(row.owner); | |
| 251 | + return rank === -1 ? owners.length : rank; | |
| 252 | + }; | |
| 253 | + return sources.policyRows.pipe( | |
| 254 | + Effect.map( | |
| 255 | + (rows): ToolPolicyEvaluator => ({ | |
| 256 | + resolve: (input) => | |
| 257 | + Effect.succeed( | |
| 258 | + resolveEffectivePolicy(input.toolId, rows, ownerRank, input.defaultRequiresApproval), | |
| 259 | + ), | |
| 260 | + }), | |
| 261 | + ), | |
| 262 | + ); | |
| 263 | + }; | |
| @@ -0,0 +1,54 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Explicit product-access postures for tests. These are the PRODUCTION rules | |
| 3 | +// from ./index, packaged so a test states its posture at the call site — | |
| 4 | +// `makeTestConfig` (from `@executor-js/sdk/testing`) has no default posture. | |
| 5 | +// | |
| 6 | +// `@executor-js/sdk`'s OWN tests import this entry too — resolved through | |
| 7 | +// the workspace ROOT devDependency on `@executor-js/product-access`, not | |
| 8 | +// through the sdk package itself, which declares no dependency on this | |
| 9 | +// package. That keeps the package graph acyclic (turbo rejects even | |
| 10 | +// dev-dependency cycles) while every test states a real product posture. | |
| 11 | +// --------------------------------------------------------------------------- | |
| 12 | + | |
| 13 | +import { Effect } from "effect"; | |
| 14 | + | |
| 15 | +import type { ExecutorAccess, OrgWriteAccess } from "@executor-js/sdk/core"; | |
| 16 | + | |
| 17 | +import { | |
| 18 | + memberAccess, | |
| 19 | + platformObserverAccess, | |
| 20 | + requestBoundMemberAccess, | |
| 21 | + workspaceServiceAccess, | |
| 22 | +} from "./index"; | |
| 23 | + | |
| 24 | +/** Explicit product postures for test bindings. */ | |
| 25 | +export const testAccess = { | |
| 26 | + /** A bound member with a static workspace-settings decision. */ | |
| 27 | + member: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => | |
| 28 | + memberAccess(workspaceWrites), | |
| 29 | + /** A bound member on a session-lifetime stack: the decision is read from | |
| 30 | + * the fiber-local `CurrentOrgWriteAccess` at every guarded sink. */ | |
| 31 | + requestBound: (): ExecutorAccess => requestBoundMemberAccess(), | |
| 32 | + /** A subject-less workspace-service binding (org partition only), with an | |
| 33 | + * optional denied workspace-settings decision for boot-convergence | |
| 34 | + * scenarios. */ | |
| 35 | + org: (workspaceWrites: OrgWriteAccess = "allowed"): ExecutorAccess => { | |
| 36 | + const base = workspaceServiceAccess(); | |
| 37 | + if (workspaceWrites === "allowed") return base; | |
| 38 | + return { | |
| 39 | + ...base, | |
| 40 | + settingsWrite: (target) => | |
| 41 | + Effect.succeed(target.kind === "owner" && target.owner === "user" ? "allowed" : "denied"), | |
| 42 | + }; | |
| 43 | + }, | |
| 44 | + /** The platform observer posture; `subject: false` is the production | |
| 45 | + * subject-less shape, the default keeps a bound subject's partitions for | |
| 46 | + * tests that combine a member binding with the platform capabilities. */ | |
| 47 | + platform: (options?: { readonly subject?: boolean }): ExecutorAccess => | |
| 48 | + options?.subject === false | |
| 49 | + ? platformObserverAccess() | |
| 50 | + : { | |
| 51 | + ...platformObserverAccess(), | |
| 52 | + owners: ["user", "org"], | |
| 53 | + }, | |
| 54 | +} as const; | |
| @@ -0,0 +1,27 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "Bundler", | |
| 6 | + "strict": true, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "outDir": "dist", | |
| 9 | + "rootDir": "src", | |
| 10 | + "declaration": true, | |
| 11 | + "declarationMap": true, | |
| 12 | + "sourceMap": true, | |
| 13 | + "lib": ["ES2022"], | |
| 14 | + "types": ["bun-types", "node"], | |
| 15 | + "noUnusedLocals": true, | |
| 16 | + "noImplicitOverride": true, | |
| 17 | + "plugins": [ | |
| 18 | + { | |
| 19 | + "name": "@effect/language-service", | |
| 20 | + "ignoreEffectSuggestionsInTscExitCode": true, | |
| 21 | + "ignoreEffectWarningsInTscExitCode": true, | |
| 22 | + "diagnosticSeverity": {} | |
| 23 | + } | |
| 24 | + ] | |
| 25 | + }, | |
| 26 | + "include": ["src/**/*.ts"] | |
| 27 | +} | |
| @@ -0,0 +1,14 @@ | ||
| 1 | +import { defineConfig } from "tsup"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + entry: { | |
| 5 | + index: "src/index.ts", | |
| 6 | + policy: "src/policy.ts", | |
| 7 | + testing: "src/testing.ts", | |
| 8 | + }, | |
| 9 | + format: ["esm"], | |
| 10 | + dts: false, | |
| 11 | + sourcemap: true, | |
| 12 | + clean: true, | |
| 13 | + external: [/^@executor-js\//, /^effect/, /^@effect\//], | |
| 14 | +}); | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + include: ["src/**/*.test.ts"], | |
| 6 | + }, | |
| 7 | +}); | |
| @@ -7,14 +7,17 @@ The default surface is `Promise`-based — plugins are built on [Effect](https:/ · 6 unmodified lines | ||
| 7 | 7 | ## Install |
| 8 | 8 | |
| 9 | 9 | ```sh |
| 10 | −bun add @executor-js/sdk | |
| 10 | +bun add @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59 | |
| 11 | 11 | # or |
| 12 | −npm install @executor-js/sdk | |
| 12 | +npm install @executor-js/sdk @executor-js/product-access effect@4.0.0-beta.59 | |
| 13 | 13 | ``` |
| 14 | 14 | |
| 15 | +`effect` is a required peer dependency (some package managers don't install peers automatically). `@executor-js/product-access` supplies the standard `access` postures `createExecutor` requires — every snippet below uses it. | |
| 16 | + | |
| 15 | 17 | ## Quick start |
| 16 | 18 | |
| 17 | 19 | ```ts |
| 20 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 18 | 21 | import { createExecutor } from "@executor-js/sdk"; |
| 19 | 22 | |
| 20 | 23 | const executor = await createExecutor({ |
| @@ -22,6 +25,9 @@ const executor = await createExecutor({ · 1 unmodified line | ||
| 22 | 25 | // `"accept-all"` auto-approves every prompt — fine for tests/automation. |
| 23 | 26 | // For an interactive host, pass a handler `(ctx) => Promise<ElicitationResponse>`. |
| 24 | 27 | onElicitation: "accept-all", |
| 28 | + // Required: the product's access decisions for this binding. The SDK ships | |
| 29 | + // no default posture; `@executor-js/product-access` has the standard ones. | |
| 30 | + access: workspaceServiceAccess(), | |
| 25 | 31 | }); |
| 26 | 32 | |
| 27 | 33 | const tools = await executor.tools.list(); |
| @@ -35,9 +41,13 @@ await executor.close(); · 7 unmodified lines | ||
| 35 | 41 | To invoke a tool once one is registered: |
| 36 | 42 | |
| 37 | 43 | ```ts |
| 44 | +import { workspaceServiceAccess } from "@executor-js/product-access"; | |
| 38 | 45 | import { createExecutor } from "@executor-js/sdk"; |
| 39 | 46 | |
| 40 | −const executor = await createExecutor({ onElicitation: "accept-all" }); | |
| 47 | +const executor = await createExecutor({ | |
| 48 | + onElicitation: "accept-all", | |
| 49 | + access: workspaceServiceAccess(), | |
| 50 | +}); | |
| 41 | 51 | |
| 42 | 52 | const tools = await executor.tools.list(); |
| 43 | 53 | const target = tools[0]; |
| @@ -110,6 +120,7 @@ export const memorySecretsPlugin = definePlugin((options?: MemorySecretsConfig) · 66 unmodified lines | ||
| 110 | 120 | const executor = await createExecutor({ |
| 111 | 121 | plugins: [memorySecretsPlugin({ initial: { greeting: "hello" } })] as const, |
| 112 | 122 | onElicitation: "accept-all", |
| 123 | + access: workspaceServiceAccess(), | |
| 113 | 124 | }); |
| 114 | 125 | |
| 115 | 126 | console.log(executor.memorySecrets.label); // "in-memory secrets" |
| @@ -0,0 +1,370 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// ExecutorAccess — the product/core boundary itself. | |
| 3 | +// | |
| 4 | +// `createExecutor` requires an access value, validates it against the | |
| 5 | +// binding, and ENFORCES its decisions without owning any of them. These | |
| 6 | +// tests pin the contract with the REAL product implementations | |
| 7 | +// (@executor-js/product-access, resolved via the workspace root | |
| 8 | +// devDependency): | |
| 9 | +// - a malformed access fails the boot loudly; | |
| 10 | +// - the settings decision is consulted live, never snapshotted; | |
| 11 | +// - `owners` really filters storage CRUD and the tool surfaces (org-only | |
| 12 | +// and personal-only views included — core forces no partition); | |
| 13 | +// - the `toolPolicy` hook is the ONLY policy authority core consults; | |
| 14 | +// - the tenant clamp holds even under an over-permissive access. | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +import { describe, expect, it } from "@effect/vitest"; | |
| 18 | +import { Effect, Ref } from "effect"; | |
| 19 | + | |
| 20 | +import { standardToolPolicy } from "@executor-js/product-access/policy"; | |
| 21 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 22 | + | |
| 23 | +import { executorAccessViolation, type ExecutorAccess } from "./access"; | |
| 24 | +import { createExecutor } from "./executor"; | |
| 25 | +import { | |
| 26 | + AuthTemplateSlug, | |
| 27 | + ConnectionName, | |
| 28 | + IntegrationSlug, | |
| 29 | + ProviderItemId, | |
| 30 | + ProviderKey, | |
| 31 | + Tenant, | |
| 32 | + ToolAddress, | |
| 33 | + ToolName, | |
| 34 | +} from "./ids"; | |
| 35 | +import { definePlugin } from "./plugin"; | |
| 36 | +import type { CredentialProvider } from "./provider"; | |
| 37 | +import { makeTestConfig } from "./testing"; | |
| 38 | + | |
| 39 | +const memberShaped: ExecutorAccess = testAccess.member(); | |
| 40 | + | |
| 41 | +/** A subject-bound binding whose product view is PERSONAL-ONLY — no | |
| 42 | + * production posture ships this shape yet, which is exactly the point: the | |
| 43 | + * contract must carry it without core forcing the org partition back in. */ | |
| 44 | +const personalOnlyAccess = (): ExecutorAccess => ({ | |
| 45 | + ...testAccess.member(), | |
| 46 | + owners: ["user"], | |
| 47 | + toolPolicy: standardToolPolicy(["user"]), | |
| 48 | +}); | |
| 49 | + | |
| 50 | +/** An org-only view for a subject-BOUND member (narrower than identity). */ | |
| 51 | +const orgOnlyMemberAccess = (): ExecutorAccess => ({ | |
| 52 | + ...testAccess.member(), | |
| 53 | + owners: ["org"], | |
| 54 | + toolPolicy: standardToolPolicy(["org"]), | |
| 55 | +}); | |
| 56 | + | |
| 57 | +const memoryProvider = (): CredentialProvider => { | |
| 58 | + const store = new Map<string, string>(); | |
| 59 | + return { | |
| 60 | + key: ProviderKey.make("memory"), | |
| 61 | + writable: true, | |
| 62 | + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), | |
| 63 | + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), | |
| 64 | + delete: (id) => Effect.sync(() => void store.delete(String(id))), | |
| 65 | + has: (id) => Effect.sync(() => store.has(String(id))), | |
| 66 | + list: () => | |
| 67 | + Effect.sync(() => | |
| 68 | + Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key })), | |
| 69 | + ), | |
| 70 | + }; | |
| 71 | +}; | |
| 72 | + | |
| 73 | +const INTEG = IntegrationSlug.make("vercel"); | |
| 74 | +const TEMPLATE = AuthTemplateSlug.make("apiKey"); | |
| 75 | + | |
| 76 | +const demoPlugin = definePlugin(() => ({ | |
| 77 | + id: "demo" as const, | |
| 78 | + credentialProviders: [memoryProvider()], | |
| 79 | + storage: () => ({}), | |
| 80 | + resolveTools: () => | |
| 81 | + Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), | |
| 82 | + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), | |
| 83 | + extension: (ctx) => ({ | |
| 84 | + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), | |
| 85 | + }), | |
| 86 | +}))(); | |
| 87 | + | |
| 88 | +/** Seed one org and one user connection (with their tools) plus one org and | |
| 89 | + * one user policy row, through a full-view member executor. */ | |
| 90 | +const seedWorkspace = Effect.fn("seedWorkspace")(function* ( | |
| 91 | + config: ReturnType<typeof makeTestConfig<readonly [typeof demoPlugin]>>, | |
| 92 | +) { | |
| 93 | + const admin = yield* createExecutor(config); | |
| 94 | + yield* Effect.addFinalizer(() => admin.close().pipe(Effect.ignore)); | |
| 95 | + yield* admin.demo.seed(); | |
| 96 | + yield* admin.connections.create({ | |
| 97 | + owner: "org", | |
| 98 | + name: ConnectionName.make("shared"), | |
| 99 | + integration: INTEG, | |
| 100 | + template: TEMPLATE, | |
| 101 | + value: "org-token", | |
| 102 | + }); | |
| 103 | + yield* admin.connections.create({ | |
| 104 | + owner: "user", | |
| 105 | + name: ConnectionName.make("mine"), | |
| 106 | + integration: INTEG, | |
| 107 | + template: TEMPLATE, | |
| 108 | + value: "user-token", | |
| 109 | + }); | |
| 110 | + yield* admin.policies.create({ owner: "org", pattern: "github.*", action: "block" }); | |
| 111 | + yield* admin.policies.create({ owner: "user", pattern: "linear.*", action: "approve" }); | |
| 112 | + return admin; | |
| 113 | +}); | |
| 114 | + | |
| 115 | +describe("executorAccessViolation", () => { | |
| 116 | + it("accepts the production postures against their bindings", () => { | |
| 117 | + expect(executorAccessViolation(memberShaped, "subject-1")).toBeNull(); | |
| 118 | + expect(executorAccessViolation(testAccess.org(), null)).toBeNull(); | |
| 119 | + expect(executorAccessViolation(testAccess.platform({ subject: false }), null)).toBeNull(); | |
| 120 | + // Personal-only is a VALID product shape: core mandates no partition. | |
| 121 | + expect(executorAccessViolation(personalOnlyAccess(), "subject-1")).toBeNull(); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + it("rejects an empty owners list", () => { | |
| 125 | + expect(executorAccessViolation({ ...memberShaped, owners: [] }, "subject-1")).toContain( | |
| 126 | + "must not be empty", | |
| 127 | + ); | |
| 128 | + }); | |
| 129 | + | |
| 130 | + it("rejects a duplicated owner", () => { | |
| 131 | + expect(executorAccessViolation({ ...memberShaped, owners: ["user", "user"] }, "s")).toContain( | |
| 132 | + "repeat", | |
| 133 | + ); | |
| 134 | + }); | |
| 135 | + | |
| 136 | + it("rejects a user partition on a subject-less binding", () => { | |
| 137 | + expect(executorAccessViolation(memberShaped, null)).toContain("no subject"); | |
| 138 | + }); | |
| 139 | +}); | |
| 140 | + | |
| 141 | +describe("createExecutor access validation", () => { | |
| 142 | + it.effect("fails the boot when the access does not fit the binding", () => | |
| 143 | + Effect.gen(function* () { | |
| 144 | + const config = makeTestConfig({ access: testAccess.org(), subject: null }); | |
| 145 | + const error = yield* Effect.flip( | |
| 146 | + // member-shaped access (user partition) on a subject-less binding. | |
| 147 | + createExecutor({ ...config, access: testAccess.member() }), | |
| 148 | + ); | |
| 149 | + expect(error).toMatchObject({ | |
| 150 | + _tag: "StorageError", | |
| 151 | + message: expect.stringContaining("no subject"), | |
| 152 | + }); | |
| 153 | + }), | |
| 154 | + ); | |
| 155 | +}); | |
| 156 | + | |
| 157 | +describe("settingsWrite is consulted live", () => { | |
| 158 | + it.effect("a flipped decision applies to the next guarded call, not a snapshot", () => | |
| 159 | + Effect.gen(function* () { | |
| 160 | + const decision = yield* Ref.make<"allowed" | "denied">("allowed"); | |
| 161 | + const config = makeTestConfig({ | |
| 162 | + access: { | |
| 163 | + ...testAccess.member(), | |
| 164 | + settingsWrite: (target) => | |
| 165 | + target.kind === "owner" && target.owner === "user" | |
| 166 | + ? Effect.succeed("allowed" as const) | |
| 167 | + : Ref.get(decision), | |
| 168 | + }, | |
| 169 | + }); | |
| 170 | + const executor = yield* createExecutor(config); | |
| 171 | + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | |
| 172 | + | |
| 173 | + const created = yield* executor.policies.create({ | |
| 174 | + owner: "org", | |
| 175 | + pattern: "vercel.*", | |
| 176 | + action: "block", | |
| 177 | + }); | |
| 178 | + expect(created.pattern).toBe("vercel.*"); | |
| 179 | + | |
| 180 | + yield* Ref.set(decision, "denied"); | |
| 181 | + const error = yield* Effect.flip( | |
| 182 | + executor.policies.create({ owner: "org", pattern: "github.*", action: "block" }), | |
| 183 | + ); | |
| 184 | + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); | |
| 185 | + | |
| 186 | + // Personal-scope writes stay open under the denied decision. | |
| 187 | + const personal = yield* executor.policies.create({ | |
| 188 | + owner: "user", | |
| 189 | + pattern: "linear.*", | |
| 190 | + action: "approve", | |
| 191 | + }); | |
| 192 | + expect(personal.owner).toBe("user"); | |
| 193 | + }).pipe(Effect.scoped), | |
| 194 | + ); | |
| 195 | +}); | |
| 196 | + | |
| 197 | +describe("owners really filters storage and tool surfaces", () => { | |
| 198 | + it.effect("an org-only member view sees no user rows anywhere", () => | |
| 199 | + Effect.gen(function* () { | |
| 200 | + const config = makeTestConfig({ | |
| 201 | + access: testAccess.member(), | |
| 202 | + plugins: [demoPlugin] as const, | |
| 203 | + }); | |
| 204 | + yield* seedWorkspace(config); | |
| 205 | + | |
| 206 | + const orgOnly = yield* createExecutor({ ...config, access: orgOnlyMemberAccess() }); | |
| 207 | + yield* Effect.addFinalizer(() => orgOnly.close().pipe(Effect.ignore)); | |
| 208 | + | |
| 209 | + const policies = yield* orgOnly.policies.list(); | |
| 210 | + expect(policies.map((p) => p.owner)).toEqual(["org"]); | |
| 211 | + | |
| 212 | + const connections = yield* orgOnly.connections.list(); | |
| 213 | + expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["org:shared"]); | |
| 214 | + | |
| 215 | + const tools = yield* orgOnly.tools.list(); | |
| 216 | + const dynamic = tools.filter((t) => !t.static); | |
| 217 | + expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["org:shared"]); | |
| 218 | + | |
| 219 | + // The excluded partition is not writable either: the settings gate | |
| 220 | + // (personal stays open) passes, and the STORAGE clamp refuses. | |
| 221 | + const error = yield* Effect.flip( | |
| 222 | + orgOnly.policies.create({ owner: "user", pattern: "x.*", action: "approve" }), | |
| 223 | + ); | |
| 224 | + expect(error).toMatchObject({ | |
| 225 | + _tag: "StorageError", | |
| 226 | + message: expect.stringContaining("does not include"), | |
| 227 | + }); | |
| 228 | + }).pipe(Effect.scoped), | |
| 229 | + ); | |
| 230 | + | |
| 231 | + it.effect("a personal-only view sees no org rows anywhere", () => | |
| 232 | + Effect.gen(function* () { | |
| 233 | + const config = makeTestConfig({ | |
| 234 | + access: testAccess.member(), | |
| 235 | + plugins: [demoPlugin] as const, | |
| 236 | + }); | |
| 237 | + yield* seedWorkspace(config); | |
| 238 | + | |
| 239 | + const personal = yield* createExecutor({ ...config, access: personalOnlyAccess() }); | |
| 240 | + yield* Effect.addFinalizer(() => personal.close().pipe(Effect.ignore)); | |
| 241 | + | |
| 242 | + const policies = yield* personal.policies.list(); | |
| 243 | + expect(policies.map((p) => p.owner)).toEqual(["user"]); | |
| 244 | + | |
| 245 | + const connections = yield* personal.connections.list(); | |
| 246 | + expect(connections.map((c) => `${c.owner}:${c.name}`)).toEqual(["user:mine"]); | |
| 247 | + | |
| 248 | + const tools = yield* personal.tools.list(); | |
| 249 | + const dynamic = tools.filter((t) => !t.static); | |
| 250 | + expect(dynamic.map((t) => `${t.owner}:${t.connection}`)).toEqual(["user:mine"]); | |
| 251 | + | |
| 252 | + // Invoking a tool over the hidden org connection fails as not-found — | |
| 253 | + // the row is invisible, not merely blocked. | |
| 254 | + const invokeError = yield* Effect.flip( | |
| 255 | + personal.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), | |
| 256 | + ); | |
| 257 | + expect(invokeError).toMatchObject({ _tag: "ToolNotFoundError" }); | |
| 258 | + | |
| 259 | + // Shared-catalog (org-partition) writes refuse loudly at storage. | |
| 260 | + const writeError = yield* Effect.flip( | |
| 261 | + personal.policies.create({ owner: "org", pattern: "x.*", action: "block" }), | |
| 262 | + ); | |
| 263 | + expect(writeError).toMatchObject({ | |
| 264 | + _tag: "StorageError", | |
| 265 | + message: expect.stringContaining("does not include"), | |
| 266 | + }); | |
| 267 | + }).pipe(Effect.scoped), | |
| 268 | + ); | |
| 269 | +}); | |
| 270 | + | |
| 271 | +describe("the product toolPolicy hook is the only policy authority", () => { | |
| 272 | + it.effect("a hook that blocks everything gates execute and list, whatever rows say", () => | |
| 273 | + Effect.gen(function* () { | |
| 274 | + const config = makeTestConfig({ | |
| 275 | + access: testAccess.member(), | |
| 276 | + plugins: [demoPlugin] as const, | |
| 277 | + }); | |
| 278 | + yield* seedWorkspace(config); | |
| 279 | + | |
| 280 | + const blockAll: ExecutorAccess = { | |
| 281 | + ...testAccess.member(), | |
| 282 | + toolPolicy: () => | |
| 283 | + Effect.succeed({ | |
| 284 | + resolve: () => | |
| 285 | + Effect.succeed({ action: "block" as const, source: "user" as const, pattern: "*" }), | |
| 286 | + }), | |
| 287 | + }; | |
| 288 | + const executor = yield* createExecutor({ ...config, access: blockAll }); | |
| 289 | + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | |
| 290 | + | |
| 291 | + const tools = yield* executor.tools.list(); | |
| 292 | + expect(tools.filter((t) => !t.static)).toHaveLength(0); | |
| 293 | + const error = yield* Effect.flip( | |
| 294 | + executor.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), | |
| 295 | + ); | |
| 296 | + expect(error).toMatchObject({ _tag: "ToolBlockedError" }); | |
| 297 | + }).pipe(Effect.scoped), | |
| 298 | + ); | |
| 299 | + | |
| 300 | + it.effect("a hook that approves everything overrides a stored org block", () => | |
| 301 | + Effect.gen(function* () { | |
| 302 | + const config = makeTestConfig({ | |
| 303 | + access: testAccess.member(), | |
| 304 | + plugins: [demoPlugin] as const, | |
| 305 | + }); | |
| 306 | + const admin = yield* seedWorkspace(config); | |
| 307 | + // A stored rule that would block the tool under the standard product | |
| 308 | + // resolution — proving core itself never consults the rows. | |
| 309 | + yield* admin.policies.create({ owner: "org", pattern: "*", action: "block" }); | |
| 310 | + | |
| 311 | + const approveAll: ExecutorAccess = { | |
| 312 | + ...testAccess.member(), | |
| 313 | + toolPolicy: () => | |
| 314 | + Effect.succeed({ | |
| 315 | + resolve: () => | |
| 316 | + Effect.succeed({ | |
| 317 | + action: "approve" as const, | |
| 318 | + source: "plugin-default" as const, | |
| 319 | + }), | |
| 320 | + }), | |
| 321 | + }; | |
| 322 | + const executor = yield* createExecutor({ ...config, access: approveAll }); | |
| 323 | + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); | |
| 324 | + | |
| 325 | + const result = yield* executor.execute( | |
| 326 | + ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), | |
| 327 | + {}, | |
| 328 | + ); | |
| 329 | + expect(result).toMatchObject({ ran: "deploy" }); | |
| 330 | + }).pipe(Effect.scoped), | |
| 331 | + ); | |
| 332 | +}); | |
| 333 | + | |
| 334 | +describe("tenant clamp is not product-configurable", () => { | |
| 335 | + it.effect("an over-permissive access never reads another tenant's rows", () => | |
| 336 | + Effect.gen(function* () { | |
| 337 | + const config = makeTestConfig({ | |
| 338 | + access: testAccess.member(), | |
| 339 | + plugins: [demoPlugin] as const, | |
| 340 | + }); | |
| 341 | + yield* seedWorkspace(config); | |
| 342 | + | |
| 343 | + // Same database, different tenant, maximally permissive product view. | |
| 344 | + const intruder = yield* createExecutor({ | |
| 345 | + ...config, | |
| 346 | + tenant: Tenant.make("other-tenant"), | |
| 347 | + access: { | |
| 348 | + ...testAccess.member(), | |
| 349 | + toolPolicy: () => | |
| 350 | + Effect.succeed({ | |
| 351 | + resolve: () => | |
| 352 | + Effect.succeed({ | |
| 353 | + action: "approve" as const, | |
| 354 | + source: "plugin-default" as const, | |
| 355 | + }), | |
| 356 | + }), | |
| 357 | + }, | |
| 358 | + }); | |
| 359 | + yield* Effect.addFinalizer(() => intruder.close().pipe(Effect.ignore)); | |
| 360 | + | |
| 361 | + expect(yield* intruder.policies.list()).toHaveLength(0); | |
| 362 | + expect(yield* intruder.connections.list()).toHaveLength(0); | |
| 363 | + expect((yield* intruder.tools.list()).filter((t) => !t.static)).toHaveLength(0); | |
| 364 | + const error = yield* Effect.flip( | |
| 365 | + intruder.execute(ToolAddress.make(`tools.${INTEG}.org.shared.deploy`), {}), | |
| 366 | + ); | |
| 367 | + expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); | |
| 368 | + }).pipe(Effect.scoped), | |
| 369 | + ); | |
| 370 | +}); | |
| @@ -0,0 +1,215 @@ | ||
| 1 | +import { Context, Effect, Ref } from "effect"; | |
| 2 | + | |
| 3 | +import type { ToolPolicyRow } from "./core-schema"; | |
| 4 | +import type { StorageFailure } from "./fuma-runtime"; | |
| 5 | +import type { Owner } from "./ids"; | |
| 6 | +import type { ToolPolicyProvider } from "./plugin"; | |
| 7 | +import type { EffectivePolicy } from "./policies"; | |
| 8 | + | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | +// Product access — the defined interface through which the PRODUCT hands the | |
| 11 | +// executor its personal/organization access decisions. | |
| 12 | +// | |
| 13 | +// Core deliberately owns no product rule here: it does not know what a role | |
| 14 | +// is, which member is an admin, or why a deployment has no role model at all. | |
| 15 | +// Core defines the decision vocabulary (this module), consults the supplied | |
| 16 | +// decisions at its enforcement sinks, and keeps enforcing the security | |
| 17 | +// invariants that are NOT product-configurable: tenant isolation and the | |
| 18 | +// storage owner policy (owner-policy.ts), which clamp whatever the product | |
| 19 | +// supplies. `ExecutorConfig.access` is required — there is no default posture | |
| 20 | +// in core, so every composition root states its product's rules explicitly. | |
| 21 | +// --------------------------------------------------------------------------- | |
| 22 | + | |
| 23 | +/** Workspace-settings authorization bound to the currently executing request. */ | |
| 24 | +export type OrgWriteAccess = "allowed" | "denied"; | |
| 25 | + | |
| 26 | +/** A single product authorization answer core enforces verbatim. */ | |
| 27 | +export type AccessDecision = "allowed" | "denied"; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * The target of a user-intent settings mutation, as core describes it to the | |
| 31 | + * product's {@link ExecutorAccess.settingsWrite} rule: | |
| 32 | + * | |
| 33 | + * - `{ kind: "owner", owner }` — an owner-scoped settings row (a connection, | |
| 34 | + * tool policy, OAuth client, …) filed under that partition. | |
| 35 | + * - `{ kind: "workspace" }` — a tenant-shared surface (the integration | |
| 36 | + * catalog and other whole-workspace settings). | |
| 37 | + */ | |
| 38 | +export type SettingsWriteTarget = | |
| 39 | + | { readonly kind: "owner"; readonly owner: Owner } | |
| 40 | + | { readonly kind: "workspace" }; | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * Structural view capabilities the product grants a binding. Core maps each | |
| 44 | + * to a non-overridable mechanism: | |
| 45 | + * | |
| 46 | + * - `adminReads` — expose `executor.admin`, the ONLY surface whose reads are | |
| 47 | + * tenant-wide. The widened context is read-only by construction | |
| 48 | + * (owner-policy reach mechanics), regardless of what the product says. | |
| 49 | + * - `storageWrites` — `"denied"` makes the WHOLE executor read-only at the | |
| 50 | + * storage boundary (every create/update/delete on every guarded table is | |
| 51 | + * refused, and catalog re-sync is skipped). The platform observer posture | |
| 52 | + * combines both. | |
| 53 | + */ | |
| 54 | +export interface ExecutorAccessCapabilities { | |
| 55 | + readonly adminReads: boolean; | |
| 56 | + readonly storageWrites: AccessDecision; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** One effective-policy question, as core asks the product's evaluator. */ | |
| 60 | +export interface ToolPolicyEvaluationInput { | |
| 61 | + /** Normalized policy id: a static tool's address, or the 4-segment | |
| 62 | + * `<integration>.<owner>.<connection>.<tool>` form. */ | |
| 63 | + readonly toolId: string; | |
| 64 | + /** The tool's own `requiresApproval` annotation — the fallback material a | |
| 65 | + * product resolution may lift when no authored rule matches. */ | |
| 66 | + readonly defaultRequiresApproval?: boolean | undefined; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * A per-operation effective-policy evaluator returned by | |
| 71 | + * {@link ExecutorAccess.toolPolicy}. Built once per surface operation (one | |
| 72 | + * tools list / schema read / invocation / post-approval recheck) so a product | |
| 73 | + * implementation can batch its underlying reads; `resolve` is then asked once | |
| 74 | + * per tool. Core ENFORCES whatever comes back — `block` refuses the call, | |
| 75 | + * `require_approval` gates it behind an elicitation — but never decides it. | |
| 76 | + */ | |
| 77 | +export interface ToolPolicyEvaluator { | |
| 78 | + readonly resolve: ( | |
| 79 | + input: ToolPolicyEvaluationInput, | |
| 80 | + ) => Effect.Effect<EffectivePolicy, StorageFailure>; | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * The rule material core hands the product's {@link ExecutorAccess.toolPolicy} | |
| 85 | + * hook for one operation. Both members are the product's to consult or | |
| 86 | + * ignore; core pre-selects nothing. | |
| 87 | + */ | |
| 88 | +export interface ToolPolicySources { | |
| 89 | + /** | |
| 90 | + * The stored, owner-scoped `tool_policy` rows visible to this binding — | |
| 91 | + * a LAZY effect, fetched only if the product's resolution reads it. | |
| 92 | + */ | |
| 93 | + readonly policyRows: Effect.Effect<readonly ToolPolicyRow[], StorageFailure>; | |
| 94 | + /** | |
| 95 | + * The plugin-registered policy provider, when a plugin opted this executor | |
| 96 | + * instance into a session rule source (a toolkit's capability set), else | |
| 97 | + * null. Toolkit sessions also hide catalog entries that grant no tools; | |
| 98 | + * that presentation follows the provider's presence (part of the | |
| 99 | + * `ToolPolicyProvider` plugin contract), while HOW its rules resolve — | |
| 100 | + * including the empty-match default — is decided here, by the product. | |
| 101 | + */ | |
| 102 | + readonly provider: ToolPolicyProvider | null; | |
| 103 | +} | |
| 104 | + | |
| 105 | +/** | |
| 106 | + * Product-owned access decisions for one executor binding. Supplied by the | |
| 107 | + * host's composition root (see `@executor-js/product-access` for the product | |
| 108 | + * rule implementations); enforced — never decided — by core. | |
| 109 | + * | |
| 110 | + * Shape invariants, validated at `createExecutor` (violations fail the boot): | |
| 111 | + * - `owners` is a non-empty, duplicate-free subset of `["user", "org"]`; | |
| 112 | + * - `"user"` may appear only when the executor binds a subject. | |
| 113 | + * | |
| 114 | + * Core's non-overridable clamps apply to every decision: the tenant clause | |
| 115 | + * is never relaxed, user rows only ever resolve to the bound subject, | |
| 116 | + * tenant-reach contexts are read-only (or delete-only, for the internal | |
| 117 | + * removal cascade), and a mid-run tool is never interrupted by a policy | |
| 118 | + * change — `block` means "don't start new runs". | |
| 119 | + * | |
| 120 | + * PAUSED EXECUTIONS: a pause/resume boundary re-evaluates the | |
| 121 | + * request-bound `settingsWrite` decision as the RESUMING principal (via | |
| 122 | + * {@link CurrentOrgWriteAccess}), and the post-approval recheck re-resolves | |
| 123 | + * the tool policy and re-reads rows — but storage reads and credential | |
| 124 | + * resolution still run under the STARTER's binding, whose executor instance | |
| 125 | + * owns the paused fiber. | |
| 126 | + */ | |
| 127 | +export interface ExecutorAccess { | |
| 128 | + /** | |
| 129 | + * ROW VISIBILITY AND WRITE AUTHORIZATION: the owner partitions this | |
| 130 | + * binding sees and may write, in precedence order (the first entry shadows | |
| 131 | + * later ones on plugin-storage reads, ranks first in policy listings, and | |
| 132 | + * is the partition static tools present under). Carried onto every storage | |
| 133 | + * context: reads, updates and deletes are FILTERED to these partitions and | |
| 134 | + * creates outside them are refused — an org-only or personal-only product | |
| 135 | + * view really is one, across storage CRUD and the tool surfaces built on | |
| 136 | + * it. Core contributes no partition of its own. | |
| 137 | + */ | |
| 138 | + readonly owners: readonly Owner[]; | |
| 139 | + /** | |
| 140 | + * USER-INTENT SETTINGS AUTHORIZATION, including the owner-target rule. | |
| 141 | + * Consulted live at every guarded sink (never cached by core, so a | |
| 142 | + * request-bound implementation is re-read after pauses and on resume); | |
| 143 | + * `"denied"` surfaces as `OrgWriteDeniedError`. Operational writes a | |
| 144 | + * denied member's usage implies (token refresh, catalog re-sync) do not | |
| 145 | + * pass through this gate — they are bounded by `owners` and the storage | |
| 146 | + * clamps instead. | |
| 147 | + */ | |
| 148 | + readonly settingsWrite: (target: SettingsWriteTarget) => Effect.Effect<AccessDecision>; | |
| 149 | + /** View capabilities — see {@link ExecutorAccessCapabilities}. */ | |
| 150 | + readonly capabilities: ExecutorAccessCapabilities; | |
| 151 | + /** | |
| 152 | + * EFFECTIVE TOOL-POLICY EVALUATION. Called once per surface operation with | |
| 153 | + * this binding's rule material; the returned evaluator answers every | |
| 154 | + * per-tool question in that operation. Owner ranking, cross-owner merging, | |
| 155 | + * plugin-default fallback and the capability-allowlist default all live in | |
| 156 | + * the product implementation. | |
| 157 | + */ | |
| 158 | + readonly toolPolicy: ( | |
| 159 | + sources: ToolPolicySources, | |
| 160 | + ) => Effect.Effect<ToolPolicyEvaluator, StorageFailure>; | |
| 161 | +} | |
| 162 | + | |
| 163 | +/** | |
| 164 | + * Validate an {@link ExecutorAccess} against the executor's `{ tenant, | |
| 165 | + * subject }` binding. Returns null when well-formed; a violation is a | |
| 166 | + * programmer error at a composition root and fails fast via the returned | |
| 167 | + * message so `createExecutor` can surface it as a startup failure. | |
| 168 | + */ | |
| 169 | +export const executorAccessViolation = ( | |
| 170 | + access: ExecutorAccess, | |
| 171 | + subject: string | null, | |
| 172 | +): string | null => { | |
| 173 | + const owners = access.owners; | |
| 174 | + if (owners.length === 0) return "ExecutorAccess.owners must not be empty."; | |
| 175 | + if (new Set(owners).size !== owners.length) { | |
| 176 | + return "ExecutorAccess.owners must not repeat an owner."; | |
| 177 | + } | |
| 178 | + if (owners.includes("user") && subject == null) { | |
| 179 | + return 'ExecutorAccess.owners includes "user" but the executor has no subject.'; | |
| 180 | + } | |
| 181 | + return null; | |
| 182 | +}; | |
| 183 | + | |
| 184 | +/** | |
| 185 | + * Fiber-local workspace-settings authorization for request-bound executors. | |
| 186 | + * | |
| 187 | + * This is the decision CARRIER, not a decision rule: a session host stamps it | |
| 188 | + * from each freshly authenticated request, the execution engine snapshots the | |
| 189 | + * state onto a paused execution and re-stamps `current` from the resuming (or | |
| 190 | + * joining) principal's fiber, and a request-bound `ExecutorAccess` | |
| 191 | + * implementation reads it at every guarded sink. The denied default makes a | |
| 192 | + * missing request binding fail closed. Executors whose product supplies a | |
| 193 | + * static decision never consult this reference. | |
| 194 | + */ | |
| 195 | +export interface OrgWriteAccessState { | |
| 196 | + /** Mutable value inherited by a detached execution and refreshed on resume. */ | |
| 197 | + readonly current: Ref.Ref<OrgWriteAccess>; | |
| 198 | +} | |
| 199 | + | |
| 200 | +/** Create an isolated request/execution authorization state. */ | |
| 201 | +export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ | |
| 202 | + current: Ref.makeUnsafe(access), | |
| 203 | +}); | |
| 204 | + | |
| 205 | +/** Request-local workspace-write authorization inherited by child fibers. */ | |
| 206 | +export const CurrentOrgWriteAccess = Context.Reference<OrgWriteAccessState>( | |
| 207 | + "@executor-js/sdk/CurrentOrgWriteAccess", | |
| 208 | + { defaultValue: () => makeOrgWriteAccessState("denied") }, | |
| 209 | +); | |
| 210 | + | |
| 211 | +/** Read the effective authorization at a workspace-write sink. */ | |
| 212 | +export const currentOrgWriteAccess: Effect.Effect<OrgWriteAccess> = Effect.gen(function* () { | |
| 213 | + const state = yield* CurrentOrgWriteAccess; | |
| 214 | + return yield* Ref.get(state.current); | |
| 215 | +}); | |
| @@ -0,0 +1,138 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Ref, Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { ElicitationResponse } from "./elicitation"; | |
| 5 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; | |
| 6 | +import { definePlugin, tool } from "./plugin"; | |
| 7 | +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; | |
| 8 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 9 | + | |
| 10 | +const integration = IntegrationSlug.make("approvalcheck"); | |
| 11 | +const connection = ConnectionName.make("main"); | |
| 12 | +const staticAddress = ToolAddress.make("approvalcheck.control.run"); | |
| 13 | +const dynamicAddress = ToolAddress.make("tools.approvalcheck.org.main.run"); | |
| 14 | + | |
| 15 | +const setup = Effect.gen(function* () { | |
| 16 | + const invocations = yield* Ref.make(0); | |
| 17 | + const recordInvocation = Ref.update(invocations, (count) => count + 1).pipe( | |
| 18 | + Effect.as("executed"), | |
| 19 | + ); | |
| 20 | + const plugin = definePlugin(() => ({ | |
| 21 | + id: "approval-check" as const, | |
| 22 | + storage: () => ({}), | |
| 23 | + staticIntegrations: () => [ | |
| 24 | + { | |
| 25 | + id: "approvalcheck.control", | |
| 26 | + kind: "control" as const, | |
| 27 | + name: "Approval check", | |
| 28 | + tools: [ | |
| 29 | + tool({ | |
| 30 | + name: "run", | |
| 31 | + description: "Record an invocation after approval.", | |
| 32 | + annotations: { requiresApproval: true }, | |
| 33 | + inputSchema: Schema.toStandardSchemaV1( | |
| 34 | + Schema.toStandardJSONSchemaV1(Schema.Struct({})), | |
| 35 | + ), | |
| 36 | + execute: () => recordInvocation, | |
| 37 | + }), | |
| 38 | + ], | |
| 39 | + }, | |
| 40 | + ], | |
| 41 | + resolveTools: () => | |
| 42 | + Effect.succeed({ | |
| 43 | + tools: [ | |
| 44 | + { | |
| 45 | + name: ToolName.make("run"), | |
| 46 | + description: "Record an invocation after approval.", | |
| 47 | + annotations: { requiresApproval: true }, | |
| 48 | + }, | |
| 49 | + ], | |
| 50 | + }), | |
| 51 | + invokeTool: () => recordInvocation, | |
| 52 | + extension: (ctx) => ({ | |
| 53 | + register: () => | |
| 54 | + ctx.core.integrations.register({ | |
| 55 | + slug: integration, | |
| 56 | + description: "Approval check", | |
| 57 | + config: {}, | |
| 58 | + }), | |
| 59 | + }), | |
| 60 | + })); | |
| 61 | + const executor = yield* makeTestExecutor({ | |
| 62 | + access: testAccess.member(), | |
| 63 | + plugins: [plugin(), memoryCredentialsPlugin()] as const, | |
| 64 | + }); | |
| 65 | + yield* executor["approval-check"].register(); | |
| 66 | + yield* executor.connections.create({ | |
| 67 | + owner: "org", | |
| 68 | + integration, | |
| 69 | + name: connection, | |
| 70 | + template: AuthTemplateSlug.make("none"), | |
| 71 | + value: "synthetic-approval-test-key", | |
| 72 | + }); | |
| 73 | + return { executor, invocations }; | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe("access changes while approval is pending", () => { | |
| 77 | + for (const address of [staticAddress, dynamicAddress]) { | |
| 78 | + it.effect(`blocks ${address} if policy changes before acceptance`, () => | |
| 79 | + Effect.gen(function* () { | |
| 80 | + const { executor, invocations } = yield* setup; | |
| 81 | + const error = yield* Effect.flip( | |
| 82 | + executor.execute( | |
| 83 | + address, | |
| 84 | + {}, | |
| 85 | + { | |
| 86 | + onElicitation: () => | |
| 87 | + executor.policies | |
| 88 | + .create({ owner: "org", pattern: "approvalcheck.*", action: "block" }) | |
| 89 | + .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))), | |
| 90 | + }, | |
| 91 | + ), | |
| 92 | + ); | |
| 93 | + expect(error).toMatchObject({ _tag: "ToolBlockedError" }); | |
| 94 | + expect(yield* Ref.get(invocations)).toBe(0); | |
| 95 | + }), | |
| 96 | + ); | |
| 97 | + } | |
| 98 | + | |
| 99 | + it.effect("does not invoke a connection disconnected before acceptance", () => | |
| 100 | + Effect.gen(function* () { | |
| 101 | + const { executor, invocations } = yield* setup; | |
| 102 | + const error = yield* Effect.flip( | |
| 103 | + executor.execute( | |
| 104 | + dynamicAddress, | |
| 105 | + {}, | |
| 106 | + { | |
| 107 | + onElicitation: () => | |
| 108 | + executor.connections | |
| 109 | + .remove({ owner: "org", integration, name: connection }) | |
| 110 | + .pipe(Effect.orDie, Effect.as(ElicitationResponse.make({ action: "accept" }))), | |
| 111 | + }, | |
| 112 | + ), | |
| 113 | + ); | |
| 114 | + expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); | |
| 115 | + expect(yield* Ref.get(invocations)).toBe(0); | |
| 116 | + }), | |
| 117 | + ); | |
| 118 | + | |
| 119 | + it.effect("accepts once when access stays valid", () => | |
| 120 | + Effect.gen(function* () { | |
| 121 | + const { executor, invocations } = yield* setup; | |
| 122 | + const approvals = yield* Ref.make(0); | |
| 123 | + const result = yield* executor.execute( | |
| 124 | + dynamicAddress, | |
| 125 | + {}, | |
| 126 | + { | |
| 127 | + onElicitation: () => | |
| 128 | + Ref.update(approvals, (count) => count + 1).pipe( | |
| 129 | + Effect.as(ElicitationResponse.make({ action: "accept" })), | |
| 130 | + ), | |
| 131 | + }, | |
| 132 | + ); | |
| 133 | + expect(result).toBe("executed"); | |
| 134 | + expect(yield* Ref.get(invocations)).toBe(1); | |
| 135 | + expect(yield* Ref.get(approvals)).toBe(1); | |
| 136 | + }), | |
| 137 | + ); | |
| 138 | +}); | |
| @@ -0,0 +1,90 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Ref } from "effect"; | |
| 3 | + | |
| 4 | +import { ElicitationResponse } from "./elicitation"; | |
| 5 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; | |
| 6 | +import { definePlugin } from "./plugin"; | |
| 7 | +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; | |
| 8 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 9 | + | |
| 10 | +// --------------------------------------------------------------------------- | |
| 11 | +// Regression: the plugin RUNTIME bound before an approval pause must never | |
| 12 | +// invoke a REPLACEMENT tool row. If the integration is torn down and | |
| 13 | +// re-registered under a different plugin while the call waits on a human, | |
| 14 | +// the tool the user approved no longer exists in that form — the resumed | |
| 15 | +// call fails as not-found instead of handing the new row (and the new | |
| 16 | +// connection's credential) to the old plugin's handler. | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | + | |
| 19 | +const integration = IntegrationSlug.make("swapcheck"); | |
| 20 | +const connection = ConnectionName.make("main"); | |
| 21 | +const address = ToolAddress.make("tools.swapcheck.org.main.run"); | |
| 22 | + | |
| 23 | +describe("integration replaced under a different plugin during approval", () => { | |
| 24 | + it.effect("fails as not-found; neither plugin's handler runs", () => | |
| 25 | + Effect.gen(function* () { | |
| 26 | + const oldInvocations = yield* Ref.make(0); | |
| 27 | + const newInvocations = yield* Ref.make(0); | |
| 28 | + | |
| 29 | + const makeCatalogPlugin = (id: "swap-old" | "swap-new", invocations: Ref.Ref<number>) => | |
| 30 | + definePlugin(() => ({ | |
| 31 | + id, | |
| 32 | + storage: () => ({}), | |
| 33 | + resolveTools: () => | |
| 34 | + Effect.succeed({ | |
| 35 | + tools: [ | |
| 36 | + { | |
| 37 | + name: ToolName.make("run"), | |
| 38 | + description: "Run after approval.", | |
| 39 | + annotations: { requiresApproval: true }, | |
| 40 | + }, | |
| 41 | + ], | |
| 42 | + }), | |
| 43 | + invokeTool: () => Ref.update(invocations, (count) => count + 1).pipe(Effect.as("ran")), | |
| 44 | + extension: (ctx) => ({ | |
| 45 | + register: () => | |
| 46 | + ctx.core.integrations.register({ | |
| 47 | + slug: integration, | |
| 48 | + description: `registered by ${id}`, | |
| 49 | + config: {}, | |
| 50 | + }), | |
| 51 | + }), | |
| 52 | + }))(); | |
| 53 | + | |
| 54 | + const executor = yield* makeTestExecutor({ | |
| 55 | + access: testAccess.member(), | |
| 56 | + plugins: [ | |
| 57 | + makeCatalogPlugin("swap-old", oldInvocations), | |
| 58 | + makeCatalogPlugin("swap-new", newInvocations), | |
| 59 | + memoryCredentialsPlugin(), | |
| 60 | + ] as const, | |
| 61 | + }); | |
| 62 | + yield* executor["swap-old"].register(); | |
| 63 | + const createConnection = executor.connections.create({ | |
| 64 | + owner: "org", | |
| 65 | + integration, | |
| 66 | + name: connection, | |
| 67 | + template: AuthTemplateSlug.make("none"), | |
| 68 | + value: "synthetic-swap-test-key", | |
| 69 | + }); | |
| 70 | + yield* createConnection; | |
| 71 | + | |
| 72 | + const swapPlugins = Effect.gen(function* () { | |
| 73 | + // Tear the integration down (cascading its connection and tool rows) | |
| 74 | + // and re-register the SAME slug under the other plugin, with a fresh | |
| 75 | + // connection producing a same-named tool row owned by `swap-new`. | |
| 76 | + yield* executor.integrations.remove(integration); | |
| 77 | + yield* executor["swap-new"].register(); | |
| 78 | + yield* createConnection; | |
| 79 | + return ElicitationResponse.make({ action: "accept" }); | |
| 80 | + }).pipe(Effect.orDie); | |
| 81 | + | |
| 82 | + const error = yield* Effect.flip( | |
| 83 | + executor.execute(address, {}, { onElicitation: () => swapPlugins }), | |
| 84 | + ); | |
| 85 | + expect(error).toMatchObject({ _tag: "ToolNotFoundError" }); | |
| 86 | + expect(yield* Ref.get(oldInvocations)).toBe(0); | |
| 87 | + expect(yield* Ref.get(newInvocations)).toBe(0); | |
| 88 | + }), | |
| 89 | + ); | |
| 90 | +}); | |
| @@ -119,11 +119,19 @@ describe("artifact table", () => { · 118 unmodified lines | ||
| 119 | 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 | 127 | const visible = await scoped.findMany("artifact", {}); |
| 124 | 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 | 135 | const otherVisible = await otherScoped.findMany("artifact", {}); |
| 128 | 136 | expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]); |
| 129 | 137 | }), |
| @@ -4,6 +4,7 @@ import { Effect, Predicate, Result } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { makeTestExecutor } from "./testing"; |
| 5 | 5 | import type { ArtifactBinding } from "./artifact"; |
| 6 | 6 | import { ConnectionName, IntegrationSlug } from "./ids"; |
| 7 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 7 | 8 | |
| 8 | 9 | const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({ |
| 9 | 10 | integration: IntegrationSlug.make(integration), |
| @@ -20,14 +21,14 @@ const CODE = "export default function App() { return <div>hi</div>; }"; · 10 unmodified lines | ||
| 20 | 21 | describe("executor.artifacts", () => { |
| 21 | 22 | it.effect("list is empty when nothing is saved", () => |
| 22 | 23 | Effect.gen(function* () { |
| 23 | − const executor = yield* makeTestExecutor(); | |
| 24 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 24 | 25 | expect(yield* executor.artifacts.list()).toEqual([]); |
| 25 | 26 | }), |
| 26 | 27 | ); |
| 27 | 28 | |
| 28 | 29 | it.effect("save mints an id and stores the source", () => |
| 29 | 30 | Effect.gen(function* () { |
| 30 | − const executor = yield* makeTestExecutor(); | |
| 31 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 31 | 32 | const saved = yield* executor.artifacts.save({ |
| 32 | 33 | title: "Active users", |
| 33 | 34 | description: "Daily active users over time", |
| @@ -46,7 +47,7 @@ describe("executor.artifacts", () => { · 12 unmodified lines | ||
| 46 | 47 | |
| 47 | 48 | it.effect("round-trips connection bindings through the json column", () => |
| 48 | 49 | Effect.gen(function* () { |
| 49 | − const executor = yield* makeTestExecutor(); | |
| 50 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 50 | 51 | const bindings = { |
| 51 | 52 | vercel: binding("vercel", "user", "personalVercel"), |
| 52 | 53 | prod: binding("linear", "org", "linearProd"), |
| @@ -59,7 +60,7 @@ describe("executor.artifacts", () => { · 6 unmodified lines | ||
| 59 | 60 | |
| 60 | 61 | it.effect("saves an artifact that binds nothing as bound-but-empty, not unbound", () => |
| 61 | 62 | Effect.gen(function* () { |
| 62 | − const executor = yield* makeTestExecutor(); | |
| 63 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 63 | 64 | const saved = yield* executor.artifacts.save({ title: "Static", code: CODE, bindings: {} }); |
| 64 | 65 | // `{}` and `null` are different facts: `{}` is an artifact that calls no |
| 65 | 66 | // integration, `null` is one written before bindings existed and whose |
| @@ -71,7 +72,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | ||
| 71 | 72 | |
| 72 | 73 | it.effect("leaves bindings null when a save omits them", () => |
| 73 | 74 | Effect.gen(function* () { |
| 74 | − const executor = yield* makeTestExecutor(); | |
| 75 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 75 | 76 | const saved = yield* executor.artifacts.save({ title: "Legacy", code: CODE }); |
| 76 | 77 | expect(saved.bindings).toBe(null); |
| 77 | 78 | }), |
| @@ -79,7 +80,7 @@ describe("executor.artifacts", () => { · 1 unmodified line | ||
| 79 | 80 | |
| 80 | 81 | it.effect("overwrites bindings along with the source", () => |
| 81 | 82 | Effect.gen(function* () { |
| 82 | − const executor = yield* makeTestExecutor(); | |
| 83 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 83 | 84 | const saved = yield* executor.artifacts.save({ |
| 84 | 85 | title: "Domains", |
| 85 | 86 | code: CODE, |
| @@ -100,7 +101,7 @@ describe("executor.artifacts", () => { · 14 unmodified lines | ||
| 100 | 101 | |
| 101 | 102 | it.effect("save defaults a missing description to null", () => |
| 102 | 103 | Effect.gen(function* () { |
| 103 | − const executor = yield* makeTestExecutor(); | |
| 104 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 104 | 105 | const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE }); |
| 105 | 106 | expect(saved.description).toBe(null); |
| 106 | 107 | }), |
| @@ -108,7 +109,7 @@ describe("executor.artifacts", () => { · 1 unmodified line | ||
| 108 | 109 | |
| 109 | 110 | it.effect("list omits the source", () => |
| 110 | 111 | Effect.gen(function* () { |
| 111 | − const executor = yield* makeTestExecutor(); | |
| 112 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 112 | 113 | const saved = yield* executor.artifacts.save({ |
| 113 | 114 | title: "Active users", |
| 114 | 115 | description: "described", |
| @@ -141,7 +142,7 @@ describe("executor.artifacts", () => { · 26 unmodified lines | ||
| 141 | 142 | // storage layer really has. |
| 142 | 143 | it.live("list returns the most recently updated artifact first", () => |
| 143 | 144 | Effect.gen(function* () { |
| 144 | − const executor = yield* makeTestExecutor(); | |
| 145 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 145 | 146 | const first = yield* executor.artifacts.save({ title: "First", code: CODE }); |
| 146 | 147 | yield* Effect.sleep("1100 millis"); |
| 147 | 148 | const second = yield* executor.artifacts.save({ title: "Second", code: CODE }); |
| @@ -159,7 +160,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | ||
| 159 | 160 | |
| 160 | 161 | it.effect("save with an id overwrites in place", () => |
| 161 | 162 | Effect.gen(function* () { |
| 162 | − const executor = yield* makeTestExecutor(); | |
| 163 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 163 | 164 | const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE }); |
| 164 | 165 | const updated = yield* executor.artifacts.save({ |
| 165 | 166 | id: saved.id, |
| @@ -182,7 +183,7 @@ describe("executor.artifacts", () => { · 16 unmodified lines | ||
| 182 | 183 | |
| 183 | 184 | it.effect("save fails for an id that names no visible artifact", () => |
| 184 | 185 | Effect.gen(function* () { |
| 185 | − const executor = yield* makeTestExecutor(); | |
| 186 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 186 | 187 | const result = yield* Effect.result( |
| 187 | 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 | 196 | it.effect("get fails for an unknown id", () => |
| 196 | 197 | Effect.gen(function* () { |
| 197 | − const executor = yield* makeTestExecutor(); | |
| 198 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 198 | 199 | const result = yield* Effect.result(executor.artifacts.get("art_missing")); |
| 199 | 200 | expect(Result.isFailure(result)).toBe(true); |
| 200 | 201 | if (!Result.isFailure(result)) return; |
| @@ -204,7 +205,7 @@ describe("executor.artifacts", () => { · 3 unmodified lines | ||
| 204 | 205 | |
| 205 | 206 | it.effect("rename changes only the title", () => |
| 206 | 207 | Effect.gen(function* () { |
| 207 | − const executor = yield* makeTestExecutor(); | |
| 208 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 208 | 209 | const saved = yield* executor.artifacts.save({ |
| 209 | 210 | title: "Old name", |
| 210 | 211 | description: "kept", |
| @@ -219,7 +220,7 @@ describe("executor.artifacts", () => { · 8 unmodified lines | ||
| 219 | 220 | |
| 220 | 221 | it.effect("rename fails for an unknown id", () => |
| 221 | 222 | Effect.gen(function* () { |
| 222 | − const executor = yield* makeTestExecutor(); | |
| 223 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 223 | 224 | const result = yield* Effect.result( |
| 224 | 225 | executor.artifacts.rename({ id: "art_missing", title: "Nope" }), |
| 225 | 226 | ); |
| @@ -231,7 +232,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | ||
| 231 | 232 | |
| 232 | 233 | it.effect("remove hard-deletes the artifact", () => |
| 233 | 234 | Effect.gen(function* () { |
| 234 | − const executor = yield* makeTestExecutor(); | |
| 235 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 235 | 236 | const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE }); |
| 236 | 237 | yield* executor.artifacts.remove({ id: saved.id }); |
| 237 | 238 | |
| @@ -245,7 +246,7 @@ describe("executor.artifacts", () => { · 7 unmodified lines | ||
| 245 | 246 | |
| 246 | 247 | it.effect("remove is idempotent", () => |
| 247 | 248 | Effect.gen(function* () { |
| 248 | − const executor = yield* makeTestExecutor(); | |
| 249 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 249 | 250 | yield* executor.artifacts.remove({ id: "art_missing" }); |
| 250 | 251 | }), |
| 251 | 252 | ); |
| @@ -254,7 +255,7 @@ describe("executor.artifacts", () => { · 2 unmodified lines | ||
| 254 | 255 | Effect.gen(function* () { |
| 255 | 256 | // Pure-org executors (host request paths with no member bound) have no |
| 256 | 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 | 259 | const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE })); |
| 259 | 260 | expect(Result.isFailure(result)).toBe(true); |
| 260 | 261 | }), |
| @@ -266,7 +267,7 @@ describe("executor.artifacts", () => { · 5 unmodified lines | ||
| 266 | 267 | |
| 267 | 268 | it.effect("round-trips layout markup, and reads it back as a layout preview", () => |
| 268 | 269 | Effect.gen(function* () { |
| 269 | − const executor = yield* makeTestExecutor(); | |
| 270 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 270 | 271 | const saved = yield* executor.artifacts.save({ |
| 271 | 272 | title: "Revenue", |
| 272 | 273 | code: CODE, |
| @@ -284,7 +285,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | ||
| 284 | 285 | |
| 285 | 286 | it.effect("leaves the preview null when a save omits one", () => |
| 286 | 287 | Effect.gen(function* () { |
| 287 | − const executor = yield* makeTestExecutor(); | |
| 288 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 288 | 289 | const saved = yield* executor.artifacts.save({ title: "Plain", code: CODE }); |
| 289 | 290 | expect(saved.preview).toBe(null); |
| 290 | 291 | }), |
| @@ -295,7 +296,7 @@ describe("executor.artifacts", () => { · 4 unmodified lines | ||
| 295 | 296 | // The preview interprets `code`, so a save that replaces the source |
| 296 | 297 | // must replace the picture — including back to null. Carrying it |
| 297 | 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 | 300 | const saved = yield* executor.artifacts.save({ |
| 300 | 301 | title: "Revenue", |
| 301 | 302 | code: CODE, |
| @@ -313,7 +314,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | ||
| 313 | 314 | |
| 314 | 315 | it.effect("setPreview upgrades the preview to the settled render", () => |
| 315 | 316 | Effect.gen(function* () { |
| 316 | − const executor = yield* makeTestExecutor(); | |
| 317 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 317 | 318 | const saved = yield* executor.artifacts.save({ |
| 318 | 319 | title: "Revenue", |
| 319 | 320 | code: CODE, |
| @@ -331,7 +332,7 @@ describe("executor.artifacts", () => { · 11 unmodified lines | ||
| 331 | 332 | Effect.gen(function* () { |
| 332 | 333 | // `updated_at` is the gallery's sort key. Opening an artifact must not |
| 333 | 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 | 336 | const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE }); |
| 336 | 337 | yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED }); |
| 337 | 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 | 342 | it.effect("setPreview fails for an artifact that is not visible", () => |
| 342 | 343 | Effect.gen(function* () { |
| 343 | − const executor = yield* makeTestExecutor(); | |
| 344 | + const executor = yield* makeTestExecutor({ access: testAccess.member() }); | |
| 344 | 345 | const result = yield* Effect.result( |
| 345 | 346 | executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }), |
| 346 | 347 | ); |
| @@ -3,23 +3,35 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { StorageError } from "./fuma-runtime"; |
| 5 | 5 | |
| 6 | −import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob"; | |
| 6 | +import { | |
| 7 | + makeInMemoryBlobStore, | |
| 8 | + pluginBlobStore, | |
| 9 | + type BlobAccess, | |
| 10 | + type OwnerPartitions, | |
| 11 | +} from "./blob"; | |
| 7 | 12 | |
| 8 | −// v2: owner partitions instead of a scope stack. Reads fall through | |
| 9 | −// [user, org] (user = innermost); writes/deletes name an explicit owner. | |
| 13 | +// v2: owner partitions instead of a scope stack. Reads fall through the | |
| 14 | +// product view's owners in their supplied order (first listed wins); writes | |
| 15 | +// and deletes name an explicit owner and are refused outside the view. | |
| 10 | 16 | const partitions = (org: string, user: string | null): OwnerPartitions => ({ |
| 11 | 17 | org, |
| 12 | 18 | user, |
| 13 | 19 | }); |
| 14 | 20 | |
| 21 | +// The full member view — the pre-BlobAccess behavior: user shadows org on | |
| 22 | +// read, both partitions writable. | |
| 23 | +const fullView: BlobAccess = { owners: ["user", "org"], storageWrites: "allowed" }; | |
| 24 | +const personalOnly: BlobAccess = { owners: ["user"], storageWrites: "allowed" }; | |
| 25 | +const orgOnly: BlobAccess = { owners: ["org"], storageWrites: "allowed" }; | |
| 26 | + | |
| 15 | 27 | describe("pluginBlobStore", () => { |
| 16 | − it.effect("get returns user (innermost) value when both owners have one", () => | |
| 28 | + it.effect("get returns user (first-ranked) value when both owners have one", () => | |
| 17 | 29 | Effect.gen(function* () { |
| 18 | 30 | const store = makeInMemoryBlobStore(); |
| 19 | 31 | yield* store.put("u/my-plugin", "k", "user-value"); |
| 20 | 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 | 35 | const value = yield* plugin.get("k"); |
| 24 | 36 | expect(value).toBe("user-value"); |
| 25 | 37 | }), |
| @@ -30,7 +42,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines | ||
| 30 | 42 | const store = makeInMemoryBlobStore(); |
| 31 | 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 | 46 | const value = yield* plugin.get("k"); |
| 35 | 47 | expect(value).toBe("org-value"); |
| 36 | 48 | }), |
| @@ -39,7 +51,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | ||
| 39 | 51 | it.effect("get returns null when no owner has the key", () => |
| 40 | 52 | Effect.gen(function* () { |
| 41 | 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 | 55 | const value = yield* plugin.get("k"); |
| 44 | 56 | expect(value).toBeNull(); |
| 45 | 57 | }), |
| @@ -50,7 +62,7 @@ describe("pluginBlobStore", () => { · 4 unmodified lines | ||
| 50 | 62 | const store = makeInMemoryBlobStore(); |
| 51 | 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 | 66 | const found = yield* plugin.has("k"); |
| 55 | 67 | expect(found).toBe(true); |
| 56 | 68 | }), |
| @@ -59,7 +71,7 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | ||
| 59 | 71 | it.effect("has returns false when no owner has the key", () => |
| 60 | 72 | Effect.gen(function* () { |
| 61 | 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 | 75 | const found = yield* plugin.has("k"); |
| 64 | 76 | expect(found).toBe(false); |
| 65 | 77 | }), |
| @@ -71,8 +83,8 @@ describe("pluginBlobStore", () => { · 5 unmodified lines | ||
| 71 | 83 | yield* store.put("u/plugin-a", "k", "a-value"); |
| 72 | 84 | yield* store.put("u/plugin-b", "k", "b-value"); |
| 73 | 85 | |
| 74 | − const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a"); | |
| 75 | − const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b"); | |
| 86 | + const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a", fullView); | |
| 87 | + const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b", fullView); | |
| 76 | 88 | expect(yield* pluginA.get("k")).toBe("a-value"); |
| 77 | 89 | expect(yield* pluginB.get("k")).toBe("b-value"); |
| 78 | 90 | }), |
| @@ -81,10 +93,14 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | ||
| 81 | 93 | it.effect("put rejects owner:user when the executor has no subject", () => |
| 82 | 94 | Effect.gen(function* () { |
| 83 | 95 | const store = makeInMemoryBlobStore(); |
| 84 | − // No user partition → owner:"user" writes fail. | |
| 85 | − const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); | |
| 96 | + // No user partition → owner:"user" writes fail even when the product | |
| 97 | + // view lists "user"; the identity clamp is not the product's to relax. | |
| 98 | + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); | |
| 86 | 99 | const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); |
| 87 | 100 | expect(err).toBeInstanceOf(StorageError); |
| 101 | + expect(err).toMatchObject({ | |
| 102 | + message: 'Blob write targets owner "user" but the executor has no subject.', | |
| 103 | + }); | |
| 88 | 104 | // Write must not have reached the store. |
| 89 | 105 | expect(yield* store.get("o/my-plugin", "k")).toBeNull(); |
| 90 | 106 | }), |
| @@ -93,9 +109,142 @@ describe("pluginBlobStore", () => { · 2 unmodified lines | ||
| 93 | 109 | it.effect("delete rejects owner:user when the executor has no subject", () => |
| 94 | 110 | Effect.gen(function* () { |
| 95 | 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 | 113 | const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip); |
| 98 | 114 | expect(err).toBeInstanceOf(StorageError); |
| 115 | + expect(err).toMatchObject({ | |
| 116 | + message: 'Blob write targets owner "user" but the executor has no subject.', | |
| 117 | + }); | |
| 118 | + }), | |
| 119 | + ); | |
| 120 | +}); | |
| 121 | + | |
| 122 | +describe("pluginBlobStore access clamp", () => { | |
| 123 | + it.effect("personal-only view cannot read an org blob", () => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const store = makeInMemoryBlobStore(); | |
| 126 | + // e.g. an org-shared provider config holding a service-account token. | |
| 127 | + yield* store.put("o/my-plugin", "k", "org-secret"); | |
| 128 | + | |
| 129 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | |
| 130 | + expect(yield* plugin.get("k")).toBeNull(); | |
| 131 | + expect(yield* plugin.has("k")).toBe(false); | |
| 132 | + }), | |
| 133 | + ); | |
| 134 | + | |
| 135 | + it.effect("personal-only view still reads its own user blob", () => | |
| 136 | + Effect.gen(function* () { | |
| 137 | + const store = makeInMemoryBlobStore(); | |
| 138 | + yield* store.put("u/my-plugin", "k", "user-value"); | |
| 139 | + yield* store.put("o/my-plugin", "k", "org-value"); | |
| 140 | + | |
| 141 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | |
| 142 | + expect(yield* plugin.get("k")).toBe("user-value"); | |
| 143 | + }), | |
| 144 | + ); | |
| 145 | + | |
| 146 | + it.effect("org-only view with a bound subject cannot read a user blob", () => | |
| 147 | + Effect.gen(function* () { | |
| 148 | + const store = makeInMemoryBlobStore(); | |
| 149 | + yield* store.put("u/my-plugin", "k", "user-value"); | |
| 150 | + | |
| 151 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly); | |
| 152 | + expect(yield* plugin.get("k")).toBeNull(); | |
| 153 | + expect(yield* plugin.has("k")).toBe(false); | |
| 154 | + }), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + it.effect("read precedence follows the supplied owner order", () => | |
| 158 | + Effect.gen(function* () { | |
| 159 | + const store = makeInMemoryBlobStore(); | |
| 160 | + yield* store.put("u/my-plugin", "k", "user-value"); | |
| 161 | + yield* store.put("o/my-plugin", "k", "org-value"); | |
| 162 | + | |
| 163 | + const orgFirst = pluginBlobStore(store, partitions("o", "u"), "my-plugin", { | |
| 164 | + owners: ["org", "user"], | |
| 165 | + storageWrites: "allowed", | |
| 166 | + }); | |
| 167 | + expect(yield* orgFirst.get("k")).toBe("org-value"); | |
| 168 | + }), | |
| 169 | + ); | |
| 170 | + | |
| 171 | + it.effect("put outside the view is rejected and does not reach the store", () => | |
| 172 | + Effect.gen(function* () { | |
| 173 | + const store = makeInMemoryBlobStore(); | |
| 174 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | |
| 175 | + const err = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip); | |
| 176 | + expect(err).toBeInstanceOf(StorageError); | |
| 177 | + expect(err).toMatchObject({ | |
| 178 | + message: | |
| 179 | + 'Blob write targets the "org" partition, which this product view does not include.', | |
| 180 | + }); | |
| 181 | + expect(yield* store.get("o/my-plugin", "k")).toBeNull(); | |
| 182 | + }), | |
| 183 | + ); | |
| 184 | + | |
| 185 | + it.effect("delete outside the view is rejected and leaves the row in place", () => | |
| 186 | + Effect.gen(function* () { | |
| 187 | + const store = makeInMemoryBlobStore(); | |
| 188 | + yield* store.put("o/my-plugin", "k", "org-value"); | |
| 189 | + | |
| 190 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", personalOnly); | |
| 191 | + const err = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip); | |
| 192 | + expect(err).toBeInstanceOf(StorageError); | |
| 193 | + expect(err).toMatchObject({ | |
| 194 | + message: | |
| 195 | + 'Blob write targets the "org" partition, which this product view does not include.', | |
| 196 | + }); | |
| 197 | + expect(yield* store.get("o/my-plugin", "k")).toBe("org-value"); | |
| 198 | + }), | |
| 199 | + ); | |
| 200 | + | |
| 201 | + it.effect("org-only view cannot write the user partition", () => | |
| 202 | + Effect.gen(function* () { | |
| 203 | + const store = makeInMemoryBlobStore(); | |
| 204 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", orgOnly); | |
| 205 | + const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); | |
| 206 | + expect(err).toBeInstanceOf(StorageError); | |
| 207 | + expect(err).toMatchObject({ | |
| 208 | + message: | |
| 209 | + 'Blob write targets the "user" partition, which this product view does not include.', | |
| 210 | + }); | |
| 211 | + expect(yield* store.get("u/my-plugin", "k")).toBeNull(); | |
| 212 | + }), | |
| 213 | + ); | |
| 214 | + | |
| 215 | + it.effect("read-only view refuses every mutation but keeps reading", () => | |
| 216 | + Effect.gen(function* () { | |
| 217 | + const store = makeInMemoryBlobStore(); | |
| 218 | + yield* store.put("o/my-plugin", "k", "org-value"); | |
| 219 | + | |
| 220 | + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin", { | |
| 221 | + owners: ["user", "org"], | |
| 222 | + storageWrites: "denied", | |
| 223 | + }); | |
| 224 | + const putErr = yield* plugin.put("k", "v", { owner: "org" }).pipe(Effect.flip); | |
| 225 | + expect(putErr).toBeInstanceOf(StorageError); | |
| 226 | + expect(putErr.message).toBe( | |
| 227 | + 'Blob write on plugin "my-plugin" is not allowed: this executor\'s storage is read-only.', | |
| 228 | + ); | |
| 229 | + const userPutErr = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); | |
| 230 | + expect(userPutErr).toBeInstanceOf(StorageError); | |
| 231 | + const deleteErr = yield* plugin.delete("k", { owner: "org" }).pipe(Effect.flip); | |
| 232 | + expect(deleteErr).toBeInstanceOf(StorageError); | |
| 233 | + // Nothing was mutated; reads keep working. | |
| 234 | + expect(yield* store.get("o/my-plugin", "k")).toBe("org-value"); | |
| 235 | + expect(yield* plugin.get("k")).toBe("org-value"); | |
| 236 | + }), | |
| 237 | + ); | |
| 238 | + | |
| 239 | + it.effect('a view listing "user" on a subject-less identity reads org rows only', () => | |
| 240 | + Effect.gen(function* () { | |
| 241 | + const store = makeInMemoryBlobStore(); | |
| 242 | + yield* store.put("o/my-plugin", "k", "org-value"); | |
| 243 | + | |
| 244 | + // `createExecutor` validation rejects this shape, but the seam must not | |
| 245 | + // assume it: the null partition contributes no namespace, not a failure. | |
| 246 | + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin", fullView); | |
| 247 | + expect(yield* plugin.get("k")).toBe("org-value"); | |
| 99 | 248 | }), |
| 100 | 249 | ); |
| 101 | 250 | }); |
| @@ -4,18 +4,21 @@ · 3 unmodified lines | ||
| 4 | 4 | // metadata and plugin-owned config rows. |
| 5 | 5 | // |
| 6 | 6 | // Plugins see a `PluginBlobStore` that's already namespaced to the |
| 7 | −// plugin id and bound to the executor's scope stack. Reads fall through | |
| 8 | −// the stack in order (innermost first, first hit wins); writes and | |
| 9 | −// deletes require an explicit scope id naming where the operation | |
| 10 | −// should land. That mirrors the secrets API — shadowing by key on | |
| 11 | −// read, explicit target on write. | |
| 7 | +// plugin id and clamped to the product's `BlobAccess` view. Reads fall | |
| 8 | +// through the view's owners in order (first listed wins); writes and | |
| 9 | +// deletes require an explicit owner naming where the operation should | |
| 10 | +// land, and are refused outside the view. That mirrors the secrets API — | |
| 11 | +// shadowing by key on read, explicit target on write — and it is where | |
| 12 | +// the executor's access decisions apply to blobs at all: the blob table | |
| 13 | +// sits outside the storage owner policy (isolation is the namespace | |
| 14 | +// string), so nothing downstream re-checks them. | |
| 12 | 15 | // |
| 13 | 16 | // Error channel is `StorageError` — blobs only do read/write/delete, so |
| 14 | 17 | // they never produce `UniqueViolationError`. The HTTP edge translates |
| 15 | 18 | // `StorageError` to the opaque public `InternalError({ traceId })`. |
| 16 | 19 | // --------------------------------------------------------------------------- |
| 17 | 20 | |
| 18 | −import { Effect } from "effect"; | |
| 21 | +import { Effect, Predicate } from "effect"; | |
| 19 | 22 | |
| 20 | 23 | import { StorageError, type IFumaClient } from "./fuma-runtime"; |
| 21 | 24 | import type { Owner } from "./ids"; |
| @@ -41,53 +44,101 @@ export interface BlobStore { · 19 unmodified lines | ||
| 41 | 44 | } |
| 42 | 45 | |
| 43 | 46 | export interface PluginBlobStore { |
| 44 | − /** Read precedence: this subject's own (`user`) value first, then the | |
| 45 | − * org-shared value. Returns the first non-null. */ | |
| 47 | + /** Read precedence: the product view's owner order (`BlobAccess.owners`, | |
| 48 | + * first entry wins). Returns the first non-null. */ | |
| 46 | 49 | readonly get: (key: string) => Effect.Effect<string | null, StorageError>; |
| 47 | 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 | 53 | readonly put: ( |
| 50 | 54 | key: string, |
| 51 | 55 | value: string, |
| 52 | 56 | options: { readonly owner: Owner }, |
| 53 | 57 | ) => Effect.Effect<void, StorageError>; |
| 54 | − /** Delete `key` for the named owner. */ | |
| 58 | + /** Delete `key` for the named owner. Bounded like `put`. */ | |
| 55 | 59 | readonly delete: ( |
| 56 | 60 | key: string, |
| 57 | 61 | options: { readonly owner: Owner }, |
| 58 | 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 | 64 | readonly has: (key: string) => Effect.Effect<boolean, StorageError>; |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | −/** The owner partition strings an executor binding resolves to: the org | |
| 67 | +/** The owner partition strings an executor IDENTITY resolves to: the org | |
| 64 | 68 | * partition (always present) and this subject's user partition (null for a |
| 65 | − * pure-org executor). Reads walk `[user, org]`; writes target one. */ | |
| 69 | + * pure-org executor). Identity-shaped on purpose — identity-bound consumers | |
| 70 | + * (the pending-approval store) keep using it directly; which partitions a | |
| 71 | + * plugin blob store actually reads or writes is the separate, product-owned | |
| 72 | + * `BlobAccess` decision. */ | |
| 66 | 73 | export interface OwnerPartitions { |
| 67 | 74 | readonly org: string; |
| 68 | 75 | readonly user: string | null; |
| 69 | 76 | } |
| 70 | 77 | |
| 78 | +/** | |
| 79 | + * The slice of the product's access decisions the blob seam enforces: the | |
| 80 | + * blob table is exempt from the storage owner policy (isolation lives in the | |
| 81 | + * row namespace), so `ExecutorAccess.owners` and the read-only capability | |
| 82 | + * must be applied HERE, at namespace construction, or not at all. | |
| 83 | + * | |
| 84 | + * Structural and required — there is no default view: the caller states the | |
| 85 | + * product's decision explicitly, and core contributes only the clamps | |
| 86 | + * (tenant/subject are already baked into `OwnerPartitions`). | |
| 87 | + */ | |
| 88 | +export interface BlobAccess { | |
| 89 | + /** The owner partitions reads see and writes may target, in read-precedence | |
| 90 | + * order — the first listed owner shadows later ones on `get`. */ | |
| 91 | + readonly owners: readonly Owner[]; | |
| 92 | + /** `"denied"` refuses every `put`/`delete` through this store (the | |
| 93 | + * read-only posture, `ExecutorAccessCapabilities.storageWrites`). */ | |
| 94 | + readonly storageWrites: "allowed" | "denied"; | |
| 95 | +} | |
| 96 | + | |
| 71 | 97 | const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`; |
| 72 | 98 | |
| 73 | 99 | /** |
| 74 | − * Bind a `BlobStore` to an owner partitioning + plugin id. Reads fall through | |
| 75 | − * `[user, org]` (user first); writes target an explicit owner. Used by the | |
| 76 | − * executor to build the `blobs` field handed to each plugin's `storage` factory. | |
| 100 | + * Bind a `BlobStore` to an owner partitioning + plugin id, clamped to the | |
| 101 | + * product's `BlobAccess`. Reads fall through the access owners in their | |
| 102 | + * supplied order (first hit wins); writes target an explicit owner and are | |
| 103 | + * refused outside the access view. Used by the executor to build the `blobs` | |
| 104 | + * field handed to each plugin's `storage` factory. | |
| 77 | 105 | */ |
| 78 | 106 | export const pluginBlobStore = ( |
| 79 | 107 | store: BlobStore, |
| 80 | 108 | partitions: OwnerPartitions, |
| 81 | 109 | pluginId: string, |
| 110 | + access: BlobAccess, | |
| 82 | 111 | ): PluginBlobStore => { |
| 83 | − const readNamespaces = (): readonly string[] => | |
| 84 | − (partitions.user == null ? [partitions.org] : [partitions.user, partitions.org]).map((p) => | |
| 85 | − nsFor(p, pluginId), | |
| 86 | − ); | |
| 112 | + const partitionOf = (owner: Owner): string | null => | |
| 113 | + owner === "org" ? partitions.org : partitions.user; | |
| 114 | + | |
| 115 | + // The product view's partitions, in ITS precedence order. An owner the | |
| 116 | + // identity cannot carry (`"user"` with no subject) maps to no namespace | |
| 117 | + // rather than failing a read — the partition simply has no rows to see. | |
| 118 | + const readNamespaces: readonly string[] = access.owners | |
| 119 | + .map(partitionOf) | |
| 120 | + .filter(Predicate.isNotNull) | |
| 121 | + .map((partition) => nsFor(partition, pluginId)); | |
| 87 | 122 | |
| 88 | 123 | const partitionFor = (owner: Owner): Effect.Effect<string, StorageError> => { |
| 89 | − if (owner === "org") return Effect.succeed(partitions.org); | |
| 90 | − if (partitions.user == null) { | |
| 124 | + if (access.storageWrites === "denied") { | |
| 125 | + return Effect.fail( | |
| 126 | + new StorageError({ | |
| 127 | + message: `Blob write on plugin "${pluginId}" is not allowed: this executor's storage is read-only.`, | |
| 128 | + cause: undefined, | |
| 129 | + }), | |
| 130 | + ); | |
| 131 | + } | |
| 132 | + if (!access.owners.includes(owner)) { | |
| 133 | + return Effect.fail( | |
| 134 | + new StorageError({ | |
| 135 | + message: `Blob write targets the "${owner}" partition, which this product view does not include.`, | |
| 136 | + cause: undefined, | |
| 137 | + }), | |
| 138 | + ); | |
| 139 | + } | |
| 140 | + const partition = partitionOf(owner); | |
| 141 | + if (partition == null) { | |
| 91 | 142 | return Effect.fail( |
| 92 | 143 | new StorageError({ |
| 93 | 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 | 152 | return { |
| 102 | 153 | get: (key) => |
| 103 | 154 | Effect.gen(function* () { |
| 104 | − const namespaces = readNamespaces(); | |
| 105 | − const hits = yield* store.getMany(namespaces, key); | |
| 155 | + const hits = yield* store.getMany(readNamespaces, key); | |
| 106 | 156 | if (hits.size === 0) return null; |
| 107 | − for (const ns of namespaces) { | |
| 157 | + for (const ns of readNamespaces) { | |
| 108 | 158 | const v = hits.get(ns); |
| 109 | 159 | if (v !== undefined) return v; |
| 110 | 160 | } |
| @@ -118,7 +168,7 @@ export const pluginBlobStore = ( · 7 unmodified lines | ||
| 118 | 168 | Effect.flatMap(partitionFor(options.owner), (partition) => |
| 119 | 169 | store.delete(nsFor(partition, pluginId), key), |
| 120 | 170 | ), |
| 121 | − has: (key) => store.getMany(readNamespaces(), key).pipe(Effect.map((hits) => hits.size > 0)), | |
| 171 | + has: (key) => store.getMany(readNamespaces, key).pipe(Effect.map((hits) => hits.size > 0)), | |
| 122 | 172 | }; |
| 123 | 173 | }; |
| 124 | 174 | |
| @@ -33,6 +33,7 @@ import { definePlugin, type ResolveToolsResult } from "./plugin"; · 32 unmodified lines | ||
| 33 | 33 | import type { CredentialProvider } from "./provider"; |
| 34 | 34 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 35 | 35 | import { ToolResult } from "./tool-result"; |
| 36 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 36 | 37 | |
| 37 | 38 | // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, |
| 38 | 39 | // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2 |
| @@ -163,7 +164,7 @@ const demoPlugin = definePlugin(() => ({ · 124 unmodified lines | ||
| 163 | 164 | }))(); |
| 164 | 165 | |
| 165 | 166 | const setup = () => |
| 166 | − makeTestExecutor({ plugins: [demoPlugin] as const }).pipe( | |
| 167 | + makeTestExecutor({ access: testAccess.member(), plugins: [demoPlugin] as const }).pipe( | |
| 167 | 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 | 346 | const executor = yield* createExecutor(config); |
| 343 | 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 | 443 | const first = yield* createExecutor(config); |
| 440 | 444 | yield* first.recoverable.seed(); |
| 441 | 445 | |
| @@ -545,7 +549,10 @@ describe("connections.create", () => { · 103 unmodified lines | ||
| 545 | 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 | 556 | const executor = yield* createExecutor({ |
| 550 | 557 | ...config, |
| 551 | 558 | db: blindfoldConnectionReads(config.db), |
| @@ -1079,6 +1086,7 @@ describe("connections.create credential-write compensation", () => { · 527 unmodified lines | ||
| 1079 | 1086 | : Effect.sync(() => void store.set(String(id), value)), |
| 1080 | 1087 | }); |
| 1081 | 1088 | const executor = yield* makeTestExecutor({ |
| 1089 | + access: testAccess.member(), | |
| 1082 | 1090 | plugins: [durabilityPlugin(provider)] as const, |
| 1083 | 1091 | }); |
| 1084 | 1092 | yield* executor.durable.seed(); |
| @@ -1116,7 +1124,10 @@ describe("connections.create credential-write compensation", () => { · 31 unmodified lines | ||
| 1116 | 1124 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) |
| 1117 | 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 | 1131 | yield* executor.durable.seed(); |
| 1121 | 1132 | |
| 1122 | 1133 | const result = yield* Effect.result( |
| @@ -1151,7 +1162,10 @@ describe("connections.create credential-write compensation", () => { · 28 unmodified lines | ||
| 1151 | 1162 | : Effect.sync(() => void store.set(String(id), value)), |
| 1152 | 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 | 1169 | yield* executor.durable.seed(); |
| 1156 | 1170 | |
| 1157 | 1171 | const warnings: string[] = []; |
| @@ -1202,7 +1216,10 @@ describe("connections.create credential-write compensation", () => { · 44 unmodified lines | ||
| 1202 | 1216 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) |
| 1203 | 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 | 1223 | const executor = yield* createExecutor({ |
| 1207 | 1224 | ...config, |
| 1208 | 1225 | db: failableCompensationRowDelete(config.db, () => failRowDelete), |
| @@ -1276,6 +1293,7 @@ describe("connections.create credential-write compensation", () => { · 67 unmodified lines | ||
| 1276 | 1293 | }, |
| 1277 | 1294 | }); |
| 1278 | 1295 | const executor = yield* makeTestExecutor({ |
| 1296 | + access: testAccess.member(), | |
| 1279 | 1297 | plugins: [durabilityPlugin(provider)] as const, |
| 1280 | 1298 | }); |
| 1281 | 1299 | yield* executor.durable.seed(); |
| @@ -1355,7 +1373,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines | ||
| 1355 | 1373 | }, |
| 1356 | 1374 | }); |
| 1357 | 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 | 1380 | const executor = yield* createExecutor({ |
| 1360 | 1381 | ...config, |
| 1361 | 1382 | db: staleCompensationRead(config.db, raceState), |
| @@ -1435,7 +1456,10 @@ describe("connections.create credential-write compensation", () => { · 73 unmodified lines | ||
| 1435 | 1456 | : Effect.sync(() => void store.set(String(id), value)), |
| 1436 | 1457 | }); |
| 1437 | 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 | 1463 | const executor = yield* createExecutor({ |
| 1440 | 1464 | ...config, |
| 1441 | 1465 | db: failableConfirmationRead(config.db, readState), |
| @@ -1510,7 +1534,10 @@ describe("connections.create credential-write compensation", () => { · 68 unmodified lines | ||
| 1510 | 1534 | ? Effect.fail(new StorageError({ message: "provider write refused", cause: undefined })) |
| 1511 | 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 | 1541 | const executor = yield* createExecutor({ |
| 1515 | 1542 | ...config, |
| 1516 | 1543 | db: failableConnectionDeletes(config.db, () => failRowDelete), |
| @@ -1586,7 +1613,10 @@ describe("connections.create credential-write compensation", () => { · 69 unmodified lines | ||
| 1586 | 1613 | ? Effect.die("provider crashed") |
| 1587 | 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 | 1620 | const executor = yield* createExecutor({ |
| 1591 | 1621 | ...config, |
| 1592 | 1622 | db: failableCompensationRowDelete(config.db, () => failRowDelete), |
| @@ -1646,7 +1676,10 @@ describe("connections.create credential-write compensation", () => { · 53 unmodified lines | ||
| 1646 | 1676 | ? Deferred.succeed(secondWriteEntered, undefined).pipe(Effect.andThen(Effect.never)) |
| 1647 | 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 | 1683 | const executor = yield* createExecutor({ |
| 1651 | 1684 | ...config, |
| 1652 | 1685 | db: failableCompensationRowDelete(config.db, () => failRowDelete), |
| @@ -1696,7 +1729,11 @@ describe("connections.create credential-write compensation", () => { · 43 unmodified lines | ||
| 1696 | 1729 | describe("connections.list / get", () => { |
| 1697 | 1730 | it.effect("only includes full health diagnostics in verbose core tool output", () => |
| 1698 | 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 | 1737 | const executor = yield* createExecutor(config); |
| 1701 | 1738 | yield* executor.demo.seed(); |
| 1702 | 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 | 1920 | const executor = yield* createExecutor(config); |
| 1881 | 1921 | yield* executor.guarded.seed(); |
| 1882 | 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 | 1989 | const executor = yield* createExecutor(config); |
| 1947 | 1990 | yield* executor.guarded.seed(); |
| 1948 | 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 | 2055 | const executor = yield* createExecutor(config); |
| 2010 | 2056 | yield* executor["stored-state"].seed(); |
| 2011 | 2057 | yield* executor.connections.create({ |
| @@ -2064,7 +2110,7 @@ describe("tool catalog sync safety", () => { · 52 unmodified lines | ||
| 2064 | 2110 | }), |
| 2065 | 2111 | }))(); |
| 2066 | 2112 | const executor = yield* createExecutor( |
| 2067 | − makeTestConfig({ plugins: [guardedPlugin] as const }), | |
| 2113 | + makeTestConfig({ access: testAccess.member(), plugins: [guardedPlugin] as const }), | |
| 2068 | 2114 | ); |
| 2069 | 2115 | yield* executor.guarded.seed(); |
| 2070 | 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 | 2175 | const executor = yield* createExecutor(config); |
| 2127 | 2176 | yield* executor.guarded.seed(); |
| 2128 | 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 | 2257 | const executor = yield* createExecutor(config); |
| 2206 | 2258 | yield* executor.guarded.seed(); |
| 2207 | 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 | 2341 | const executor = yield* createExecutor(config); |
| 2287 | 2342 | yield* executor.guarded.seed(); |
| 2288 | 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 | 2430 | const executor = yield* createExecutor({ |
| 2373 | 2431 | ...config, |
| 2374 | 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 | 2522 | const executor = yield* createExecutor(config); |
| 2462 | 2523 | yield* executor.guarded.seed(); |
| 2463 | 2524 | for (const name of ["broken", "healthy"]) { |
| @@ -2676,6 +2737,7 @@ const makeHealthHarness = (options?: { · 212 unmodified lines | ||
| 2676 | 2737 | |
| 2677 | 2738 | return Effect.gen(function* () { |
| 2678 | 2739 | const config = makeTestConfig({ |
| 2740 | + access: testAccess.member(), | |
| 2679 | 2741 | plugins: [plugin] as const, |
| 2680 | 2742 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 2681 | 2743 | }); |
| @@ -3421,6 +3483,7 @@ describe("health probe gate key integrity", () => { · 739 unmodified lines | ||
| 3421 | 3483 | // Both executors share ONE root db handle — and therefore one gate map; |
| 3422 | 3484 | // only the key separates their probes. |
| 3423 | 3485 | const configA = makeTestConfig({ |
| 3486 | + access: testAccess.member(), | |
| 3424 | 3487 | plugins: [probingPlugin()] as const, |
| 3425 | 3488 | tenant: "a", |
| 3426 | 3489 | subject: "user:b", |
| @@ -49,7 +49,23 @@ const unscopedExecutorTable = <const TColumns extends UserColumns>( · 48 unmodified lines | ||
| 49 | 49 | id: keyColumn("id"), |
| 50 | 50 | }); |
| 51 | 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 | 71 | /** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */ |
| @@ -149,10 +149,9 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass<I · 148 unmodified lines | ||
| 149 | 149 | } |
| 150 | 150 | } |
| 151 | 151 | |
| 152 | −/** A connection create or workspace-level mutation (an `owner: "org"` row, or | |
| 153 | − * the tenant-shared integration catalog) was attempted on an executor bound | |
| 154 | − * with `orgWrites: "denied"` — a member who may USE workspace resources but | |
| 155 | − * not configure them. Reads, tool execution, and operational writes (token | |
| 152 | +/** A user-intent settings mutation was denied by the product's | |
| 153 | + * `ExecutorAccess.settingsWrite` decision for its target. | |
| 154 | + * Reads, tool execution, and operational writes (token | |
| 156 | 155 | * refresh, catalog re-sync) are unaffected; only the user-intent settings |
| 157 | 156 | * surfaces raise this. */ |
| 158 | 157 | export class OrgWriteDeniedError |
| @@ -20,6 +20,7 @@ import type { CredentialProvider } from "./provider"; · 19 unmodified lines | ||
| 20 | 20 | import { IntegrationDetectionResult } from "./types"; |
| 21 | 21 | import { makeTestConfig, makeTestExecutor, memoryCredentialsPlugin } from "./testing"; |
| 22 | 22 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 23 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 23 | 24 | |
| 24 | 25 | // removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id |
| 25 | 26 | // resolution, secrets/sources/scope-stack. The integration coverage below is |
| @@ -215,6 +216,7 @@ describe("createExecutor", () => { · 189 unmodified lines | ||
| 215 | 216 | it.effect("rolls back plugin and core writes from ctx.transaction failures", () => |
| 216 | 217 | Effect.gen(function* () { |
| 217 | 218 | const executor = yield* makeTestExecutor({ |
| 219 | + access: testAccess.member(), | |
| 218 | 220 | plugins: [demoPlugin] as const, |
| 219 | 221 | }); |
| 220 | 222 | const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites()); |
| @@ -237,6 +239,7 @@ describe("createExecutor", () => { · 16 unmodified lines | ||
| 237 | 239 | close: () => Effect.sync(() => void (closed = true)), |
| 238 | 240 | }))(); |
| 239 | 241 | const executor = yield* makeTestExecutor({ |
| 242 | + access: testAccess.member(), | |
| 240 | 243 | plugins: [closingPlugin] as const, |
| 241 | 244 | }); |
| 242 | 245 | yield* executor.close(); |
| @@ -248,6 +251,7 @@ describe("createExecutor", () => { · 5 unmodified lines | ||
| 248 | 251 | Effect.gen(function* () { |
| 249 | 252 | const events: Array<{ kind: string; pluginKey: string; slug: string }> = []; |
| 250 | 253 | const executor = yield* makeTestExecutor({ |
| 254 | + access: testAccess.member(), | |
| 251 | 255 | plugins: [demoPlugin] as const, |
| 252 | 256 | onIntegrationChange: (event) => |
| 253 | 257 | Effect.sync(() => { |
| @@ -281,6 +285,7 @@ describe("createExecutor", () => { · 27 unmodified lines | ||
| 281 | 285 | it.effect("a failing onIntegrationChange observer never fails the operation", () => |
| 282 | 286 | Effect.gen(function* () { |
| 283 | 287 | const executor = yield* makeTestExecutor({ |
| 288 | + access: testAccess.member(), | |
| 284 | 289 | plugins: [demoPlugin] as const, |
| 285 | 290 | onIntegrationChange: () => Effect.die("observer exploded"), |
| 286 | 291 | }); |
| @@ -294,6 +299,7 @@ describe("createExecutor", () => { · 7 unmodified lines | ||
| 294 | 299 | Effect.gen(function* () { |
| 295 | 300 | const events: string[] = []; |
| 296 | 301 | const executor = yield* makeTestExecutor({ |
| 302 | + access: testAccess.member(), | |
| 297 | 303 | plugins: [demoPlugin] as const, |
| 298 | 304 | onIntegrationChange: (event) => Effect.sync(() => void events.push(String(event.slug))), |
| 299 | 305 | }); |
| @@ -306,6 +312,7 @@ describe("createExecutor", () => { · 6 unmodified lines | ||
| 306 | 312 | it.effect("projects core tools as the built-in Executor integration", () => |
| 307 | 313 | Effect.gen(function* () { |
| 308 | 314 | const executor = yield* makeTestExecutor({ |
| 315 | + access: testAccess.member(), | |
| 309 | 316 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 310 | 317 | }); |
| 311 | 318 | const integrations = yield* executor.integrations.list(); |
| @@ -351,6 +358,7 @@ describe("createExecutor", () => { · 39 unmodified lines | ||
| 351 | 358 | it.effect("can omit provider tools from the built-in Executor integration", () => |
| 352 | 359 | Effect.gen(function* () { |
| 353 | 360 | const executor = yield* makeTestExecutor({ |
| 361 | + access: testAccess.member(), | |
| 354 | 362 | coreTools: { |
| 355 | 363 | webBaseUrl: "http://localhost:3000", |
| 356 | 364 | includeProviders: false, |
| @@ -372,6 +380,7 @@ describe("createExecutor", () => { · 15 unmodified lines | ||
| 372 | 380 | it.effect("creates provider-backed connections through the built-in Executor tools", () => |
| 373 | 381 | Effect.gen(function* () { |
| 374 | 382 | const executor = yield* makeTestExecutor({ |
| 383 | + access: testAccess.member(), | |
| 375 | 384 | plugins: [demoPlugin] as const, |
| 376 | 385 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 377 | 386 | }); |
| @@ -414,6 +423,7 @@ describe("createExecutor", () => { · 36 unmodified lines | ||
| 414 | 423 | it.effect("creates a provider-backed legacy slug-none connection through the core tool", () => |
| 415 | 424 | Effect.gen(function* () { |
| 416 | 425 | const executor = yield* makeTestExecutor({ |
| 426 | + access: testAccess.member(), | |
| 417 | 427 | plugins: [demoPlugin] as const, |
| 418 | 428 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 419 | 429 | }); |
| @@ -466,6 +476,7 @@ describe("createExecutor", () => { · 46 unmodified lines | ||
| 466 | 476 | it.effect("removes catalog integrations through the built-in Executor tools", () => |
| 467 | 477 | Effect.gen(function* () { |
| 468 | 478 | const executor = yield* makeTestExecutor({ |
| 479 | + access: testAccess.member(), | |
| 469 | 480 | plugins: [demoPlugin] as const, |
| 470 | 481 | coreTools: {}, |
| 471 | 482 | }); |
| @@ -526,6 +537,7 @@ describe("createExecutor", () => { · 54 unmodified lines | ||
| 526 | 537 | it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () => |
| 527 | 538 | Effect.gen(function* () { |
| 528 | 539 | const executor = yield* makeTestExecutor({ |
| 540 | + access: testAccess.member(), | |
| 529 | 541 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, |
| 530 | 542 | coreTools: {}, |
| 531 | 543 | }); |
| @@ -604,6 +616,7 @@ describe("createExecutor", () => { · 72 unmodified lines | ||
| 604 | 616 | it.effect("preserves actionable health from an incomplete tool catalog", () => |
| 605 | 617 | Effect.gen(function* () { |
| 606 | 618 | const executor = yield* makeTestExecutor({ |
| 619 | + access: testAccess.member(), | |
| 607 | 620 | plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, |
| 608 | 621 | coreTools: {}, |
| 609 | 622 | }); |
| @@ -639,6 +652,7 @@ describe("createExecutor", () => { · 29 unmodified lines | ||
| 639 | 652 | it.effect("hands pasted credential entry to the web UI", () => |
| 640 | 653 | Effect.gen(function* () { |
| 641 | 654 | const executor = yield* makeTestExecutor({ |
| 655 | + access: testAccess.member(), | |
| 642 | 656 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 643 | 657 | }); |
| 644 | 658 | |
| @@ -672,6 +686,7 @@ describe("createExecutor", () => { · 27 unmodified lines | ||
| 672 | 686 | Effect.gen(function* () { |
| 673 | 687 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 674 | 688 | const executor = yield* makeTestExecutor({ |
| 689 | + access: testAccess.member(), | |
| 675 | 690 | plugins: [demoPlugin] as const, |
| 676 | 691 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 677 | 692 | redirectUri: null, |
| @@ -739,7 +754,7 @@ describe("createExecutor", () => { · 61 unmodified lines | ||
| 739 | 754 | detector("high-detector", "high"), |
| 740 | 755 | detector("medium-detector", "medium"), |
| 741 | 756 | ] as const; |
| 742 | − const executor = yield* makeTestExecutor({ plugins }); | |
| 757 | + const executor = yield* makeTestExecutor({ access: testAccess.member(), plugins }); | |
| 743 | 758 | const results = yield* executor.integrations.detect("https://example.com/thing"); |
| 744 | 759 | // Every detector recognizes the URL; the list contains all three. |
| 745 | 760 | expect(results.map((r) => r.kind).sort()).toEqual([ |
| @@ -753,6 +768,7 @@ describe("createExecutor", () => { · 7 unmodified lines | ||
| 753 | 768 | it.effect("tools.schema returns roots with shared reachable definitions", () => |
| 754 | 769 | Effect.gen(function* () { |
| 755 | 770 | const executor = yield* makeTestExecutor({ |
| 771 | + access: testAccess.member(), | |
| 756 | 772 | plugins: [demoPlugin] as const, |
| 757 | 773 | }); |
| 758 | 774 | yield* executor.demo.seed(); |
| @@ -778,6 +794,7 @@ describe("createExecutor", () => { · 19 unmodified lines | ||
| 778 | 794 | it.effect("execute dispatches a connection-produced tool to the owning plugin", () => |
| 779 | 795 | Effect.gen(function* () { |
| 780 | 796 | const executor = yield* makeTestExecutor({ |
| 797 | + access: testAccess.member(), | |
| 781 | 798 | plugins: [demoPlugin] as const, |
| 782 | 799 | }); |
| 783 | 800 | yield* executor.demo.seed(); |
| @@ -800,6 +817,7 @@ describe("createExecutor", () => { · 16 unmodified lines | ||
| 800 | 817 | it.effect("execute on a missing address fails with ToolNotFoundError", () => |
| 801 | 818 | Effect.gen(function* () { |
| 802 | 819 | const executor = yield* makeTestExecutor({ |
| 820 | + access: testAccess.member(), | |
| 803 | 821 | plugins: [demoPlugin] as const, |
| 804 | 822 | }); |
| 805 | 823 | yield* executor.demo.seed(); |
| @@ -843,6 +861,7 @@ describe("createExecutor", () => { · 37 unmodified lines | ||
| 843 | 861 | describe("muscle memory (observed output shapes)", () => { |
| 844 | 862 | const provisioned = Effect.fn(function* () { |
| 845 | 863 | const executor = yield* makeTestExecutor({ |
| 864 | + access: testAccess.member(), | |
| 846 | 865 | plugins: [demoPlugin] as const, |
| 847 | 866 | coreTools: { webBaseUrl: "http://localhost:3000" }, |
| 848 | 867 | }); |
| @@ -1049,7 +1068,7 @@ const seedRunConnection = < · 200 unmodified lines | ||
| 1049 | 1068 | const recordToolRowLaunch = (maxOps?: number) => |
| 1050 | 1069 | Effect.gen(function* () { |
| 1051 | 1070 | const recorder = makeReadOrderRecorder(); |
| 1052 | − const config = makeTestConfig({ plugins: [demoPlugin] as const }); | |
| 1071 | + const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const }); | |
| 1053 | 1072 | const executor = yield* createExecutor({ |
| 1054 | 1073 | ...config, |
| 1055 | 1074 | db: withRecordedReads(config.db, recorder.record), |
| @@ -1073,6 +1092,7 @@ const recordCredentialLaunch = (maxOps?: number) => · 17 unmodified lines | ||
| 1073 | 1092 | const calls = { count: 0 }; |
| 1074 | 1093 | const provider = countingProvider(calls, (run) => recorder.record("credential.get", run)); |
| 1075 | 1094 | const config = makeTestConfig({ |
| 1095 | + access: testAccess.member(), | |
| 1076 | 1096 | plugins: [invokeConcurrencyPlugin(provider)] as const, |
| 1077 | 1097 | }); |
| 1078 | 1098 | const executor = yield* createExecutor({ |
| @@ -1199,6 +1219,7 @@ describe("execute read concurrency", () => { · 120 unmodified lines | ||
| 1199 | 1219 | Effect.gen(function* () { |
| 1200 | 1220 | const calls = { count: 0 }; |
| 1201 | 1221 | const executor = yield* makeTestExecutor({ |
| 1222 | + access: testAccess.member(), | |
| 1202 | 1223 | plugins: [invokeConcurrencyPlugin(countingProvider(calls))] as const, |
| 1203 | 1224 | }); |
| 1204 | 1225 | yield* seedRunConnection(executor); |
| @@ -1227,7 +1248,10 @@ describe("execute read concurrency", () => { · 22 unmodified lines | ||
| 1227 | 1248 | |
| 1228 | 1249 | it.effect("fails with ConnectionNotFoundError when the tool row outlives its connection", () => |
| 1229 | 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 | 1255 | const executor = yield* createExecutor(config); |
| 1232 | 1256 | yield* seedRunConnection(executor); |
| 1233 | 1257 | |
| @@ -1367,7 +1391,10 @@ describe("speculative read abandonment", () => { · 133 unmodified lines | ||
| 1367 | 1391 | it.effect("unknown-tool error surfaces while the speculative reads hang forever", () => |
| 1368 | 1392 | Effect.gen(function* () { |
| 1369 | 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 | 1398 | const executor = yield* createExecutor({ |
| 1372 | 1399 | ...config, |
| 1373 | 1400 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1393,7 +1420,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines | ||
| 1393 | 1420 | it.effect("a blocked tool reports ToolBlockedError while the connection read hangs", () => |
| 1394 | 1421 | Effect.gen(function* () { |
| 1395 | 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 | 1427 | const executor = yield* createExecutor({ |
| 1398 | 1428 | ...config, |
| 1399 | 1429 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1419,7 +1449,10 @@ describe("speculative read abandonment", () => { · 19 unmodified lines | ||
| 1419 | 1449 | it.effect("failing speculative reads neither mask the branch error nor unhandled-reject", () => |
| 1420 | 1450 | Effect.gen(function* () { |
| 1421 | 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 | 1456 | const executor = yield* createExecutor({ |
| 1424 | 1457 | ...config, |
| 1425 | 1458 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1452,7 +1485,10 @@ describe("speculative read abandonment", () => { · 26 unmodified lines | ||
| 1452 | 1485 | () => |
| 1453 | 1486 | Effect.gen(function* () { |
| 1454 | 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 | 1492 | const executor = yield* createExecutor({ |
| 1457 | 1493 | ...config, |
| 1458 | 1494 | db: withFaultedReads(config.db, faults.fault), |
| @@ -1479,6 +1515,7 @@ describe("speculative read abandonment", () => { · 20 unmodified lines | ||
| 1479 | 1515 | const faults = makeReadFaults(); |
| 1480 | 1516 | const armable = armableFailingProvider(); |
| 1481 | 1517 | const config = makeTestConfig({ |
| 1518 | + access: testAccess.member(), | |
| 1482 | 1519 | plugins: [invokeConcurrencyPlugin(armable.provider)] as const, |
| 1483 | 1520 | }); |
| 1484 | 1521 | const executor = yield* createExecutor({ |
| @@ -62,7 +62,6 @@ import { · 61 unmodified lines | ||
| 62 | 62 | type OAuthClientRow, |
| 63 | 63 | type ToolInvocationRow, |
| 64 | 64 | type ToolRow, |
| 65 | − type ToolPolicyRow, | |
| 66 | 65 | } from "./core-schema"; |
| 67 | 66 | import { |
| 68 | 67 | ElicitationDeclinedError, |
| @@ -73,7 +72,7 @@ import { · 4 unmodified lines | ||
| 73 | 72 | type OnElicitation, |
| 74 | 73 | type InvokeOptions, |
| 75 | 74 | } from "./elicitation"; |
| 76 | −import { currentOrgWriteAccess, type OrgWriteAccess } from "./org-write-access"; | |
| 75 | +import { executorAccessViolation, type ExecutorAccess, type ToolPolicyEvaluator } from "./access"; | |
| 77 | 76 | import { |
| 78 | 77 | restoreCredentialSnapshotsWithRecheck, |
| 79 | 78 | snapshotCredentialWrites, |
| @@ -155,9 +154,7 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; · 75 unmodified lines | ||
| 155 | 154 | import { |
| 156 | 155 | comparePolicyRow, |
| 157 | 156 | isValidPattern, |
| 158 | − matchPattern, | |
| 159 | 157 | positionForNewPattern, |
| 160 | − resolveEffectivePolicy, | |
| 161 | 158 | rowToToolPolicy, |
| 162 | 159 | type CreateToolPolicyInput, |
| 163 | 160 | type EffectivePolicy, |
| @@ -181,7 +178,6 @@ import type { · 17 unmodified lines | ||
| 181 | 178 | StaticToolDecl, |
| 182 | 179 | StorageDeps, |
| 183 | 180 | ToolPolicyProvider, |
| 184 | − ToolPolicyProviderRule, | |
| 185 | 181 | ToolInvocationCredential, |
| 186 | 182 | } from "./plugin"; |
| 187 | 183 | import { |
| @@ -493,10 +489,11 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines | ||
| 493 | 489 | |
| 494 | 490 | /** |
| 495 | 491 | * The PLATFORM VIEW: read-only, tenant-wide reads across every subject. |
| 496 | − * Present only when the executor was built with `platformView: true` | |
| 497 | − * (default off) — every other surface on this executor stays bound to the | |
| 498 | − * single `{ tenant, subject }` product view and is unaffected by this one. | |
| 499 | − * Internal admin surface; the public HTTP shape is a separate concern. | |
| 492 | + * Present only when the executor was built with a product access granting | |
| 493 | + * `capabilities.adminReads` — every other surface on this executor stays | |
| 494 | + * bound to the single `{ tenant, subject }` product view and is unaffected | |
| 495 | + * by this one. Internal admin surface; the public HTTP shape is a separate | |
| 496 | + * concern. | |
| 500 | 497 | */ |
| 501 | 498 | readonly admin?: ExecutorAdmin; |
| 502 | 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 | 808 | readonly onIntegrationChange?: (event: IntegrationChangeEvent) => Effect.Effect<void>; |
| 812 | 809 | /** |
| 813 | − * Opt into the PLATFORM VIEW: a read-only, tenant-wide `executor.admin` | |
| 814 | − * surface that reads across every subject in the tenant (see | |
| 815 | − * {@link ExecutorAdmin}). Default OFF — `admin` is simply absent, so the | |
| 816 | − * escape hatch has to be asked for by a host that has authorized an | |
| 817 | − * org-level caller. | |
| 810 | + * The product's access decisions for this binding — REQUIRED, with no core | |
| 811 | + * default. The product (host composition root) states which owner | |
| 812 | + * partitions this binding reads and in what precedence, whether it may | |
| 813 | + * configure workspace-level state (consulted live at every guarded sink), | |
| 814 | + * and its view capabilities (`capabilities.adminReads` exposes the | |
| 815 | + * tenant-observing `admin` surface, whose reads span every subject; | |
| 816 | + * `capabilities.storageWrites` sets the storage posture). | |
| 817 | + * Core validates the shape against the binding, enforces the decisions at | |
| 818 | + * its sinks, and clamps everything with the storage owner policy — tenant | |
| 819 | + * isolation is never product-configurable. See {@link ExecutorAccess} and | |
| 820 | + * `@executor-js/product-access` for the product rule implementations. | |
| 818 | 821 | * |
| 819 | − * Enabling it makes the WHOLE executor read-only, not just `admin`: the base | |
| 820 | − * owner context carries `writes: "denied"`, so `connections`, `policies`, | |
| 821 | − * `integrations` and `oauth` refuse every create/update/delete at the storage | |
| 822 | − * boundary. An executor built for an org-level caller is an observer, and | |
| 823 | − * `admin` being its only tenant-wide surface is not the same as it being its | |
| 824 | − * only guarded one. | |
| 825 | − * | |
| 826 | − * READS still differ by surface: only `admin` is tenant-wide. Every other | |
| 827 | − * surface stays bound to `{ tenant, subject }` — widening them would expose | |
| 828 | − * every subject's connection rows, credential item ids included. | |
| 829 | − */ | |
| 830 | − readonly platformView?: boolean; | |
| 831 | − /** | |
| 832 | − * Whether this binding may CONFIGURE workspace-level state: `owner: "org"` | |
| 833 | − * rows (shared connections, org tool policies, org OAuth clients) and the | |
| 834 | − * tenant-shared integration catalog. Hosts derive it from the acting | |
| 835 | − * member's role — admins bind `"allowed"`, plain members `"denied"`. | |
| 836 | − * `"request"` reads the fiber-local {@link CurrentOrgWriteAccess} at every | |
| 837 | − * guarded sink. Session hosts bind that reference from the freshly | |
| 838 | − * authenticated request without caching a positive authorization decision | |
| 839 | − * for the session lifetime. | |
| 840 | − * Defaults to `"allowed"` for non-session callers with no role model | |
| 841 | − * (local's single user, the CLI, tests). | |
| 842 | − * | |
| 843 | − * `"denied"` gates only the USER-INTENT settings surfaces (`policies`, | |
| 844 | − * `connections` create/update/remove in Workspace scope, `integrations` | |
| 845 | − * update/remove/healthCheck, OAuth client CRUD and connect flows in Workspace | |
| 846 | − * scope, new-integration registration). Members may still create and manage | |
| 847 | − * Personal connections and OAuth apps. They also USE workspace resources: | |
| 848 | − * reads, tool execution over org connections, and the operational writes | |
| 849 | − * those imply (token refresh, tool-catalog re-sync) are deliberately | |
| 850 | − * untouched — which is why this is a surface gate, not a storage-policy axis | |
| 851 | − * like `platformView`'s blanket `writes: "denied"`. | |
| 822 | + * `capabilities.storageWrites: "denied"` makes the WHOLE executor | |
| 823 | + * read-only, not just `admin`: the base owner context carries | |
| 824 | + * `writes: "denied"`, so `connections`, `policies`, `integrations` and | |
| 825 | + * `oauth` refuse every create/update/delete at the storage boundary. READS | |
| 826 | + * still differ by surface: only `admin` is tenant-wide; every other | |
| 827 | + * surface stays bound to `{ tenant, subject }` — widening them would | |
| 828 | + * expose every subject's connection rows, credential item ids included. | |
| 852 | 829 | */ |
| 853 | − readonly orgWrites?: OrgWriteAccess | "request"; | |
| 830 | + readonly access: ExecutorAccess; | |
| 854 | 831 | } |
| 855 | 832 | |
| 856 | 833 | /** Default freshness window for remote-catalog connections (see |
| @@ -1522,9 +1499,11 @@ const makePluginStorageFacade = (input: { · 665 unmodified lines | ||
| 1522 | 1499 | readonly core: CoreDb; |
| 1523 | 1500 | readonly pluginId: string; |
| 1524 | 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 | 1505 | }): PluginStorageFacade => { |
| 1526 | − // Owner partitions: org always, plus this subject's user partition. | |
| 1527 | − const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"]; | |
| 1506 | + const readOwners = input.readOwners; | |
| 1528 | 1507 | |
| 1529 | 1508 | const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => { |
| 1530 | 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 | 1544 | const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => |
| 1561 | 1545 | [...rows].sort((left, right) => { |
| @@ -1919,6 +1903,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 357 unmodified lines | ||
| 1919 | 1903 | const tenant = String(config.tenant); |
| 1920 | 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 | 1919 | const ownerBinding: OwnerBinding = { |
| 1923 | 1920 | tenant: config.tenant, |
| 1924 | 1921 | subject: config.subject ?? null, |
| @@ -1946,17 +1943,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 21 unmodified lines | ||
| 1946 | 1943 | ) |
| 1947 | 1944 | : Effect.void; |
| 1948 | 1945 | |
| 1949 | − // Workspace-settings gate (`ExecutorConfig.orgWrites`). Called at the top | |
| 1950 | − // of every user-intent workspace-level mutation: with an explicit owner it | |
| 1951 | − // refuses only `"org"` targets; with no owner it guards a tenant-shared | |
| 1952 | − // surface outright. Deliberately NOT wired into the storage owner policy — | |
| 1953 | − // operational org-row writes (token refresh, tool-catalog re-sync) must | |
| 1954 | − // keep working for a denied member. | |
| 1946 | + // Settings gate (`ExecutorAccess.settingsWrite`). Called at the top of | |
| 1947 | + // every user-intent settings mutation with the TARGET core is about to | |
| 1948 | + // touch — an owner-scoped row, or (with no owner) a tenant-shared | |
| 1949 | + // workspace surface. WHICH targets a given principal may configure is | |
| 1950 | + // entirely the product's rule; core only describes the target, asks | |
| 1951 | + // live on every call (a request-bound implementation is re-read after | |
| 1952 | + // pauses and on resume; nothing is cached), and enforces a denial as | |
| 1953 | + // `OrgWriteDeniedError`. Deliberately NOT wired into the storage owner | |
| 1954 | + // policy — operational org-row writes (token refresh, tool-catalog | |
| 1955 | + // re-sync) must keep working for a settings-denied member. | |
| 1955 | 1956 | const guardOrgWrite = (owner?: Owner): Effect.Effect<void, OrgWriteDeniedError> => |
| 1956 | 1957 | Effect.gen(function* () { |
| 1957 | − const access = | |
| 1958 | − config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites; | |
| 1959 | − if (access === "denied" && (owner === undefined || owner === "org")) { | |
| 1958 | + const decision = yield* access.settingsWrite( | |
| 1959 | + owner === undefined ? { kind: "workspace" } : { kind: "owner", owner }, | |
| 1960 | + ); | |
| 1961 | + if (decision === "denied") { | |
| 1960 | 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 | 2012 | const ownerContext: ExecutorOwnerPolicyContext = { |
| 2011 | 2013 | tenant, |
| 2012 | 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 | 2021 | const rootDb = withQueryContext(rootDbUntyped, ownerContext); |
| 2016 | 2022 | // Shared across executors over one database, so the gate key must carry the |
| @@ -2035,7 +2041,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines | ||
| 2035 | 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 | 2069 | const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect); |
| 2040 | 2070 | |
| 2041 | 2071 | // Runtime-observed output shapes ("muscle memory"): learned on the |
| @@ -2046,6 +2076,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines | ||
| 2046 | 2076 | core, |
| 2047 | 2077 | pluginId: SHAPE_MEMORY_PLUGIN_ID, |
| 2048 | 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 | 2093 | // runtime can recognize and retry a row stranded before its write ran. |
| 2063 | 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 | 2099 | const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName => |
| 2067 | 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 | 3309 | const existing = yield* findIntegrationRow(input.slug); |
| 3277 | 3310 | const config = input.config === undefined ? null : input.config; |
| 3278 | 3311 | if (existing) { |
| 3279 | − // Extension methods also run for subjectless boot/system executors, | |
| 3280 | − // which must be able to converge existing catalog rows. A bound | |
| 3281 | − // subject is an end-user principal, so its replacement is the same | |
| 3282 | − // workspace mutation as creation and requires the live role guard. | |
| 3283 | − if (subject !== null) yield* guardOrgWrite(); | |
| 3312 | + // Replacement of an existing catalog row is the same workspace | |
| 3313 | + // mutation as creation. WHO may perform it — an end-user admin, or | |
| 3314 | + // a subject-less boot-convergence service — is entirely the | |
| 3315 | + // product's `settingsWrite` rule (workspaceServiceAccess allows | |
| 3316 | + // it); core keeps no subject-less exemption of its own. | |
| 3317 | + yield* guardOrgWrite(); | |
| 3284 | 3318 | yield* core.updateMany("integration", { |
| 3285 | 3319 | where: (b: AnyCb) => b("slug", "=", String(input.slug)), |
| 3286 | 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 = | |
| 5394 | − | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] } | |
| 5395 | − | { | |
| 5396 | − readonly kind: "provider"; | |
| 5397 | − readonly provider: ToolPolicyProvider; | |
| 5398 | − readonly rules: readonly ToolPolicyProviderRule[] | null; | |
| 5399 | − } | |
| 5400 | − | { | |
| 5401 | − readonly kind: "prepared"; | |
| 5402 | − readonly resolve: (input: { | |
| 5403 | − readonly toolId: string; | |
| 5404 | − readonly defaultRequiresApproval?: boolean; | |
| 5405 | − }) => EffectivePolicy; | |
| 5406 | − }; | |
| 5407 | − | |
| 5408 | − const compareProviderPolicyRule = ( | |
| 5409 | − a: ToolPolicyProviderRule, | |
| 5410 | − b: ToolPolicyProviderRule, | |
| 5411 | − ): number => { | |
| 5412 | − if (a.position < b.position) return -1; | |
| 5413 | − if (a.position > b.position) return 1; | |
| 5414 | − return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | |
| 5415 | − }; | |
| 5416 | − | |
| 5417 | − const resolveProviderPolicyFromRules = ( | |
| 5418 | − toolId: string, | |
| 5419 | − rules: readonly ToolPolicyProviderRule[], | |
| 5420 | − ): EffectivePolicy => { | |
| 5421 | − for (const rule of [...rules].sort(compareProviderPolicyRule)) { | |
| 5422 | − if (!matchPattern(rule.pattern, toolId)) continue; | |
| 5423 | − return { | |
| 5424 | − action: rule.action, | |
| 5425 | − source: "user", | |
| 5426 | − pattern: rule.pattern, | |
| 5427 | − policyId: rule.id, | |
| 5428 | − }; | |
| 5429 | − } | |
| 5430 | − // Toolkit-style providers are capability allowlists. No matching rule | |
| 5431 | − // means the tool is outside the capability boundary. | |
| 5432 | − return { | |
| 5433 | − action: "block", | |
| 5434 | − source: "user", | |
| 5435 | − pattern: "*", | |
| 5436 | − }; | |
| 5437 | − }; | |
| 5438 | − | |
| 5439 | − const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> => | |
| 5440 | − activeToolPolicyProvider | |
| 5441 | − ? // Batched per-operation resolver: fetch all policy + connection state | |
| 5442 | − // once, then resolve every tool in this operation against that | |
| 5443 | − // snapshot. Avoids the per-tool resolve N+1 on the list surface. | |
| 5444 | − activeToolPolicyProvider.prepare | |
| 5445 | − ? activeToolPolicyProvider.prepare().pipe( | |
| 5446 | − Effect.map((resolve) => ({ | |
| 5447 | − kind: "prepared" as const, | |
| 5448 | − resolve, | |
| 5449 | − })), | |
| 5450 | − ) | |
| 5451 | − : activeToolPolicyProvider.resolve | |
| 5452 | − ? Effect.succeed({ | |
| 5453 | − kind: "provider" as const, | |
| 5454 | − provider: activeToolPolicyProvider, | |
| 5455 | − rules: null, | |
| 5456 | − }) | |
| 5457 | − : activeToolPolicyProvider.list().pipe( | |
| 5458 | − Effect.map((rules) => ({ | |
| 5459 | − kind: "provider" as const, | |
| 5460 | − provider: activeToolPolicyProvider!, | |
| 5461 | − rules, | |
| 5462 | − })), | |
| 5463 | − ) | |
| 5464 | − : core | |
| 5465 | − .findMany("tool_policy", {}) | |
| 5466 | − .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); | |
| 5467 | − | |
| 5468 | − const resolvePolicyFromRuleSet = ( | |
| 5469 | − toolId: string, | |
| 5470 | − ruleSet: ActivePolicyRuleSet, | |
| 5471 | − defaultRequiresApproval?: boolean, | |
| 5472 | − ): Effect.Effect<EffectivePolicy, StorageFailure> => | |
| 5473 | − ruleSet.kind === "prepared" | |
| 5474 | − ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval })) | |
| 5475 | − : ruleSet.kind === "provider" | |
| 5476 | − ? ruleSet.provider.resolve | |
| 5477 | − ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) | |
| 5478 | − : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) | |
| 5479 | − : Effect.succeed( | |
| 5480 | − resolveEffectivePolicy( | |
| 5481 | − toolId, | |
| 5482 | − ruleSet.rows, | |
| 5483 | − ownerRankForRow, | |
| 5484 | − defaultRequiresApproval, | |
| 5485 | − ), | |
| 5486 | − ); | |
| 5435 | + const makeToolPolicyEvaluator = (): Effect.Effect<ToolPolicyEvaluator, StorageFailure> => | |
| 5436 | + access.toolPolicy({ | |
| 5437 | + policyRows: core.findMany("tool_policy", {}), | |
| 5438 | + provider: activeToolPolicyProvider, | |
| 5439 | + }); | |
| 5487 | 5440 | |
| 5488 | 5441 | // ------------------------------------------------------------------ |
| 5489 | 5442 | // Tools (read surface) |
| @@ -5528,7 +5481,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 38 unmodified lines | ||
| 5528 | 5481 | // network side effects on a read-only credential. Skip it entirely: |
| 5529 | 5482 | // read-only-ness of the platform read path is a stated invariant here, |
| 5530 | 5483 | // not an accident of the best-effort catch below. |
| 5531 | − if (config.platformView === true) return; | |
| 5484 | + if (access.capabilities.storageWrites === "denied") return; | |
| 5532 | 5485 | const integrations = yield* core.findMany("integration", {}); |
| 5533 | 5486 | if (integrations.length === 0) return; |
| 5534 | 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 | 5645 | select: TOOL_INVOCATION_COLUMNS, |
| 5693 | 5646 | }); |
| 5694 | 5647 | const includeBlocked = filter?.includeBlocked ?? false; |
| 5695 | − const policyRules = yield* listActivePolicyRuleSet(); | |
| 5648 | + const policyEvaluator = yield* makeToolPolicyEvaluator(); | |
| 5696 | 5649 | // Only tools whose integration is still in the catalog. A tool row |
| 5697 | 5650 | // whose integration was removed is an orphan (a removal that could |
| 5698 | 5651 | // not reach this subject's rows): listing it invites an invoke that |
| @@ -5704,11 +5657,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 5704 | 5657 | const tool = rowToTool(row); |
| 5705 | 5658 | if (!matchesToolFilter(tool, filter)) continue; |
| 5706 | 5659 | if (!includeBlocked) { |
| 5707 | − const effective = yield* resolvePolicyFromRuleSet( | |
| 5708 | − normalizedPolicyId(tool), | |
| 5709 | − policyRules, | |
| 5710 | − tool.annotations?.requiresApproval, | |
| 5711 | − ); | |
| 5660 | + const effective = yield* policyEvaluator.resolve({ | |
| 5661 | + toolId: normalizedPolicyId(tool), | |
| 5662 | + defaultRequiresApproval: tool.annotations?.requiresApproval, | |
| 5663 | + }); | |
| 5712 | 5664 | if (effective.action === "block") continue; |
| 5713 | 5665 | } |
| 5714 | 5666 | tools.push(tool); |
| @@ -5717,11 +5669,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | ||
| 5717 | 5669 | const tool = staticToolToTool(entry); |
| 5718 | 5670 | if (!matchesToolFilter(tool, filter)) continue; |
| 5719 | 5671 | if (!includeBlocked) { |
| 5720 | − const effective = yield* resolvePolicyFromRuleSet( | |
| 5721 | − normalizedPolicyId(tool), | |
| 5722 | − policyRules, | |
| 5723 | − tool.annotations?.requiresApproval, | |
| 5724 | − ); | |
| 5672 | + const effective = yield* policyEvaluator.resolve({ | |
| 5673 | + toolId: normalizedPolicyId(tool), | |
| 5674 | + defaultRequiresApproval: tool.annotations?.requiresApproval, | |
| 5675 | + }); | |
| 5725 | 5676 | if (effective.action === "block") continue; |
| 5726 | 5677 | } |
| 5727 | 5678 | tools.push(tool); |
| @@ -5733,15 +5684,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 5733 | 5684 | address: ToolAddress, |
| 5734 | 5685 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => |
| 5735 | 5686 | Effect.gen(function* () { |
| 5736 | − const policyRules = yield* listActivePolicyRuleSet(); | |
| 5687 | + const policyEvaluator = yield* makeToolPolicyEvaluator(); | |
| 5737 | 5688 | const staticEntry = staticTools.get(String(address)); |
| 5738 | 5689 | if (staticEntry) { |
| 5739 | 5690 | const tool = staticToolToTool(staticEntry); |
| 5740 | − const effective = yield* resolvePolicyFromRuleSet( | |
| 5741 | − normalizedPolicyId(tool), | |
| 5742 | − policyRules, | |
| 5743 | − tool.annotations?.requiresApproval, | |
| 5744 | − ); | |
| 5691 | + const effective = yield* policyEvaluator.resolve({ | |
| 5692 | + toolId: normalizedPolicyId(tool), | |
| 5693 | + defaultRequiresApproval: tool.annotations?.requiresApproval, | |
| 5694 | + }); | |
| 5745 | 5695 | if (effective.action === "block") return null; |
| 5746 | 5696 | const preview = yield* Effect.tryPromise({ |
| 5747 | 5697 | try: () => |
| @@ -5779,11 +5729,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines | ||
| 5779 | 5729 | }); |
| 5780 | 5730 | if (!row) return null; |
| 5781 | 5731 | const tool = rowToTool(row); |
| 5782 | − const effective = yield* resolvePolicyFromRuleSet( | |
| 5783 | − normalizedPolicyId(tool), | |
| 5784 | − policyRules, | |
| 5785 | − tool.annotations?.requiresApproval, | |
| 5786 | − ); | |
| 5732 | + const effective = yield* policyEvaluator.resolve({ | |
| 5733 | + toolId: normalizedPolicyId(tool), | |
| 5734 | + defaultRequiresApproval: tool.annotations?.requiresApproval, | |
| 5735 | + }); | |
| 5787 | 5736 | if (effective.action === "block") return null; |
| 5788 | 5737 | |
| 5789 | 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 => | |
| 5948 | − row.owner === "user" ? 0 : 1; | |
| 5898 | + const ownerListRank = (row: { readonly owner: string }): number => { | |
| 5899 | + const rank = accessOwners.indexOf(row.owner as Owner); | |
| 5900 | + return rank === -1 ? accessOwners.length : rank; | |
| 5901 | + }; | |
| 5949 | 5902 | |
| 5950 | 5903 | // Tool policies gate by tool identity (`<integration>.<tool>`), independent of |
| 5951 | 5904 | // which connection serves it; the org/user split is handled by owner-scoped |
| @@ -5961,7 +5914,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines | ||
| 5961 | 5914 | .pipe( |
| 5962 | 5915 | Effect.map((rows) => |
| 5963 | 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 | 5918 | .map(rowToToolPolicy), |
| 5966 | 5919 | ), |
| 5967 | 5920 | ); |
| @@ -6089,7 +6042,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 121 unmodified lines | ||
| 6089 | 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 | 6294 | // execute — the invoke path. |
| 6310 | 6295 | // ------------------------------------------------------------------ |
| @@ -6388,12 +6373,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 77 unmodified lines | ||
| 6388 | 6373 | // not the 5-segment dynamic form. |
| 6389 | 6374 | const staticEntry = staticTools.get(String(address)); |
| 6390 | 6375 | if (staticEntry) { |
| 6391 | − const policyRules = yield* listActivePolicyRuleSet(); | |
| 6392 | − const policy = yield* resolvePolicyFromRuleSet( | |
| 6393 | − String(address), | |
| 6394 | − policyRules, | |
| 6395 | − staticEntry.tool.annotations?.requiresApproval, | |
| 6396 | − ); | |
| 6376 | + const policyEvaluator = yield* makeToolPolicyEvaluator(); | |
| 6377 | + const policy = yield* policyEvaluator.resolve({ | |
| 6378 | + toolId: String(address), | |
| 6379 | + defaultRequiresApproval: staticEntry.tool.annotations?.requiresApproval, | |
| 6380 | + }); | |
| 6397 | 6381 | if (policy.action === "block") { |
| 6398 | 6382 | return yield* new ToolBlockedError({ |
| 6399 | 6383 | address, |
| @@ -6401,6 +6385,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line | ||
| 6401 | 6385 | }); |
| 6402 | 6386 | } |
| 6403 | 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 | 6395 | return yield* wrapInvocationError( |
| 6405 | 6396 | staticEntry.tool.handler({ |
| 6406 | 6397 | ctx: staticEntry.ctx, |
| @@ -6472,7 +6463,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines | ||
| 6472 | 6463 | }), |
| 6473 | 6464 | { startImmediately: true }, |
| 6474 | 6465 | ); |
| 6475 | − const policyRulesFiber = yield* Effect.forkChild(listActivePolicyRuleSet()); | |
| 6466 | + const policyEvaluatorFiber = yield* Effect.forkChild(makeToolPolicyEvaluator()); | |
| 6476 | 6467 | const connectionRowFiber = yield* Effect.forkChild( |
| 6477 | 6468 | findConnectionRow({ |
| 6478 | 6469 | owner: parsed.owner, |
| @@ -6508,13 +6499,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | ||
| 6508 | 6499 | |
| 6509 | 6500 | // Resolve policy (owner-ranked). |
| 6510 | 6501 | const toolForPolicy = rowToTool(row); |
| 6511 | − const policyRules = yield* Fiber.join(policyRulesFiber); | |
| 6502 | + const policyEvaluator = yield* Fiber.join(policyEvaluatorFiber); | |
| 6512 | 6503 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; |
| 6513 | − const policy = yield* resolvePolicyFromRuleSet( | |
| 6514 | − normalizedPolicyId(toolForPolicy), | |
| 6515 | − policyRules, | |
| 6516 | − annotations?.requiresApproval, | |
| 6517 | − ); | |
| 6504 | + const policy = yield* policyEvaluator.resolve({ | |
| 6505 | + toolId: normalizedPolicyId(toolForPolicy), | |
| 6506 | + defaultRequiresApproval: annotations?.requiresApproval, | |
| 6507 | + }); | |
| 6518 | 6508 | if (policy.action === "block") { |
| 6519 | 6509 | return yield* new ToolBlockedError({ |
| 6520 | 6510 | address, |
| @@ -6571,6 +6561,56 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines | ||
| 6571 | 6561 | } |
| 6572 | 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 | 6614 | // Resolve every named credential input (`variable → value`); `value` is |
| 6575 | 6615 | // the primary `token` for single-input + OAuth callers. The |
| 6576 | 6616 | // integration-row read is independent of credential resolution, so |
| @@ -6594,9 +6634,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines | ||
| 6594 | 6634 | // integration read cannot gate the credential error and a failed |
| 6595 | 6635 | // one is deliberately abandoned, never silently dropped as an |
| 6596 | 6636 | // unobserved value. |
| 6597 | − const valuesFiber = yield* Effect.forkChild(resolveConnectionValues(connectionRow), { | |
| 6598 | − startImmediately: true, | |
| 6599 | − }); | |
| 6637 | + const valuesFiber = yield* Effect.forkChild( | |
| 6638 | + resolveConnectionValues(approvedConnectionRow), | |
| 6639 | + { | |
| 6640 | + startImmediately: true, | |
| 6641 | + }, | |
| 6642 | + ); | |
| 6600 | 6643 | const integrationRowFiber = yield* Effect.forkChild( |
| 6601 | 6644 | findIntegrationRow(parsed.integration), |
| 6602 | 6645 | ); |
| @@ -6611,7 +6654,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines | ||
| 6611 | 6654 | if (!integrationRow) { |
| 6612 | 6655 | return yield* new IntegrationNotFoundError({ slug: parsed.integration }); |
| 6613 | 6656 | } |
| 6614 | − const grantedScopes = grantedScopesFromRow(connectionRow); | |
| 6657 | + const grantedScopes = grantedScopesFromRow(approvedConnectionRow); | |
| 6615 | 6658 | const invokeTool = runtime.plugin.invokeTool; |
| 6616 | 6659 | const invokeWith = ( |
| 6617 | 6660 | resolved: Record<string, string | null>, |
| @@ -6620,7 +6663,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | ||
| 6620 | 6663 | owner: parsed.owner, |
| 6621 | 6664 | integration: parsed.integration, |
| 6622 | 6665 | connection: parsed.connection, |
| 6623 | − template: AuthTemplateSlug.make(connectionRow.template), | |
| 6666 | + template: AuthTemplateSlug.make(approvedConnectionRow.template), | |
| 6624 | 6667 | value: resolved[PRIMARY_INPUT_VARIABLE] ?? null, |
| 6625 | 6668 | values: resolved, |
| 6626 | 6669 | config: decodeJsonColumn(integrationRow.config), |
| @@ -6629,7 +6672,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | ||
| 6629 | 6672 | return wrapInvocationError( |
| 6630 | 6673 | invokeTool({ |
| 6631 | 6674 | ctx: runtime.ctx, |
| 6632 | − toolRow: row, | |
| 6675 | + toolRow: approvedToolRow, | |
| 6633 | 6676 | credential, |
| 6634 | 6677 | args, |
| 6635 | 6678 | elicit: buildElicit(address, args, handler), |
| @@ -6656,7 +6699,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines | ||
| 6656 | 6699 | // guidance rather than a masked one. |
| 6657 | 6700 | const { result, usedValues } = yield* Effect.gen(function* () { |
| 6658 | 6701 | if (!isUnauthorizedToolFailure(first)) return { result: first, usedValues: values }; |
| 6659 | − const refreshed = yield* forceRefreshConnectionValues(connectionRow).pipe( | |
| 6702 | + const refreshed = yield* forceRefreshConnectionValues(approvedConnectionRow).pipe( | |
| 6660 | 6703 | // A failed re-mint is not this call's failure to report: the upstream |
| 6661 | 6704 | // already produced an auth failure with recovery guidance, which is |
| 6662 | 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 | 6714 | usedValues: refreshed, |
| 6672 | 6715 | }; |
| 6673 | 6716 | }); |
| 6674 | − yield* healPersistedHealthOnUse(connectionRow, result, usedValues); | |
| 6717 | + yield* healPersistedHealthOnUse(approvedConnectionRow, result, usedValues); | |
| 6675 | 6718 | return result; |
| 6676 | 6719 | }); |
| 6677 | 6720 | // Interrupting an already-completed (or already-joined) fiber is a |
| @@ -6684,7 +6727,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6 unmodified lines | ||
| 6684 | 6727 | // so an interruption that lands before the join reaches it promptly. |
| 6685 | 6728 | return yield* Effect.ensuring( |
| 6686 | 6729 | invokeDynamicTool, |
| 6687 | − Fiber.interruptAll([toolRowFiber, policyRulesFiber, connectionRowFiber]), | |
| 6730 | + Fiber.interruptAll([toolRowFiber, policyEvaluatorFiber, connectionRowFiber]), | |
| 6688 | 6731 | ); |
| 6689 | 6732 | }).pipe( |
| 6690 | 6733 | // Expected tool failures (`ToolResult.fail`) resolve through the |
| @@ -6788,16 +6831,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 97 unmodified lines | ||
| 6788 | 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 | 6839 | const blobPartitions: OwnerPartitions = { |
| 6792 | 6840 | org: `o:${tenant}`, |
| 6793 | 6841 | user: subject != null ? `u:${tenant}:${subject}` : null, |
| 6794 | 6842 | }; |
| 6795 | 6843 | |
| 6796 | − // Pending approvals file under the narrowest partition this executor has: | |
| 6797 | − // a subject-bound executor keeps them private to that member, and a pure-org | |
| 6798 | − // executor (no subject) files them at the org. Either way the partition IS | |
| 6799 | − // the ownership check — another caller's executor reads a different | |
| 6800 | − // namespace and simply does not see the record. | |
| 6844 | + // Pending approvals file under the narrowest IDENTITY partition this | |
| 6845 | + // executor has: a subject-bound executor keeps them private to that | |
| 6846 | + // member, and a pure-org executor (no subject) files them at the org. | |
| 6847 | + // The partition IS the ownership check — another caller's executor reads | |
| 6848 | + // a different namespace and simply does not see the record. CONTINUATION | |
| 6849 | + // IDENTITY, not resource visibility: this routing must never follow the | |
| 6850 | + // product's narrowed `access.owners` view — rerouting an org-only | |
| 6851 | + // member's approvals into the SHARED org namespace would let any | |
| 6852 | + // coworker's binding consume them. | |
| 6801 | 6853 | const pendingApprovals = makePendingApprovalStore( |
| 6802 | 6854 | blobs, |
| 6803 | 6855 | blobPartitions.user ?? blobPartitions.org, |
| @@ -6815,10 +6867,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 11 unmodified lines | ||
| 6815 | 6867 | core, |
| 6816 | 6868 | pluginId: plugin.id, |
| 6817 | 6869 | owner: ownerBinding, |
| 6870 | + readOwners: accessOwners, | |
| 6818 | 6871 | }); |
| 6819 | 6872 | const storageDeps: StorageDeps = { |
| 6820 | 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 | 6878 | pluginStorage, |
| 6823 | 6879 | }; |
| 6824 | 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 | 7197 | // Default OFF: without the opt-in there is no `admin` key at all, so the |
| 7142 | 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 | 7202 | // close |
| @@ -188,11 +188,12 @@ export { · 187 unmodified lines | ||
| 188 | 188 | ownerForItemId, |
| 189 | 189 | } from "./provider-item-owner"; |
| 190 | 190 | |
| 191 | −// Tool policies. | |
| 191 | +// Tool policies — vocabulary + matching/placement utilities. Resolution | |
| 192 | +// semantics live in `@executor-js/product-access/policy`. | |
| 192 | 193 | export { |
| 193 | 194 | matchPattern, |
| 194 | 195 | isValidPattern, |
| 195 | − effectivePolicyFromSorted, | |
| 196 | + comparePolicyRow, | |
| 196 | 197 | ToolPolicyActionSchema, |
| 197 | 198 | type ToolPolicy, |
| 198 | 199 | type CreateToolPolicyInput, |
| @@ -446,10 +447,18 @@ export { · 247 unmodified lines | ||
| 446 | 447 | export { |
| 447 | 448 | CurrentOrgWriteAccess, |
| 448 | 449 | currentOrgWriteAccess, |
| 450 | + executorAccessViolation, | |
| 449 | 451 | makeOrgWriteAccessState, |
| 452 | + type AccessDecision, | |
| 453 | + type ExecutorAccess, | |
| 454 | + type ExecutorAccessCapabilities, | |
| 450 | 455 | type OrgWriteAccess, |
| 451 | 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 | 463 | // CLI / runtime config. |
| 455 | 464 | export { |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | import { definePlugin } from "./plugin"; |
| 17 | 17 | import type { CredentialProvider } from "./provider"; |
| 18 | 18 | import { makeTestConfig } from "./testing"; |
| 19 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 19 | 20 | |
| 20 | 21 | // --------------------------------------------------------------------------- |
| 21 | 22 | // Removing an integration must cascade to EVERY member's rows under it, not |
| @@ -62,7 +63,11 @@ const demoPlugin = definePlugin(() => ({ · 40 unmodified lines | ||
| 62 | 63 | |
| 63 | 64 | const setup = () => |
| 64 | 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 | 71 | const alice = yield* createExecutor(config); |
| 67 | 72 | const bob = yield* createExecutor({ |
| 68 | 73 | ...config, |
| @@ -119,7 +124,7 @@ describe("integrations.remove cascade", () => { · 50 unmodified lines | ||
| 119 | 124 | it.effect("a member (org writes denied) still cannot remove", () => |
| 120 | 125 | Effect.gen(function* () { |
| 121 | 126 | const { config } = yield* setup(); |
| 122 | − const member = yield* createExecutor({ ...config, orgWrites: "denied" }); | |
| 127 | + const member = yield* createExecutor({ ...config, access: testAccess.member("denied") }); | |
| 123 | 128 | yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); |
| 124 | 129 | const error = yield* Effect.flip(member.integrations.remove(INTEG)); |
| 125 | 130 | expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true); |
| @@ -129,7 +134,7 @@ describe("integrations.remove cascade", () => { · 3 unmodified lines | ||
| 129 | 134 | it.effect("the platform view cannot remove (read-only holds ahead of the cascade)", () => |
| 130 | 135 | Effect.gen(function* () { |
| 131 | 136 | const { config } = yield* setup(); |
| 132 | − const platform = yield* createExecutor({ ...config, platformView: true }); | |
| 137 | + const platform = yield* createExecutor({ ...config, access: testAccess.platform() }); | |
| 133 | 138 | yield* Effect.addFinalizer(() => platform.close().pipe(Effect.ignore)); |
| 134 | 139 | yield* platform.integrations.remove(INTEG).pipe( |
| 135 | 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 | 175 | withQueryContext(config.testDb.db, { |
| 171 | 176 | tenant: String(config.tenant), |
| 172 | 177 | subject: BOB, |
| 178 | + owners: ["user", "org"], | |
| 173 | 179 | }).findMany("tool", {}), |
| 174 | 180 | ); |
| 175 | 181 | expect(stored.map((row) => String(row.integration)).sort()).toEqual([ |
| @@ -24,6 +24,7 @@ import type { OAuthService } from "./oauth-client"; · 23 unmodified lines | ||
| 24 | 24 | import { definePlugin } from "./plugin"; |
| 25 | 25 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 26 | 26 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; |
| 27 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 27 | 28 | |
| 28 | 29 | const INTEG = IntegrationSlug.make("acme"); |
| 29 | 30 | const TEMPLATE = AuthTemplateSlug.make("oauth"); |
| @@ -157,7 +158,10 @@ describe("enterprise-managed connections", () => { · 127 unmodified lines | ||
| 157 | 158 | Effect.scoped( |
| 158 | 159 | Effect.gen(function* () { |
| 159 | 160 | const servers = yield* enterpriseServers({}); |
| 160 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 161 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 162 | + access: testAccess.member(), | |
| 163 | + plugins, | |
| 164 | + }); | |
| 161 | 165 | yield* executor.acme.seed(); |
| 162 | 166 | yield* registerClients(executor.oauth.createClient, servers); |
| 163 | 167 | |
| @@ -182,7 +186,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines | ||
| 182 | 186 | Effect.scoped( |
| 183 | 187 | Effect.gen(function* () { |
| 184 | 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 | 193 | yield* executor.acme.seed(); |
| 187 | 194 | yield* registerClients(executor.oauth.createClient, servers); |
| 188 | 195 | yield* executor.oauth.start(startEnterpriseConnect(servers)); |
| @@ -207,7 +214,10 @@ describe("enterprise-managed connections", () => { · 18 unmodified lines | ||
| 207 | 214 | Effect.scoped( |
| 208 | 215 | Effect.gen(function* () { |
| 209 | 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 | 221 | yield* executor.acme.seed(); |
| 212 | 222 | yield* registerClients(executor.oauth.createClient, servers); |
| 213 | 223 | yield* executor.oauth.start(startEnterpriseConnect(servers)); |
| @@ -241,7 +251,10 @@ describe("enterprise-managed connections", () => { · 27 unmodified lines | ||
| 241 | 251 | Effect.scoped( |
| 242 | 252 | Effect.gen(function* () { |
| 243 | 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 | 258 | yield* executor.acme.seed(); |
| 246 | 259 | yield* registerClients(executor.oauth.createClient, servers); |
| 247 | 260 | yield* executor.oauth.start(startEnterpriseConnect(servers)); |
| @@ -287,7 +300,10 @@ describe("enterprise-managed connections", () => { · 39 unmodified lines | ||
| 287 | 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 | 307 | yield* executor.acme.seed(); |
| 292 | 308 | yield* registerClients(executor.oauth.createClient, servers); |
| 293 | 309 | |
| @@ -316,7 +332,10 @@ describe("enterprise-managed connections", () => { · 22 unmodified lines | ||
| 316 | 332 | Effect.scoped( |
| 317 | 333 | Effect.gen(function* () { |
| 318 | 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 | 339 | yield* executor.acme.seed(); |
| 321 | 340 | yield* registerClients(executor.oauth.createClient, servers); |
| 322 | 341 | |
| @@ -34,6 +34,7 @@ import type { · 33 unmodified lines | ||
| 34 | 34 | import { definePlugin } from "./plugin"; |
| 35 | 35 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 36 | 36 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; |
| 37 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 37 | 38 | |
| 38 | 39 | const INTEG = IntegrationSlug.make("acme"); |
| 39 | 40 | const TEMPLATE = AuthTemplateSlug.make("oauth"); |
| @@ -233,6 +234,7 @@ const tokenExchangeCount = (servers: EnterpriseServers) => · 193 unmodified lines | ||
| 233 | 234 | |
| 234 | 235 | const harness = (rollout: EnterpriseManagedRollout | undefined) => |
| 235 | 236 | makeTestWorkspaceHarness({ |
| 237 | + access: testAccess.member(), | |
| 236 | 238 | plugins, |
| 237 | 239 | tenant: TENANT, |
| 238 | 240 | subject: SUBJECT, |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | import { definePlugin } from "./plugin"; |
| 19 | 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 20 | 20 | import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 21 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 21 | 22 | |
| 22 | 23 | // First-party OAuth clients: host-operated apps declared in executor config |
| 23 | 24 | // (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from |
| @@ -119,6 +120,7 @@ describe("first-party oauth clients", () => { · 95 unmodified lines | ||
| 119 | 120 | Effect.gen(function* () { |
| 120 | 121 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 121 | 122 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 123 | + access: testAccess.member(), | |
| 122 | 124 | plugins, |
| 123 | 125 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 124 | 126 | }); |
| @@ -170,6 +172,7 @@ describe("first-party oauth clients", () => { · 45 unmodified lines | ||
| 170 | 172 | Effect.gen(function* () { |
| 171 | 173 | const server = yield* serveOAuthTestServer({ scopes: ["repo"] }); |
| 172 | 174 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 175 | + access: testAccess.member(), | |
| 173 | 176 | plugins, |
| 174 | 177 | firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }], |
| 175 | 178 | }); |
| @@ -204,6 +207,7 @@ describe("first-party oauth clients", () => { · 28 unmodified lines | ||
| 204 | 207 | Effect.gen(function* () { |
| 205 | 208 | const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); |
| 206 | 209 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 210 | + access: testAccess.member(), | |
| 207 | 211 | plugins, |
| 208 | 212 | firstPartyOAuthClients: [ |
| 209 | 213 | { |
| @@ -240,6 +244,7 @@ describe("first-party oauth clients", () => { · 30 unmodified lines | ||
| 240 | 244 | Effect.gen(function* () { |
| 241 | 245 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 242 | 246 | const harness = yield* makeTestWorkspaceHarness({ |
| 247 | + access: testAccess.member(), | |
| 243 | 248 | plugins, |
| 244 | 249 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 245 | 250 | }); |
| @@ -288,6 +293,7 @@ describe("first-party oauth clients", () => { · 42 unmodified lines | ||
| 288 | 293 | Effect.gen(function* () { |
| 289 | 294 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 290 | 295 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 296 | + access: testAccess.member(), | |
| 291 | 297 | plugins, |
| 292 | 298 | firstPartyOAuthClients: [ |
| 293 | 299 | { |
| @@ -330,6 +336,7 @@ describe("first-party oauth clients", () => { · 36 unmodified lines | ||
| 330 | 336 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 331 | 337 | let enabled = true; |
| 332 | 338 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 339 | + access: testAccess.member(), | |
| 333 | 340 | plugins, |
| 334 | 341 | subject: "review-user", |
| 335 | 342 | tenant: "review-org", |
| @@ -368,6 +375,7 @@ describe("first-party oauth clients", () => { · 32 unmodified lines | ||
| 368 | 375 | Effect.gen(function* () { |
| 369 | 376 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 370 | 377 | const harness = yield* makeTestWorkspaceHarness({ |
| 378 | + access: testAccess.member(), | |
| 371 | 379 | plugins, |
| 372 | 380 | firstPartyOAuthClients: [ |
| 373 | 381 | { |
| @@ -439,6 +447,7 @@ describe("first-party oauth clients", () => { · 65 unmodified lines | ||
| 439 | 447 | Effect.gen(function* () { |
| 440 | 448 | const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); |
| 441 | 449 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 450 | + access: testAccess.member(), | |
| 442 | 451 | plugins, |
| 443 | 452 | firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }], |
| 444 | 453 | }); |
| @@ -467,6 +476,7 @@ describe("first-party oauth clients", () => { · 22 unmodified lines | ||
| 467 | 476 | Effect.gen(function* () { |
| 468 | 477 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 469 | 478 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 479 | + access: testAccess.member(), | |
| 470 | 480 | plugins, |
| 471 | 481 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 472 | 482 | }); |
| @@ -496,7 +506,10 @@ describe("first-party oauth clients", () => { · 23 unmodified lines | ||
| 496 | 506 | Effect.scoped( |
| 497 | 507 | Effect.gen(function* () { |
| 498 | 508 | yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 499 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 509 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 510 | + access: testAccess.member(), | |
| 511 | + plugins, | |
| 512 | + }); | |
| 500 | 513 | yield* executor.acme.seed(); |
| 501 | 514 | |
| 502 | 515 | const error = yield* executor.oauth |
| @@ -523,6 +536,7 @@ describe("first-party oauth clients", () => { · 20 unmodified lines | ||
| 523 | 536 | Effect.gen(function* () { |
| 524 | 537 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 525 | 538 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 539 | + access: testAccess.member(), | |
| 526 | 540 | plugins, |
| 527 | 541 | firstPartyOAuthClients: [firstPartyClientFor(server)], |
| 528 | 542 | }); |
| @@ -24,6 +24,7 @@ import type { CredentialProvider } from "./provider"; · 23 unmodified lines | ||
| 24 | 24 | import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 25 | 25 | import { ToolResult } from "./tool-result"; |
| 26 | 26 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 27 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 27 | 28 | |
| 28 | 29 | // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow |
| 29 | 30 | // and OAuth access-token refresh end to end against the test authorization |
| @@ -197,7 +198,10 @@ describe("oauth.start / oauth.complete", () => { · 167 unmodified lines | ||
| 197 | 198 | Effect.scoped( |
| 198 | 199 | Effect.gen(function* () { |
| 199 | 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 | 205 | yield* executor.acme.seed(); |
| 202 | 206 | |
| 203 | 207 | yield* executor.oauth.createClient({ |
| @@ -302,6 +306,7 @@ describe("oauth.start / oauth.complete", () => { · 98 unmodified lines | ||
| 302 | 306 | }))(); |
| 303 | 307 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 304 | 308 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 309 | + access: testAccess.member(), | |
| 305 | 310 | plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const, |
| 306 | 311 | waitUntil: (promise) => keptAlive.push(promise), |
| 307 | 312 | }); |
| @@ -363,7 +368,10 @@ describe("oauth.start / oauth.complete", () => { · 55 unmodified lines | ||
| 363 | 368 | scopes: ["read"], |
| 364 | 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 | 375 | yield* executor.acme.seed(); |
| 368 | 376 | |
| 369 | 377 | yield* executor.oauth.createClient({ |
| @@ -428,7 +436,10 @@ describe("oauth.start / oauth.complete", () => { · 58 unmodified lines | ||
| 428 | 436 | scopes: ["read"], |
| 429 | 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 | 443 | yield* executor.acme.seed(); |
| 433 | 444 | |
| 434 | 445 | yield* executor.oauth.createClient({ |
| @@ -486,6 +497,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines | ||
| 486 | 497 | Effect.gen(function* () { |
| 487 | 498 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 488 | 499 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 500 | + access: testAccess.member(), | |
| 489 | 501 | plugins, |
| 490 | 502 | oauthCallbackStateOrgSlug: "acme", |
| 491 | 503 | }); |
| @@ -546,7 +558,10 @@ describe("oauth.start / oauth.complete", () => { · 54 unmodified lines | ||
| 546 | 558 | scopes: ["offline_access", "read"], |
| 547 | 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 | 565 | yield* executor.acme.seed(["offline_access", "read"]); |
| 551 | 566 | |
| 552 | 567 | yield* executor.oauth.createClient({ |
| @@ -590,7 +605,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines | ||
| 590 | 605 | scopes: ["openid", "email", "profile", "read"], |
| 591 | 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 | 612 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 595 | 613 | |
| 596 | 614 | yield* executor.oauth.createClient({ |
| @@ -634,7 +652,10 @@ describe("oauth.start / oauth.complete", () => { · 37 unmodified lines | ||
| 634 | 652 | scopes: ["openid", "email", "profile", "read"], |
| 635 | 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 | 659 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 639 | 660 | |
| 640 | 661 | yield* executor.oauth.createClient({ |
| @@ -679,7 +700,10 @@ describe("oauth.start / oauth.complete", () => { · 38 unmodified lines | ||
| 679 | 700 | scopes: ["openid", "email", "profile", "read"], |
| 680 | 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 | 707 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 684 | 708 | |
| 685 | 709 | yield* executor.oauth.createClient({ |
| @@ -739,7 +763,10 @@ describe("oauth.start / oauth.complete", () => { · 53 unmodified lines | ||
| 739 | 763 | scopes: ["openid", "email", "profile", "read"], |
| 740 | 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 | 770 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 744 | 771 | |
| 745 | 772 | yield* executor.oauth.createClient({ |
| @@ -797,7 +824,10 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines | ||
| 797 | 824 | scopes: ["openid", "email", "profile", "read"], |
| 798 | 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 | 831 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 802 | 832 | |
| 803 | 833 | yield* executor.oauth.createClient({ |
| @@ -852,7 +882,7 @@ describe("oauth.start / oauth.complete", () => { · 48 unmodified lines | ||
| 852 | 882 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, |
| 853 | 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 | 886 | const { executor, config } = harness; |
| 857 | 887 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); |
| 858 | 888 | |
| @@ -910,6 +940,7 @@ describe("oauth.start / oauth.complete", () => { · 51 unmodified lines | ||
| 910 | 940 | // is no silent localhost default. The redirect flow must fail loudly |
| 911 | 941 | // rather than handing the provider a wrong `http://127.0.0.1/callback`. |
| 912 | 942 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 943 | + access: testAccess.member(), | |
| 913 | 944 | plugins, |
| 914 | 945 | redirectUri: null, |
| 915 | 946 | }); |
| @@ -951,6 +982,7 @@ describe("oauth.start / oauth.complete", () => { · 35 unmodified lines | ||
| 951 | 982 | // No redirectUri configured, but client_credentials never redirects — |
| 952 | 983 | // it must still mint the connection inline. |
| 953 | 984 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 985 | + access: testAccess.member(), | |
| 954 | 986 | plugins, |
| 955 | 987 | redirectUri: null, |
| 956 | 988 | }); |
| @@ -983,7 +1015,10 @@ describe("oauth.start / oauth.complete", () => { · 26 unmodified lines | ||
| 983 | 1015 | Effect.scoped( |
| 984 | 1016 | Effect.gen(function* () { |
| 985 | 1017 | const server = yield* serveOAuthTestServer(); |
| 986 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 1018 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 1019 | + access: testAccess.member(), | |
| 1020 | + plugins, | |
| 1021 | + }); | |
| 987 | 1022 | yield* executor.acme.seed(); |
| 988 | 1023 | yield* executor.oauth.createClient({ |
| 989 | 1024 | owner: "org", |
| @@ -1011,7 +1046,10 @@ describe("oauth.start / oauth.complete", () => { · 21 unmodified lines | ||
| 1011 | 1046 | Effect.scoped( |
| 1012 | 1047 | Effect.gen(function* () { |
| 1013 | 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 | 1053 | yield* executor.acme.seed(); |
| 1016 | 1054 | |
| 1017 | 1055 | // The app is registered under the WORKSPACE (org) — "shared with |
| @@ -1062,7 +1100,10 @@ describe("oauth.start / oauth.complete", () => { · 44 unmodified lines | ||
| 1062 | 1100 | Effect.scoped( |
| 1063 | 1101 | Effect.gen(function* () { |
| 1064 | 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 | 1107 | yield* executor.acme.seed(); |
| 1067 | 1108 | |
| 1068 | 1109 | // A PRIVATE app owned by the member. |
| @@ -1100,7 +1141,10 @@ describe("oauth.start / oauth.complete", () => { · 31 unmodified lines | ||
| 1100 | 1141 | Effect.scoped( |
| 1101 | 1142 | Effect.gen(function* () { |
| 1102 | 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 | 1148 | // Deliberately NOT seeded: the slug names nothing in the catalog — the |
| 1105 | 1149 | // shape of a reconnect against a connection whose integration was |
| 1106 | 1150 | // removed, or an agent replaying a stale slug. |
| @@ -1141,7 +1185,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 34 unmodified lines | ||
| 1141 | 1185 | Effect.scoped( |
| 1142 | 1186 | Effect.gen(function* () { |
| 1143 | 1187 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1144 | − const harness = yield* makeTestWorkspaceHarness({ plugins }); | |
| 1188 | + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); | |
| 1145 | 1189 | const { executor, config } = harness; |
| 1146 | 1190 | yield* executor.acme.seed(); |
| 1147 | 1191 | |
| @@ -1225,7 +1269,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 77 unmodified lines | ||
| 1225 | 1269 | |
| 1226 | 1270 | // One database handle and one credential store under two execution |
| 1227 | 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 | 1276 | const sessionA = yield* createExecutor(config); |
| 1230 | 1277 | const sessionB = yield* createExecutor(config); |
| 1231 | 1278 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); |
| @@ -1322,7 +1369,12 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 90 unmodified lines | ||
| 1322 | 1369 | // the store layer and mask the gate-key collision this test is about. |
| 1323 | 1370 | const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const; |
| 1324 | 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 | 1378 | const configA = { ...shared, fetch: parkA.fetch }; |
| 1327 | 1379 | const configB = { |
| 1328 | 1380 | ...shared, |
| @@ -1379,7 +1431,11 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines | ||
| 1379 | 1431 | |
| 1380 | 1432 | // Expire BOTH rows so both tenants must refresh. `shared.db` is bound |
| 1381 | 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 | 1439 | yield* Effect.promise(() => |
| 1384 | 1440 | shared.db.updateMany("connection", { |
| 1385 | 1441 | where: (b) => b("name", "=", "mine"), |
| @@ -1452,7 +1508,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 66 unmodified lines | ||
| 1452 | 1508 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1453 | 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 | 1515 | const sessionA = yield* createExecutor(config); |
| 1457 | 1516 | const sessionB = yield* createExecutor(config); |
| 1458 | 1517 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); |
| @@ -1598,6 +1657,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 139 unmodified lines | ||
| 1598 | 1657 | // handle, which is what the extra `withQueryContext` wrapper is. |
| 1599 | 1658 | const config = { |
| 1600 | 1659 | ...makeTestConfig({ |
| 1660 | + access: testAccess.member(), | |
| 1601 | 1661 | plugins: [oauthPlugin] as const, |
| 1602 | 1662 | tenant: SHARED_STORE_TENANT, |
| 1603 | 1663 | subject: SHARED_STORE_SUBJECT, |
| @@ -1722,7 +1782,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 118 unmodified lines | ||
| 1722 | 1782 | Effect.scoped( |
| 1723 | 1783 | Effect.gen(function* () { |
| 1724 | 1784 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 1725 | − const harness = yield* makeTestWorkspaceHarness({ plugins }); | |
| 1785 | + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); | |
| 1726 | 1786 | const { executor, config } = harness; |
| 1727 | 1787 | yield* executor.acme.seed(); |
| 1728 | 1788 | |
| @@ -1790,7 +1850,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 61 unmodified lines | ||
| 1790 | 1850 | tokenExpiresInSeconds: 0, |
| 1791 | 1851 | invalidRefreshTokenDescription: "Grant not found", |
| 1792 | 1852 | }); |
| 1793 | − const harness = yield* makeTestWorkspaceHarness({ plugins }); | |
| 1853 | + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); | |
| 1794 | 1854 | const { executor, config } = harness; |
| 1795 | 1855 | yield* executor.acme.seed(); |
| 1796 | 1856 | |
| @@ -1876,7 +1936,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 79 unmodified lines | ||
| 1876 | 1936 | tokenExpiresInSeconds: 0, |
| 1877 | 1937 | invalidRefreshTokenDescription: "Grant revoked", |
| 1878 | 1938 | }); |
| 1879 | − const harness = yield* makeTestWorkspaceHarness({ plugins }); | |
| 1939 | + const harness = yield* makeTestWorkspaceHarness({ access: testAccess.member(), plugins }); | |
| 1880 | 1940 | const { executor, config } = harness; |
| 1881 | 1941 | yield* executor.acme.seed(); |
| 1882 | 1942 | |
| @@ -1983,7 +2043,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 100 unmodified lines | ||
| 1983 | 2043 | Effect.scoped( |
| 1984 | 2044 | Effect.gen(function* () { |
| 1985 | 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 | 2050 | yield* executor.acme.seed(); |
| 1988 | 2051 | |
| 1989 | 2052 | yield* executor.oauth.createClient({ |
| @@ -2040,7 +2103,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 50 unmodified lines | ||
| 2040 | 2103 | scopes: ["openid", "email", "profile", "offline_access", "read", "write"], |
| 2041 | 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 | 2110 | yield* executor.acme.seed([ |
| 2045 | 2111 | "openid", |
| 2046 | 2112 | "email", |
| @@ -2098,7 +2164,10 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 51 unmodified lines | ||
| 2098 | 2164 | Effect.scoped( |
| 2099 | 2165 | Effect.gen(function* () { |
| 2100 | 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 | 2171 | yield* executor.acme.seed(["read", "write"]); |
| 2103 | 2172 | |
| 2104 | 2173 | yield* executor.oauth.createClient({ |
| @@ -2159,7 +2228,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 54 unmodified lines | ||
| 2159 | 2228 | Effect.scoped( |
| 2160 | 2229 | Effect.gen(function* () { |
| 2161 | 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 | 2235 | yield* executor.acme.seed(); |
| 2164 | 2236 | |
| 2165 | 2237 | // Reroute the https regional/advertised hosts back to the loopback test |
| @@ -2248,7 +2320,10 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 82 unmodified lines | ||
| 2248 | 2320 | Effect.scoped( |
| 2249 | 2321 | Effect.gen(function* () { |
| 2250 | 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 | 2327 | yield* executor.acme.seed(); |
| 2253 | 2328 | |
| 2254 | 2329 | const tokenCalls: TokenEndpointCall[] = []; |
| @@ -2423,6 +2498,7 @@ const connectRejecting = (options?: { readonly tokenExpiresInSeconds?: number }) · 168 unmodified lines | ||
| 2423 | 2498 | return issued.length > 0 ? issued[issued.length - 1]! : null; |
| 2424 | 2499 | }); |
| 2425 | 2500 | const harness = yield* makeTestWorkspaceHarness({ |
| 2501 | + access: testAccess.member(), | |
| 2426 | 2502 | plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const, |
| 2427 | 2503 | }); |
| 2428 | 2504 | const { executor, config } = harness; |
| @@ -2612,6 +2688,7 @@ describe("reactive OAuth refresh on upstream 401", () => { · 183 unmodified lines | ||
| 2612 | 2688 | }))(); |
| 2613 | 2689 | |
| 2614 | 2690 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 2691 | + access: testAccess.member(), | |
| 2615 | 2692 | plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const, |
| 2616 | 2693 | }); |
| 2617 | 2694 | yield* executor.acme.seed(); |
| @@ -2692,7 +2769,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 74 unmodified lines | ||
| 2692 | 2769 | Effect.scoped( |
| 2693 | 2770 | Effect.gen(function* () { |
| 2694 | 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 | 2776 | yield* executor.acme.seed(); |
| 2697 | 2777 | |
| 2698 | 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 | 2841 | Effect.scoped( |
| 2762 | 2842 | Effect.gen(function* () { |
| 2763 | 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 | 2848 | yield* executor.acme.seed(); |
| 2766 | 2849 | |
| 2767 | 2850 | yield* executor.oauth.createClient({ |
| @@ -2799,7 +2882,10 @@ describe("resource-less client sends no resource parameter (#1789)", () => { · 31 unmodified lines | ||
| 2799 | 2882 | Effect.scoped( |
| 2800 | 2883 | Effect.gen(function* () { |
| 2801 | 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 | 2889 | yield* executor.acme.seed(); |
| 2804 | 2890 | |
| 2805 | 2891 | yield* executor.oauth.createClient({ |
| @@ -7,6 +7,7 @@ import { Effect, Predicate } from "effect"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { OAuthClientSlug } from "./ids"; |
| 9 | 9 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 10 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 10 | 11 | |
| 11 | 12 | // listClients returns metadata-only summaries of the clients visible to the |
| 12 | 13 | // caller — the tenant's org clients plus the caller's own user clients — and |
| @@ -21,7 +22,10 @@ describe("oauth.listClients", () => { · 8 unmodified lines | ||
| 21 | 22 | it.effect("rejects metadata URLs persisted as authorization URLs", () => |
| 22 | 23 | Effect.scoped( |
| 23 | 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 | 30 | const error = yield* Effect.flip( |
| 27 | 31 | executor.oauth.createClient({ |
| @@ -50,7 +54,10 @@ describe("oauth.listClients", () => { · 22 unmodified lines | ||
| 50 | 54 | it.effect("returns owner-visible clients as summaries without the secret", () => |
| 51 | 55 | Effect.scoped( |
| 52 | 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 | 62 | yield* executor.oauth.createClient({ |
| 56 | 63 | owner: "org", |
| @@ -113,7 +120,10 @@ describe("oauth.listClients", () => { · 56 unmodified lines | ||
| 113 | 120 | it.effect("rejects HTTP Basic authentication without a client secret", () => |
| 114 | 121 | Effect.scoped( |
| 115 | 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 | 128 | const error = yield* Effect.flip( |
| 119 | 129 | executor.oauth.createClient({ |
| @@ -147,6 +157,7 @@ describe("oauth.listClients", () => { · 27 unmodified lines | ||
| 147 | 157 | const tenant = "shared-tenant"; |
| 148 | 158 | |
| 149 | 159 | const a = yield* makeTestWorkspaceHarness({ |
| 160 | + access: testAccess.member(), | |
| 150 | 161 | plugins, |
| 151 | 162 | tenant, |
| 152 | 163 | subject: "subject-a", |
| @@ -172,6 +183,7 @@ describe("oauth.listClients", () => { · 19 unmodified lines | ||
| 172 | 183 | }); |
| 173 | 184 | |
| 174 | 185 | const b = yield* makeTestWorkspaceHarness({ |
| 186 | + access: testAccess.member(), | |
| 175 | 187 | plugins, |
| 176 | 188 | tenant, |
| 177 | 189 | subject: "subject-b", |
| @@ -190,7 +202,10 @@ describe("oauth.listClients", () => { · 12 unmodified lines | ||
| 190 | 202 | it.effect("classifies legacy MCP DCR-looking clients as dynamic registration", () => |
| 191 | 203 | Effect.scoped( |
| 192 | 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 | 210 | yield* executor.oauth.createClient({ |
| 196 | 211 | owner: "org", |
| @@ -12,6 +12,7 @@ import { OAuthRegisterDynamicError } from "./oauth-client"; · 11 unmodified lines | ||
| 12 | 12 | import { definePlugin } from "./plugin"; |
| 13 | 13 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 14 | 14 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 15 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 15 | 16 | |
| 16 | 17 | // RFC 7591 Dynamic Client Registration, end to end: |
| 17 | 18 | // probe → registerDynamicClient (no pasted client id/secret) → listClients |
| @@ -64,7 +65,7 @@ describe("oauth.registerDynamicClient", () => { · 46 unmodified lines | ||
| 64 | 65 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 65 | 66 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 66 | 67 | plugins, |
| 67 | − orgWrites: "denied", | |
| 68 | + access: testAccess.member("denied"), | |
| 68 | 69 | }); |
| 69 | 70 | |
| 70 | 71 | const error = yield* executor.oauth |
| @@ -94,7 +95,10 @@ describe("oauth.registerDynamicClient", () => { · 23 unmodified lines | ||
| 94 | 95 | Effect.scoped( |
| 95 | 96 | Effect.gen(function* () { |
| 96 | 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 | 102 | yield* executor.acme.seed(); |
| 99 | 103 | |
| 100 | 104 | // Probe surfaces the registration endpoint + advertised auth methods so |
| @@ -197,7 +201,10 @@ describe("oauth.registerDynamicClient", () => { · 96 unmodified lines | ||
| 197 | 201 | Effect.scoped( |
| 198 | 202 | Effect.gen(function* () { |
| 199 | 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 | 208 | yield* executor.acme.seed(); |
| 202 | 209 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 203 | 210 | |
| @@ -230,7 +237,10 @@ describe("oauth.registerDynamicClient", () => { · 26 unmodified lines | ||
| 230 | 237 | Effect.scoped( |
| 231 | 238 | Effect.gen(function* () { |
| 232 | 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 | 244 | yield* executor.acme.seed(); |
| 235 | 245 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 236 | 246 | |
| @@ -276,7 +286,10 @@ describe("oauth.registerDynamicClient", () => { · 39 unmodified lines | ||
| 276 | 286 | Effect.scoped( |
| 277 | 287 | Effect.gen(function* () { |
| 278 | 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 | 293 | yield* executor.acme.seed(); |
| 281 | 294 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 282 | 295 | |
| @@ -327,7 +340,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines | ||
| 327 | 340 | // reused, so a fresh DCR registration happens. The GC migration then |
| 328 | 341 | // backfills/GCs any duplicate this transient window mints. |
| 329 | 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 | 347 | yield* executor.acme.seed(); |
| 332 | 348 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 333 | 349 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); |
| @@ -379,7 +395,10 @@ describe("oauth.registerDynamicClient", () => { · 45 unmodified lines | ||
| 379 | 395 | const server = yield* serveOAuthTestServer({ |
| 380 | 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 | 402 | yield* executor.acme.seed(); |
| 384 | 403 | |
| 385 | 404 | yield* executor.oauth.registerDynamicClient({ |
| @@ -413,7 +432,10 @@ describe("oauth.registerDynamicClient", () => { · 27 unmodified lines | ||
| 413 | 432 | // row's origin_issuer, the reuse lookup keys on it and mints no |
| 414 | 433 | // duplicate. This is the steady state the migration establishes. |
| 415 | 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 | 439 | yield* executor.acme.seed(); |
| 418 | 440 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 419 | 441 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); |
| @@ -467,7 +489,10 @@ describe("oauth.registerDynamicClient", () => { · 47 unmodified lines | ||
| 467 | 489 | Effect.scoped( |
| 468 | 490 | Effect.gen(function* () { |
| 469 | 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 | 496 | yield* executor.acme.seed(); |
| 472 | 497 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 473 | 498 | const resourceA = `${server.issuerUrl}/mcp/a`; |
| @@ -533,7 +558,10 @@ describe("oauth.registerDynamicClient", () => { · 59 unmodified lines | ||
| 533 | 558 | Effect.scoped( |
| 534 | 559 | Effect.gen(function* () { |
| 535 | 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 | 565 | yield* executor.acme.seed(); |
| 538 | 566 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 539 | 567 | const resourceA = `${server.issuerUrl}/mcp/a`; |
| @@ -638,7 +666,10 @@ describe("oauth.registerDynamicClient", () => { · 98 unmodified lines | ||
| 638 | 666 | Effect.scoped( |
| 639 | 667 | Effect.gen(function* () { |
| 640 | 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 | 673 | yield* executor.acme.seed(); |
| 643 | 674 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 644 | 675 | |
| @@ -733,7 +764,10 @@ describe("oauth.registerDynamicClient", () => { · 88 unmodified lines | ||
| 733 | 764 | Effect.scoped( |
| 734 | 765 | Effect.gen(function* () { |
| 735 | 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 | 771 | yield* executor.acme.seed(); |
| 738 | 772 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 739 | 773 | |
| @@ -784,7 +818,10 @@ describe("oauth.registerDynamicClient", () => { · 44 unmodified lines | ||
| 784 | 818 | // Mirror Mercury: reject any client_name containing the brand. |
| 785 | 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 | 825 | yield* executor.acme.seed(); |
| 789 | 826 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 790 | 827 | |
| @@ -821,7 +858,10 @@ describe("oauth.registerDynamicClient", () => { · 30 unmodified lines | ||
| 821 | 858 | scopes: ["read"], |
| 822 | 859 | approveClientName: () => false, |
| 823 | 860 | }); |
| 824 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 861 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 862 | + access: testAccess.member(), | |
| 863 | + plugins, | |
| 864 | + }); | |
| 825 | 865 | yield* executor.acme.seed(); |
| 826 | 866 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 827 | 867 | |
| @@ -866,7 +906,10 @@ describe("oauth.registerDynamicClient", () => { · 38 unmodified lines | ||
| 866 | 906 | approveRedirectUri: (uri) => |
| 867 | 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 | 913 | yield* executor.acme.seed(); |
| 871 | 914 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 872 | 915 | |
| @@ -910,7 +953,10 @@ describe("oauth.registerDynamicClient", () => { · 37 unmodified lines | ||
| 910 | 953 | scopes: ["read"], |
| 911 | 954 | approveRedirectUri: () => false, // reject every redirect URI, even loopback |
| 912 | 955 | }); |
| 913 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 956 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 957 | + access: testAccess.member(), | |
| 958 | + plugins, | |
| 959 | + }); | |
| 914 | 960 | yield* executor.acme.seed(); |
| 915 | 961 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 916 | 962 | |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | import { definePlugin } from "./plugin"; |
| 19 | 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 20 | 20 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 21 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 21 | 22 | |
| 22 | 23 | // removeClient permanently deletes an owner-scoped oauth_client row, keyed by |
| 23 | 24 | // (owner, slug). The owner policy on `oauth_client` prevents removing another |
| @@ -32,7 +33,10 @@ describe("oauth.removeClient", () => { · 8 unmodified lines | ||
| 32 | 33 | it.effect("removes a client so it no longer lists", () => |
| 33 | 34 | Effect.scoped( |
| 34 | 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 | 41 | yield* executor.oauth.createClient({ |
| 38 | 42 | owner: "user", |
| @@ -58,7 +62,10 @@ describe("oauth.removeClient", () => { · 19 unmodified lines | ||
| 58 | 62 | it.effect("is idempotent — removing a non-existent client succeeds", () => |
| 59 | 63 | Effect.scoped( |
| 60 | 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 | 70 | // No client was ever created; removing it must not error. |
| 64 | 71 | yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed")); |
| @@ -72,7 +79,10 @@ describe("oauth.removeClient", () => { · 7 unmodified lines | ||
| 72 | 79 | it.effect("removing an org client leaves a user client intact (and vice versa)", () => |
| 73 | 80 | Effect.scoped( |
| 74 | 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 | 87 | yield* executor.oauth.createClient({ |
| 78 | 88 | owner: "org", |
| @@ -115,6 +125,7 @@ describe("oauth.removeClient", () => { · 36 unmodified lines | ||
| 115 | 125 | const tenant = "shared-tenant"; |
| 116 | 126 | |
| 117 | 127 | const a = yield* makeTestWorkspaceHarness({ |
| 128 | + access: testAccess.member(), | |
| 118 | 129 | plugins, |
| 119 | 130 | tenant, |
| 120 | 131 | subject: "subject-a", |
| @@ -131,6 +142,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines | ||
| 131 | 142 | }); |
| 132 | 143 | |
| 133 | 144 | const b = yield* makeTestWorkspaceHarness({ |
| 145 | + access: testAccess.member(), | |
| 134 | 146 | plugins, |
| 135 | 147 | tenant, |
| 136 | 148 | subject: "subject-b", |
| @@ -155,6 +167,7 @@ describe("oauth.removeClient", () => { · 18 unmodified lines | ||
| 155 | 167 | const tenant = "shared-tenant"; |
| 156 | 168 | |
| 157 | 169 | const a = yield* makeTestWorkspaceHarness({ |
| 170 | + access: testAccess.member(), | |
| 158 | 171 | plugins, |
| 159 | 172 | tenant, |
| 160 | 173 | subject: "subject-a", |
| @@ -171,6 +184,7 @@ describe("oauth.removeClient", () => { · 10 unmodified lines | ||
| 171 | 184 | }); |
| 172 | 185 | |
| 173 | 186 | const b = yield* makeTestWorkspaceHarness({ |
| 187 | + access: testAccess.member(), | |
| 174 | 188 | plugins, |
| 175 | 189 | tenant, |
| 176 | 190 | subject: "subject-b", |
| @@ -194,6 +208,7 @@ describe("oauth.removeClient", () => { · 17 unmodified lines | ||
| 194 | 208 | Effect.scoped( |
| 195 | 209 | Effect.gen(function* () { |
| 196 | 210 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 211 | + access: testAccess.member(), | |
| 197 | 212 | plugins, |
| 198 | 213 | coreTools: {}, |
| 199 | 214 | }); |
| @@ -306,6 +321,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 106 unmodified lines | ||
| 306 | 321 | Effect.gen(function* () { |
| 307 | 322 | const store = new Map<string, string>(); |
| 308 | 323 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 324 | + access: testAccess.member(), | |
| 309 | 325 | plugins: [txPlugin(store)] as const, |
| 310 | 326 | }); |
| 311 | 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 | 355 | Effect.gen(function* () { |
| 340 | 356 | const store = new Map<string, string>(); |
| 341 | 357 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 358 | + access: testAccess.member(), | |
| 342 | 359 | plugins: [txPlugin(store)] as const, |
| 343 | 360 | }); |
| 344 | 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 | 377 | Effect.gen(function* () { |
| 361 | 378 | const store = new Map<string, string>(); |
| 362 | 379 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 380 | + access: testAccess.member(), | |
| 363 | 381 | plugins: [txPlugin(store)] as const, |
| 364 | 382 | }); |
| 365 | 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 | 403 | const tenant = "shared-tenant"; |
| 386 | 404 | |
| 387 | 405 | const a = yield* makeTestWorkspaceHarness({ |
| 406 | + access: testAccess.member(), | |
| 388 | 407 | plugins, |
| 389 | 408 | tenant, |
| 390 | 409 | subject: "subject-a", |
| @@ -394,6 +413,7 @@ describe("removing a client defers the secret deletion to the outermost commit", · 3 unmodified lines | ||
| 394 | 413 | expect(secretValue(store)).toBe("a-secret"); |
| 395 | 414 | |
| 396 | 415 | const b = yield* makeTestWorkspaceHarness({ |
| 416 | + access: testAccess.member(), | |
| 397 | 417 | plugins, |
| 398 | 418 | tenant, |
| 399 | 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 | 475 | const store = new Map<string, string>(); |
| 456 | 476 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 477 | + access: testAccess.member(), | |
| 457 | 478 | plugins: [txPlugin(store), integrationPlugin] as const, |
| 458 | 479 | redirectUri: null, |
| 459 | 480 | }); |
| @@ -16,6 +16,7 @@ import { definePlugin, type IntegrationRecord } from "./plugin"; · 15 unmodified lines | ||
| 16 | 16 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 17 | 17 | import { serveTestHttpApp } from "./testing"; |
| 18 | 18 | import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 19 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 19 | 20 | |
| 20 | 21 | // Integration-driven scopes: at connect, `oauth.start` requests the integration's |
| 21 | 22 | // scopes — its DECLARED oauth scopes when it has any, otherwise the scopes |
| @@ -187,7 +188,7 @@ const setupMcpScopeClient = ( · 165 unmodified lines | ||
| 187 | 188 | ) => |
| 188 | 189 | Effect.gen(function* () { |
| 189 | 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 | 192 | yield* executor.mcp.seed(); |
| 192 | 193 | yield* executor.oauth.createClient({ |
| 193 | 194 | owner: "org", |
| @@ -213,7 +214,10 @@ describe("oauth.start integration-driven scopes", () => { · 19 unmodified lines | ||
| 213 | 214 | memoryCredentialsPlugin(), |
| 214 | 215 | makeScopePlugin({ scopes: DECLARED_SCOPES }), |
| 215 | 216 | ] as const; |
| 216 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 217 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 218 | + access: testAccess.member(), | |
| 219 | + plugins, | |
| 220 | + }); | |
| 217 | 221 | yield* executor.acme.seed(); |
| 218 | 222 | |
| 219 | 223 | // The app is pure identity — no scope set. |
| @@ -265,7 +269,10 @@ describe("oauth.start integration-driven scopes", () => { · 45 unmodified lines | ||
| 265 | 269 | authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, |
| 266 | 270 | }), |
| 267 | 271 | ] as const; |
| 268 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 272 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 273 | + access: testAccess.member(), | |
| 274 | + plugins, | |
| 275 | + }); | |
| 269 | 276 | yield* executor.acme.seed(); |
| 270 | 277 | yield* executor.oauth.createClient({ |
| 271 | 278 | owner: "org", |
| @@ -303,7 +310,10 @@ describe("oauth.start integration-driven scopes", () => { · 31 unmodified lines | ||
| 303 | 310 | memoryCredentialsPlugin(), |
| 304 | 311 | makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }), |
| 305 | 312 | ] as const; |
| 306 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 313 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 314 | + access: testAccess.member(), | |
| 315 | + plugins, | |
| 316 | + }); | |
| 307 | 317 | yield* executor.acme.seed(); |
| 308 | 318 | |
| 309 | 319 | yield* executor.oauth.createClient({ |
| @@ -340,7 +350,10 @@ describe("oauth.start integration-driven scopes", () => { · 30 unmodified lines | ||
| 340 | 350 | // `scopes: null` ⇒ the integration declares an oauth method with no |
| 341 | 351 | // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested. |
| 342 | 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 | 357 | yield* executor.acme.seed(); |
| 345 | 358 | |
| 346 | 359 | yield* executor.oauth.createClient({ |
| @@ -411,7 +424,10 @@ describe("oauth.start integration-driven scopes", () => { · 64 unmodified lines | ||
| 411 | 424 | memoryCredentialsPlugin(), |
| 412 | 425 | makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }), |
| 413 | 426 | ] as const; |
| 414 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 427 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 428 | + access: testAccess.member(), | |
| 429 | + plugins, | |
| 430 | + }); | |
| 415 | 431 | yield* executor.acme.seed(); |
| 416 | 432 | |
| 417 | 433 | yield* executor.oauth.createClient({ |
| @@ -635,7 +651,10 @@ describe("oauth.start integration-driven scopes", () => { · 217 unmodified lines | ||
| 635 | 651 | { discoversScopes: true, discoveryUrl: server.mcpResourceUrl }, |
| 636 | 652 | ), |
| 637 | 653 | ] as const; |
| 638 | − const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 654 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 655 | + access: testAccess.member(), | |
| 656 | + plugins, | |
| 657 | + }); | |
| 639 | 658 | yield* executor.mcp.seed(); |
| 640 | 659 | |
| 641 | 660 | // No `resource` on the client — the wire parameter is absent by |
| @@ -681,6 +700,7 @@ describe("oauth.start integration-driven scopes", () => { · 39 unmodified lines | ||
| 681 | 700 | makeMcpScopePlugin({ scopes: null }), |
| 682 | 701 | ] as const; |
| 683 | 702 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 703 | + access: testAccess.member(), | |
| 684 | 704 | plugins, |
| 685 | 705 | firstPartyOAuthClients: [ |
| 686 | 706 | { |
| @@ -835,7 +855,10 @@ describe("oauth.start recorded scope fallback", () => { · 148 unmodified lines | ||
| 835 | 855 | memoryCredentialsPlugin(), |
| 836 | 856 | makeScopePlugin({ scopes: DECLARED_SCOPES }), |
| 837 | 857 | ] as const; |
| 838 | − const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 858 | + const { executor, config } = yield* makeTestWorkspaceHarness({ | |
| 859 | + access: testAccess.member(), | |
| 860 | + plugins, | |
| 861 | + }); | |
| 839 | 862 | yield* executor.acme.seed(); |
| 840 | 863 | |
| 841 | 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 | 912 | memoryCredentialsPlugin(), |
| 890 | 913 | makeMcpScopePlugin({ scopes: null }), |
| 891 | 914 | ] as const; |
| 892 | − const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 915 | + const { executor, config } = yield* makeTestWorkspaceHarness({ | |
| 916 | + access: testAccess.member(), | |
| 917 | + plugins, | |
| 918 | + }); | |
| 893 | 919 | yield* executor.mcp.seed(); |
| 894 | 920 | |
| 895 | 921 | yield* executor.oauth.createClient({ |
| @@ -232,9 +232,9 @@ export interface OAuthServiceDeps { · 231 unmodified lines | ||
| 232 | 232 | readonly owner: Owner; |
| 233 | 233 | readonly subject: string; |
| 234 | 234 | }; |
| 235 | − /** Workspace-settings gate from the executor binding | |
| 236 | − * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the | |
| 237 | − * user-intent client/connect surfaces. */ | |
| 235 | + /** Settings gate from the executor binding (`ExecutorAccess.settingsWrite`): | |
| 236 | + * enforces the product's decision for the owner targeted by a user-intent | |
| 237 | + * client/connect operation. */ | |
| 238 | 238 | readonly guardOrgWrite: (owner: Owner) => Effect.Effect<void, OrgWriteDeniedError>; |
| 239 | 239 | readonly defaultWritableProvider: () => CredentialProvider | null; |
| 240 | 240 | /** Write the connection row with OAuth lifecycle fields + produce its tools. */ |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | import { definePlugin } from "./plugin"; |
| 21 | 21 | import { makeTestConfig, makeTestWorkspaceHarness } from "./test-config"; |
| 22 | 22 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 23 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 23 | 24 | |
| 24 | 25 | // An in-flight authorization flow parks its PKCE verifier in `oauth_session` in |
| 25 | 26 | // plaintext, which is fine while the flow can still spend it. What is not fine is |
| @@ -127,6 +128,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 101 unmodified lines | ||
| 127 | 128 | Effect.gen(function* () { |
| 128 | 129 | const server = yield* serveOAuthTestServer({}); |
| 129 | 130 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 131 | + access: testAccess.member(), | |
| 130 | 132 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, |
| 131 | 133 | }); |
| 132 | 134 | yield* executor.acme.seed(); |
| @@ -205,6 +207,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 72 unmodified lines | ||
| 205 | 207 | Effect.gen(function* () { |
| 206 | 208 | const server = yield* serveOAuthTestServer({}); |
| 207 | 209 | const { executor, config } = yield* makeTestWorkspaceHarness({ |
| 210 | + access: testAccess.member(), | |
| 208 | 211 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, |
| 209 | 212 | }); |
| 210 | 213 | yield* executor.acme.seed(); |
| @@ -280,6 +283,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 69 unmodified lines | ||
| 280 | 283 | const plugins = [memoryCredentialsPlugin, acmePlugin] as const; |
| 281 | 284 | |
| 282 | 285 | const a = yield* makeTestWorkspaceHarness({ |
| 286 | + access: testAccess.member(), | |
| 283 | 287 | plugins, |
| 284 | 288 | tenant, |
| 285 | 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 | 303 | // A second member of the same tenant, on the same database. |
| 300 | 304 | const b = yield* makeTestWorkspaceHarness({ |
| 305 | + access: testAccess.member(), | |
| 301 | 306 | plugins, |
| 302 | 307 | tenant, |
| 303 | 308 | subject: "subject-b", |
| @@ -363,6 +368,7 @@ describe("a dead authorization flow does not keep its PKCE verifier", () => { · 59 unmodified lines | ||
| 363 | 368 | Effect.gen(function* () { |
| 364 | 369 | const server = yield* serveOAuthTestServer({}); |
| 365 | 370 | const base = makeTestConfig({ |
| 371 | + access: testAccess.member(), | |
| 366 | 372 | plugins: [memoryCredentialsPlugin, acmePlugin] as const, |
| 367 | 373 | }); |
| 368 | 374 | const executor = yield* createExecutor({ |
| @@ -1,33 +0,0 @@ | ||
| 1 | −import { Context, Effect, Ref } from "effect"; | |
| 2 | − | |
| 3 | −/** Workspace-settings authorization bound to the currently executing request. */ | |
| 4 | −export type OrgWriteAccess = "allowed" | "denied"; | |
| 5 | − | |
| 6 | −/** | |
| 7 | − * Fiber-local workspace-settings authorization for request-bound executors. | |
| 8 | − * | |
| 9 | − * The denied default makes a missing request binding fail closed. Non-session | |
| 10 | − * executors continue to use their explicit {@link ExecutorConfig.orgWrites} | |
| 11 | − * value (or the allowed default) and never consult this reference. | |
| 12 | − */ | |
| 13 | −export interface OrgWriteAccessState { | |
| 14 | − /** Mutable value inherited by a detached execution and refreshed on resume. */ | |
| 15 | − readonly current: Ref.Ref<OrgWriteAccess>; | |
| 16 | −} | |
| 17 | − | |
| 18 | −/** Create an isolated request/execution authorization state. */ | |
| 19 | −export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ | |
| 20 | − current: Ref.makeUnsafe(access), | |
| 21 | −}); | |
| 22 | − | |
| 23 | −/** Request-local workspace-write authorization inherited by child fibers. */ | |
| 24 | −export const CurrentOrgWriteAccess = Context.Reference<OrgWriteAccessState>( | |
| 25 | − "@executor-js/sdk/CurrentOrgWriteAccess", | |
| 26 | − { defaultValue: () => makeOrgWriteAccessState("denied") }, | |
| 27 | −); | |
| 28 | − | |
| 29 | −/** Read the effective authorization at a workspace-write sink. */ | |
| 30 | −export const currentOrgWriteAccess: Effect.Effect<OrgWriteAccess> = Effect.gen(function* () { | |
| 31 | − const state = yield* CurrentOrgWriteAccess; | |
| 32 | − return yield* Ref.get(state.current); | |
| 33 | −}); | |
| @@ -12,14 +12,15 @@ import { · 11 unmodified lines | ||
| 12 | 12 | ToolName, |
| 13 | 13 | } from "./ids"; |
| 14 | 14 | import { createExecutor } from "./executor"; |
| 15 | −import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./org-write-access"; | |
| 15 | +import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./access"; | |
| 16 | 16 | import { definePlugin } from "./plugin"; |
| 17 | 17 | import type { CredentialProvider } from "./provider"; |
| 18 | 18 | import { makeTestConfig } from "./testing"; |
| 19 | 19 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 20 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 20 | 21 | |
| 21 | 22 | // --------------------------------------------------------------------------- |
| 22 | −// `ExecutorConfig.orgWrites` — the workspace-settings gate. | |
| 23 | +// `ExecutorAccess.workspaceWrites` — the workspace-settings gate. | |
| 23 | 24 | // |
| 24 | 25 | // A `"denied"` binding (a plain member) may USE workspace resources — read |
| 25 | 26 | // them, execute tools over org connections — but every user-intent |
| @@ -29,8 +30,8 @@ import { serveOAuthTestServer } from "./testing/oauth-test-server"; · 3 unmodified lines | ||
| 29 | 30 | // `"allowed"` (admins, and hosts with no role model) behaves exactly as before. |
| 30 | 31 | // |
| 31 | 32 | // The fixtures build TWO executors over ONE test database: an admin |
| 32 | −// (default `orgWrites`) that seeds the workspace, and a member | |
| 33 | −// (`orgWrites: "denied"`) that the assertions run against. | |
| 33 | +// (workspace writes allowed) that seeds the workspace, and a member | |
| 34 | +// (workspace writes denied) that the assertions run against. | |
| 34 | 35 | // --------------------------------------------------------------------------- |
| 35 | 36 | |
| 36 | 37 | const memoryProvider = (): CredentialProvider => { |
| @@ -83,9 +84,9 @@ const demoPlugin = definePlugin(() => ({ · 46 unmodified lines | ||
| 83 | 84 | |
| 84 | 85 | const setup = () => |
| 85 | 86 | Effect.gen(function* () { |
| 86 | − const config = makeTestConfig({ plugins: [demoPlugin] as const }); | |
| 87 | + const config = makeTestConfig({ access: testAccess.member(), plugins: [demoPlugin] as const }); | |
| 87 | 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 | 90 | yield* Effect.addFinalizer(() => |
| 90 | 91 | admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), |
| 91 | 92 | ); |
| @@ -101,13 +102,16 @@ const expectOrgWriteDenied = <A, R>(effect: Effect.Effect<A, unknown, R>) => · 9 unmodified lines | ||
| 101 | 102 | }), |
| 102 | 103 | ); |
| 103 | 104 | |
| 104 | −describe("orgWrites: denied", () => { | |
| 105 | +describe("workspace writes denied", () => { | |
| 105 | 106 | it.effect("reads a live session binding at every workspace-write sink", () => |
| 106 | 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 | 112 | const executor = yield* createExecutor({ |
| 109 | 113 | ...config, |
| 110 | − orgWrites: "request", | |
| 114 | + access: testAccess.requestBound(), | |
| 111 | 115 | }); |
| 112 | 116 | yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); |
| 113 | 117 | const policy = yield* executor.demo.seed().pipe( |
| @@ -249,12 +253,18 @@ describe("orgWrites: denied", () => { · 135 unmodified lines | ||
| 249 | 253 | |
| 250 | 254 | it.effect("allows subjectless system re-registration during boot convergence", () => |
| 251 | 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 | 260 | const admin = yield* createExecutor(config); |
| 254 | 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 | 265 | const system = yield* createExecutor({ |
| 256 | 266 | ...systemConfig, |
| 257 | − orgWrites: "denied", | |
| 267 | + access: testAccess.org(), | |
| 258 | 268 | }); |
| 259 | 269 | yield* Effect.addFinalizer(() => |
| 260 | 270 | admin.close().pipe(Effect.andThen(system.close()), Effect.ignore), |
| @@ -357,7 +367,7 @@ describe("orgWrites: denied", () => { · 96 unmodified lines | ||
| 357 | 367 | ); |
| 358 | 368 | }); |
| 359 | 369 | |
| 360 | −describe("orgWrites: default (allowed)", () => { | |
| 370 | +describe("workspace writes allowed", () => { | |
| 361 | 371 | it.effect("admin bindings mutate workspace-level state as before", () => |
| 362 | 372 | Effect.gen(function* () { |
| 363 | 373 | const { admin } = yield* setup(); |
| @@ -95,7 +95,11 @@ describe("owner policy reach", () => { · 94 unmodified lines | ||
| 95 | 95 | withDb(async (db) => { |
| 96 | 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 | 103 | const rows = await bound.findMany("connection", {}); |
| 100 | 104 | |
| 101 | 105 | expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]); |
| @@ -111,6 +115,7 @@ describe("owner policy reach", () => { · 9 unmodified lines | ||
| 111 | 115 | const platform = withQueryContext(db.db, { |
| 112 | 116 | tenant: TENANT, |
| 113 | 117 | subject: SUBJECT_A, |
| 118 | + owners: ["user", "org"], | |
| 114 | 119 | reach: "tenant", |
| 115 | 120 | }); |
| 116 | 121 | const rows = await platform.findMany("connection", {}); |
| @@ -129,6 +134,7 @@ describe("owner policy reach", () => { · 12 unmodified lines | ||
| 129 | 134 | const platform = withQueryContext(db.db, { |
| 130 | 135 | tenant: TENANT, |
| 131 | 136 | subject: SUBJECT_A, |
| 137 | + owners: ["user", "org"], | |
| 132 | 138 | reach: "tenant", |
| 133 | 139 | }); |
| 134 | 140 | const rows = await platform.findMany("connection", {}); |
| @@ -149,6 +155,7 @@ describe("owner policy reach", () => { · 14 unmodified lines | ||
| 149 | 155 | const platform = withQueryContext(db.db, { |
| 150 | 156 | tenant: TENANT, |
| 151 | 157 | subject: null, |
| 158 | + owners: ["org"], | |
| 152 | 159 | reach: "tenant", |
| 153 | 160 | }); |
| 154 | 161 | const rows = await platform.findMany("connection", {}); |
| @@ -168,6 +175,7 @@ describe("owner policy reach", () => { · 13 unmodified lines | ||
| 168 | 175 | const platform = withQueryContext(db.db, { |
| 169 | 176 | tenant: TENANT, |
| 170 | 177 | subject: SUBJECT_A, |
| 178 | + owners: ["user", "org"], | |
| 171 | 179 | reach: "tenant", |
| 172 | 180 | }); |
| 173 | 181 | |
| @@ -197,6 +205,7 @@ describe("owner policy reach", () => { · 23 unmodified lines | ||
| 197 | 205 | const platform = withQueryContext(db.db, { |
| 198 | 206 | tenant: TENANT, |
| 199 | 207 | subject: SUBJECT_A, |
| 208 | + owners: ["user", "org"], | |
| 200 | 209 | reach: "tenant", |
| 201 | 210 | }); |
| 202 | 211 | |
| @@ -225,6 +234,7 @@ describe("owner policy reach", () => { · 22 unmodified lines | ||
| 225 | 234 | const platform = withQueryContext(db.db, { |
| 226 | 235 | tenant: TENANT, |
| 227 | 236 | subject: SUBJECT_A, |
| 237 | + owners: ["user", "org"], | |
| 228 | 238 | reach: "tenant", |
| 229 | 239 | }); |
| 230 | 240 | |
| @@ -249,6 +259,7 @@ describe("owner policy reach", () => { · 18 unmodified lines | ||
| 249 | 259 | const platform = withQueryContext(db.db, { |
| 250 | 260 | tenant: TENANT, |
| 251 | 261 | subject: SUBJECT_A, |
| 262 | + owners: ["user", "org"], | |
| 252 | 263 | reach: "tenant", |
| 253 | 264 | }); |
| 254 | 265 | |
| @@ -272,12 +283,17 @@ describe("owner policy reach", () => { · 17 unmodified lines | ||
| 272 | 283 | withDb(async (db) => { |
| 273 | 284 | // The guard must not disturb the product view: an ordinary bound |
| 274 | 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 | 292 | await bound.create("connection", { |
| 278 | 293 | tenant: TENANT, |
| 279 | 294 | owner: "user", |
| 280 | 295 | subject: SUBJECT_A, |
| 296 | + owners: ["user", "org"], | |
| 281 | 297 | integration: "github", |
| 282 | 298 | name: "written-by-bound-view", |
| 283 | 299 | template: "apiKey", |
| @@ -296,13 +312,18 @@ describe("owner policy reach", () => { · 12 unmodified lines | ||
| 296 | 312 | it.effect("a bound context still cannot write outside its own subject", () => |
| 297 | 313 | Effect.promise(() => |
| 298 | 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 | 321 | await expect( |
| 302 | 322 | bound.create("connection", { |
| 303 | 323 | tenant: TENANT, |
| 304 | 324 | owner: "user", |
| 305 | 325 | subject: SUBJECT_B, |
| 326 | + owners: ["user", "org"], | |
| 306 | 327 | integration: "github", |
| 307 | 328 | name: "cross-subject", |
| 308 | 329 | template: "apiKey", |
| @@ -2,6 +2,7 @@ import { type Condition, type ConditionBuilder } from "@executor-js/fumadb/query · 1 unmodified line | ||
| 2 | 2 | import type { AnyColumn, AnyTable } from "@executor-js/fumadb/schema"; |
| 3 | 3 | |
| 4 | 4 | import { StorageError } from "./fuma-runtime"; |
| 5 | +import type { Owner } from "./ids"; | |
| 5 | 6 | |
| 6 | 7 | /* The v2 owner policy — successor to v1's `executor.scope` policy. Every owned |
| 7 | 8 | * row carries `tenant` + `owner`('org'|'user') + `subject`; org rows use the |
| @@ -53,6 +54,19 @@ export interface ExecutorOwnerPolicyContext { · 45 unmodified lines | ||
| 53 | 54 | /** The acting member, or null for a pure-org executor (no `owner:"user"` |
| 54 | 55 | * reads/writes are allowed when null). */ |
| 55 | 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 | 70 | /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the |
| 57 | 71 | * platform view: it sees the whole tenant but writes exactly as a bound |
| 58 | 72 | * context does, so it can never mutate another subject's rows. */ |
| @@ -106,17 +120,29 @@ export const ownerVisibilityCondition = ( · 47 unmodified lines | ||
| 106 | 120 | // The platform view: partition by tenant alone. Still never cross-tenant — |
| 107 | 121 | // `tenant` is the one clause that is NEVER relaxed, at any reach. |
| 108 | 122 | if (context.reach === "tenant") return builder("tenant", "=", context.tenant); |
| 109 | − const orgClause = builder.and( | |
| 110 | − builder("tenant", "=", context.tenant), | |
| 111 | − builder("owner", "=", "org"), | |
| 112 | − ); | |
| 113 | − if (context.subject == null) return orgClause; | |
| 114 | − const userClause = builder.and( | |
| 115 | − builder("tenant", "=", context.tenant), | |
| 116 | − builder("owner", "=", "user"), | |
| 117 | − builder("subject", "=", context.subject), | |
| 118 | − ); | |
| 119 | − return builder.or(orgClause, userClause); | |
| 123 | + // The product's visible partitions. A malformed context that reaches the | |
| 124 | + // policy without them (only possible past the type system) fails CLOSED: | |
| 125 | + // no partitions means no rows. Core contributes only the clamps inside | |
| 126 | + // each clause — never a partition of its own. | |
| 127 | + const owners: readonly Owner[] = Array.isArray(context.owners) ? context.owners : []; | |
| 128 | + const clauses: (Condition | boolean)[] = []; | |
| 129 | + if (owners.includes("org")) { | |
| 130 | + clauses.push(builder.and(builder("tenant", "=", context.tenant), builder("owner", "=", "org"))); | |
| 131 | + } | |
| 132 | + if (owners.includes("user") && context.subject != null) { | |
| 133 | + clauses.push( | |
| 134 | + builder.and( | |
| 135 | + builder("tenant", "=", context.tenant), | |
| 136 | + builder("owner", "=", "user"), | |
| 137 | + builder("subject", "=", context.subject), | |
| 138 | + ), | |
| 139 | + ); | |
| 140 | + } | |
| 141 | + // A context whose product view lists no partition this binding can carry | |
| 142 | + // (personal-only with no subject) sees nothing — fail closed, not open. | |
| 143 | + if (clauses.length === 0) return false; | |
| 144 | + if (clauses.length === 1) return clauses[0]!; | |
| 145 | + return builder.or(...clauses); | |
| 120 | 146 | }; |
| 121 | 147 | |
| 122 | 148 | /** |
| @@ -172,6 +198,21 @@ export const assertOwnerWritable = ( · 49 unmodified lines | ||
| 172 | 198 | if (values.tenant !== ctx.tenant) { |
| 173 | 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 | 216 | if (values.owner === "org") { |
| 176 | 217 | if (values.subject !== ORG_SUBJECT) { |
| 177 | 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 | 245 | if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) { |
| 205 | 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 | 256 | if (patch.owner === "user" && (ctx.subject == null || patch.subject !== ctx.subject)) { |
| 208 | 257 | policyViolation( |
| 209 | 258 | `Storage write on table "${tableName}" cannot move a row outside the bound subject.`, |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | ||
| 22 | 22 | } from "./ids"; |
| 23 | 23 | import { definePlugin } from "./plugin"; |
| 24 | 24 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; |
| 25 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 25 | 26 | |
| 26 | 27 | // The platform view: `executor.admin`, an OPT-IN read-only surface that reads |
| 27 | 28 | // across every subject in the tenant. Written against the real SQLite bring-up |
| @@ -254,7 +255,12 @@ const makePlatformExecutor = ( · 226 unmodified lines | ||
| 254 | 255 | db: db.db, |
| 255 | 256 | plugins: [providerPlugin], |
| 256 | 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 | 264 | }).pipe(Effect.orDie); |
| 259 | 265 | |
| 260 | 266 | const requireAdmin = (executor: Executor) => { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | import { definePlugin } from "./plugin"; |
| 14 | 14 | import { makeTestExecutor } from "./test-config"; |
| 15 | 15 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| 16 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 16 | 17 | |
| 17 | 18 | // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is |
| 18 | 19 | // what makes its database work atomic with the row deletions. The same property |
| @@ -98,9 +99,10 @@ const revokingPlugin = (revoked: string[]) => · 79 unmodified lines | ||
| 98 | 99 | })(); |
| 99 | 100 | |
| 100 | 101 | const setup = (revoked: string[]) => |
| 101 | − makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe( | |
| 102 | − Effect.tap((executor) => executor.demo.seed()), | |
| 103 | − ); | |
| 102 | + makeTestExecutor({ | |
| 103 | + access: testAccess.member(), | |
| 104 | + plugins: [revokingPlugin(revoked)] as const, | |
| 105 | + }).pipe(Effect.tap((executor) => executor.demo.seed())); | |
| 104 | 106 | |
| 105 | 107 | const REF = { |
| 106 | 108 | owner: "org", |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | type PluginStorageCollectionWhere, |
| 13 | 13 | } from "./plugin-storage"; |
| 14 | 14 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 15 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 15 | 16 | |
| 16 | 17 | const ToolCall = Schema.Struct({ |
| 17 | 18 | runId: Schema.String, |
| @@ -167,6 +168,7 @@ describe("plugin storage collections", () => { · 149 unmodified lines | ||
| 167 | 168 | it.effect("queries declared indexes through the executor's SQLite FumaDB target", () => |
| 168 | 169 | Effect.gen(function* () { |
| 169 | 170 | const executor = yield* makeTestExecutor({ |
| 171 | + access: testAccess.member(), | |
| 170 | 172 | backend: "sqlite", |
| 171 | 173 | plugins: [executionHistoryPlugin] as const, |
| 172 | 174 | }); |
| @@ -229,6 +231,7 @@ describe("plugin storage collections", () => { · 56 unmodified lines | ||
| 229 | 231 | it.effect("bulk puts and removes plugin storage rows", () => |
| 230 | 232 | Effect.gen(function* () { |
| 231 | 233 | const executor = yield* makeTestExecutor({ |
| 234 | + access: testAccess.member(), | |
| 232 | 235 | backend: "sqlite", |
| 233 | 236 | plugins: [executionHistoryPlugin] as const, |
| 234 | 237 | }); |
| @@ -275,6 +278,7 @@ describe("plugin storage collections", () => { · 40 unmodified lines | ||
| 275 | 278 | it.effect("stores and overwrites every row when a bulk write spans multiple batches", () => |
| 276 | 279 | Effect.gen(function* () { |
| 277 | 280 | const executor = yield* makeTestExecutor({ |
| 281 | + access: testAccess.member(), | |
| 278 | 282 | backend: "sqlite", |
| 279 | 283 | plugins: [executionHistoryPlugin] as const, |
| 280 | 284 | }); |
| @@ -312,6 +316,7 @@ describe("plugin storage collections", () => { · 31 unmodified lines | ||
| 312 | 316 | it.effect("rolls back every plugin storage row when a bulk write fails", () => |
| 313 | 317 | Effect.gen(function* () { |
| 314 | 318 | const config = makeTestConfig({ |
| 319 | + access: testAccess.member(), | |
| 315 | 320 | backend: "sqlite", |
| 316 | 321 | plugins: [executionHistoryPlugin] as const, |
| 317 | 322 | }); |
| @@ -369,6 +374,7 @@ describe("plugin storage collections", () => { · 51 unmodified lines | ||
| 369 | 374 | it.effect("leaves pre-existing rows intact when a bulk overwrite fails mid-batch", () => |
| 370 | 375 | Effect.gen(function* () { |
| 371 | 376 | const config = makeTestConfig({ |
| 377 | + access: testAccess.member(), | |
| 372 | 378 | backend: "sqlite", |
| 373 | 379 | plugins: [executionHistoryPlugin] as const, |
| 374 | 380 | }); |
| @@ -435,6 +441,7 @@ describe("plugin storage collections", () => { · 60 unmodified lines | ||
| 435 | 441 | () => |
| 436 | 442 | Effect.gen(function* () { |
| 437 | 443 | const executor = yield* makeTestExecutor({ |
| 444 | + access: testAccess.member(), | |
| 438 | 445 | backend: "sqlite", |
| 439 | 446 | plugins: [executionHistoryPlugin] as const, |
| 440 | 447 | }); |
| @@ -463,6 +470,7 @@ describe("plugin storage collections", () => { · 22 unmodified lines | ||
| 463 | 470 | // One executor bound to a subject sees both org and user owner rows; a |
| 464 | 471 | // user-owned row shadows an org-owned row under the same key on read. |
| 465 | 472 | const executor = yield* makeTestExecutor({ |
| 473 | + access: testAccess.member(), | |
| 466 | 474 | backend: "sqlite", |
| 467 | 475 | plugins: [executionHistoryPlugin] as const, |
| 468 | 476 | }); |
| @@ -508,6 +516,7 @@ describe("plugin storage collections", () => { · 39 unmodified lines | ||
| 508 | 516 | it.effect("rejects runtime queries against undeclared index fields", () => |
| 509 | 517 | Effect.gen(function* () { |
| 510 | 518 | const executor = yield* makeTestExecutor({ |
| 519 | + access: testAccess.member(), | |
| 511 | 520 | backend: "sqlite", |
| 512 | 521 | plugins: [executionHistoryPlugin] as const, |
| 513 | 522 | }); |
| @@ -168,9 +168,9 @@ export interface PluginCtx<TStore = unknown> { · 167 unmodified lines | ||
| 168 | 168 | * external work or writes to storage outside the catalog transaction. */ |
| 169 | 169 | readonly authorizeWrite: () => Effect.Effect<void, OrgWriteDeniedError>; |
| 170 | 170 | /** Register / replace this plugin's integration in the catalog. Both |
| 171 | − * operations are workspace-level changes gated by the executor's | |
| 172 | − * `orgWrites` binding for end-user principals. Subjectless system | |
| 173 | − * executors may re-register an existing row during boot convergence. */ | |
| 171 | + * operations are workspace-level changes gated by the product's | |
| 172 | + * `ExecutorAccess.settingsWrite` decision, including replacement by | |
| 173 | + * subjectless system executors during boot convergence. */ | |
| 174 | 174 | readonly register: ( |
| 175 | 175 | input: RegisterIntegrationInput, |
| 176 | 176 | ) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>; |
| @@ -15,15 +15,15 @@ import { · 14 unmodified lines | ||
| 15 | 15 | import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; |
| 16 | 16 | import { createExecutor } from "./executor"; |
| 17 | 17 | import type { FumaDb } from "./fuma-runtime"; |
| 18 | −import { | |
| 19 | − effectivePolicyFromSorted, | |
| 20 | − isValidPattern, | |
| 21 | − matchPattern, | |
| 22 | − resolveToolPolicy, | |
| 23 | −} from "./policies"; | |
| 18 | +// Resolution semantics are PRODUCT rules — these tests exercise the real | |
| 19 | +// implementations from @executor-js/product-access (via the workspace root | |
| 20 | +// devDependency), never a copy. | |
| 21 | +import { effectivePolicyFromSorted, resolveToolPolicy } from "@executor-js/product-access/policy"; | |
| 22 | +import { isValidPattern, matchPattern } from "./policies"; | |
| 24 | 23 | import { definePlugin, tool } from "./plugin"; |
| 25 | 24 | import type { CredentialProvider } from "./provider"; |
| 26 | 25 | import { makeTestConfig, makeTestExecutor } from "./testing"; |
| 26 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 27 | 27 | |
| 28 | 28 | // --------------------------------------------------------------------------- |
| 29 | 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 | 332 | ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`); |
| 333 | 333 | |
| 334 | 334 | const setupExecutor = () => |
| 335 | − makeTestExecutor({ plugins: [policyTestPlugin()] as const }).pipe( | |
| 335 | + makeTestExecutor({ access: testAccess.member(), plugins: [policyTestPlugin()] as const }).pipe( | |
| 336 | 336 | Effect.tap((executor) => |
| 337 | 337 | Effect.gen(function* () { |
| 338 | 338 | yield* executor.ptest.seed(); |
| @@ -533,7 +533,10 @@ describe("executor.policies", () => { · 194 unmodified lines | ||
| 533 | 533 | it.effect("fails when the policy vanishes during update", () => |
| 534 | 534 | Effect.gen(function* () { |
| 535 | 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 | 540 | const executor = yield* createExecutor({ |
| 538 | 541 | ...config, |
| 539 | 542 | db: removePolicyAfterUpdate(config.db, () => armed), |
| @@ -685,6 +688,7 @@ describe("active tool-policy provider", () => { · 145 unmodified lines | ||
| 685 | 688 | it.effect("uses provider rules as an allowlist for list, schema, and execute", () => |
| 686 | 689 | Effect.gen(function* () { |
| 687 | 690 | const executor = yield* makeTestExecutor({ |
| 691 | + access: testAccess.member(), | |
| 688 | 692 | plugins: [staticPlugin, policyProviderPlugin] as const, |
| 689 | 693 | }); |
| 690 | 694 | |
| @@ -1,15 +1,17 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// Tool policies — pattern matcher + policy resolution. Pure functions; the | |
| 3 | −// executor stitches them into `tools.list`, `execute`, and the public | |
| 4 | −// `executor.policies` CRUD surface. Plugins consume the same surface. | |
| 2 | +// Tool policies — the decision VOCABULARY (types, schemas, projections) plus | |
| 3 | +// the pure pattern-matching and rule-placement utilities the executor's CRUD | |
| 4 | +// surface needs. Policies are owner-scoped (org | user) rows. | |
| 5 | 5 | // |
| 6 | −// v2: policies are owner-scoped (org | user) instead of scope-stacked. Each | |
| 7 | −// owner contributes its first matching rule by local position; the final answer | |
| 8 | −// is the most restrictive matched action across owners, so a user preference | |
| 9 | −// cannot weaken an org guardrail (org = outer, user = inner). | |
| 6 | +// HOW rules resolve into an effective decision — owner ranking, the | |
| 7 | +// most-restrictive merge across owners, the plugin-default fallback and the | |
| 8 | +// capability-allowlist default — is a PRODUCT rule: it lives in | |
| 9 | +// `@executor-js/product-access/policy` and reaches core only through the | |
| 10 | +// `ExecutorAccess.toolPolicy` hook, whose `EffectivePolicy` answers core | |
| 11 | +// enforces at list/schema/invoke/approval-recheck. | |
| 10 | 12 | // --------------------------------------------------------------------------- |
| 11 | 13 | |
| 12 | −import { Match, Schema } from "effect"; | |
| 14 | +import { Schema } from "effect"; | |
| 13 | 15 | import { generateKeyBetween } from "fractional-indexing"; |
| 14 | 16 | |
| 15 | 17 | import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; |
| @@ -121,10 +123,11 @@ export const isValidPattern = (pattern: string): boolean => { · 105 unmodified lines | ||
| 121 | 123 | }; |
| 122 | 124 | |
| 123 | 125 | // --------------------------------------------------------------------------- |
| 124 | −// Resolution — each owner contributes its first matching rule by local | |
| 125 | −// position; the most restrictive matched action across owners wins. Caller | |
| 126 | −// passes an `ownerRank` so the resolver doesn't need to know which owner is | |
| 127 | −// the outer guardrail. | |
| 126 | +// Ordering / placement utilities. HOW matched rules combine into an | |
| 127 | +// effective decision (owner ranking, most-restrictive merge, fallbacks) is a | |
| 128 | +// PRODUCT rule and lives in `@executor-js/product-access/policy`; core only | |
| 129 | +// consumes the resulting `EffectivePolicy` through | |
| 130 | +// `ExecutorAccess.toolPolicy` and enforces it. | |
| 128 | 131 | // --------------------------------------------------------------------------- |
| 129 | 132 | |
| 130 | 133 | export const comparePolicyRow = ( |
| @@ -180,106 +183,6 @@ export const positionForNewPattern = ( · 49 unmodified lines | ||
| 180 | 183 | return generateKeyBetween(prev, next); |
| 181 | 184 | }; |
| 182 | 185 | |
| 183 | −const actionRestrictionRank = (action: ToolPolicyAction): number => | |
| 184 | − Match.value(action).pipe( | |
| 185 | − Match.when("block", () => 3), | |
| 186 | − Match.when("require_approval", () => 2), | |
| 187 | − Match.when("approve", () => 1), | |
| 188 | − Match.exhaustive, | |
| 189 | − ); | |
| 190 | − | |
| 191 | −const moreRestrictive = <T extends { readonly action: ToolPolicyAction }>( | |
| 192 | − current: T | undefined, | |
| 193 | − candidate: T, | |
| 194 | −): T => { | |
| 195 | − if (!current) return candidate; | |
| 196 | − const currentRank = actionRestrictionRank(current.action); | |
| 197 | − const candidateRank = actionRestrictionRank(candidate.action); | |
| 198 | − return candidateRank > currentRank ? candidate : current; | |
| 199 | −}; | |
| 200 | − | |
| 201 | −export const resolveToolPolicy = ( | |
| 202 | − toolId: string, | |
| 203 | − policies: readonly ToolPolicyRow[], | |
| 204 | − ownerRank: (row: Pick<ToolPolicyRow, "owner">) => number, | |
| 205 | −): PolicyMatch | undefined => { | |
| 206 | − if (policies.length === 0) return undefined; | |
| 207 | − const sorted = [...policies].sort((a, b) => { | |
| 208 | − const sa = ownerRank(a); | |
| 209 | − const sb = ownerRank(b); | |
| 210 | − if (sa !== sb) return sa - sb; | |
| 211 | − return comparePolicyRow(a, b); | |
| 212 | − }); | |
| 213 | − const firstMatchByOwner = new Map<string, PolicyMatch>(); | |
| 214 | − for (const row of sorted) { | |
| 215 | − if (firstMatchByOwner.has(row.owner)) continue; | |
| 216 | − if (matchPattern(row.pattern, toolId)) { | |
| 217 | − firstMatchByOwner.set(row.owner, { | |
| 218 | − action: row.action as ToolPolicyAction, | |
| 219 | − pattern: row.pattern, | |
| 220 | − policyId: row.id, | |
| 221 | − }); | |
| 222 | − } | |
| 223 | − } | |
| 224 | − let selected: PolicyMatch | undefined; | |
| 225 | − for (const match of firstMatchByOwner.values()) { | |
| 226 | − selected = moreRestrictive(selected, match); | |
| 227 | − } | |
| 228 | − return selected; | |
| 229 | −}; | |
| 230 | − | |
| 231 | −// --------------------------------------------------------------------------- | |
| 232 | −// Layered resolution — user-authored rules + plugin default `requiresApproval`. | |
| 233 | −// --------------------------------------------------------------------------- | |
| 234 | − | |
| 235 | −const liftPlugin = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => | |
| 236 | − defaultRequiresApproval | |
| 237 | − ? { action: "require_approval", source: "plugin-default" } | |
| 238 | − : { action: "approve", source: "plugin-default" }; | |
| 239 | − | |
| 240 | −const liftUser = (match: PolicyMatch): EffectivePolicy => ({ | |
| 241 | − action: match.action, | |
| 242 | − source: "user", | |
| 243 | − pattern: match.pattern, | |
| 244 | − policyId: match.policyId, | |
| 245 | −}); | |
| 246 | − | |
| 247 | −export const resolveEffectivePolicy = ( | |
| 248 | − toolId: string, | |
| 249 | − policies: readonly ToolPolicyRow[], | |
| 250 | − ownerRank: (row: Pick<ToolPolicyRow, "owner">) => number, | |
| 251 | − defaultRequiresApproval?: boolean, | |
| 252 | −): EffectivePolicy => { | |
| 253 | − const match = resolveToolPolicy(toolId, policies, ownerRank); | |
| 254 | − return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); | |
| 255 | −}; | |
| 256 | − | |
| 257 | −export const effectivePolicyFromSorted = ( | |
| 258 | − toolId: string, | |
| 259 | − sortedPolicies: readonly (Pick<ToolPolicy, "pattern" | "action" | "id"> & | |
| 260 | − Partial<Pick<ToolPolicy, "owner">>)[], | |
| 261 | − defaultRequiresApproval?: boolean, | |
| 262 | −): EffectivePolicy => { | |
| 263 | − const firstMatchByOwner = new Map<string, EffectivePolicy>(); | |
| 264 | − for (const p of sortedPolicies) { | |
| 265 | − const ownerKey = "owner" in p && p.owner ? String(p.owner) : "__flat__"; | |
| 266 | − if (firstMatchByOwner.has(ownerKey)) continue; | |
| 267 | − if (matchPattern(p.pattern, toolId)) { | |
| 268 | − firstMatchByOwner.set(ownerKey, { | |
| 269 | − action: p.action, | |
| 270 | − source: "user", | |
| 271 | − pattern: p.pattern, | |
| 272 | − policyId: p.id, | |
| 273 | − }); | |
| 274 | − } | |
| 275 | − } | |
| 276 | − let selected: EffectivePolicy | undefined; | |
| 277 | − for (const match of firstMatchByOwner.values()) { | |
| 278 | − selected = moreRestrictive(selected, match); | |
| 279 | − } | |
| 280 | − return selected ?? liftPlugin(defaultRequiresApproval); | |
| 281 | −}; | |
| 282 | − | |
| 283 | 186 | // --------------------------------------------------------------------------- |
| 284 | 187 | // Row → public projection. |
| 285 | 188 | // --------------------------------------------------------------------------- |
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | type InvokeOptions as EffectInvokeOptions, |
| 22 | 22 | type OnElicitation, |
| 23 | 23 | } from "./executor"; |
| 24 | +import type { ExecutorAccess } from "./access"; | |
| 24 | 25 | import type { ElicitationContext, ElicitationResponse } from "./elicitation"; |
| 25 | 26 | import type { FumaDb, FumaTables } from "./fuma-runtime"; |
| 26 | 27 | import { Subject, Tenant } from "./ids"; |
| @@ -127,6 +128,15 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 100 unmodified lines | ||
| 127 | 128 | * an options arg. |
| 128 | 129 | */ |
| 129 | 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 | 223 | plugins, |
| 214 | 224 | ...(config.providers ? { providers: config.providers } : {}), |
| 215 | 225 | onElicitation: toEffectOnElicitation(config.onElicitation), |
| 226 | + access: config.access, | |
| 216 | 227 | ...(db ? { db } : {}), |
| 217 | 228 | }; |
| 218 | 229 | |
| @@ -5,6 +5,7 @@ import { definePlugin, tool } from "./plugin"; · 4 unmodified lines | ||
| 5 | 5 | import type { ToolDef } from "./tool"; |
| 6 | 6 | import { IntegrationSlug, ToolName } from "./ids"; |
| 7 | 7 | import { Effect, Schema } from "effect"; |
| 8 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 8 | 9 | |
| 9 | 10 | // A minimal static-tool plugin built on the Effect surface, consumed |
| 10 | 11 | // through the Promise façade. Exercises the proxy's ability to promisify |
| @@ -41,6 +42,7 @@ describe("promise/createExecutor", () => { · 30 unmodified lines | ||
| 41 | 42 | it("returns Promise-shaped executor and invokes static tools", async () => { |
| 42 | 43 | const plugins = [echoPlugin()] as const; |
| 43 | 44 | const executor = await createExecutor({ |
| 45 | + access: testAccess.org(), | |
| 44 | 46 | plugins, |
| 45 | 47 | onElicitation: "accept-all", |
| 46 | 48 | }); |
| @@ -54,6 +56,7 @@ describe("promise/createExecutor", () => { · 7 unmodified lines | ||
| 54 | 56 | it("promisifies plugin extension methods", async () => { |
| 55 | 57 | const plugins = [echoPlugin()] as const; |
| 56 | 58 | const executor = await createExecutor({ |
| 59 | + access: testAccess.org(), | |
| 57 | 60 | plugins, |
| 58 | 61 | onElicitation: "accept-all", |
| 59 | 62 | }); |
| @@ -94,6 +97,7 @@ describe("promise/createExecutor", () => { · 34 unmodified lines | ||
| 94 | 97 | |
| 95 | 98 | const plugins = [approvedPlugin()] as const; |
| 96 | 99 | const executor = await createExecutor({ |
| 100 | + access: testAccess.org(), | |
| 97 | 101 | plugins, |
| 98 | 102 | onElicitation: "accept-all", // default → auto-approve |
| 99 | 103 | }); |
| @@ -157,6 +161,7 @@ describe("promise/createExecutor", () => { · 57 unmodified lines | ||
| 157 | 161 | |
| 158 | 162 | const plugins = [inventoryPlugin()] as const; |
| 159 | 163 | const executor = await createExecutor({ |
| 164 | + access: testAccess.org(), | |
| 160 | 165 | plugins, |
| 161 | 166 | providers: [memoryProvider], |
| 162 | 167 | onElicitation: "accept-all", |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | ||
| 29 | 29 | import { definePlugin } from "./plugin"; |
| 30 | 30 | import type { CredentialProvider } from "./provider"; |
| 31 | 31 | import { makeTestConfig } from "./test-config"; |
| 32 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 32 | 33 | |
| 33 | 34 | const STORE = ProviderKey.make("remote-store"); |
| 34 | 35 | const INTEG = IntegrationSlug.make("acme"); |
| @@ -55,7 +56,7 @@ const plugin = (provider: CredentialProvider) => · 20 unmodified lines | ||
| 55 | 56 | const executorWithConnection = (provider: CredentialProvider) => |
| 56 | 57 | Effect.gen(function* () { |
| 57 | 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 | 61 | yield* executor.acme.seed(); |
| 61 | 62 | yield* executor.connections.create({ |
| @@ -110,7 +111,9 @@ describe("a credential provider that stops answering", () => { · 48 unmodified lines | ||
| 110 | 111 | get: (id) => Effect.sync(() => items.get(String(id)) ?? null), |
| 111 | 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 | 117 | yield* executor.acme.seed(); |
| 115 | 118 | yield* executor.connections.create({ |
| 116 | 119 | owner: "org", |
| @@ -144,7 +147,10 @@ describe("a credential provider that stops answering", () => { · 27 unmodified lines | ||
| 144 | 147 | } |
| 145 | 148 | |
| 146 | 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 | 155 | yield* executor.acme.seed(); |
| 150 | 156 | yield* executor.connections.create({ |
| @@ -184,6 +190,7 @@ describe("a credential provider that stops answering", () => { · 33 unmodified lines | ||
| 184 | 190 | |
| 185 | 191 | const executor = yield* createExecutor( |
| 186 | 192 | makeTestConfig({ |
| 193 | + access: testAccess.member(), | |
| 187 | 194 | plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const, |
| 188 | 195 | }), |
| 189 | 196 | ); |
| @@ -0,0 +1,118 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Result } from "effect"; | |
| 3 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 4 | + | |
| 5 | +import type { ExecutorAccess } from "./access"; | |
| 6 | +import { createExecutor } from "./executor"; | |
| 7 | +import { IntegrationSlug, Subject } from "./ids"; | |
| 8 | +import { definePlugin } from "./plugin"; | |
| 9 | +import { makeTestConfig } from "./testing"; | |
| 10 | + | |
| 11 | +// --------------------------------------------------------------------------- | |
| 12 | +// `ExecutorAccess.settingsWrite` is the product's decision, and core enforces | |
| 13 | +// it VERBATIM at every user-intent settings sink — including catalog | |
| 14 | +// replacement from a SUBJECT-LESS executor. A host that hands a subject-less | |
| 15 | +// binding a denying `settingsWrite` has stated its rule; core must not keep a | |
| 16 | +// residual rule of its own ("subject-less replacement of an existing row is | |
| 17 | +// always boot convergence") that overrides the supplied denial. Products that | |
| 18 | +// WANT boot convergence say so through their access implementation | |
| 19 | +// (`workspaceServiceAccess`), which these tests also pin. | |
| 20 | +// | |
| 21 | +// The fixtures build executors over ONE test database: a workspace-service | |
| 22 | +// seeder that registers the catalog row, then differently-postured executors | |
| 23 | +// that attempt to replace it through the same plugin-extension path. | |
| 24 | +// --------------------------------------------------------------------------- | |
| 25 | + | |
| 26 | +const SLUG = IntegrationSlug.make("settings-authority"); | |
| 27 | +const SEEDED_DESCRIPTION = "seeded by the workspace service"; | |
| 28 | +const REPLACED_DESCRIPTION = "replaced despite a denied settings decision"; | |
| 29 | + | |
| 30 | +const catalogPlugin = definePlugin(() => ({ | |
| 31 | + id: "settings-authority-demo" as const, | |
| 32 | + storage: () => ({}), | |
| 33 | + extension: (ctx) => ({ | |
| 34 | + register: (description: string) => | |
| 35 | + ctx.core.integrations.register({ slug: SLUG, description, config: {} }), | |
| 36 | + }), | |
| 37 | +}))(); | |
| 38 | + | |
| 39 | +/** The workspace-service posture with the product's settings decision | |
| 40 | + * replaced by an unconditional denial — the host's explicit word. */ | |
| 41 | +const denyingServiceAccess = (): ExecutorAccess => ({ | |
| 42 | + ...testAccess.org(), | |
| 43 | + settingsWrite: () => Effect.succeed("denied" as const), | |
| 44 | +}); | |
| 45 | + | |
| 46 | +const setup = () => | |
| 47 | + Effect.gen(function* () { | |
| 48 | + const config = makeTestConfig({ | |
| 49 | + subject: null, | |
| 50 | + access: testAccess.org(), | |
| 51 | + plugins: [catalogPlugin] as const, | |
| 52 | + }); | |
| 53 | + const seeder = yield* createExecutor(config); | |
| 54 | + yield* Effect.addFinalizer(() => | |
| 55 | + seeder | |
| 56 | + .close() | |
| 57 | + .pipe( | |
| 58 | + Effect.ignore, | |
| 59 | + Effect.andThen(Effect.promise(() => config.testDb.close()).pipe(Effect.ignore)), | |
| 60 | + ), | |
| 61 | + ); | |
| 62 | + yield* seeder["settings-authority-demo"].register(SEEDED_DESCRIPTION); | |
| 63 | + return { config, seeder }; | |
| 64 | + }); | |
| 65 | + | |
| 66 | +describe("settingsWrite authority over catalog replacement", () => { | |
| 67 | + it.effect("a supplied denial refuses replacement from a subject-less executor", () => | |
| 68 | + Effect.gen(function* () { | |
| 69 | + const { config, seeder } = yield* setup(); | |
| 70 | + const denied = yield* createExecutor({ ...config, access: denyingServiceAccess() }); | |
| 71 | + yield* Effect.addFinalizer(() => denied.close().pipe(Effect.ignore)); | |
| 72 | + | |
| 73 | + const outcome = yield* Effect.result( | |
| 74 | + denied["settings-authority-demo"].register(REPLACED_DESCRIPTION), | |
| 75 | + ); | |
| 76 | + // The product said "denied"; core must enforce it even with no bound | |
| 77 | + // subject — there is no core-owned boot-convergence exemption. | |
| 78 | + expect(Result.isFailure(outcome)).toBe(true); | |
| 79 | + expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } }); | |
| 80 | + | |
| 81 | + const after = yield* seeder.integrations.get(SLUG); | |
| 82 | + expect(after?.description).toBe(SEEDED_DESCRIPTION); | |
| 83 | + }).pipe(Effect.scoped), | |
| 84 | + ); | |
| 85 | + | |
| 86 | + it.effect("boot convergence stays available when the product allows it", () => | |
| 87 | + Effect.gen(function* () { | |
| 88 | + const { seeder } = yield* setup(); | |
| 89 | + // The same subject-less posture with the product's own (allowing) | |
| 90 | + // decision converges the existing row — the rule lives in the product, | |
| 91 | + // not in core. | |
| 92 | + yield* seeder["settings-authority-demo"].register("converged at boot"); | |
| 93 | + const after = yield* seeder.integrations.get(SLUG); | |
| 94 | + expect(after?.description).toBe("converged at boot"); | |
| 95 | + }).pipe(Effect.scoped), | |
| 96 | + ); | |
| 97 | + | |
| 98 | + it.effect("baseline: a bound member's denial already refuses replacement", () => | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const { config, seeder } = yield* setup(); | |
| 101 | + const member = yield* createExecutor({ | |
| 102 | + ...config, | |
| 103 | + subject: Subject.make("member-1"), | |
| 104 | + access: testAccess.member("denied"), | |
| 105 | + }); | |
| 106 | + yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); | |
| 107 | + | |
| 108 | + const outcome = yield* Effect.result( | |
| 109 | + member["settings-authority-demo"].register(REPLACED_DESCRIPTION), | |
| 110 | + ); | |
| 111 | + expect(Result.isFailure(outcome)).toBe(true); | |
| 112 | + expect(outcome).toMatchObject({ failure: { _tag: "OrgWriteDeniedError" } }); | |
| 113 | + | |
| 114 | + const after = yield* seeder.integrations.get(SLUG); | |
| 115 | + expect(after?.description).toBe(SEEDED_DESCRIPTION); | |
| 116 | + }).pipe(Effect.scoped), | |
| 117 | + ); | |
| 118 | +}); | |
| @@ -88,10 +88,11 @@ export { · 87 unmodified lines | ||
| 88 | 88 | } from "./elicitation"; |
| 89 | 89 | |
| 90 | 90 | // Tool-policy helpers + projections (pure functions / Schema). |
| 91 | +// Resolution semantics (owner ranking, most-restrictive merge, fallbacks) | |
| 92 | +// live in `@executor-js/product-access/policy`, which builds on these. | |
| 91 | 93 | export { |
| 92 | 94 | matchPattern, |
| 93 | 95 | isValidPattern, |
| 94 | − effectivePolicyFromSorted, | |
| 95 | 96 | comparePolicyRow, |
| 96 | 97 | patternSpecificity, |
| 97 | 98 | positionForNewPattern, |
| @@ -88,7 +88,11 @@ describe("legacy bigint storage class migration", () => { · 87 unmodified lines | ||
| 88 | 88 | }); |
| 89 | 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 | 96 | // Not "returns a bad value" — the read THROWS, which is why the gateway |
| 93 | 97 | // lost every saved integration rather than one field of one row. |
| 94 | 98 | await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); |
| @@ -118,7 +122,11 @@ describe("legacy bigint storage class migration", () => { · 23 unmodified lines | ||
| 118 | 122 | expect(await storageClassOf(db, "c_healthy")).toBe("blob"); |
| 119 | 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 | 130 | const rows = await scoped.findMany("connection", {}); |
| 123 | 131 | expect( |
| 124 | 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 | 151 | expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1); |
| 144 | 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 | 159 | const rows = await scoped.findMany("connection", {}); |
| 148 | 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 | 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 | 189 | const integrations = await scoped.findMany("integration", {}); |
| 174 | 190 | expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([ |
| 175 | 191 | LEGACY_EXPIRES_AT, |
| @@ -9,6 +9,7 @@ import { definePlugin } from "./plugin"; · 8 unmodified lines | ||
| 9 | 9 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; |
| 10 | 10 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; |
| 11 | 11 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 12 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 12 | 13 | |
| 13 | 14 | // `touchSubject` is the only writer of the `subject` table. Written against the |
| 14 | 15 | // real SQLite bring-up (the same statements local/self-host/D1 boot with) so |
| @@ -32,6 +33,7 @@ const scopedTo = (db: SqliteTestFumaDb, tenant: string) => · 17 unmodified lines | ||
| 32 | 33 | withQueryContext(db.db, { |
| 33 | 34 | tenant, |
| 34 | 35 | subject: null, |
| 36 | + owners: [], | |
| 35 | 37 | } satisfies ExecutorOwnerPolicyContext); |
| 36 | 38 | |
| 37 | 39 | const storedSubjects = ( |
| @@ -345,6 +347,7 @@ const subjectsOf = ( · 307 unmodified lines | ||
| 345 | 347 | const rows = await withQueryContext(harness.config.db as never, { |
| 346 | 348 | tenant, |
| 347 | 349 | subject: null, |
| 350 | + owners: [], | |
| 348 | 351 | } satisfies ExecutorOwnerPolicyContext).findMany("subject", {}); |
| 349 | 352 | return rows.map((row) => String(row.external_id)); |
| 350 | 353 | }); |
| @@ -354,6 +357,7 @@ describe("connections.create subject sighting", () => { · 3 unmodified lines | ||
| 354 | 357 | Effect.scoped( |
| 355 | 358 | Effect.gen(function* () { |
| 356 | 359 | const harness = yield* makeTestWorkspaceHarness({ |
| 360 | + access: testAccess.member(), | |
| 357 | 361 | plugins, |
| 358 | 362 | tenant: TENANT, |
| 359 | 363 | }); |
| @@ -377,6 +381,7 @@ describe("connections.create subject sighting", () => { · 17 unmodified lines | ||
| 377 | 381 | // The row names the acting principal, not the connection's owner tier: |
| 378 | 382 | // a member who only ever creates org connections still exists. |
| 379 | 383 | const harness = yield* makeTestWorkspaceHarness({ |
| 384 | + access: testAccess.member(), | |
| 380 | 385 | plugins, |
| 381 | 386 | tenant: TENANT, |
| 382 | 387 | }); |
| @@ -398,6 +403,7 @@ describe("connections.create subject sighting", () => { · 15 unmodified lines | ||
| 398 | 403 | Effect.scoped( |
| 399 | 404 | Effect.gen(function* () { |
| 400 | 405 | const harness = yield* makeTestWorkspaceHarness({ |
| 406 | + access: testAccess.org(), | |
| 401 | 407 | plugins, |
| 402 | 408 | tenant: TENANT, |
| 403 | 409 | subject: null, |
| @@ -133,6 +133,9 @@ export const touchSubject = (db: FumaDb<any>, input: TouchSubjectInput): Effect. · 132 unmodified lines | ||
| 133 | 133 | withQueryContext(db, { |
| 134 | 134 | tenant: input.tenant, |
| 135 | 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 | 139 | } satisfies ExecutorOwnerPolicyContext), |
| 137 | 140 | ); |
| 138 | 141 | // No `tenant` clause: the tenant policy adds it to every read/update. |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | import { withQueryContext } from "@executor-js/fumadb/query"; |
| 3 | +import type { ExecutorAccess } from "./access"; | |
| 3 | 4 | import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor"; |
| 4 | 5 | import type { FumaDb } from "./fuma-runtime"; |
| 5 | 6 | import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids"; |
| @@ -134,15 +135,18 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 128 unmodified lines | ||
| 134 | 135 | readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"]; |
| 135 | 136 | readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"]; |
| 136 | 137 | readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"]; |
| 137 | − /** Workspace-settings permission for the test binding (see | |
| 138 | − * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts | |
| 139 | − * with no role model. */ | |
| 140 | − readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"]; | |
| 138 | + /** Product access decisions for the test binding (see | |
| 139 | + * `ExecutorConfig.access`). REQUIRED — this fixture is generic and has no | |
| 140 | + * posture of its own. Tests state one explicitly, normally from | |
| 141 | + * `@executor-js/product-access/testing` (`testAccess.member()`, …; the | |
| 142 | + * sdk's own tests resolve that package through the workspace ROOT | |
| 143 | + * devDependency, keeping the package graph acyclic). */ | |
| 144 | + readonly access: ExecutorAccess; | |
| 141 | 145 | readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"]; |
| 142 | 146 | }; |
| 143 | 147 | |
| 144 | 148 | export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 145 | − options?: TestConfigOptions<TPlugins>, | |
| 149 | + options: TestConfigOptions<TPlugins>, | |
| 146 | 150 | ): Omit<ExecutorConfig<TPlugins>, "db"> & { |
| 147 | 151 | readonly db: FumaDb; |
| 148 | 152 | readonly testDb: TestFumaDb; |
| @@ -156,9 +160,14 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | ||
| 156 | 160 | backend: options?.backend ?? "sqlite", |
| 157 | 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 | 167 | const db = withQueryContext(testDb.db, { |
| 160 | 168 | tenant, |
| 161 | 169 | subject, |
| 170 | + owners: subject != null ? ["user", "org"] : ["org"], | |
| 162 | 171 | } satisfies ExecutorOwnerPolicyContext); |
| 163 | 172 | |
| 164 | 173 | // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow |
| @@ -178,7 +187,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 13 unmodified lines | ||
| 178 | 187 | onElicitation: "accept-all", |
| 179 | 188 | onIntegrationChange: options?.onIntegrationChange, |
| 180 | 189 | ...(redirectUri != null ? { redirectUri } : {}), |
| 181 | − ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), | |
| 190 | + access: options.access, | |
| 182 | 191 | oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug, |
| 183 | 192 | firstPartyOAuthClients: options?.firstPartyOAuthClients, |
| 184 | 193 | enterpriseManagedRollout: options?.enterpriseManagedRollout, |
| @@ -209,7 +218,7 @@ export class TestWorkspace extends Context.Service<TestWorkspace, TestWorkspaceH · 24 unmodified lines | ||
| 209 | 218 | } |
| 210 | 219 | |
| 211 | 220 | export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 212 | − options?: TestConfigOptions<TPlugins>, | |
| 221 | + options: TestConfigOptions<TPlugins>, | |
| 213 | 222 | ) => |
| 214 | 223 | Effect.acquireRelease( |
| 215 | 224 | Effect.gen(function* () { |
| @@ -233,7 +242,7 @@ export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlug · 17 unmodified lines | ||
| 233 | 242 | ); |
| 234 | 243 | |
| 235 | 244 | export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 236 | − options?: TestConfigOptions<TPlugins>, | |
| 245 | + options: TestConfigOptions<TPlugins>, | |
| 237 | 246 | ) => |
| 238 | 247 | Layer.effect(TestWorkspace)( |
| 239 | 248 | makeTestWorkspaceHarness(options).pipe( |
| @@ -242,7 +251,7 @@ export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin · 2 unmodified lines | ||
| 242 | 251 | ); |
| 243 | 252 | |
| 244 | 253 | export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>( |
| 245 | − options?: TestConfigOptions<TPlugins>, | |
| 254 | + options: TestConfigOptions<TPlugins>, | |
| 246 | 255 | ) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); |
| 247 | 256 | |
| 248 | 257 | /** Built-in in-memory writable credential provider, contributed as a plugin |
| @@ -7,10 +7,14 @@ import { · 6 unmodified lines | ||
| 7 | 7 | OAuthTestServer, |
| 8 | 8 | TestWorkspace, |
| 9 | 9 | } from "./testing"; |
| 10 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 10 | 11 | |
| 11 | 12 | const plugins = [memoryCredentialsPlugin()] as const; |
| 12 | 13 | |
| 13 | −const TestLayer = Layer.mergeAll(makeTestWorkspaceLayer({ plugins }), OAuthTestServer.layer()); | |
| 14 | +const TestLayer = Layer.mergeAll( | |
| 15 | + makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), | |
| 16 | + OAuthTestServer.layer(), | |
| 17 | +); | |
| 14 | 18 | |
| 15 | 19 | layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { |
| 16 | 20 | it.effect("TestWorkspace exposes the real executor bound to tenant/subject", () => |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | ||
| 30 | 30 | import { definePlugin } from "./plugin"; |
| 31 | 31 | import type { CredentialProvider } from "./provider"; |
| 32 | 32 | import { makeTestConfig } from "./test-config"; |
| 33 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 33 | 34 | |
| 34 | 35 | const STORE = ProviderKey.make("memory"); |
| 35 | 36 | const INTEG = IntegrationSlug.make("demo"); |
| @@ -65,7 +66,9 @@ const POPULATED = pluginWith([ · 29 unmodified lines | ||
| 65 | 66 | |
| 66 | 67 | const failInvoking = (plugin: ReturnType<typeof pluginWith>, tool: string) => |
| 67 | 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 | 72 | yield* executor.demo.seed(); |
| 70 | 73 | yield* executor.connections.create({ |
| 71 | 74 | owner: "org", |
| @@ -4,6 +4,7 @@ import { Effect } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; |
| 5 | 5 | import { definePlugin } from "./plugin"; |
| 6 | 6 | import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; |
| 7 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 7 | 8 | |
| 8 | 9 | // --------------------------------------------------------------------------- |
| 9 | 10 | // Cross-owner merge regression (the "Axiom shows 0 tools" bug). |
| @@ -55,9 +56,10 @@ const demoPlugin = definePlugin(() => ({ · 45 unmodified lines | ||
| 55 | 56 | // Default test executor binds `subject: "test-subject"`, so user-owned |
| 56 | 57 | // connections/tools can be created and merge with org rows. |
| 57 | 58 | const setup = () => |
| 58 | − makeTestExecutor({ plugins: [memoryCredentialsPlugin(), demoPlugin] as const }).pipe( | |
| 59 | − Effect.tap((executor) => executor.demo.seed()), | |
| 60 | − ); | |
| 59 | + makeTestExecutor({ | |
| 60 | + access: testAccess.member(), | |
| 61 | + plugins: [memoryCredentialsPlugin(), demoPlugin] as const, | |
| 62 | + }).pipe(Effect.tap((executor) => executor.demo.seed())); | |
| 61 | 63 | |
| 62 | 64 | describe("tools.list cross-owner merge", () => { |
| 63 | 65 | it.effect("lists user-owned tools when owner is omitted (the fix)", () => |
| @@ -93,7 +93,10 @@ describe("makeR2BlobStore", () => { · 92 unmodified lines | ||
| 93 | 93 | Effect.gen(function* () { |
| 94 | 94 | const { bucket } = makeFakeBucket(); |
| 95 | 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 | 101 | yield* plugin.put("spec/h1", "org-spec", { owner: "org" }); |
| 99 | 102 | expect(yield* plugin.get("spec/h1")).toBe("org-spec"); |
| @@ -79,6 +79,7 @@ · 78 unmodified lines | ||
| 79 | 79 | "@effect/vitest": "catalog:", |
| 80 | 80 | "@executor-js/execution": "workspace:*", |
| 81 | 81 | "@executor-js/host-mcp": "workspace:*", |
| 82 | + "@executor-js/product-access": "workspace:*", | |
| 82 | 83 | "@executor-js/sdk": "workspace:*", |
| 83 | 84 | "@tailwindcss/browser": "^4.2.2", |
| 84 | 85 | "@tailwindcss/vite": "catalog:", |
| @@ -41,6 +41,7 @@ import type * as Cause from "effect/Cause"; · 40 unmodified lines | ||
| 41 | 41 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 42 | 42 | |
| 43 | 43 | import { loadMcpAppsShellHtml } from "../shell-html"; |
| 44 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 44 | 45 | |
| 45 | 46 | type ShellServer = { |
| 46 | 47 | readonly url: string; |
| @@ -1305,7 +1306,10 @@ const startMcpHarness = async (openApi: OpenApiServer): Promise<McpHarness> => { · 1258 unmodified lines | ||
| 1305 | 1306 | const executor = await Effect.runPromise( |
| 1306 | 1307 | Effect.gen(function* () { |
| 1307 | 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 | 1314 | // Tools only exist per connection, so register the integration and open |
| 1311 | 1315 | // one org `main` connection — that is what makes |
| @@ -46,6 +46,7 @@ · 45 unmodified lines | ||
| 46 | 46 | "dependencies": { |
| 47 | 47 | "@cfworker/json-schema": "^4.1.1", |
| 48 | 48 | "@executor-js/execution": "workspace:*", |
| 49 | + "@executor-js/product-access": "workspace:*", | |
| 49 | 50 | "@executor-js/sdk": "workspace:*", |
| 50 | 51 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 51 | 52 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | } from "./in-memory-session-store"; |
| 14 | 14 | import { defaultMcpResource, type Principal } from "./seams"; |
| 15 | 15 | import { createExecutorMcpServer } from "./tool-server"; |
| 16 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 16 | 17 | |
| 17 | 18 | const TEST_PRINCIPAL: Principal = { |
| 18 | 19 | accountId: "acct_test", |
| @@ -161,7 +162,10 @@ const openSession = async ( · 142 unmodified lines | ||
| 161 | 162 | |
| 162 | 163 | it("keeps overlapping warm-session workspace writes bound to their request roles", async () => { |
| 163 | 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 | 170 | const started = new Map<string, () => void>(); |
| 167 | 171 | const startedPromises = ["member", "admin"].map( |
| @@ -240,7 +244,10 @@ it("keeps overlapping warm-session workspace writes bound to their request roles · 72 unmodified lines | ||
| 240 | 244 | |
| 241 | 245 | it("binds a paused workspace write to the resuming principal after demotion", async () => { |
| 242 | 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 | 252 | const executionId = "exec_resume_demotion"; |
| 246 | 253 | const pattern = "paused-resume-demotion.*"; |
| @@ -313,7 +320,10 @@ it("binds a paused workspace write to the resuming principal after demotion", as · 66 unmodified lines | ||
| 313 | 320 | |
| 314 | 321 | it("uses the browser approver's demoted role after an admin starts waiting", async () => { |
| 315 | 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 | 328 | const executionId = "exec_browser_resume_demotion"; |
| 319 | 329 | const pattern = "browser-resume-demotion.*"; |
| @@ -695,7 +705,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 375 unmodified lines | ||
| 695 | 705 | it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => { |
| 696 | 706 | let executorClosed = 0; |
| 697 | 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 | 711 | const testExecutor = { |
| 700 | 712 | ...realExecutor, |
| 701 | 713 | close: () => |
| @@ -743,7 +755,9 @@ describe("pre-initialize dispatch through the in-memory session store", () => { · 41 unmodified lines | ||
| 743 | 755 | |
| 744 | 756 | it("shuts down the scoped executor when sessions.close() is called", async () => { |
| 745 | 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 | 761 | const testExecutor = { |
| 748 | 762 | ...realExecutor, |
| 749 | 763 | close: () => |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Context, Effect, Layer, Schema } from "effect"; |
| 2 | 2 | import type { Cause } from "effect"; |
| 3 | 3 | |
| 4 | +import { orgWriteAccessForRole } from "@executor-js/product-access"; | |
| 4 | 5 | import type { OrgWriteAccess } from "@executor-js/sdk"; |
| 5 | 6 | |
| 6 | 7 | // --------------------------------------------------------------------------- |
| @@ -72,9 +73,11 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 65 unmodified lines | ||
| 72 | 73 | /** Internal header overwritten by a trusted session store before MCP dispatch. */ |
| 73 | 74 | export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access"; |
| 74 | 75 | |
| 75 | −/** Derive the effective workspace-write access for one authenticated request. */ | |
| 76 | +/** Derive the effective workspace-write access for one authenticated request | |
| 77 | + * — the product's role rule (`@executor-js/product-access`), applied here at | |
| 78 | + * the serving boundary that stamps the request-bound carrier. */ | |
| 76 | 79 | export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess => |
| 77 | − principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied"; | |
| 80 | + orgWriteAccessForRole(principal); | |
| 78 | 81 | |
| 79 | 82 | /** |
| 80 | 83 | * Stamp request-bound workspace-write access for the MCP SDK request handler. |
| @@ -25,6 +25,7 @@ · 24 unmodified lines | ||
| 25 | 25 | "@executor-js/execution": "workspace:*", |
| 26 | 26 | "@executor-js/fumadb": "workspace:*", |
| 27 | 27 | "@executor-js/plugin-openapi": "workspace:*", |
| 28 | + "@executor-js/product-access": "workspace:*", | |
| 28 | 29 | "@executor-js/sdk": "workspace:*", |
| 29 | 30 | "@types/node": "catalog:", |
| 30 | 31 | "drizzle-orm": "catalog:", |
| @@ -45,6 +45,7 @@ import { · 44 unmodified lines | ||
| 45 | 45 | type FumaDb, |
| 46 | 46 | type FumaTables, |
| 47 | 47 | } from "@executor-js/sdk"; |
| 48 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 48 | 49 | import { makeExecutorToolInvoker } from "@executor-js/execution"; |
| 49 | 50 | import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi"; |
| 50 | 51 | |
| @@ -231,6 +232,7 @@ const buildSandboxBridge = (spec: string, slug: string, baseUrl = "https://upstr · 180 unmodified lines | ||
| 231 | 232 | providers: [memoryProvider()], |
| 232 | 233 | plugins, |
| 233 | 234 | onElicitation: "accept-all", |
| 235 | + access: testAccess.member(), | |
| 234 | 236 | }); |
| 235 | 237 | // v2: addSpec registers the integration; tools are produced per-connection, |
| 236 | 238 | // so an org `main` connection is required for the operation to be callable. |
| @@ -47,6 +47,7 @@ · 46 unmodified lines | ||
| 47 | 47 | "react": "catalog:" |
| 48 | 48 | }, |
| 49 | 49 | "devDependencies": { |
| 50 | + "@executor-js/product-access": "workspace:*", | |
| 50 | 51 | "@types/react": "catalog:", |
| 51 | 52 | "tsup": "catalog:", |
| 52 | 53 | "typescript": "catalog:" |
| @@ -5,11 +5,13 @@ import { ToolAddress } from "@executor-js/sdk"; · 4 unmodified lines | ||
| 5 | 5 | import { makeTestExecutor } from "@executor-js/sdk/testing"; |
| 6 | 6 | |
| 7 | 7 | import { desktopSettingsPlugin } from "./server"; |
| 8 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 8 | 9 | |
| 9 | 10 | describe("desktopSettingsPlugin", () => { |
| 10 | 11 | it.effect("returns a browser handoff URL for Desktop-only settings", () => |
| 11 | 12 | Effect.gen(function* () { |
| 12 | 13 | const executor = yield* makeTestExecutor({ |
| 14 | + access: testAccess.member(), | |
| 13 | 15 | plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const, |
| 14 | 16 | }); |
| 15 | 17 | |
| @@ -47,6 +47,7 @@ · 46 unmodified lines | ||
| 47 | 47 | "@executor-js/sdk": "workspace:*" |
| 48 | 48 | }, |
| 49 | 49 | "devDependencies": { |
| 50 | + "@executor-js/product-access": "workspace:*", | |
| 50 | 51 | "@types/node": "catalog:", |
| 51 | 52 | "bun-types": "catalog:", |
| 52 | 53 | "effect": "catalog:", |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; |
| 15 | 15 | |
| 16 | 16 | import { fileSecretsPlugin } from "./index"; |
| 17 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 17 | 18 | |
| 18 | 19 | const INTEGRATION = IntegrationSlug.make("durable-secrets"); |
| 19 | 20 | const CONNECTION = ConnectionName.make("main"); |
| @@ -69,7 +70,11 @@ describe("file secrets data directory", () => { · 49 unmodified lines | ||
| 69 | 70 | vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome); |
| 70 | 71 | const firstAuthPath = yield* Effect.scoped( |
| 71 | 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 | 78 | yield* first.executor.connectionFixture.registerIntegration(); |
| 74 | 79 | const connection = yield* first.executor.connections.create({ |
| 75 | 80 | owner: "org", |
| @@ -96,7 +101,11 @@ describe("file secrets data directory", () => { · 20 unmodified lines | ||
| 96 | 101 | vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome); |
| 97 | 102 | yield* Effect.scoped( |
| 98 | 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 | 109 | const connections = yield* recreated.executor.connections.list({ |
| 101 | 110 | integration: INTEGRATION, |
| 102 | 111 | }); |
| @@ -17,13 +17,17 @@ import { ProviderKey } from "@executor-js/sdk"; · 16 unmodified lines | ||
| 17 | 17 | import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; |
| 18 | 18 | |
| 19 | 19 | import { fileSecretsPlugin } from "./index"; |
| 20 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 20 | 21 | |
| 21 | 22 | const FILE_PROVIDER = ProviderKey.make("file"); |
| 22 | 23 | |
| 23 | 24 | const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) => |
| 24 | 25 | Effect.scoped( |
| 25 | 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 | 31 | const items = yield* workspace.executor.providers.items(FILE_PROVIDER); |
| 28 | 32 | return { |
| 29 | 33 | filePath: workspace.executor.fileSecrets.filePath, |
| @@ -136,6 +140,7 @@ describe("file secrets auth location", () => { · 106 unmodified lines | ||
| 136 | 140 | vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); |
| 137 | 141 | writeAuthFile(legacyFilePath, "not-json"); |
| 138 | 142 | const workspace = yield* makeTestWorkspaceHarness({ |
| 143 | + access: testAccess.member(), | |
| 139 | 144 | plugins: [fileSecretsPlugin()] as const, |
| 140 | 145 | }); |
| 141 | 146 | |
| @@ -158,6 +163,7 @@ describe("file secrets auth location", () => { · 16 unmodified lines | ||
| 158 | 163 | vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); |
| 159 | 164 | mkdirSync(legacyFilePath, { recursive: true }); |
| 160 | 165 | const workspace = yield* makeTestWorkspaceHarness({ |
| 166 | + access: testAccess.member(), | |
| 161 | 167 | plugins: [fileSecretsPlugin()] as const, |
| 162 | 168 | }); |
| 163 | 169 | |
| @@ -185,6 +191,7 @@ describe("file secrets auth location", () => { · 21 unmodified lines | ||
| 185 | 191 | const legacyContents = '{"legacy-token":"legacy-secret"}'; |
| 186 | 192 | writeAuthFile(legacyFilePath, legacyContents); |
| 187 | 193 | const workspace = yield* makeTestWorkspaceHarness({ |
| 194 | + access: testAccess.member(), | |
| 188 | 195 | plugins: [fileSecretsPlugin()] as const, |
| 189 | 196 | }); |
| 190 | 197 | |
| @@ -71,6 +71,7 @@ · 70 unmodified lines | ||
| 71 | 71 | "@effect/atom-react": "catalog:", |
| 72 | 72 | "@effect/vitest": "catalog:", |
| 73 | 73 | "@executor-js/api": "workspace:*", |
| 74 | + "@executor-js/product-access": "workspace:*", | |
| 74 | 75 | "@executor-js/react": "workspace:*", |
| 75 | 76 | "@types/node": "catalog:", |
| 76 | 77 | "@types/react": "catalog:", |
| @@ -21,10 +21,12 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | import { graphqlPlugin } from "./plugin"; |
| 23 | 23 | import type { GraphqlAuthMethod } from "./types"; |
| 24 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 24 | 25 | |
| 25 | 26 | const makeExecutor = () => |
| 26 | 27 | createExecutor( |
| 27 | 28 | makeTestConfig({ |
| 29 | + access: testAccess.member(), | |
| 28 | 30 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, |
| 29 | 31 | }), |
| 30 | 32 | ); |
| @@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import { makeGreetingGraphqlSchema } from "../testing"; |
| 18 | 18 | import { graphqlPlugin } from "./plugin"; |
| 19 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 19 | 20 | |
| 20 | 21 | const INVOCATION_TIMEOUT_MS = 100; |
| 21 | 22 | |
| @@ -61,6 +62,7 @@ describe("GraphQL invocation timeout", () => { · 39 unmodified lines | ||
| 61 | 62 | const server = yield* startHangingResponseServer(closed); |
| 62 | 63 | const executor = yield* createExecutor( |
| 63 | 64 | makeTestConfig({ |
| 65 | + access: testAccess.member(), | |
| 64 | 66 | plugins: [ |
| 65 | 67 | memoryCredentialsPlugin(), |
| 66 | 68 | graphqlPlugin({ |
| @@ -23,12 +23,16 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 22 unmodified lines | ||
| 23 | 23 | import { graphqlPlugin } from "./plugin"; |
| 24 | 24 | import { variable } from "@executor-js/sdk/http-auth"; |
| 25 | 25 | import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; |
| 26 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 26 | 27 | |
| 27 | 28 | const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); |
| 28 | 29 | |
| 29 | 30 | const makeExecutor = () => |
| 30 | 31 | createExecutor( |
| 31 | − makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | |
| 32 | + makeTestConfig({ | |
| 33 | + access: testAccess.member(), | |
| 34 | + plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | |
| 35 | + }), | |
| 32 | 36 | ); |
| 33 | 37 | |
| 34 | 38 | const toolAddr = (integration: string, connection: string, tool: string): ToolAddress => |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | ||
| 35 | 35 | serveGraphqlTestServer, |
| 36 | 36 | waitForRecordedRequests, |
| 37 | 37 | } from "../testing"; |
| 38 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 38 | 39 | |
| 39 | 40 | // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/ |
| 40 | 41 | // usagesForConnection, multi-scope shadowing, and `executor.sources.*` / |
| @@ -118,7 +119,10 @@ const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphql · 77 unmodified lines | ||
| 118 | 119 | |
| 119 | 120 | const makeExecutor = () => |
| 120 | 121 | createExecutor( |
| 121 | − makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | |
| 122 | + makeTestConfig({ | |
| 123 | + access: testAccess.member(), | |
| 124 | + plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, | |
| 125 | + }), | |
| 122 | 126 | ); |
| 123 | 127 | |
| 124 | 128 | const recordingBlobStore = () => { |
| @@ -160,11 +164,14 @@ describe("graphqlPlugin real protocol server", () => { · 35 unmodified lines | ||
| 160 | 164 | it.effect("denies member schema persistence before writing an org blob", () => |
| 161 | 165 | Effect.gen(function* () { |
| 162 | 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 | 171 | const member = yield* createExecutor({ |
| 165 | 172 | ...config, |
| 166 | 173 | blobs: blobs.store, |
| 167 | − orgWrites: "denied", | |
| 174 | + access: testAccess.member("denied"), | |
| 168 | 175 | }); |
| 169 | 176 | |
| 170 | 177 | const error = yield* member.graphql |
| @@ -420,6 +427,7 @@ describe("graphqlPlugin real protocol server", () => { · 249 unmodified lines | ||
| 420 | 427 | }), |
| 421 | 428 | ); |
| 422 | 429 | const config = makeTestConfig({ |
| 430 | + access: testAccess.member(), | |
| 423 | 431 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, |
| 424 | 432 | }); |
| 425 | 433 | const executor = yield* createExecutor({ ...config, httpClientLayer }); |
| @@ -1236,6 +1244,7 @@ describe("graphqlPlugin", () => { · 810 unmodified lines | ||
| 1236 | 1244 | it.effect("static executor.graphql.addIntegration registers an unreachable endpoint", () => |
| 1237 | 1245 | Effect.gen(function* () { |
| 1238 | 1246 | const config = makeTestConfig({ |
| 1247 | + access: testAccess.member(), | |
| 1239 | 1248 | plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, |
| 1240 | 1249 | }); |
| 1241 | 1250 | const executor = yield* createExecutor(config); |
| @@ -49,6 +49,7 @@ · 48 unmodified lines | ||
| 49 | 49 | }, |
| 50 | 50 | "devDependencies": { |
| 51 | 51 | "@effect/vitest": "catalog:", |
| 52 | + "@executor-js/product-access": "workspace:*", | |
| 52 | 53 | "@types/node": "catalog:", |
| 53 | 54 | "bun-types": "catalog:", |
| 54 | 55 | "effect": "catalog:", |
| @@ -5,6 +5,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 4 unmodified lines | ||
| 5 | 5 | import { keychainPlugin } from "./index"; |
| 6 | 6 | import { makeKeychainProvider } from "./provider"; |
| 7 | 7 | import { setPassword, deletePassword } from "./keyring"; |
| 8 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 8 | 9 | |
| 9 | 10 | // removed: v1 tests routed through `executor.secrets.set/get/remove` with |
| 10 | 11 | // `ScopeId`/`SecretId`/`SetSecretInput`/`RemoveSecretInput` and a scope-derived |
| @@ -33,9 +34,7 @@ describe("keychain plugin", () => { · 22 unmodified lines | ||
| 33 | 34 | it.effect("exposes keychain metadata and registers a provider when reachable", () => |
| 34 | 35 | Effect.gen(function* () { |
| 35 | 36 | const executor = yield* createExecutor( |
| 36 | − makeTestConfig({ | |
| 37 | − plugins: [keychainPlugin()] as const, | |
| 38 | − }), | |
| 37 | + makeTestConfig({ access: testAccess.member(), plugins: [keychainPlugin()] as const }), | |
| 39 | 38 | ); |
| 40 | 39 | |
| 41 | 40 | expect(executor.keychain.displayName).toBeTypeOf("string"); |
| @@ -60,6 +59,7 @@ describe("keychain plugin", () => { · 18 unmodified lines | ||
| 60 | 59 | |
| 61 | 60 | const executor = yield* createExecutor( |
| 62 | 61 | makeTestConfig({ |
| 62 | + access: testAccess.member(), | |
| 63 | 63 | plugins: [keychainPlugin({ serviceName })] as const, |
| 64 | 64 | }), |
| 65 | 65 | ); |
| @@ -86,6 +86,7 @@ describe("keychain plugin", () => { · 20 unmodified lines | ||
| 86 | 86 | Effect.gen(function* () { |
| 87 | 87 | const executor = yield* createExecutor( |
| 88 | 88 | makeTestConfig({ |
| 89 | + access: testAccess.member(), | |
| 89 | 90 | plugins: [keychainPlugin({ serviceName: "executor-test" })] as const, |
| 90 | 91 | }), |
| 91 | 92 | ); |
| @@ -76,6 +76,7 @@ · 75 unmodified lines | ||
| 76 | 76 | "@effect/atom-react": "catalog:", |
| 77 | 77 | "@effect/vitest": "catalog:", |
| 78 | 78 | "@executor-js/api": "workspace:*", |
| 79 | + "@executor-js/product-access": "workspace:*", | |
| 79 | 80 | "@executor-js/react": "workspace:*", |
| 80 | 81 | "@modelcontextprotocol/server": "2.0.0", |
| 81 | 82 | "@types/node": "catalog:", |
| @@ -35,6 +35,7 @@ import { mcpPlugin } from "./plugin"; · 34 unmodified lines | ||
| 35 | 35 | import { createMcpConnector } from "./connection"; |
| 36 | 36 | import { discoverTools } from "./discover"; |
| 37 | 37 | import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing"; |
| 38 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 38 | 39 | |
| 39 | 40 | const INTEG = IntegrationSlug.make("catalog_mcp"); |
| 40 | 41 | const CONNECTION = ConnectionName.make("main"); |
| @@ -48,7 +49,10 @@ const makeCatalogTestExecutor = ( · 7 unmodified lines | ||
| 48 | 49 | }, |
| 49 | 50 | ) => |
| 50 | 51 | createExecutor({ |
| 51 | − ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | |
| 52 | + ...makeTestConfig({ | |
| 53 | + access: testAccess.member(), | |
| 54 | + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | |
| 55 | + }), | |
| 52 | 56 | ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }), |
| 53 | 57 | ...(options?.toolsSyncGraceMs === undefined |
| 54 | 58 | ? {} |
| @@ -357,7 +361,10 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines | ||
| 357 | 361 | Effect.gen(function* () { |
| 358 | 362 | const fixture = yield* serveLatchedListServer(); |
| 359 | 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 | 368 | // Everything is expired on every read, so a single tools read has the |
| 362 | 369 | // whole set to rebuild. |
| 363 | 370 | toolsSyncTtlMs: 0, |
| @@ -23,6 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | import { mcpPlugin } from "./plugin"; |
| 25 | 25 | import { makeElicitationMcpServer, serveMcpServer } from "../testing"; |
| 26 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 26 | 27 | |
| 27 | 28 | const isFormElicitation = Schema.is(FormElicitation); |
| 28 | 29 | |
| @@ -60,7 +61,12 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 31 unmodified lines | ||
| 60 | 61 | |
| 61 | 62 | const makeTestExecutor = (serverUrl: string) => |
| 62 | 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 | 70 | (executor) => executor.close().pipe(Effect.ignore), |
| 65 | 71 | ).pipe( |
| 66 | 72 | Effect.tap((executor) => |
| @@ -373,7 +379,9 @@ describe("MCP elicitation (end-to-end)", () => { · 306 unmodified lines | ||
| 373 | 379 | it.effect("addServer preserves the configured display name as the integration description", () => |
| 374 | 380 | Effect.gen(function* () { |
| 375 | 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 | 386 | yield* executor.mcp.addServer({ |
| 379 | 387 | name: "Gmail", |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | ||
| 17 | 17 | makeImageMcpServer, |
| 18 | 18 | serveMcpServer, |
| 19 | 19 | } from "../testing"; |
| 20 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 20 | 21 | |
| 21 | 22 | const INTEGRATION = IntegrationSlug.make("image_mcp"); |
| 22 | 23 | const TEMPLATE = AuthTemplateSlug.make("none"); |
| @@ -32,7 +33,10 @@ describe("MCP image content", () => { · 9 unmodified lines | ||
| 32 | 33 | Effect.gen(function* () { |
| 33 | 34 | const server = yield* serveMcpServer(makeImageMcpServer); |
| 34 | 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 | 42 | yield* executor.mcp.addServer({ |
| @@ -24,6 +24,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 23 unmodified lines | ||
| 24 | 24 | import { mcpPlugin } from "./plugin"; |
| 25 | 25 | import { variable } from "@executor-js/sdk/http-auth"; |
| 26 | 26 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; |
| 27 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 27 | 28 | |
| 28 | 29 | const serveRecordingServer = serveMcpServer(() => |
| 29 | 30 | makeEchoMcpServer({ |
| @@ -40,7 +41,10 @@ describe("MCP multi-placement auth", () => { · 10 unmodified lines | ||
| 40 | 41 | Effect.gen(function* () { |
| 41 | 42 | const server = yield* serveRecordingServer; |
| 42 | 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 | 50 | yield* executor.mcp.addServer({ |
| @@ -88,7 +92,10 @@ describe("MCP multi-placement auth", () => { · 41 unmodified lines | ||
| 88 | 92 | Effect.gen(function* () { |
| 89 | 93 | const server = yield* serveRecordingServer; |
| 90 | 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 | 101 | yield* executor.mcp.addServer({ |
| @@ -126,7 +133,10 @@ describe("MCP multi-placement auth", () => { · 31 unmodified lines | ||
| 126 | 133 | Effect.gen(function* () { |
| 127 | 134 | const server = yield* serveRecordingServer; |
| 128 | 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 | 142 | // Two declared methods; each connection picks one by template slug. |
| @@ -193,7 +203,10 @@ describe("MCP multi-placement auth", () => { · 60 unmodified lines | ||
| 193 | 203 | Effect.gen(function* () { |
| 194 | 204 | const server = yield* serveRecordingServer; |
| 195 | 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 | 212 | yield* executor.mcp.addServer({ |
| @@ -23,6 +23,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | import { mcpPlugin } from "./plugin"; |
| 25 | 25 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; |
| 26 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 26 | 27 | |
| 27 | 28 | const INTEG = IntegrationSlug.make("iso_mcp"); |
| 28 | 29 | const TEMPLATE = AuthTemplateSlug.make("bearer"); |
| @@ -43,7 +44,10 @@ describe("MCP owner isolation", () => { · 14 unmodified lines | ||
| 43 | 44 | Effect.gen(function* () { |
| 44 | 45 | const server = yield* serveAuthRecordingMcpServer; |
| 45 | 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 | 53 | // One integration with a Bearer header auth template; the connection's |
| @@ -29,6 +29,7 @@ import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin · 28 unmodified lines | ||
| 29 | 29 | import { McpInvocationError } from "./errors"; |
| 30 | 30 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; |
| 31 | 31 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; |
| 32 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 32 | 33 | |
| 33 | 34 | // removed: the v1 addSource / scopes / secrets / credential-binding / usages / |
| 34 | 35 | // sources.configure / multi-scope shadowing suites. v2 has no scope stack, no |
| @@ -261,6 +262,7 @@ const seedCallToolExecutor = (input: { · 226 unmodified lines | ||
| 261 | 262 | Effect.gen(function* () { |
| 262 | 263 | const server = yield* serveCallToolServer(input.callTool); |
| 263 | 264 | const config = makeTestConfig({ |
| 265 | + access: testAccess.member(), | |
| 264 | 266 | plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, |
| 265 | 267 | }); |
| 266 | 268 | const executor = yield* createExecutor(config); |
| @@ -590,9 +592,7 @@ describe("mcpPlugin", () => { · 323 unmodified lines | ||
| 590 | 592 | it.effect("creates executor with mcp plugin", () => |
| 591 | 593 | Effect.gen(function* () { |
| 592 | 594 | const executor = yield* createExecutor( |
| 593 | − makeTestConfig({ | |
| 594 | − plugins: [mcpPlugin()] as const, | |
| 595 | − }), | |
| 595 | + makeTestConfig({ access: testAccess.member(), plugins: [mcpPlugin()] as const }), | |
| 596 | 596 | ); |
| 597 | 597 | |
| 598 | 598 | expect(executor.mcp).toBeDefined(); |
| @@ -631,7 +631,9 @@ describe("mcpPlugin", () => { · 32 unmodified lines | ||
| 631 | 631 | |
| 632 | 632 | it.effect("integration catalog has no configured MCP integrations initially", () => |
| 633 | 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 | 637 | const integrations = yield* executor.integrations.list(); |
| 636 | 638 | expect(integrations.filter((i) => i.kind === "mcp")).toHaveLength(0); |
| 637 | 639 | }), |
| @@ -639,7 +641,9 @@ describe("mcpPlugin", () => { · 1 unmodified line | ||
| 639 | 641 | |
| 640 | 642 | it.effect("projects an MCP server family into the integration catalog", () => |
| 641 | 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 | 647 | yield* executor.mcp.addServer({ |
| 644 | 648 | name: "Cloudflare Docs", |
| 645 | 649 | family: "cloudflare", |
| @@ -656,7 +660,9 @@ describe("mcpPlugin", () => { · 10 unmodified lines | ||
| 656 | 660 | |
| 657 | 661 | it.effect("connection tools list is empty until a connection is created", () => |
| 658 | 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 | 666 | const tools = yield* executor.tools.list(); |
| 661 | 667 | expect(tools.filter((tool) => String(tool.address).startsWith("tools."))).toHaveLength(0); |
| 662 | 668 | }), |
| @@ -667,7 +673,10 @@ describe("mcpPlugin", () => { · 4 unmodified lines | ||
| 667 | 673 | Effect.gen(function* () { |
| 668 | 674 | const server = yield* serveOAuthTestServer({ scopes: [] }); |
| 669 | 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 | 681 | const attachedClient = OAuthClientSlug.make("axiom-mcp"); |
| 673 | 682 | const unrelatedClient = OAuthClientSlug.make("manual-app"); |
| @@ -726,7 +735,10 @@ describe("mcpPlugin", () => { · 52 unmodified lines | ||
| 726 | 735 | Effect.scoped( |
| 727 | 736 | Effect.gen(function* () { |
| 728 | 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 | 744 | yield* executor.mcp.addServer({ |
| @@ -769,7 +781,9 @@ describe("mcpPlugin", () => { · 36 unmodified lines | ||
| 769 | 781 | // adding an API key to an OAuth server must NOT displace the OAuth method. |
| 770 | 782 | it.effect("configureAuth merge-appends a custom method without clobbering oauth", () => |
| 771 | 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 | 788 | yield* executor.mcp.addServer({ |
| 775 | 789 | name: "OAuth MCP", |
| @@ -796,7 +810,9 @@ describe("mcpPlugin", () => { · 20 unmodified lines | ||
| 796 | 810 | |
| 797 | 811 | it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () => |
| 798 | 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 | 817 | yield* executor.mcp.addServer({ |
| 802 | 818 | name: "Open MCP", |
| @@ -826,7 +842,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines | ||
| 826 | 842 | scopes: ["channels:history", "users:read"], |
| 827 | 843 | }); |
| 828 | 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 | 851 | yield* executor.mcp.addServer({ |
| @@ -870,7 +889,10 @@ describe("mcpPlugin", () => { · 37 unmodified lines | ||
| 870 | 889 | Effect.gen(function* () { |
| 871 | 890 | const server = yield* serveOAuthTestServer({ scopes: ["mcp"] }); |
| 872 | 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 | 898 | yield* executor.mcp.addServer({ |
| @@ -911,7 +933,10 @@ describe("mcpPlugin", () => { · 34 unmodified lines | ||
| 911 | 933 | it.effect("registers integration + connection with 0 tools when discovery fails", () => |
| 912 | 934 | Effect.gen(function* () { |
| 913 | 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 | 942 | const slugStr = "broken_source"; |
| @@ -941,7 +966,10 @@ describe("mcpPlugin", () => { · 23 unmodified lines | ||
| 941 | 966 | |
| 942 | 967 | it.effect("static probeEndpoint returns actionable tool failures", () => |
| 943 | 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 | 973 | const executor = yield* createExecutor(config); |
| 946 | 974 | |
| 947 | 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 | 1329 | const executor = yield* createExecutor(config); |
| 1299 | 1330 | |
| 1300 | 1331 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); |
| @@ -1330,7 +1361,10 @@ describe("mcpPlugin", () => { · 29 unmodified lines | ||
| 1330 | 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 | 1368 | const executor = yield* createExecutor(config); |
| 1335 | 1369 | |
| 1336 | 1370 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); |
| @@ -1372,7 +1406,10 @@ describe("mcpPlugin", () => { · 35 unmodified lines | ||
| 1372 | 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 | 1413 | const executor = yield* createExecutor(config); |
| 1377 | 1414 | |
| 1378 | 1415 | const result = yield* executor.mcp.probeEndpoint(server.url("/mcp")); |
| @@ -1400,7 +1437,10 @@ const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); · 21 unmodified lines | ||
| 1400 | 1437 | |
| 1401 | 1438 | const seedAnnotationsExecutor = (serverUrl: string) => |
| 1402 | 1439 | createExecutor( |
| 1403 | − makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | |
| 1440 | + makeTestConfig({ | |
| 1441 | + access: testAccess.member(), | |
| 1442 | + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, | |
| 1443 | + }), | |
| 1404 | 1444 | ).pipe( |
| 1405 | 1445 | Effect.tap((executor) => |
| 1406 | 1446 | Effect.gen(function* () { |
| @@ -1508,7 +1548,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 101 unmodified lines | ||
| 1508 | 1548 | // a candidate. |
| 1509 | 1549 | it.effect("returns low-confidence candidate when path has /mcp segment", () => |
| 1510 | 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 | 1554 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/mcp"); |
| 1513 | 1555 | const mcp = results.find((r) => r.kind === "mcp"); |
| 1514 | 1556 | expect(mcp).toBeDefined(); |
| @@ -1518,7 +1560,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 3 unmodified lines | ||
| 1518 | 1560 | |
| 1519 | 1561 | it.effect("matches mcp on hostname label", () => |
| 1520 | 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 | 1566 | const results = yield* executor.integrations.detect("http://mcp.127.0.0.1.nip.io:1/"); |
| 1523 | 1567 | const mcp = results.find((r) => r.kind === "mcp"); |
| 1524 | 1568 | expect(mcp?.confidence).toBe("low"); |
| @@ -1527,7 +1571,9 @@ describe("mcpPlugin detect URL-token fallback", () => { · 2 unmodified lines | ||
| 1527 | 1571 | |
| 1528 | 1572 | it.effect("does not match mcp as a substring", () => |
| 1529 | 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 | 1577 | // `/mcpstore` contains `mcp` but it is not a separator-bounded run, so |
| 1532 | 1578 | // the URL-token fallback must not fire. |
| 1533 | 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 | 1584 | it.effect("returns null when no token match and no wire-shape match", () => |
| 1539 | 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 | 1589 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1"); |
| 1542 | 1590 | expect(results.find((r) => r.kind === "mcp")).toBeUndefined(); |
| 1543 | 1591 | }), |
| @@ -1590,7 +1638,9 @@ describe("mcpPlugin endpoint telemetry", () => { · 46 unmodified lines | ||
| 1590 | 1638 | it.effect("stamps a sanitized endpoint on the detect span", () => |
| 1591 | 1639 | Effect.gen(function* () { |
| 1592 | 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 | 1645 | yield* executor.integrations |
| 1596 | 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 | 1670 | it.effect("stamps a sanitized endpoint on the probe_endpoint span", () => |
| 1621 | 1671 | Effect.gen(function* () { |
| 1622 | 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 | 1677 | yield* executor.mcp |
| 1626 | 1678 | .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`) |
| @@ -1649,6 +1701,7 @@ describe("stdio static env", () => { · 22 unmodified lines | ||
| 1649 | 1701 | Effect.scoped( |
| 1650 | 1702 | Effect.gen(function* () { |
| 1651 | 1703 | const config = makeTestConfig({ |
| 1704 | + access: testAccess.member(), | |
| 1652 | 1705 | plugins: [ |
| 1653 | 1706 | memoryCredentialsPlugin(), |
| 1654 | 1707 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -1725,6 +1778,7 @@ describe("stdio static env", () => { · 70 unmodified lines | ||
| 1725 | 1778 | Effect.scoped( |
| 1726 | 1779 | Effect.gen(function* () { |
| 1727 | 1780 | const config = makeTestConfig({ |
| 1781 | + access: testAccess.member(), | |
| 1728 | 1782 | plugins: [ |
| 1729 | 1783 | memoryCredentialsPlugin(), |
| 1730 | 1784 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -1777,6 +1831,7 @@ describe("stdio static env", () => { · 46 unmodified lines | ||
| 1777 | 1831 | Effect.scoped( |
| 1778 | 1832 | Effect.gen(function* () { |
| 1779 | 1833 | const config = makeTestConfig({ |
| 1834 | + access: testAccess.member(), | |
| 1780 | 1835 | plugins: [ |
| 1781 | 1836 | memoryCredentialsPlugin(), |
| 1782 | 1837 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -1832,6 +1887,7 @@ describe("stdio static env", () => { · 49 unmodified lines | ||
| 1832 | 1887 | Effect.scoped( |
| 1833 | 1888 | Effect.gen(function* () { |
| 1834 | 1889 | const config = makeTestConfig({ |
| 1890 | + access: testAccess.member(), | |
| 1835 | 1891 | plugins: [ |
| 1836 | 1892 | memoryCredentialsPlugin(), |
| 1837 | 1893 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| @@ -29,6 +29,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 28 unmodified lines | ||
| 29 | 29 | |
| 30 | 30 | import { mcpPlugin } from "./plugin"; |
| 31 | 31 | import { probeMcpEndpointShape } from "./probe-shape"; |
| 32 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 32 | 33 | |
| 33 | 34 | const MCP_INITIALIZE_BODY = JSON.stringify({ |
| 34 | 35 | jsonrpc: "2.0", |
| @@ -176,7 +177,9 @@ const messageFromUnknown = (cause: unknown): string => · 141 unmodified lines | ||
| 176 | 177 | |
| 177 | 178 | const runEndpointProbe = (url: string): Effect.Effect<EndpointProbeOutcome> => |
| 178 | 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 | 183 | return yield* executor.mcp.probeEndpoint(url).pipe( |
| 181 | 184 | Effect.map( |
| 182 | 185 | (r) => |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 13 | 13 | |
| 14 | 14 | import { mcpPlugin } from "./plugin"; |
| 15 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 15 | 16 | |
| 16 | 17 | const FILE_ID = "F012ABC3456"; |
| 17 | 18 | const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); |
| @@ -128,6 +129,7 @@ describe("Slack Connect file fallback", () => { · 110 unmodified lines | ||
| 128 | 129 | Effect.gen(function* () { |
| 129 | 130 | const config = { |
| 130 | 131 | ...makeTestConfig({ |
| 132 | + access: testAccess.member(), | |
| 131 | 133 | plugins: [ |
| 132 | 134 | memoryCredentialsPlugin(), |
| 133 | 135 | mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }), |
| @@ -60,6 +60,7 @@ · 59 unmodified lines | ||
| 60 | 60 | "devDependencies": { |
| 61 | 61 | "@effect/vitest": "catalog:", |
| 62 | 62 | "@executor-js/api": "workspace:*", |
| 63 | + "@executor-js/product-access": "workspace:*", | |
| 63 | 64 | "@types/node": "catalog:", |
| 64 | 65 | "@types/react": "catalog:", |
| 65 | 66 | "bun-types": "catalog:", |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | import type { OnePasswordService } from "./service"; |
| 16 | 16 | import { OnePasswordError } from "./errors"; |
| 17 | 17 | import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types"; |
| 18 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 18 | 19 | |
| 19 | 20 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` |
| 20 | 21 | // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and |
| @@ -63,7 +64,7 @@ describe("onepassword plugin", () => { · 42 unmodified lines | ||
| 63 | 64 | it.effect("registers onepassword as a credential provider", () => |
| 64 | 65 | Effect.gen(function* () { |
| 65 | 66 | const executor = yield* createExecutor( |
| 66 | − makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | |
| 67 | + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), | |
| 67 | 68 | ); |
| 68 | 69 | const providers = yield* executor.providers.list(); |
| 69 | 70 | expect(providers).toContain(ONEPASSWORD); |
| @@ -73,7 +74,7 @@ describe("onepassword plugin", () => { · 3 unmodified lines | ||
| 73 | 74 | it.effect("configure upserts accounts by id and removeConfig removes them one by one", () => |
| 74 | 75 | Effect.gen(function* () { |
| 75 | 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 | 80 | const initial = yield* executor.onepassword.getConfig(); |
| @@ -124,7 +125,7 @@ describe("onepassword plugin", () => { · 44 unmodified lines | ||
| 124 | 125 | it.effect("removeConfig without an id removes everything", () => |
| 125 | 126 | Effect.gen(function* () { |
| 126 | 127 | const executor = yield* createExecutor( |
| 127 | − makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | |
| 128 | + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), | |
| 128 | 129 | ); |
| 129 | 130 | yield* executor.onepassword.configure({ |
| 130 | 131 | name: "Work", |
| @@ -144,7 +145,7 @@ describe("onepassword plugin", () => { · 13 unmodified lines | ||
| 144 | 145 | it.effect("getConfig redacts every account's service-account token", () => |
| 145 | 146 | Effect.gen(function* () { |
| 146 | 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 | 151 | yield* executor.onepassword.configure({ |
| @@ -171,7 +172,7 @@ describe("onepassword plugin", () => { · 20 unmodified lines | ||
| 171 | 172 | it.effect("exposes provider configuration as agent-callable static tools", () => |
| 172 | 173 | Effect.gen(function* () { |
| 173 | 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 | 178 | const configured = yield* executor.execute( |
| @@ -225,7 +226,7 @@ describe("onepassword plugin", () => { · 47 unmodified lines | ||
| 225 | 226 | it.effect("status reports not-configured before configure", () => |
| 226 | 227 | Effect.gen(function* () { |
| 227 | 228 | const executor = yield* createExecutor( |
| 228 | − makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | |
| 229 | + makeTestConfig({ access: testAccess.member(), plugins: [onepasswordPlugin()] as const }), | |
| 229 | 230 | ); |
| 230 | 231 | const status = yield* executor.onepassword.status(); |
| 231 | 232 | expect(status.connected).toBe(false); |
| @@ -248,6 +249,7 @@ describe("onepassword store", () => { · 16 unmodified lines | ||
| 248 | 249 | makeInMemoryBlobStore(), |
| 249 | 250 | { org: "org_test", user: null }, |
| 250 | 251 | "onepassword", |
| 252 | + { owners: ["org"], storageWrites: "allowed" }, | |
| 251 | 253 | ); |
| 252 | 254 | return { blobs, store: makeOnePasswordStore(blobs) }; |
| 253 | 255 | }; |
| @@ -86,6 +86,7 @@ · 85 unmodified lines | ||
| 86 | 86 | "@effect/atom-react": "catalog:", |
| 87 | 87 | "@effect/vitest": "catalog:", |
| 88 | 88 | "@executor-js/api": "workspace:*", |
| 89 | + "@executor-js/product-access": "workspace:*", | |
| 89 | 90 | "@executor-js/react": "workspace:*", |
| 90 | 91 | "@types/js-yaml": "4.0.9", |
| 91 | 92 | "@types/node": "catalog:", |
| @@ -9,6 +9,7 @@ import type { AuthenticationInput } from "@executor-js/plugin-openapi"; · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| 11 | 11 | import { googleCatalog } from "./presets"; |
| 12 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 12 | 13 | |
| 13 | 14 | const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; |
| 14 | 15 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; |
| @@ -179,6 +180,7 @@ it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived i · 164 unmodified lines | ||
| 179 | 180 | Effect.gen(function* () { |
| 180 | 181 | const executor = yield* createExecutor( |
| 181 | 182 | makeTestConfig({ |
| 183 | + access: testAccess.member(), | |
| 182 | 184 | plugins: [ |
| 183 | 185 | openApiPlugin({ |
| 184 | 186 | httpClientLayer: discoveryHttpClientLayer, |
| @@ -211,6 +213,7 @@ it.effect( · 26 unmodified lines | ||
| 211 | 213 | ); |
| 212 | 214 | const executor = yield* createExecutor( |
| 213 | 215 | makeTestConfig({ |
| 216 | + access: testAccess.member(), | |
| 214 | 217 | plugins: [ |
| 215 | 218 | openApiPlugin({ |
| 216 | 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 | 261 | const executor = yield* createExecutor( |
| 259 | 262 | makeTestConfig({ |
| 263 | + access: testAccess.member(), | |
| 260 | 264 | plugins: [ |
| 261 | 265 | openApiPlugin({ |
| 262 | 266 | httpClientLayer: gmailDiscoveryHttpClientLayer, |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | ||
| 35 | 35 | serveOpenApiHttpApiTestServer, |
| 36 | 36 | unwrapInvocation, |
| 37 | 37 | } from "../testing"; |
| 38 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 38 | 39 | |
| 39 | 40 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => |
| 40 | 41 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -90,7 +91,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 49 unmodified lines | ||
| 90 | 91 | it.effect("adds a custom apiKey method and getConfig reflects it", () => |
| 91 | 92 | Effect.scoped( |
| 92 | 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 | 98 | yield* executor.openapi.addSpec({ |
| 96 | 99 | spec: { kind: "blob", value: specText() }, |
| @@ -119,7 +122,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 22 unmodified lines | ||
| 119 | 122 | it.effect("appends to an existing spec-derived template without dropping entries", () => |
| 120 | 123 | Effect.scoped( |
| 121 | 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 | 129 | const seedTemplate: AuthenticationInput = { |
| 125 | 130 | slug: "seed", |
| @@ -145,7 +150,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 19 unmodified lines | ||
| 145 | 150 | it.effect("generates a custom_<id> slug for a method submitted without one", () => |
| 146 | 151 | Effect.scoped( |
| 147 | 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 | 157 | yield* executor.openapi.addSpec({ |
| 151 | 158 | spec: { kind: "blob", value: specText() }, |
| @@ -167,7 +174,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 15 unmodified lines | ||
| 167 | 174 | it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () => |
| 168 | 175 | Effect.scoped( |
| 169 | 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 | 181 | yield* executor.openapi.addSpec({ |
| 173 | 182 | spec: { kind: "blob", value: specText() }, |
| @@ -208,7 +217,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 34 unmodified lines | ||
| 208 | 217 | it.effect("replace mode overwrites the auth template array", () => |
| 209 | 218 | Effect.scoped( |
| 210 | 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 | 224 | const seedTemplate: AuthenticationInput = { |
| 214 | 225 | slug: "seed", |
| @@ -236,7 +247,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 21 unmodified lines | ||
| 236 | 247 | Effect.scoped( |
| 237 | 248 | Effect.gen(function* () { |
| 238 | 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 | 254 | yield* executor.openapi.addSpec({ |
| 242 | 255 | spec: { kind: "blob", value: server.specJson }, |
| @@ -272,7 +285,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 29 unmodified lines | ||
| 272 | 285 | Effect.scoped( |
| 273 | 286 | Effect.gen(function* () { |
| 274 | 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 | 292 | yield* executor.openapi.addSpec({ |
| 278 | 293 | spec: { kind: "blob", value: server.specJson }, |
| @@ -320,7 +335,9 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 41 unmodified lines | ||
| 320 | 335 | it.effect("getConfig returns null for an unknown integration", () => |
| 321 | 336 | Effect.scoped( |
| 322 | 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 | 341 | expect(yield* executor.openapi.getConfig("nope")).toBeNull(); |
| 325 | 342 | }), |
| 326 | 343 | ), |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | ||
| 30 | 30 | } from "@executor-js/plugin-openapi/testing"; |
| 31 | 31 | |
| 32 | 32 | import { openApiPlugin } from "./plugin"; |
| 33 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 33 | 34 | |
| 34 | 35 | type Captured = { |
| 35 | 36 | contentType: string; |
| @@ -76,30 +77,28 @@ const plugins = [ · 40 unmodified lines | ||
| 76 | 77 | memoryCredentialsPlugin(), |
| 77 | 78 | ] as const; |
| 78 | 79 | |
| 79 | −layer( | |
| 80 | − makeTestWorkspaceLayer({ | |
| 81 | − plugins, | |
| 82 | − }), | |
| 83 | − { timeout: "15 seconds" }, | |
| 84 | −)("OpenAPI non-JSON request body serialization", (it) => { | |
| 85 | − it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => | |
| 86 | − Effect.gen(function* () { | |
| 87 | − const { server, captured } = yield* startEchoServer(); | |
| 88 | − const { config } = yield* TestWorkspace; | |
| 89 | − const executor = yield* createExecutor({ ...config, plugins }); | |
| 80 | +layer(makeTestWorkspaceLayer({ access: testAccess.member(), plugins }), { timeout: "15 seconds" })( | |
| 81 | + "OpenAPI non-JSON request body serialization", | |
| 82 | + (it) => { | |
| 83 | + it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => | |
| 84 | + Effect.gen(function* () { | |
| 85 | + const { server, captured } = yield* startEchoServer(); | |
| 86 | + const { config } = yield* TestWorkspace; | |
| 87 | + const executor = yield* createExecutor({ ...config, plugins }); | |
| 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"), { | |
| 94 | − body: { name: "Acme", email: "a@b.com" }, | |
| 95 | − }); | |
| 91 | + yield* executor.execute(conn.address("forms.submit"), { | |
| 92 | + body: { name: "Acme", email: "a@b.com" }, | |
| 93 | + }); | |
| 96 | 94 | |
| 97 | − expect(captured.contentType).toBe("application/x-www-form-urlencoded"); | |
| 98 | − expect(captured.body).not.toBe("[object Object]"); | |
| 95 | + expect(captured.contentType).toBe("application/x-www-form-urlencoded"); | |
| 96 | + expect(captured.body).not.toBe("[object Object]"); | |
| 99 | 97 | |
| 100 | − const parsed = new URLSearchParams(captured.body); | |
| 101 | − expect(parsed.get("name")).toBe("Acme"); | |
| 102 | − expect(parsed.get("email")).toBe("a@b.com"); | |
| 103 | − }), | |
| 104 | − ); | |
| 105 | −}); | |
| 98 | + const parsed = new URLSearchParams(captured.body); | |
| 99 | + expect(parsed.get("name")).toBe("Acme"); | |
| 100 | + expect(parsed.get("email")).toBe("a@b.com"); | |
| 101 | + }), | |
| 102 | + ); | |
| 103 | + }, | |
| 104 | +); | |
| @@ -34,6 +34,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 33 unmodified lines | ||
| 34 | 34 | |
| 35 | 35 | import { serveOpenApiHttpApiTestServer } from "../testing"; |
| 36 | 36 | import { openApiPlugin } from "./plugin"; |
| 37 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 37 | 38 | |
| 38 | 39 | /** The credential the connection authenticates with. Longer than the sample's |
| 39 | 40 | * 120-char value cap on purpose: truncating before scrubbing would leave a |
| @@ -84,6 +85,7 @@ const withProbe = <A, E, R>( · 44 unmodified lines | ||
| 84 | 85 | }); |
| 85 | 86 | const executor = yield* createExecutor( |
| 86 | 87 | makeTestConfig({ |
| 88 | + access: testAccess.member(), | |
| 87 | 89 | plugins: [ |
| 88 | 90 | openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), |
| 89 | 91 | memoryCredentialsPlugin(), |
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | ||
| 37 | 37 | |
| 38 | 38 | import { openApiPlugin } from "./plugin"; |
| 39 | 39 | import { type AuthenticationInput } from "./types"; |
| 40 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 40 | 41 | |
| 41 | 42 | // --------------------------------------------------------------------------- |
| 42 | 43 | // Test API — a single endpoint that echoes the Authorization header so the |
| @@ -86,7 +87,9 @@ describe("OpenAPI owner-isolated bearer", () => { · 43 unmodified lines | ||
| 86 | 87 | Effect.scoped( |
| 87 | 88 | Effect.gen(function* () { |
| 88 | 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 | 94 | // Admin uploads the spec once; the integration declares a bearer |
| 92 | 95 | // template but no token value. |
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | ||
| 32 | 32 | } from "@executor-js/plugin-openapi/testing"; |
| 33 | 33 | |
| 34 | 34 | import { openApiPlugin } from "./plugin"; |
| 35 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 35 | 36 | |
| 36 | 37 | const JsonNameBody = Schema.fromJsonString( |
| 37 | 38 | Schema.Struct({ |
| @@ -172,7 +173,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 134 unmodified lines | ||
| 172 | 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 | 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 | 231 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" }); |
| 227 | 232 | |
| 228 | 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 | 309 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" }); |
| 303 | 310 | |
| 304 | 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 | 391 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" }); |
| 383 | 392 | |
| 384 | 393 | const exit = yield* executor |
| @@ -424,7 +433,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 39 unmodified lines | ||
| 424 | 433 | }), |
| 425 | 434 | }); |
| 426 | 435 | |
| 427 | − const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | |
| 436 | + const executor = yield* createExecutor( | |
| 437 | + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | |
| 438 | + ); | |
| 428 | 439 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" }); |
| 429 | 440 | |
| 430 | 441 | // The rewrite is scoped to multipart bodies: a JSON body keeps its |
| @@ -465,7 +476,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 34 unmodified lines | ||
| 465 | 476 | }), |
| 466 | 477 | }); |
| 467 | 478 | |
| 468 | − const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | |
| 479 | + const executor = yield* createExecutor( | |
| 480 | + makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }), | |
| 481 | + ); | |
| 469 | 482 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" }); |
| 470 | 483 | |
| 471 | 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 | 530 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" }); |
| 516 | 531 | |
| 517 | 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 | 585 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" }); |
| 569 | 586 | |
| 570 | 587 | // Documented limitation: component schemas are carried through |
| @@ -584,7 +601,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines | ||
| 584 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 735 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 705 | 736 | slug: "bin_nested_b64_bad", |
| @@ -723,7 +754,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines | ||
| 723 | 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 | 761 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 729 | 762 | slug: "bin_b64_missing", |
| @@ -743,7 +776,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 13 unmodified lines | ||
| 743 | 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 | 783 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 749 | 784 | slug: "bin_object_body", |
| @@ -767,7 +802,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 17 unmodified lines | ||
| 767 | 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 | 809 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 773 | 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 | 880 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail" }); |
| 842 | 881 | |
| 843 | 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 | 966 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_text" }); |
| 926 | 967 | |
| 927 | 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 | 1036 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "zip_attachment" }); |
| 994 | 1037 | |
| 995 | 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 | 1156 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "gmail_meta" }); |
| 1112 | 1157 | |
| 1113 | 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 | 1207 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); |
| 1161 | 1208 | |
| 1162 | 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 | 1263 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "files" }); |
| 1215 | 1264 | |
| 1216 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 1434 | const { server } = yield* startEchoServer({ |
| 1376 | 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 | 1441 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 1381 | 1442 | slug: "mc3", |
| @@ -1404,7 +1465,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 22 unmodified lines | ||
| 1404 | 1465 | const { server } = yield* startEchoServer({ |
| 1405 | 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 | 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 | 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 | 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 | 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 | 1626 | // No encoding → OAS3 defaults: style=form, explode=true. |
| 1556 | 1627 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "fdx" }); |
| @@ -49,6 +49,7 @@ import { · 48 unmodified lines | ||
| 49 | 49 | serveOpenApiHttpApiTestServer, |
| 50 | 50 | unwrapInvocation, |
| 51 | 51 | } from "../testing"; |
| 52 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 52 | 53 | |
| 53 | 54 | const TOOL_ERROR_TYPESCRIPT = |
| 54 | 55 | "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; |
| @@ -393,7 +394,9 @@ describe("OpenAPI Plugin", () => { · 338 unmodified lines | ||
| 393 | 394 | Effect.gen(function* () { |
| 394 | 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 | 401 | const preview = yield* executor.openapi.previewSpec(server.specJson); |
| 399 | 402 | |
| @@ -408,7 +411,10 @@ describe("OpenAPI Plugin", () => { · 8 unmodified lines | ||
| 408 | 411 | Effect.gen(function* () { |
| 409 | 412 | const server = yield* serveOAuthDiscoverableOpenApiSpec(); |
| 410 | 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 | 420 | const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/")); |
| @@ -456,7 +462,9 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines | ||
| 456 | 462 | |
| 457 | 463 | it.effect("exposes static openapi executor control tools via execute", () => |
| 458 | 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 | 469 | // v2: static control tools are NOT part of `tools.list()` (that's the |
| 462 | 470 | // persisted per-connection catalog) and aren't `tools.schema()`-resolvable; |
| @@ -473,7 +481,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines | ||
| 473 | 481 | |
| 474 | 482 | it.effect("invokes static previewSpec through executor.execute", () => |
| 475 | 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 | 488 | const preview = unwrapInvocation( |
| 479 | 489 | yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { |
| @@ -495,7 +505,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines | ||
| 495 | 505 | |
| 496 | 506 | it.effect("invokes static addSpec through executor.execute", () => |
| 497 | 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 | 512 | const result = unwrapInvocation( |
| 501 | 513 | yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), { |
| @@ -517,7 +529,9 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines | ||
| 517 | 529 | Effect.scoped( |
| 518 | 530 | Effect.gen(function* () { |
| 519 | 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 | 536 | yield* executor.openapi.addSpec({ |
| 523 | 537 | spec: { kind: "url", url: specServer.specUrl }, |
| @@ -565,7 +579,10 @@ describe("OpenAPI Plugin", () => { · 41 unmodified lines | ||
| 565 | 579 | |
| 566 | 580 | it.effect("static previewSpec returns actionable tool failures", () => |
| 567 | 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 | 586 | const executor = yield* createExecutor(config); |
| 570 | 587 | |
| 571 | 588 | const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), { |
| @@ -587,7 +604,7 @@ describe("OpenAPI Plugin", () => { · 15 unmodified lines | ||
| 587 | 604 | it.effect("requires approval before adding an integration through the runtime tool", () => |
| 588 | 605 | Effect.gen(function* () { |
| 589 | 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 | 610 | const declined = yield* executor |
| @@ -607,7 +624,9 @@ describe("OpenAPI Plugin", () => { · 13 unmodified lines | ||
| 607 | 624 | Effect.scoped( |
| 608 | 625 | Effect.gen(function* () { |
| 609 | 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 | 631 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 613 | 632 | |
| @@ -625,7 +644,9 @@ describe("OpenAPI Plugin", () => { · 11 unmodified lines | ||
| 625 | 644 | Effect.scoped( |
| 626 | 645 | Effect.gen(function* () { |
| 627 | 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 | 651 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 631 | 652 | |
| @@ -642,7 +663,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines | ||
| 642 | 663 | Effect.scoped( |
| 643 | 664 | Effect.gen(function* () { |
| 644 | 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 | 670 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 648 | 671 | const calls = { count: 0 }; |
| @@ -671,7 +694,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines | ||
| 671 | 694 | Effect.scoped( |
| 672 | 695 | Effect.gen(function* () { |
| 673 | 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 | 701 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 677 | 702 | const calls = { count: 0 }; |
| @@ -705,7 +730,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines | ||
| 705 | 730 | Effect.scoped( |
| 706 | 731 | Effect.gen(function* () { |
| 707 | 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 | 737 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 711 | 738 | const calls = { count: 0 }; |
| @@ -738,7 +765,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines | ||
| 738 | 765 | Effect.scoped( |
| 739 | 766 | Effect.gen(function* () { |
| 740 | 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 | 772 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 744 | 773 | const calls = { count: 0 }; |
| @@ -771,7 +800,9 @@ describe("OpenAPI Plugin", () => { · 26 unmodified lines | ||
| 771 | 800 | Effect.scoped( |
| 772 | 801 | Effect.gen(function* () { |
| 773 | 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 | 807 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 777 | 808 | |
| @@ -812,7 +843,9 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | ||
| 812 | 843 | Effect.scoped( |
| 813 | 844 | Effect.gen(function* () { |
| 814 | 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 | 850 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 818 | 851 | |
| @@ -829,7 +862,9 @@ describe("OpenAPI Plugin", () => { · 10 unmodified lines | ||
| 829 | 862 | Effect.scoped( |
| 830 | 863 | Effect.gen(function* () { |
| 831 | 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 | 869 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" }); |
| 835 | 870 | const failure = yield* executor |
| @@ -853,7 +888,9 @@ describe("OpenAPI Plugin", () => { · 17 unmodified lines | ||
| 853 | 888 | Effect.scoped( |
| 854 | 889 | Effect.gen(function* () { |
| 855 | 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 | 894 | const multiServer = { |
| 858 | 895 | ...server, |
| 859 | 896 | specJson: makeOpenApiTestSpecJson(TestApi, { |
| @@ -889,7 +926,9 @@ describe("OpenAPI Plugin", () => { · 29 unmodified lines | ||
| 889 | 926 | const server = yield* serveOpenApiEchoTestServer({ |
| 890 | 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 | 932 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 894 | 933 | slug: "cookie_params", |
| 895 | 934 | headers: { Cookie: "configured=yes" }, |
| @@ -923,7 +962,9 @@ describe("OpenAPI Plugin", () => { · 27 unmodified lines | ||
| 923 | 962 | const server = yield* serveOpenApiEchoTestServer({ |
| 924 | 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 | 968 | const conn = yield* addOpenApiTestConnection(executor, server, { |
| 928 | 969 | slug: "cookie_unknown", |
| 929 | 970 | }); |
| @@ -951,7 +992,9 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines | ||
| 951 | 992 | Effect.scoped( |
| 952 | 993 | Effect.gen(function* () { |
| 953 | 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 | 999 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "records" }); |
| 957 | 1000 | |
| @@ -980,7 +1023,9 @@ describe("OpenAPI Plugin", () => { · 22 unmodified lines | ||
| 980 | 1023 | Effect.scoped( |
| 981 | 1024 | Effect.gen(function* () { |
| 982 | 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 | 1030 | yield* executor.openapi.addSpec({ |
| 986 | 1031 | spec: { kind: "blob", value: server.specJson }, |
| @@ -1012,7 +1057,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines | ||
| 1012 | 1057 | Effect.scoped( |
| 1013 | 1058 | Effect.gen(function* () { |
| 1014 | 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 | 1064 | yield* executor.openapi.addSpec({ |
| 1018 | 1065 | spec: { kind: "blob", value: server.specJson }, |
| @@ -1044,7 +1091,9 @@ describe("OpenAPI Plugin", () => { · 25 unmodified lines | ||
| 1044 | 1091 | Effect.scoped( |
| 1045 | 1092 | Effect.gen(function* () { |
| 1046 | 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 | 1098 | // The spec declares bearer auth; the caller passes NO template — the |
| 1050 | 1099 | // agentic add path (MCP/API) does exactly this. Without server-side |
| @@ -1097,7 +1146,9 @@ describe("OpenAPI Plugin", () => { · 46 unmodified lines | ||
| 1097 | 1146 | Effect.scoped( |
| 1098 | 1147 | Effect.gen(function* () { |
| 1099 | 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 | 1153 | // No baseUrl override: the spec declares `servers`, so the host is |
| 1103 | 1154 | // resolved per call from the operation's servers rather than baked into |
| @@ -1127,7 +1178,9 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines | ||
| 1127 | 1178 | Effect.scoped( |
| 1128 | 1179 | Effect.gen(function* () { |
| 1129 | 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 | 1185 | // The add page sends [] when the user deletes every detected method. |
| 1133 | 1186 | // That intent must survive — deriving methods back from the spec here |
| @@ -1158,7 +1211,9 @@ describe("OpenAPI Plugin", () => { · 24 unmodified lines | ||
| 1158 | 1211 | |
| 1159 | 1212 | it.effect("addSpec accepts Graph-sized OpenAPI blobs", () => |
| 1160 | 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 | 1217 | const largeDescription = "x".repeat(36 * 1024 * 1024); |
| 1163 | 1218 | |
| 1164 | 1219 | const added = yield* executor.openapi.addSpec({ |
| @@ -1191,7 +1246,9 @@ paths: · 26 unmodified lines | ||
| 1191 | 1246 | "addSpec accepts Microsoft Graph-scale operation catalogs from one spec", |
| 1192 | 1247 | () => |
| 1193 | 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 | 1253 | const added = yield* executor.openapi.addSpec({ |
| 1197 | 1254 | spec: { kind: "blob", value: microsoftGraphScaleSpecText() }, |
| @@ -1208,7 +1265,9 @@ paths: · 10 unmodified lines | ||
| 1208 | 1265 | Effect.scoped( |
| 1209 | 1266 | Effect.gen(function* () { |
| 1210 | 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 | 1272 | yield* addOpenApiTestConnection(executor, server, { slug: "removable" }); |
| 1214 | 1273 | expect((yield* executor.tools.list()).map((t) => String(t.name))).toContain( |
| @@ -1232,7 +1291,9 @@ paths: · 17 unmodified lines | ||
| 1232 | 1291 | Effect.scoped( |
| 1233 | 1292 | Effect.gen(function* () { |
| 1234 | 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 | 1298 | // First add carries an apiKey auth template + a distinctive description. |
| 1238 | 1299 | // A silent upsert on re-add would clobber both. |
| @@ -1294,7 +1355,9 @@ paths: · 55 unmodified lines | ||
| 1294 | 1355 | // The mutable spec server is a real 127.0.0.1 listener — reach it over |
| 1295 | 1356 | // the default fetch-based client, like production would. |
| 1296 | 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 | 1362 | const added = yield* executor.openapi.addSpec({ |
| 1300 | 1363 | spec: { kind: "url", url: specServer.specUrl }, |
| @@ -1370,7 +1433,7 @@ paths: · 69 unmodified lines | ||
| 1370 | 1433 | it.effect("denies member updateSpec before writing an org blob", () => |
| 1371 | 1434 | Effect.gen(function* () { |
| 1372 | 1435 | const blobs = recordingBlobStore(); |
| 1373 | − const config = makeTestConfig({ plugins: testPlugins() }); | |
| 1436 | + const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); | |
| 1374 | 1437 | const admin = yield* createExecutor({ ...config, blobs: blobs.store }); |
| 1375 | 1438 | yield* admin.openapi.addSpec({ |
| 1376 | 1439 | spec: { kind: "blob", value: testApiSpecText() }, |
| @@ -1380,7 +1443,7 @@ paths: · 3 unmodified lines | ||
| 1380 | 1443 | const member = yield* createExecutor({ |
| 1381 | 1444 | ...config, |
| 1382 | 1445 | blobs: blobs.store, |
| 1383 | − orgWrites: "denied", | |
| 1446 | + access: testAccess.member("denied"), | |
| 1384 | 1447 | }); |
| 1385 | 1448 | |
| 1386 | 1449 | const error = yield* member.openapi |
| @@ -1398,7 +1461,9 @@ paths: · 11 unmodified lines | ||
| 1398 | 1461 | Effect.scoped( |
| 1399 | 1462 | Effect.gen(function* () { |
| 1400 | 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 | 1467 | yield* addOpenApiTestConnection(executor, server, { slug: "pasted" }); |
| 1403 | 1468 | |
| 1404 | 1469 | // A pasted-blob integration has no source URL — re-fetch must say so. |
| @@ -1450,6 +1515,7 @@ paths: · 45 unmodified lines | ||
| 1450 | 1515 | const plugins = testPlugins(); |
| 1451 | 1516 | |
| 1452 | 1517 | const alice = yield* makeTestWorkspaceHarness({ |
| 1518 | + access: testAccess.member(), | |
| 1453 | 1519 | plugins, |
| 1454 | 1520 | tenant, |
| 1455 | 1521 | subject: "alice", |
| @@ -1470,6 +1536,7 @@ paths: · 14 unmodified lines | ||
| 1470 | 1536 | }); |
| 1471 | 1537 | |
| 1472 | 1538 | const bob = yield* makeTestWorkspaceHarness({ |
| 1539 | + access: testAccess.member(), | |
| 1473 | 1540 | plugins, |
| 1474 | 1541 | tenant, |
| 1475 | 1542 | subject: "bob", |
| @@ -1513,7 +1580,9 @@ paths: · 37 unmodified lines | ||
| 1513 | 1580 | |
| 1514 | 1581 | it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () => |
| 1515 | 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 | 1586 | const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip); |
| 1518 | 1587 | expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true); |
| 1519 | 1588 | }), |
| @@ -28,6 +28,7 @@ import { openApiPlugin } from "./plugin"; · 27 unmodified lines | ||
| 28 | 28 | import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; |
| 29 | 29 | import { previewSpec as previewSpecRaw } from "./preview"; |
| 30 | 30 | import type { ExtractionResult } from "./types"; |
| 31 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 31 | 32 | |
| 32 | 33 | const previewSpec = (input: string) => |
| 33 | 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 | 93 | const executor = yield* createExecutor( |
| 93 | 94 | makeTestConfig({ |
| 95 | + access: testAccess.member(), | |
| 94 | 96 | plugins: [openApiPlugin(), memoryCredentialsPlugin()] as const, |
| 95 | 97 | }), |
| 96 | 98 | ); |
| @@ -16,6 +16,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 15 unmodified lines | ||
| 16 | 16 | import { makeOpenApiHttpApiTestIntegrationConfig } from "../testing"; |
| 17 | 17 | |
| 18 | 18 | import { openApiPlugin } from "./plugin"; |
| 19 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 19 | 20 | |
| 20 | 21 | const BODY_READ_DEADLINE_MS = 15_000; |
| 21 | 22 | const RESPONSE_BODY_TIMEOUT_MS = 100; |
| @@ -42,7 +43,9 @@ const testPlugins = () => · 20 unmodified lines | ||
| 42 | 43 | |
| 43 | 44 | const buildExecutor = (baseUrl: string) => |
| 44 | 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 | 49 | yield* executor.openapi.addSpec( |
| 47 | 50 | makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { |
| 48 | 51 | slug: "body_timeout", |
| @@ -18,6 +18,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 17 unmodified lines | ||
| 18 | 18 | import { invokeWithLayer } from "./invoke"; |
| 19 | 19 | import { openApiPlugin } from "./plugin"; |
| 20 | 20 | import type { OperationBinding } from "./types"; |
| 21 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 21 | 22 | |
| 22 | 23 | const RESPONSE_HEADERS_TIMEOUT_MS = 100; |
| 23 | 24 | const STREAM_TOOL = "logs.getLogs"; |
| @@ -44,7 +45,9 @@ const testPlugins = () => · 20 unmodified lines | ||
| 44 | 45 | |
| 45 | 46 | const buildExecutor = (baseUrl: string) => |
| 46 | 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 | 51 | yield* executor.openapi.addSpec( |
| 49 | 52 | makeOpenApiHttpApiTestIntegrationConfig(TimeoutApi, { slug: "headers_timeout", baseUrl }), |
| 50 | 53 | ); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | ||
| 38 | 38 | serveOpenApiHttpApiTestServer, |
| 39 | 39 | unwrapInvocation, |
| 40 | 40 | } from "../testing"; |
| 41 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 41 | 42 | |
| 42 | 43 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => |
| 43 | 44 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -113,7 +114,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 69 unmodified lines | ||
| 113 | 114 | it.effect("addSpec stores a content pointer, not the inline spec text", () => |
| 114 | 115 | Effect.scoped( |
| 115 | 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 | 120 | const text = specText(); |
| 118 | 121 | |
| 119 | 122 | yield* executor.openapi.addSpec({ |
| @@ -135,7 +138,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 15 unmodified lines | ||
| 135 | 138 | api: TestApi, |
| 136 | 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 | 145 | yield* executor.openapi.addSpec({ |
| 141 | 146 | spec: { kind: "blob", value: server.specJson }, |
| @@ -168,7 +173,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 26 unmodified lines | ||
| 168 | 173 | () => |
| 169 | 174 | Effect.scoped( |
| 170 | 175 | Effect.gen(function* () { |
| 171 | − const config = makeTestConfig({ plugins: testPlugins() }); | |
| 176 | + const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); | |
| 172 | 177 | const executor = yield* createExecutor(config); |
| 173 | 178 | const text = specTextWithDefinition(); |
| 174 | 179 | const hash = yield* sha256Hex(text); |
| @@ -245,7 +250,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 70 unmodified lines | ||
| 245 | 250 | it.effect("stale sync preserves tools and definitions when the spec blob is missing", () => |
| 246 | 251 | Effect.scoped( |
| 247 | 252 | Effect.gen(function* () { |
| 248 | − const config = makeTestConfig({ plugins: testPlugins() }); | |
| 253 | + const config = makeTestConfig({ access: testAccess.member(), plugins: testPlugins() }); | |
| 249 | 254 | const executor = yield* createExecutor(config); |
| 250 | 255 | const text = specTextWithDefinition(); |
| 251 | 256 | const hash = yield* sha256Hex(text); |
| @@ -318,7 +323,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 66 unmodified lines | ||
| 318 | 323 | it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () => |
| 319 | 324 | Effect.scoped( |
| 320 | 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 | 329 | const emptySpec = JSON.stringify({ |
| 323 | 330 | openapi: "3.1.0", |
| 324 | 331 | info: { title: "Empty", version: "1.0.0" }, |
| @@ -409,7 +416,9 @@ describe("OpenAPI plugin — spec blob storage", () => { · 84 unmodified lines | ||
| 409 | 416 | it.effect("remove + re-add of the same spec is idempotent over the shared blob", () => |
| 410 | 417 | Effect.scoped( |
| 411 | 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 | 422 | const text = specText(); |
| 414 | 423 | |
| 415 | 424 | yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" }); |
| @@ -9,6 +9,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 8 unmodified lines | ||
| 9 | 9 | import { openApiPlugin } from "./plugin"; |
| 10 | 10 | import { applySpecOverrides, type SpecOverrides } from "./spec-overrides"; |
| 11 | 11 | import { serveMutableOpenApiSpecTestServer } from "../testing"; |
| 12 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 12 | 13 | |
| 13 | 14 | const testPlugins = () => |
| 14 | 15 | [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; |
| @@ -60,7 +61,9 @@ describe("OpenAPI spec override lifecycle", () => { · 45 unmodified lines | ||
| 60 | 61 | it.effect("applies scope overrides to preview and persists raw and patched hashes", () => |
| 61 | 62 | Effect.scoped( |
| 62 | 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 | 67 | const sourceText = encodeJsonText(oauthSpec); |
| 65 | 68 | |
| 66 | 69 | const preview = yield* executor.openapi.previewSpec({ |
| @@ -93,7 +96,9 @@ describe("OpenAPI spec override lifecycle", () => { · 26 unmodified lines | ||
| 93 | 96 | it.effect("changes and clears overrides for an inline spec without repasting the source", () => |
| 94 | 97 | Effect.scoped( |
| 95 | 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 | 102 | const sourceText = encodeJsonText(oauthSpec); |
| 98 | 103 | yield* executor.openapi.addSpec({ |
| 99 | 104 | spec: { kind: "blob", value: sourceText }, |
| @@ -130,7 +135,9 @@ describe("OpenAPI spec override lifecycle", () => { · 30 unmodified lines | ||
| 130 | 135 | .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String })); |
| 131 | 136 | const InitialApi = HttpApi.make("initial").add(Items); |
| 132 | 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 | 141 | const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }]; |
| 135 | 142 | |
| 136 | 143 | yield* executor.openapi.addSpec({ |
| @@ -17,6 +17,7 @@ import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../te · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke"; |
| 19 | 19 | import { openApiPlugin } from "./plugin"; |
| 20 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 20 | 21 | |
| 21 | 22 | const testPlugins = () => |
| 22 | 23 | [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; |
| @@ -35,7 +36,9 @@ const StreamingApi = HttpApi.make("streamingResponseTest") · 12 unmodified lines | ||
| 35 | 36 | |
| 36 | 37 | const buildExecutor = (baseUrl: string) => |
| 37 | 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 | 42 | yield* executor.openapi.addSpec( |
| 40 | 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 | 302 | yield* executor.openapi.addSpec({ |
| 298 | 303 | spec: { |
| 299 | 304 | kind: "blob", |
| @@ -25,6 +25,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 24 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | import { openApiPlugin } from "./plugin"; |
| 27 | 27 | import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing"; |
| 28 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 28 | 29 | |
| 29 | 30 | const testPlugins = (httpClientLayer = FetchHttpClient.layer) => |
| 30 | 31 | [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -58,7 +59,9 @@ const setup = Effect.gen(function* () { · 27 unmodified lines | ||
| 58 | 59 | api: TestApi, |
| 59 | 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 | 65 | yield* executor.openapi.addSpec({ |
| 63 | 66 | spec: { kind: "blob", value: server.specJson }, |
| 64 | 67 | slug: "proj_api", |
| @@ -48,6 +48,7 @@ import { · 47 unmodified lines | ||
| 48 | 48 | } from "../testing"; |
| 49 | 49 | |
| 50 | 50 | import { openApiPlugin } from "./plugin"; |
| 51 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 51 | 52 | |
| 52 | 53 | class AdapterDefect extends Data.TaggedError("AdapterDefect") {} |
| 53 | 54 | |
| @@ -149,7 +150,7 @@ const FailureApi = HttpApi.make("failuresTest") · 95 unmodified lines | ||
| 149 | 150 | const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) => |
| 150 | 151 | Effect.gen(function* () { |
| 151 | 152 | const executor = yield* createExecutor( |
| 152 | − makeTestConfig({ plugins: testPlugins(httpClientLayer) }), | |
| 153 | + makeTestConfig({ access: testAccess.member(), plugins: testPlugins(httpClientLayer) }), | |
| 153 | 154 | ); |
| 154 | 155 | yield* executor.openapi.addSpec( |
| 155 | 156 | makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }), |
| @@ -167,7 +168,9 @@ const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) · 11 unmodified lines | ||
| 167 | 168 | |
| 168 | 169 | const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) => |
| 169 | 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 | 174 | const conn = yield* addOpenApiTestConnection(executor, server, { slug: "f" }); |
| 172 | 175 | return { executor, address: conn.address(LIST_THINGS) }; |
| 173 | 176 | }); |
| @@ -349,7 +352,9 @@ describe("OpenAPI upstream failure modes", () => { · 175 unmodified lines | ||
| 349 | 352 | headers: { "content-type": "application/json" }, |
| 350 | 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 | 358 | // Declare the operation's scope in the spec blob before registering it, |
| 354 | 359 | // so the extracted binding carries requiredScopes. |
| 355 | 360 | const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); |
| @@ -56,6 +56,7 @@ · 55 unmodified lines | ||
| 56 | 56 | "@effect/atom-react": "catalog:", |
| 57 | 57 | "@effect/vitest": "catalog:", |
| 58 | 58 | "@executor-js/api": "workspace:*", |
| 59 | + "@executor-js/product-access": "workspace:*", | |
| 59 | 60 | "@executor-js/react": "workspace:*", |
| 60 | 61 | "@tanstack/react-router": "catalog:", |
| 61 | 62 | "@types/node": "catalog:", |
| @@ -3,11 +3,13 @@ import { Effect, Predicate, Result } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { makeTestExecutor } from "@executor-js/sdk/testing"; |
| 4 | 4 | |
| 5 | 5 | import { toolkitsPlugin } from "./server"; |
| 6 | +import { testAccess } from "@executor-js/product-access/testing"; | |
| 6 | 7 | |
| 7 | 8 | describe("toolkitsPlugin", () => { |
| 8 | 9 | it.effect("creates toolkits and manages ordered policy rules", () => |
| 9 | 10 | Effect.gen(function* () { |
| 10 | 11 | const executor = yield* makeTestExecutor({ |
| 12 | + access: testAccess.member(), | |
| 11 | 13 | plugins: [toolkitsPlugin()] as const, |
| 12 | 14 | }); |
| 13 | 15 | |
| @@ -51,6 +53,7 @@ describe("toolkitsPlugin", () => { · 37 unmodified lines | ||
| 51 | 53 | it.effect("rejects duplicate visible slugs", () => |
| 52 | 54 | Effect.gen(function* () { |
| 53 | 55 | const executor = yield* makeTestExecutor({ |
| 56 | + access: testAccess.member(), | |
| 54 | 57 | plugins: [toolkitsPlugin()] as const, |
| 55 | 58 | }); |
| 56 | 59 | yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" }); |
| @@ -67,6 +70,7 @@ describe("toolkitsPlugin", () => { · 10 unmodified lines | ||
| 67 | 70 | it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => |
| 68 | 71 | Effect.gen(function* () { |
| 69 | 72 | const executor = yield* makeTestExecutor({ |
| 73 | + access: testAccess.member(), | |
| 70 | 74 | plugins: [toolkitsPlugin()] as const, |
| 71 | 75 | }); |
| 72 | 76 | |
| @@ -135,6 +139,7 @@ describe("toolkitsPlugin", () => { · 62 unmodified lines | ||
| 135 | 139 | it.effect("treats a persisted connection-root approve as an access policy", () => |
| 136 | 140 | Effect.gen(function* () { |
| 137 | 141 | const executor = yield* makeTestExecutor({ |
| 142 | + access: testAccess.member(), | |
| 138 | 143 | plugins: [toolkitsPlugin()] as const, |
| 139 | 144 | }); |
| 140 | 145 | |
| @@ -169,6 +174,7 @@ describe("toolkitsPlugin", () => { · 28 unmodified lines | ||
| 169 | 174 | it.effect("applies a broad approve policy over a narrower connection", () => |
| 170 | 175 | Effect.gen(function* () { |
| 171 | 176 | const executor = yield* makeTestExecutor({ |
| 177 | + access: testAccess.member(), | |
| 172 | 178 | plugins: [toolkitsPlugin()] as const, |
| 173 | 179 | }); |
| 174 | 180 | |
| @@ -36,6 +36,7 @@ · 35 unmodified lines | ||
| 36 | 36 | "@base-ui/react": "^1.3.0", |
| 37 | 37 | "@effect/atom-react": "catalog:", |
| 38 | 38 | "@executor-js/api": "workspace:*", |
| 39 | + "@executor-js/product-access": "workspace:*", | |
| 39 | 40 | "@executor-js/sdk": "workspace:*", |
| 40 | 41 | "@hookform/resolvers": "^5.2.2", |
| 41 | 42 | "@lobehub/icons": "^5.4.0", |
| @@ -10,10 +10,10 @@ import { · 9 unmodified lines | ||
| 10 | 10 | AuthTemplateSlug, |
| 11 | 11 | IntegrationSlug, |
| 12 | 12 | ToolAddress, |
| 13 | − effectivePolicyFromSorted, | |
| 14 | 13 | type Connection, |
| 15 | 14 | type Owner, |
| 16 | 15 | } from "@executor-js/sdk/shared"; |
| 16 | +import { effectivePolicyFromSorted } from "@executor-js/product-access/policy"; | |
| 17 | 17 | import { |
| 18 | 18 | checkConnectionHealth, |
| 19 | 19 | connectionsAllAtom, |
| @@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | −import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared"; | |
| 5 | +import { ToolAddress } from "@executor-js/sdk/shared"; | |
| 6 | +import { effectivePolicyFromSorted } from "@executor-js/product-access/policy"; | |
| 6 | 7 | |
| 7 | 8 | import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; |
| 8 | 9 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| @@ -31,6 +31,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 30 unmodified lines | ||
| 31 | 31 | "packages/kernel/core", |
| 32 | 32 | "packages/kernel/runtime-quickjs", |
| 33 | 33 | "packages/core/sdk", |
| 34 | + "packages/core/product-access", | |
| 34 | 35 | "packages/core/config", |
| 35 | 36 | "packages/core/execution", |
| 36 | 37 | "packages/core/cli", |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | * name (`@executor-js/sdk`) instead of relying on workspace resolution. |
| 8 | 8 | */ |
| 9 | 9 | import { $ } from "bun"; |
| 10 | +import { Schema } from "effect"; | |
| 10 | 11 | import { existsSync, readdirSync } from "node:fs"; |
| 11 | 12 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -14,10 +15,15 @@ import { dirname, join, resolve } from "node:path"; · 1 unmodified line | ||
| 14 | 15 | import { fileURLToPath } from "node:url"; |
| 15 | 16 | |
| 16 | 17 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 17 | −const documentedPackages = ["@executor-js/sdk", "@executor-js/plugin-openapi"] as const; | |
| 18 | +const documentedPackages = [ | |
| 19 | + "@executor-js/sdk", | |
| 20 | + "@executor-js/product-access", | |
| 21 | + "@executor-js/plugin-openapi", | |
| 22 | +] as const; | |
| 18 | 23 | const publicPackageDirs = [ |
| 19 | 24 | "packages/core/fumadb", |
| 20 | 25 | "packages/core/sdk", |
| 26 | + "packages/core/product-access", | |
| 21 | 27 | "packages/core/config", |
| 22 | 28 | "packages/plugins/openapi", |
| 23 | 29 | ] as const; |
| @@ -27,6 +33,21 @@ const readPackageName = async (pkgDir: string): Promise<string> => { · 3 unmodified lines | ||
| 27 | 33 | return (JSON.parse(raw) as { name: string }).name; |
| 28 | 34 | }; |
| 29 | 35 | |
| 36 | +// `effect` is the SDK's (and product-access's) required peer, and the docs | |
| 37 | +// install command tells consumers to add it themselves. `npm install | |
| 38 | +// --legacy-peer-deps` below skips auto-installing peers, so the fixture must | |
| 39 | +// declare it like a real consumer would — pinned from the workspace catalog | |
| 40 | +// so it matches what the packed manifests resolved `catalog:` to. | |
| 41 | +const decodeCatalog = Schema.decodeUnknownSync( | |
| 42 | + Schema.Struct({ catalog: Schema.Struct({ effect: Schema.NonEmptyString }) }), | |
| 43 | +); | |
| 44 | + | |
| 45 | +const readCatalogEffectVersion = async (): Promise<string> => { | |
| 46 | + const raw = await readFile(join(repoRoot, "package.json"), "utf8"); | |
| 47 | + const manifest = decodeCatalog(JSON.parse(raw)); | |
| 48 | + return manifest.catalog.effect; | |
| 49 | +}; | |
| 50 | + | |
| 30 | 51 | const findTarball = (pkgDir: string, packageName: string): string => { |
| 31 | 52 | const tarball = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz")); |
| 32 | 53 | if (!tarball) { |
| @@ -48,7 +69,7 @@ for (const relDir of publicPackageDirs) { · 15 unmodified lines | ||
| 48 | 69 | const tmp = await mkdtemp(join(tmpdir(), "executor-docs-install-")); |
| 49 | 70 | |
| 50 | 71 | try { |
| 51 | − const dependencies: Record<string, string> = {}; | |
| 72 | + const dependencies: Record<string, string> = { effect: await readCatalogEffectVersion() }; | |
| 52 | 73 | const overrides: Record<string, string> = {}; |
| 53 | 74 | for (const [name, tarball] of tarballs) { |
| 54 | 75 | overrides[name] = `file:${tarball}`; |
| @@ -89,9 +110,23 @@ try { · 34 unmodified lines | ||
| 89 | 110 | } |
| 90 | 111 | |
| 91 | 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( | |
| 93 | − tmp, | |
| 94 | − ); | |
| 113 | + // The documented minimal embed: the SDK root export is the Promise façade | |
| 114 | + // and ships no access posture of its own, so the probe constructs a | |
| 115 | + // subject-less workspace-service executor with the posture from | |
| 116 | + // `@executor-js/product-access` (ephemeral in-memory backend, nothing | |
| 117 | + // invoked over the network) and closes it. | |
| 118 | + const importProbe = [ | |
| 119 | + `const sdk = await import("@executor-js/sdk");`, | |
| 120 | + `const access = await import("@executor-js/product-access");`, | |
| 121 | + `const openapi = await import("@executor-js/plugin-openapi");`, | |
| 122 | + `if (typeof sdk.createExecutor !== "function") throw new Error("missing createExecutor");`, | |
| 123 | + `if (typeof access.workspaceServiceAccess !== "function") throw new Error("missing workspaceServiceAccess");`, | |
| 124 | + `if (typeof openapi.openApiPlugin !== "function") throw new Error("missing openApiPlugin");`, | |
| 125 | + `const executor = await sdk.createExecutor({ access: access.workspaceServiceAccess(), onElicitation: "accept-all" });`, | |
| 126 | + `if (typeof executor.close !== "function") throw new Error("missing executor.close");`, | |
| 127 | + `await executor.close();`, | |
| 128 | + ].join("\n"); | |
| 129 | + await $`node --input-type=module --eval ${importProbe}`.cwd(tmp); | |
| 95 | 130 | } finally { |
| 96 | 131 | await rm(tmp, { recursive: true, force: true }); |
| 97 | 132 | } |
| @@ -37,6 +37,7 @@ const PUBLIC_PACKAGE_DIRS = [ · 36 unmodified lines | ||
| 37 | 37 | "packages/kernel/core", |
| 38 | 38 | "packages/kernel/runtime-quickjs", |
| 39 | 39 | "packages/core/sdk", |
| 40 | + "packages/core/product-access", | |
| 40 | 41 | "packages/core/config", |
| 41 | 42 | "packages/core/execution", |
| 42 | 43 | "packages/core/cli", |