Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/cloud/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Cloud-side `HttpApi` definition. Pulled out of `layers.ts` /
// `protected-layers.ts` so handler files can reference it without
// creating a cycle (handlers → layers → handlers).

import { CoreExecutorApi, InternalError } from "@executor/api";
import { OpenApiGroup } from "@executor/plugin-openapi/api";
import { McpGroup } from "@executor/plugin-mcp/api";
import { GraphqlGroup } from "@executor/plugin-graphql/api";

import { OrgAuth, ScopeForbidden } from "../auth/middleware";

export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
.add(McpGroup)
.add(GraphqlGroup)
.addError(InternalError)
.addError(ScopeForbidden)
.middleware(OrgAuth);
54 changes: 54 additions & 0 deletions apps/cloud/src/api/handlers/connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";
import type { ConnectionRef } from "@executor/sdk";

import { capture } from "@executor/api";
import { ExecutorService } from "@executor/api/server";

import { assertScopeAccess } from "../../auth/scope-access";
import { ProtectedCloudApi } from "../api";

const refToResponse = (ref: ConnectionRef) => ({
id: ref.id,
scopeId: ref.scopeId,
provider: ref.provider,
kind: ref.kind,
identityLabel: ref.identityLabel,
accessTokenSecretId: ref.accessTokenSecretId,
refreshTokenSecretId: ref.refreshTokenSecretId,
expiresAt: ref.expiresAt,
oauthScope: ref.oauthScope,
createdAt: ref.createdAt.getTime(),
updatedAt: ref.updatedAt.getTime(),
});

export const ConnectionsHandlers = HttpApiBuilder.group(
ProtectedCloudApi,
"connections",
(handlers) =>
handlers
.handle("list", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const refs = yield* executor.connections.list();
return refs.map(refToResponse);
}),
);
}),
)
.handle("remove", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
yield* executor.connections.remove(path.connectionId);
return { removed: true };
}),
);
}),
),
);
76 changes: 76 additions & 0 deletions apps/cloud/src/api/handlers/executions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";

import { capture, captureEngineError } from "@executor/api";
import { ExecutionEngineService } from "@executor/api/server";
import { formatExecuteResult, formatPausedExecution } from "@executor/execution";

import { ProtectedCloudApi } from "../api";

// `/executions/...` — no scopeId path param. The engine is already
// bound to the request-scoped executor, which is pinned to the
// session's org at bootstrap.
export const ExecutionsHandlers = HttpApiBuilder.group(ProtectedCloudApi, "executions", (handlers) =>
handlers
.handle("execute", ({ payload }) =>
capture(
Effect.gen(function* () {
const engine = yield* ExecutionEngineService;
const outcome = yield* captureEngineError(engine.executeWithPause(payload.code));

if (outcome.status === "completed") {
const formatted = formatExecuteResult(outcome.result);
return {
status: "completed" as const,
text: formatted.text,
structured: formatted.structured,
isError: formatted.isError,
};
}

const formatted = formatPausedExecution(outcome.execution);
return {
status: "paused" as const,
text: formatted.text,
structured: formatted.structured,
};
}),
),
)
.handle("resume", ({ path, payload }) =>
capture(
Effect.gen(function* () {
const engine = yield* ExecutionEngineService;
const result = yield* captureEngineError(
engine.resume(path.executionId, {
action: payload.action,
content: payload.content as Record<string, unknown> | undefined,
}),
);

if (!result) {
return yield* Effect.fail({
_tag: "ExecutionNotFoundError" as const,
executionId: path.executionId,
});
}

if (result.status === "completed") {
const formatted = formatExecuteResult(result.result);
return {
text: formatted.text,
structured: formatted.structured,
isError: formatted.isError,
};
}

const formatted = formatPausedExecution(result.execution);
return {
text: formatted.text,
structured: formatted.structured,
isError: false,
};
}),
),
),
);
28 changes: 28 additions & 0 deletions apps/cloud/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Cloud-side handler layer. Replaces `CoreHandlers` from
// `@executor/api/server` with copies that call
// `assertScopeAccess(path.scopeId)` before touching the request-scoped
// executor. The check compares the decoded `ScopeId` from the URL
// against `AuthContext` — so a caller authenticated as orgB who hits
// `/scopes/orgA/...` gets a typed `ScopeForbidden` (403) before any
// business logic runs.
//
// Endpoints without a `scopeId` in their route (`/scope`, `/executions/...`)
// skip the check — the executor is already pinned to the session org.

import { Layer } from "effect";

