import { HttpApiBuilder } from "effect/unstable/httpapi";import { HttpRouter, HttpServer } from "effect/unstable/http";import { describe, expect, it } from "@effect/vitest";import { Context, Effect, Layer } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName, createExecutor, definePlugin, type Executor,} from "@executor-js/sdk";import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { ExecutorApi } from "./api";import { observabilityMiddleware } from "./observability";import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; // ---------------------------------------------------------------------------// v2 owner-scoped API behaviour.//// v1's "explicit target scope" tests gated writes by a route scope vs a payload// `targetScope`, and exercised the `[user, org]` scope-stack shadowing rules.// v2 has neither: the executor binds `{ tenant, subject }` from auth, addresses// name their owner explicitly (`tools.<int>.<owner>.<conn>.<tool>`), and there is// no shadowing (D12) — an org connection and a user connection are DISTINCT rows// with distinct addresses. These ports keep the spirit (writes target an owner,// owner rows are independent) against the real v2 surface.// --------------------------------------------------------------------------- // removed: "policy update uses the row target scope instead of the route read// scope" — v2 policies have no route read-scope vs payload target-scope split;// they are owner-scoped. The owner-scoped create/update path is covered below.// removed: "OAuth start requires the route scope to match the requested token// scope" and "OAuth complete requires the route scope to match the pending// session scope" — v2 OAuth carries no scope segment; start/complete are stubbed// in the SDK (milestone 2) and have no scope-matching gate to assert. const webHandlerFor = (executor: Executor) => Effect.acquireRelease( Effect.sync(() => HttpRouter.toWebHandler( HttpApiBuilder.layer(ExecutorApi).pipe( Layer.provide(CoreHandlers), Layer.provide(observabilityMiddleware(ExecutorApi)), Layer.provide(Layer.succeed(ExecutorService)(executor)), Layer.provide( Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), ), Layer.provideMerge(HttpServer.layerServices), Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), ), { disableLogger: true }, ), ), (web) => Effect.promise(() => web.dispose()), ); const handlerContextFor = (executor: Executor) => Context.make(ExecutorService, executor).pipe( Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), ); const INTEGRATION = IntegrationSlug.make("vercel"); // A plugin that owns the `vercel` integration and produces one tool per// connection so the per-connection address scheme is exercised.const vercelPlugin = definePlugin(() => ({ id: "vercel" as const, storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }], }), invokeTool: () => Effect.succeed({ ok: true }), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ slug: INTEGRATION, description: "Vercel", config: {}, }), }),}))(); describe("core API owner-scoped writes (v2)", () => { it.effect("policy create + update target an explicit owner", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({})); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); const createResponse = yield* Effect.promise(() => web.handler( new Request("http://localhost/policies", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ owner: "org", pattern: "vercel.*", action: "require_approval", }), }), context, ), ); expect(createResponse.status).toBe(200); const created = (yield* Effect.promise(() => createResponse.json())) as { id: string; }; const updateResponse = yield* Effect.promise(() => web.handler( new Request(`http://localhost/policies/${encodeURIComponent(created.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ owner: "org", action: "block" }), }), context, ), ); expect(updateResponse.status).toBe(200); const policies = yield* executor.policies.list(); expect(policies[0]).toMatchObject({ id: created.id, owner: "org", action: "block", }); }), ); it.effect("connection remove deletes the named owner row, not the other owner", () => Effect.gen(function* () { const config = makeTestConfig({ plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); yield* executor.vercel.seed(); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); const name = ConnectionName.make("default"); yield* executor.connections.create({ owner: "org", name, integration: INTEGRATION, template: AuthTemplateSlug.make("apiKey"), value: "org-token", }); yield* executor.connections.create({ owner: "user", name, integration: INTEGRATION, template: AuthTemplateSlug.make("apiKey"), value: "user-token", }); const response = yield* Effect.promise(() => web.handler( new Request(`http://localhost/connections/org/${INTEGRATION}/${name}`, { method: "DELETE", }), context, ), ); expect(response.status).toBe(200); // The org row is gone; the user row survives (no shadowing — distinct rows). const remaining = yield* executor.connections.list({ integration: INTEGRATION, }); expect(remaining.map((c) => c.owner).sort()).toEqual(["user"]); }), ); it.effect("connection create accepts pasted values payloads", () => Effect.gen(function* () { const config = makeTestConfig({ plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); yield* executor.vercel.seed(); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); const response = yield* Effect.promise(() => web.handler( new Request("http://localhost/connections", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ owner: "user", name: "api-key", integration: "vercel", template: "apiKey", values: { token: "user-token" }, }), }), context, ), ); expect(response.status).toBe(200); const body = (yield* Effect.promise(() => response.json())) as { readonly owner: string; readonly name: string; readonly provider: string; }; expect(body).toMatchObject({ owner: "user", name: "apiKey", provider: "memory", }); }), ); it.effect("connection list returns both owners' rows under one integration", () => Effect.gen(function* () { const config = makeTestConfig({ plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); yield* executor.vercel.seed(); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); yield* executor.connections.create({ owner: "org", name: ConnectionName.make("default"), integration: INTEGRATION, template: AuthTemplateSlug.make("apiKey"), value: "org-token", }); yield* executor.connections.create({ owner: "user", name: ConnectionName.make("personal"), integration: INTEGRATION, template: AuthTemplateSlug.make("apiKey"), value: "user-token", }); const response = yield* Effect.promise(() => web.handler(new Request("http://localhost/connections", { method: "GET" }), context), ); expect(response.status).toBe(200); const body = (yield* Effect.promise(() => response.json())) as ReadonlyArray<{ readonly owner: string; readonly name: string; }>; expect(body.map((c) => `${c.owner}:${c.name}`).sort()).toEqual([ "org:default", "user:personal", ]); }), );});
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { HttpRouter, HttpServer } from "effect/unstable/http";
import { describe, expect, it } from "@effect/vitest";
import { Context, Effect, Layer } from "effect";
import {
AuthTemplateSlug,
ConnectionName,
IntegrationSlug,
ToolName,
createExecutor,
definePlugin,
type Executor,
} from "@executor-js/sdk";
import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
import { ExecutorApi } from "./api";
import { observabilityMiddleware } from "./observability";
import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server";
// ---------------------------------------------------------------------------
// v2 owner-scoped API behaviour.
//
// v1's "explicit target scope" tests gated writes by a route scope vs a payload
// `targetScope`, and exercised the `[user, org]` scope-stack shadowing rules.
// v2 has neither: the executor binds `{ tenant, subject }` from auth, addresses
// name their owner explicitly (`tools.<int>.<owner>.<conn>.<tool>`), and there is
// no shadowing (D12) — an org connection and a user connection are DISTINCT rows
// with distinct addresses. These ports keep the spirit (writes target an owner,
// owner rows are independent) against the real v2 surface.
// ---------------------------------------------------------------------------
// removed: "policy update uses the row target scope instead of the route read
// scope" — v2 policies have no route read-scope vs payload target-scope split;
// they are owner-scoped. The owner-scoped create/update path is covered below.
// removed: "OAuth start requires the route scope to match the requested token
// scope" and "OAuth complete requires the route scope to match the pending
// session scope" — v2 OAuth carries no scope segment; start/complete are stubbed
// in the SDK (milestone 2) and have no scope-matching gate to assert.
const webHandlerFor = (executor: Executor) =>
Effect.acquireRelease(
Effect.sync(() =>
HttpRouter.toWebHandler(
HttpApiBuilder.layer(ExecutorApi).pipe(
Layer.provide(CoreHandlers),
Layer.provide(observabilityMiddleware(ExecutorApi)),
Layer.provide(Layer.succeed(ExecutorService)(executor)),
Layer.provide(
Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]),
),
Layer.provideMerge(HttpServer.layerServices),
Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
),
{ disableLogger: true },
),
),
(web) => Effect.promise(() => web.dispose()),
);
const handlerContextFor = (executor: Executor) =>
Context.make(ExecutorService, executor).pipe(
Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]),
);
const INTEGRATION = IntegrationSlug.make("vercel");
// A plugin that owns the `vercel` integration and produces one tool per
// connection so the per-connection address scheme is exercised.
const vercelPlugin = definePlugin(() => ({
id: "vercel" as const,
storage: () => ({}),
resolveTools: () =>
Effect.succeed({
tools: [{ name: ToolName.make("deploy"), description: "deploy" }],
}),
invokeTool: () => Effect.succeed({ ok: true }),
extension: (ctx) => ({
seed: () =>
ctx.core.integrations.register({
slug: INTEGRATION,
description: "Vercel",
config: {},
}),
}),
}))();
describe("core API owner-scoped writes (v2)", () => {
it.effect("policy create + update target an explicit owner", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(makeTestConfig({}));
const web = yield* webHandlerFor(executor);
const context = handlerContextFor(executor);
const createResponse = yield* Effect.promise(() =>
web.handler(
new Request("http://localhost/policies", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
owner: "org",
pattern: "vercel.*",
action: "require_approval",
}),
}),
context,
),
);
expect(createResponse.status).toBe(200);
const created = (yield* Effect.promise(() => createResponse.json())) as {
id: string;
};
const updateResponse = yield* Effect.promise(() =>
web.handler(
new Request(`http://localhost/policies/${encodeURIComponent(created.id)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ owner: "org", action: "block" }),
}),
context,
),
);
expect(updateResponse.status).toBe(200);
const policies = yield* executor.policies.list();
expect(policies[0]).toMatchObject({
id: created.id,
owner: "org",
action: "block",
});
}),
);
it.effect("connection remove deletes the named owner row, not the other owner", () =>
Effect.gen(function* () {
const config = makeTestConfig({
plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
});
const executor = yield* createExecutor(config);
yield* executor.vercel.seed();
const web = yield* webHandlerFor(executor);
const context = handlerContextFor(executor);
const name = ConnectionName.make("default");
yield* executor.connections.create({
owner: "org",
name,
integration: INTEGRATION,
template: AuthTemplateSlug.make("apiKey"),
value: "org-token",
});
yield* executor.connections.create({
owner: "user",
name,
integration: INTEGRATION,
template: AuthTemplateSlug.make("apiKey"),
value: "user-token",
});
const response = yield* Effect.promise(() =>
web.handler(
new Request(`http://localhost/connections/org/${INTEGRATION}/${name}`, {
method: "DELETE",
}),
context,
),
);
expect(response.status).toBe(200);
// The org row is gone; the user row survives (no shadowing — distinct rows).
const remaining = yield* executor.connections.list({
integration: INTEGRATION,
});
expect(remaining.map((c) => c.owner).sort()).toEqual(["user"]);
}),
);
it.effect("connection create accepts pasted values payloads", () =>
Effect.gen(function* () {
const config = makeTestConfig({
plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
});
const executor = yield* createExecutor(config);
yield* executor.vercel.seed();
const web = yield* webHandlerFor(executor);
const context = handlerContextFor(executor);
const response = yield* Effect.promise(() =>
web.handler(
new Request("http://localhost/connections", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
owner: "user",
name: "api-key",
integration: "vercel",
template: "apiKey",
values: { token: "user-token" },
}),
}),
context,
),
);
expect(response.status).toBe(200);
const body = (yield* Effect.promise(() => response.json())) as {
readonly owner: string;
readonly name: string;
readonly provider: string;
};
expect(body).toMatchObject({
owner: "user",
name: "apiKey",
provider: "memory",
});
}),
);
it.effect("connection list returns both owners' rows under one integration", () =>
Effect.gen(function* () {
const config = makeTestConfig({
plugins: [memoryCredentialsPlugin(), vercelPlugin] as const,
});
const executor = yield* createExecutor(config);
yield* executor.vercel.seed();
const web = yield* webHandlerFor(executor);
const context = handlerContextFor(executor);
yield* executor.connections.create({
owner: "org",
name: ConnectionName.make("default"),
integration: INTEGRATION,
template: AuthTemplateSlug.make("apiKey"),
value: "org-token",
});
yield* executor.connections.create({
owner: "user",
name: ConnectionName.make("personal"),
integration: INTEGRATION,
template: AuthTemplateSlug.make("apiKey"),
value: "user-token",
});
const response = yield* Effect.promise(() =>
web.handler(new Request("http://localhost/connections", { method: "GET" }), context),
);
expect(response.status).toBe(200);
const body = (yield* Effect.promise(() => response.json())) as ReadonlyArray<{
readonly owner: string;
readonly name: string;
}>;
expect(body.map((c) => `${c.owner}:${c.name}`).sort()).toEqual([
"org:default",
"user:personal",
]);
}),
);
});