import { ToolsHandlers } from "./tools";
import { SourcesHandlers } from "./sources";
import { SecretsHandlers } from "./secrets";
import { ConnectionsHandlers } from "./connections";
import { ScopeHandlers } from "./scope";
import { ExecutionsHandlers } from "./executions";

export const CloudCoreHandlers = Layer.mergeAll(
ToolsHandlers,
SourcesHandlers,
SecretsHandlers,
ConnectionsHandlers,
ScopeHandlers,
ExecutionsHandlers,
);
26 changes: 26 additions & 0 deletions apps/cloud/src/api/handlers/scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";

import { capture } from "@executor/api";
import { ExecutorService } from "@executor/api/server";

import { ProtectedCloudApi } from "../api";

// `/scope` — no scopeId path param, so no `assertScopeAccess` call.
// Returns the caller's authenticated scope (the one the request-scoped
// executor was built for), which is already pinned to the session.
export const ScopeHandlers = HttpApiBuilder.group(ProtectedCloudApi, "scope", (handlers) =>
handlers.handle("info", () =>
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const scope = executor.scopes.at(-1)!;
return {
id: scope.id,
name: scope.name,
dir: scope.name,
};
}),
),
),
);
92 changes: 92 additions & 0 deletions apps/cloud/src/api/handlers/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";
import { SecretNotFoundError, SetSecretInput, type SecretRef } from "@executor/sdk";

import { capture } from "@executor/api";
import { ExecutorService } from "@executor/api/server";

import { assertScopeAccess } from "../../auth/scope-access";
import { ProtectedCloudApi } from "../api";

const refToResponse = (ref: SecretRef) => ({
id: ref.id,
scopeId: ref.scopeId,
name: ref.name,
provider: ref.provider,
createdAt: ref.createdAt.getTime(),
});

export const SecretsHandlers = HttpApiBuilder.group(ProtectedCloudApi, "secrets", (handlers) =>
handlers
.handle("list", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const refs = yield* executor.secrets.list();
return refs.map(refToResponse);
}),
);
}),
)
.handle("status", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const status = yield* executor.secrets.status(path.secretId);
return { secretId: path.secretId, status };
}),
);
}),
)
.handle("set", ({ path, payload }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const ref = yield* executor.secrets.set(
new SetSecretInput({
id: payload.id,
scope: path.scopeId,
name: payload.name,
value: payload.value,
provider: payload.provider,
}),
);
return refToResponse(ref);
}),
);
}),
)
.handle("resolve", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const value = yield* executor.secrets.get(path.secretId);
if (value === null) {
return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));
}
return { secretId: path.secretId, value };
}),
);
}),
)
.handle("remove", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
yield* executor.secrets.remove(path.secretId);
return { removed: true };
}),
);
}),
),
);
95 changes: 95 additions & 0 deletions apps/cloud/src/api/handlers/sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";
import { ToolId } from "@executor/sdk";

import { capture } from "@executor/api";
import { ExecutorService } from "@executor/api/server";

import { assertScopeAccess } from "../../auth/scope-access";
import { ProtectedCloudApi } from "../api";

export const SourcesHandlers = HttpApiBuilder.group(ProtectedCloudApi, "sources", (handlers) =>
handlers
.handle("list", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const sources = yield* executor.sources.list();
return sources.map((s) => ({
id: s.id,
name: s.name,
kind: s.kind,
url: s.url,
runtime: s.runtime,
canRemove: s.canRemove,
canRefresh: s.canRefresh,
canEdit: s.canEdit,
}));
}),
);
}),
)
.handle("remove", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
yield* executor.sources.remove(path.sourceId);
return { removed: true };
}),
);
}),
)
.handle("refresh", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
yield* executor.sources.refresh(path.sourceId);
return { refreshed: true };
}),
);
}),
)
.handle("tools", ({ path }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const tools = yield* executor.tools.list({ sourceId: path.sourceId });
return tools.map((t) => ({
id: ToolId.make(t.id),
pluginId: t.pluginId,
sourceId: t.sourceId,
name: t.name,
description: t.description,
mayElicit: t.annotations?.mayElicit,
}));
}),
);
}),
)
.handle("detect", ({ path, payload }) =>
Effect.gen(function* () {
yield* assertScopeAccess(path.scopeId);
return yield* capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const results = yield* executor.sources.detect(payload.url);
return results.map((r) => ({
kind: r.kind,
confidence: r.confidence,
endpoint: r.endpoint,
name: r.name,
namespace: r.namespace,
}));
}),
);
}),
),
);
Loading
Loading