History

clean up

Rhys Sullivan committed 8 months ago

Commit 2ae1ae81 parent e604ea630 files · +1859 −2554Browse files

Unified · Split

30 files
  1. apps/executor/src/server/index.ts +1 −6
  2. packages/control-plane/src/api/executions/api.ts +2 −1
  3. packages/control-plane/src/api/executions/http.ts +16 −81
  4. packages/control-plane/src/api/http-auth.ts +98 −0
  5. packages/control-plane/src/api/http.ts +11 −6
  6. packages/control-plane/src/api/index.ts +2 −35
  7. packages/control-plane/src/api/local/http.ts +6 −7
  8. packages/control-plane/src/api/memberships/http.ts +20 −92
  9. packages/control-plane/src/api/organizations/api.ts +13 −19
  10. packages/control-plane/src/api/organizations/http.ts +24 −102
  11. packages/control-plane/src/api/payload-schemas.test.ts +86 −0
  12. packages/control-plane/src/api/policies/api.ts +30 −32
  13. packages/control-plane/src/api/policies/http.ts +24 −107
  14. packages/control-plane/src/api/service.ts +0 −343
  15. packages/control-plane/src/api/sources/api.ts +8 −4
  16. packages/control-plane/src/api/sources/http.ts +24 −107
  17. packages/control-plane/src/api/string-schemas.ts +9 −0
  18. packages/control-plane/src/api/workspaces/api.ts +10 −9
  19. packages/control-plane/src/api/workspaces/http.ts +26 −114
  20. packages/control-plane/src/runtime/execution-service.ts +259 −280
  21. packages/control-plane/src/runtime/index.ts +46 −120
  22. packages/control-plane/src/runtime/local-operations.ts +60 −0
  23. packages/control-plane/src/runtime/operation-errors.ts +99 −0
  24. packages/control-plane/src/runtime/operations-shared.ts +102 −0
  25. packages/control-plane/src/runtime/organizations-operations.ts +434 −0
  26. packages/control-plane/src/runtime/policies-operations.ts +197 −0
  27. packages/control-plane/src/runtime/services.ts +0 −1083
  28. packages/control-plane/src/runtime/sources-operations.ts +236 −0
  29. packages/control-plane/src/runtime/store.ts +15 −0
  30. packages/control-plane/src/runtime/test-http-client.ts +1 −6
apps/executor/src/server/index.ts+1−6
@@ -5,7 +5,6 @@ import { dirname } from "node:path"; · 4 unmodified lines
5import { HttpApiBuilder, HttpServer } from "@effect/platform";5import { HttpApiBuilder, HttpServer } from "@effect/platform";
6import { NodeHttpServer } from "@effect/platform-node";6import { NodeHttpServer } from "@effect/platform-node";
7import {7import {
8 ControlPlaneActorResolver,
9 createControlPlaneApiLayer,8 createControlPlaneApiLayer,
10 createSqlControlPlaneRuntime,9 createSqlControlPlaneRuntime,
11 type ResolveExecutionEnvironment,10 type ResolveExecutionEnvironment,
@@ -46,11 +45,7 @@ const createControlPlaneServerLayer = (input: { · 34 unmodified lines
46 host: string;45 host: string;
47 port: number;46 port: number;
48}) => {47}) => {
49 const actorResolverLayer = Layer.succeed(48 const apiLayer = createControlPlaneApiLayer(input.runtime.runtimeLayer);
50 ControlPlaneActorResolver,
51 input.runtime.actorResolver,
52 );
53 const apiLayer = createControlPlaneApiLayer(input.runtime.serviceLayer, actorResolverLayer);
54 49
55 return HttpApiBuilder.serve().pipe(50 return HttpApiBuilder.serve().pipe(
56 Layer.provide(apiLayer),51 Layer.provide(apiLayer),
packages/control-plane/src/api/executions/api.ts+2−1
@@ -13,9 +13,10 @@ import { · 12 unmodified lines
13 ControlPlaneStorageError,13 ControlPlaneStorageError,
14 ControlPlaneUnauthorizedError,14 ControlPlaneUnauthorizedError,
15} from "../errors";15} from "../errors";
16import { TrimmedNonEmptyStringSchema } from "../string-schemas";
16 17
17export const CreateExecutionPayloadSchema = Schema.Struct({18export const CreateExecutionPayloadSchema = Schema.Struct({
18 code: Schema.String,19 code: TrimmedNonEmptyStringSchema,
19});20});
20 21
21export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type;22export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type;
packages/control-plane/src/api/executions/http.ts+16−81
@@ -1,50 +1,15 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import type { WorkspaceId } from "#schema";2import type { WorkspaceId } from "#schema";
4 3
4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 Actor,6 createExecution,
7 ActorForbiddenError,7 getExecution,
8 ActorUnauthenticatedError,8 resumeExecution,
9 requirePermission,9} from "../../runtime/execution-service";
10 withPolicy,
11} from "#domain";
12 10
13import { ControlPlaneApi } from "../api";11import { ControlPlaneApi } from "../api";
14import { ControlPlaneActorResolver } from "../auth/actor-resolver";12import { withWorkspaceRequestActor } from "../http-auth";
15import {
16 ControlPlaneForbiddenError,
17 ControlPlaneUnauthorizedError,
18} from "../errors";
19import { ControlPlaneExecutionsService } from "../service";
20
21const toForbiddenError = (
22 operation: string,
23 cause: ActorForbiddenError,
24): ControlPlaneForbiddenError =>
25 new ControlPlaneForbiddenError({
26 operation,
27 message: "Access denied",
28 details: `${cause.permission} on ${cause.scope}`,
29 });
30
31const toUnauthorizedError = (
32 operation: string,
33 cause: ActorUnauthenticatedError,
34): ControlPlaneUnauthorizedError =>
35 new ControlPlaneUnauthorizedError({
36 operation,
37 message: cause.message,
38 details: "Authentication required",
39 });
40
41const resolveWorkspaceActor = (workspaceId: WorkspaceId) =>
42 Effect.gen(function* () {
43 const actorResolver = yield* ControlPlaneActorResolver;
44 const request = yield* HttpServerRequest.HttpServerRequest;
45
46 return yield* actorResolver.resolveWorkspaceActor({ workspaceId, headers: request.headers });
47 });
48 13
49const requireExecuteWorkspace = (workspaceId: WorkspaceId) =>14const requireExecuteWorkspace = (workspaceId: WorkspaceId) =>
50 requirePermission({15 requirePermission({
@@ -58,65 +23,35 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 7 unmodified lines
58 (handlers) =>23 (handlers) =>
59 handlers24 handlers
60 .handle("create", ({ path, payload }) =>25 .handle("create", ({ path, payload }) =>
61 Effect.gen(function* () {26 withWorkspaceRequestActor("executions.create", path.workspaceId, (actor) =>
62 const service = yield* ControlPlaneExecutionsService;27 withPolicy(requireExecuteWorkspace(path.workspaceId))(
63 const actor = yield* resolveWorkspaceActor(path.workspaceId);28 createExecution({
64
65 return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))(
66 service.createExecution({
67 workspaceId: path.workspaceId,29 workspaceId: path.workspaceId,
68 payload,30 payload,
69 createdByAccountId: actor.principal.accountId,31 createdByAccountId: actor.principal.accountId,
70 }),32 }),
71 ).pipe(Effect.provideService(Actor, actor));
72 }).pipe(
73 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
74 Effect.fail(toUnauthorizedError("executions.create", cause)),
75 ),
76 Effect.catchTag("ActorForbiddenError", (cause) =>
77 Effect.fail(toForbiddenError("executions.create", cause)),
78 ),33 ),
79 ),34 ),
80 )35 )
81 .handle("get", ({ path }) =>36 .handle("get", ({ path }) =>
82 Effect.gen(function* () {37 withWorkspaceRequestActor("executions.get", path.workspaceId, () =>
83 const service = yield* ControlPlaneExecutionsService;38 withPolicy(requireExecuteWorkspace(path.workspaceId))(
84 const actor = yield* resolveWorkspaceActor(path.workspaceId);39 getExecution({
85
86 return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))(
87 service.getExecution({
88 workspaceId: path.workspaceId,40 workspaceId: path.workspaceId,
89 executionId: path.executionId,41 executionId: path.executionId,
90 }),42 }),
91 ).pipe(Effect.provideService(Actor, actor));
92 }).pipe(
93 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
94 Effect.fail(toUnauthorizedError("executions.get", cause)),
95 ),
96 Effect.catchTag("ActorForbiddenError", (cause) =>
97 Effect.fail(toForbiddenError("executions.get", cause)),
98 ),43 ),
99 ),44 ),
100 )45 )
101 .handle("resume", ({ path, payload }) =>46 .handle("resume", ({ path, payload }) =>
102 Effect.gen(function* () {47 withWorkspaceRequestActor("executions.resume", path.workspaceId, (actor) =>
103 const service = yield* ControlPlaneExecutionsService;48 withPolicy(requireExecuteWorkspace(path.workspaceId))(
104 const actor = yield* resolveWorkspaceActor(path.workspaceId);49 resumeExecution({
105
106 return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))(
107 service.resumeExecution({
108 workspaceId: path.workspaceId,50 workspaceId: path.workspaceId,
109 executionId: path.executionId,51 executionId: path.executionId,
110 payload,52 payload,
111 resumedByAccountId: actor.principal.accountId,53 resumedByAccountId: actor.principal.accountId,
112 }),54 }),
113 ).pipe(Effect.provideService(Actor, actor));
114 }).pipe(
115 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
116 Effect.fail(toUnauthorizedError("executions.resume", cause)),
117 ),
118 Effect.catchTag("ActorForbiddenError", (cause) =>
119 Effect.fail(toForbiddenError("executions.resume", cause)),
120 ),55 ),
121 ),56 ),
122 ),57 ),
packages/control-plane/src/api/http-auth.ts+98−0
@@ -0,0 +1,98 @@
1import { HttpServerRequest } from "@effect/platform";
2import type { WorkspaceId } from "#schema";
3import {
4 Actor,
5 ActorForbiddenError,
6 type ActorShape,
7 ActorUnauthenticatedError,
8} from "#domain";
9import * as Effect from "effect/Effect";
10
11import { ControlPlaneActorResolver } from "./auth/actor-resolver";
12import {
13 ControlPlaneForbiddenError,
14 ControlPlaneUnauthorizedError,
15} from "./errors";
16
17const requestHeaders = Effect.map(
18 HttpServerRequest.HttpServerRequest,
19 (request) => request.headers,
20);
21
22const readString = (value: unknown, fallback: string): string =>
23 typeof value === "string" && value.length > 0 ? value : fallback;
24
25const toForbiddenError = (
26 operation: string,
27 cause: unknown,
28): ControlPlaneForbiddenError =>
29 new ControlPlaneForbiddenError({
30 operation,
31 message: "Access denied",
32 details: `${readString((cause as { permission?: unknown })?.permission, "unknown")} on ${readString((cause as { scope?: unknown })?.scope, "unknown")}`,
33 });
34
35const toUnauthorizedError = (
36 operation: string,
37 cause: unknown,
38): ControlPlaneUnauthorizedError =>
39 new ControlPlaneUnauthorizedError({
40 operation,
41 message: readString((cause as { message?: unknown })?.message, "Authentication required"),
42 details: "Authentication required",
43 });
44
45export const resolveRequestActor = Effect.flatMap(
46 ControlPlaneActorResolver,
47 (actorResolver) =>
48 Effect.flatMap(requestHeaders, (headers) => actorResolver.resolveActor({ headers })),
49);
50
51export const resolveWorkspaceRequestActor = (workspaceId: WorkspaceId) =>
52 Effect.flatMap(ControlPlaneActorResolver, (actorResolver) =>
53 Effect.flatMap(requestHeaders, (headers) =>
54 actorResolver.resolveWorkspaceActor({ workspaceId, headers })
55 ));
56
57const provideActor = <A, E, R>(
58 actor: ActorShape,
59 effect: Effect.Effect<A, E, R>,
60) => effect.pipe(Effect.provideService(Actor, actor));
61
62export const mapActorHttpErrors = <A, E, R>(
63 operation: string,
64 effect: Effect.Effect<A, E | ActorUnauthenticatedError | ActorForbiddenError, R>,
65): Effect.Effect<A, E | ControlPlaneUnauthorizedError | ControlPlaneForbiddenError, R> =>
66 effect.pipe(
67 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
68 Effect.fail(toUnauthorizedError(operation, cause)),
69 ),
70 Effect.catchTag("ActorForbiddenError", (cause) =>
71 Effect.fail(toForbiddenError(operation, cause)),
72 ),
73 );
74
75export const withRequestActor = <A, E, R>(
76 operation: string,
77 run: (actor: ActorShape) => Effect.Effect<A, E | ActorForbiddenError, R | Actor>,
78) =>
79 mapActorHttpErrors(
80 operation,
81 Effect.gen(function* () {
82 const actor = yield* resolveRequestActor;
83 return yield* provideActor(actor, run(actor));
84 }),
85 );
86
87export const withWorkspaceRequestActor = <A, E, R>(
88 operation: string,
89 workspaceId: WorkspaceId,
90 run: (actor: ActorShape) => Effect.Effect<A, E | ActorForbiddenError, R | Actor>,
91) =>
92 mapActorHttpErrors(
93 operation,
94 Effect.gen(function* () {
95 const actor = yield* resolveWorkspaceRequestActor(workspaceId);
96 return yield* provideActor(actor, run(actor));
97 }),
98 );
packages/control-plane/src/api/http.ts+11−6
@@ -11,7 +11,6 @@ import { ControlPlaneLocalLive } from "./local/http"; · 10 unmodified lines
11import { ControlPlaneMembershipsLive } from "./memberships/http";11import { ControlPlaneMembershipsLive } from "./memberships/http";
12import { ControlPlaneOrganizationsLive } from "./organizations/http";12import { ControlPlaneOrganizationsLive } from "./organizations/http";
13import { ControlPlanePoliciesLive } from "./policies/http";13import { ControlPlanePoliciesLive } from "./policies/http";
14import { type ControlPlaneApiServiceContext } from "./service";
15import { ControlPlaneSourcesLive } from "./sources/http";14import { ControlPlaneSourcesLive } from "./sources/http";
16import { ControlPlaneWorkspacesLive } from "./workspaces/http";15import { ControlPlaneWorkspacesLive } from "./workspaces/http";
17 16
@@ -25,13 +24,19 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines
25 Layer.provide(ControlPlaneExecutionsLive),24 Layer.provide(ControlPlaneExecutionsLive),
26);25);
27 26
28export const createControlPlaneApiLayer = <EService, EResolver>(27export type ControlPlaneApiRuntimeContext = Layer.Layer.Context<typeof ControlPlaneApiLive>;
29 serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, EService, never>,28
30 actorResolverLayer: Layer.Layer<ControlPlaneActorResolver, EResolver, never>,29export type BuiltControlPlaneApiLayer = Layer.Layer<
30 Layer.Layer.Success<typeof ControlPlaneApiLive>,
31 Layer.Layer.Error<typeof ControlPlaneApiLive>,
32 never
33>;
34
35export const createControlPlaneApiLayer = <ERuntime>(
36 runtimeLayer: Layer.Layer<ControlPlaneApiRuntimeContext, ERuntime, never>,
31) =>37) =>
32 ControlPlaneApiLive.pipe(38 ControlPlaneApiLive.pipe(
33 Layer.provide(serviceLayer),39 Layer.provide(runtimeLayer),
34 Layer.provide(actorResolverLayer),
35 );40 );
36 41
37export const ControlPlaneActorResolverLive = (42export const ControlPlaneActorResolverLive = (
packages/control-plane/src/api/index.ts+2−35
@@ -13,43 +13,10 @@ export { · 12 unmodified lines
13 ControlPlaneUnauthorizedError,13 ControlPlaneUnauthorizedError,
14} from "./errors";14} from "./errors";
15 15
16export {
17 ControlPlaneOrganizationsService,
18 type ControlPlaneOrganizationsServiceShape,
19 ControlPlaneMembershipsService,
20 type ControlPlaneMembershipsServiceShape,
21 ControlPlaneWorkspacesService,
22 type ControlPlaneWorkspacesServiceShape,
23 ControlPlaneSourcesService,
24 type ControlPlaneSourcesServiceShape,
25 ControlPlaneExecutionsService,
26 type ControlPlaneExecutionsServiceShape,
27 ControlPlaneLocalService,
28 type ControlPlaneLocalServiceShape,
29 ControlPlanePoliciesService,
30 type ControlPlanePoliciesServiceShape,
31 type ControlPlaneApiServiceContext,
32 type CreateOrganizationInput,
33 type UpdateOrganizationInput,
34 type RemoveOrganizationInput,
35 type CreateMembershipInput,
36 type UpdateMembershipInput,
37 type RemoveMembershipInput,
38 type CreateWorkspaceInput,
39 type UpdateWorkspaceInput,
40 type RemoveWorkspaceInput,
41 type CreateSourceInput,
42 type GetSourceInput,
43 type UpdateSourceInput,
44 type RemoveSourceInput,
45 type CreatePolicyInput,
46 type GetPolicyInput,
47 type UpdatePolicyInput,
48 type RemovePolicyInput,
49} from "./service";
50
51export {16export {
52 ControlPlaneApiLive,17 ControlPlaneApiLive,
18 type ControlPlaneApiRuntimeContext,
19 type BuiltControlPlaneApiLayer,
53 ControlPlaneActorResolverLive,20 ControlPlaneActorResolverLive,
54 createControlPlaneApiLayer,21 createControlPlaneApiLayer,
55} from "./http";22} from "./http";
packages/control-plane/src/api/local/http.ts+6−7
@@ -1,8 +1,11 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import {
4 completeSourceAuthCallback,
5 getLocalInstallation,
6} from "../../runtime/local-operations";
3 7
4import { ControlPlaneApi } from "../api";8import { ControlPlaneApi } from "../api";
5import { ControlPlaneLocalService } from "../service";
6 9
7export const ControlPlaneLocalLive = HttpApiBuilder.group(10export const ControlPlaneLocalLive = HttpApiBuilder.group(
8 ControlPlaneApi,11 ControlPlaneApi,
@@ -10,18 +13,14 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 1 unmodified line
10 (handlers) =>13 (handlers) =>
11 handlers14 handlers
12 .handle("installation", () =>15 .handle("installation", () =>
13 Effect.gen(function* () {16 getLocalInstallation(),
14 const service = yield* ControlPlaneLocalService;
15 return yield* service.getLocalInstallation();
16 }),
17 )17 )
18 .handle("oauthCallback", () =>18 .handle("oauthCallback", () =>
19 Effect.gen(function* () {19 Effect.gen(function* () {
20 const service = yield* ControlPlaneLocalService;
21 const request = yield* HttpServerRequest.HttpServerRequest;20 const request = yield* HttpServerRequest.HttpServerRequest;
22 const requestUrl = new URL(request.url, "http://127.0.0.1");21 const requestUrl = new URL(request.url, "http://127.0.0.1");
23 22
24 const source = yield* service.completeSourceAuthCallback({23 const source = yield* completeSourceAuthCallback({
25 state: requestUrl.searchParams.get("state") ?? "",24 state: requestUrl.searchParams.get("state") ?? "",
26 code: requestUrl.searchParams.get("code"),25 code: requestUrl.searchParams.get("code"),
27 error: requestUrl.searchParams.get("error"),26 error: requestUrl.searchParams.get("error"),
packages/control-plane/src/api/memberships/http.ts+20−92
@@ -1,48 +1,16 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import type { OrganizationId } from "#schema";2import type { OrganizationId } from "#schema";
4 3
4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 Actor,6 createMembership,
7 ActorForbiddenError,7 listMemberships,
8 ActorUnauthenticatedError,8 removeMembership,
9 requirePermission,9 updateMembership,
10 withPolicy,10} from "../../runtime/organizations-operations";
11} from "#domain";
12 11
13import { ControlPlaneApi } from "../api";12import { ControlPlaneApi } from "../api";
14import { ControlPlaneActorResolver } from "../auth/actor-resolver";13import { withRequestActor } from "../http-auth";
15import {
16 ControlPlaneForbiddenError,
17 ControlPlaneUnauthorizedError,
18} from "../errors";
19import { ControlPlaneMembershipsService } from "../service";
20
21const toForbiddenError = (
22 operation: string,
23 cause: ActorForbiddenError,
24): ControlPlaneForbiddenError =>
25 new ControlPlaneForbiddenError({
26 operation,
27 message: "Access denied",
28 details: `${cause.permission} on ${cause.scope}`,
29 });
30
31const toUnauthorizedError = (
32 operation: string,
33 cause: ActorUnauthenticatedError,
34): ControlPlaneUnauthorizedError =>
35 new ControlPlaneUnauthorizedError({
36 operation,
37 message: cause.message,
38 details: "Authentication required",
39 });
40
41const resolveActor = Effect.gen(function* () {
42 const actorResolver = yield* ControlPlaneActorResolver;
43 const request = yield* HttpServerRequest.HttpServerRequest;
44 return yield* actorResolver.resolveActor({ headers: request.headers });
45});
46 14
47const requireReadMemberships = (organizationId: OrganizationId) =>15const requireReadMemberships = (organizationId: OrganizationId) =>
48 requirePermission({16 requirePermission({
@@ -62,77 +30,37 @@ export const ControlPlaneMembershipsLive = HttpApiBuilder.group( · 13 unmodified lines
62 (handlers) =>30 (handlers) =>
63 handlers31 handlers
64 .handle("list", ({ path }) =>32 .handle("list", ({ path }) =>
65 Effect.gen(function* () {33 withRequestActor("memberships.list", () =>
66 const service = yield* ControlPlaneMembershipsService;34 withPolicy(requireReadMemberships(path.organizationId))(
67 const actor = yield* resolveActor;35 listMemberships(path.organizationId),
68
69 return yield* withPolicy(requireReadMemberships(path.organizationId))(
70 service.listMemberships(path.organizationId),
71 ).pipe(Effect.provideService(Actor, actor));
72 }).pipe(
73 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
74 Effect.fail(toUnauthorizedError("memberships.list", cause)),
75 ),
76 Effect.catchTag("ActorForbiddenError", (cause) =>
77 Effect.fail(toForbiddenError("memberships.list", cause)),
78 ),36 ),
79 ),37 ),
80 )38 )
81 .handle("create", ({ path, payload }) =>39 .handle("create", ({ path, payload }) =>
82 Effect.gen(function* () {40 withRequestActor("memberships.create", () =>
83 const service = yield* ControlPlaneMembershipsService;41 withPolicy(requireWriteMemberships(path.organizationId))(
84 const actor = yield* resolveActor;42 createMembership({ organizationId: path.organizationId, payload }),
85
86 return yield* withPolicy(requireWriteMemberships(path.organizationId))(
87 service.createMembership({ organizationId: path.organizationId, payload }),
88 ).pipe(Effect.provideService(Actor, actor));
89 }).pipe(
90 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
91 Effect.fail(toUnauthorizedError("memberships.create", cause)),
92 ),
93 Effect.catchTag("ActorForbiddenError", (cause) =>
94 Effect.fail(toForbiddenError("memberships.create", cause)),
95 ),43 ),
96 ),44 ),
97 )45 )
98 .handle("update", ({ path, payload }) =>46 .handle("update", ({ path, payload }) =>
99 Effect.gen(function* () {47 withRequestActor("memberships.update", () =>
100 const service = yield* ControlPlaneMembershipsService;48 withPolicy(requireWriteMemberships(path.organizationId))(
101 const actor = yield* resolveActor;49 updateMembership({
102
103 return yield* withPolicy(requireWriteMemberships(path.organizationId))(
104 service.updateMembership({
105 organizationId: path.organizationId,50 organizationId: path.organizationId,
106 accountId: path.accountId,51 accountId: path.accountId,
107 payload,52 payload,
108 }),53 }),
109 ).pipe(Effect.provideService(Actor, actor));
110 }).pipe(
111 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
112 Effect.fail(toUnauthorizedError("memberships.update", cause)),
113 ),
114 Effect.catchTag("ActorForbiddenError", (cause) =>
115 Effect.fail(toForbiddenError("memberships.update", cause)),
116 ),54 ),
117 ),55 ),
118 )56 )
119 .handle("remove", ({ path }) =>57 .handle("remove", ({ path }) =>
120 Effect.gen(function* () {58 withRequestActor("memberships.remove", () =>
121 const service = yield* ControlPlaneMembershipsService;59 withPolicy(requireWriteMemberships(path.organizationId))(
122 const actor = yield* resolveActor;60 removeMembership({
123
124 return yield* withPolicy(requireWriteMemberships(path.organizationId))(
125 service.removeMembership({
126 organizationId: path.organizationId,61 organizationId: path.organizationId,
127 accountId: path.accountId,62 accountId: path.accountId,
128 }),63 }),
129 ).pipe(Effect.provideService(Actor, actor));
130 }).pipe(
131 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
132 Effect.fail(toUnauthorizedError("memberships.remove", cause)),
133 ),
134 Effect.catchTag("ActorForbiddenError", (cause) =>
135 Effect.fail(toForbiddenError("memberships.remove", cause)),
136 ),64 ),
137 ),65 ),
138 ),66 ),
packages/control-plane/src/api/organizations/api.ts+13−19
@@ -1,9 +1,8 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 OrganizationIdSchema,3 OrganizationIdSchema,
4 OrganizationInsertSchema,4 OrganizationStatusSchema,
5 OrganizationSchema,5 OrganizationSchema,
6 OrganizationUpdateSchema,
7} from "#schema";6} from "#schema";
8import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
9 8
@@ -14,27 +13,22 @@ import { · 4 unmodified lines
14 ControlPlaneStorageError,13 ControlPlaneStorageError,
15 ControlPlaneUnauthorizedError,14 ControlPlaneUnauthorizedError,
16} from "../errors";15} from "../errors";
16import {
17 OptionalTrimmedNonEmptyStringSchema,
18 TrimmedNonEmptyStringSchema,
19} from "../string-schemas";
17 20
18const createOrganizationPayloadRequiredSchema = OrganizationInsertSchema.pipe(21export const CreateOrganizationPayloadSchema = Schema.Struct({
19 Schema.pick("name"),22 name: TrimmedNonEmptyStringSchema,
20);23 slug: OptionalTrimmedNonEmptyStringSchema,
21 24});
22const createOrganizationPayloadOptionalSchema = OrganizationInsertSchema.pipe(
23 Schema.pick("slug"),
24 Schema.partialWith({ exact: true }),
25);
26
27export const CreateOrganizationPayloadSchema = Schema.extend(
28 createOrganizationPayloadRequiredSchema,
29 createOrganizationPayloadOptionalSchema,
30);
31 25
32export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type;26export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type;
33 27
34export const UpdateOrganizationPayloadSchema = OrganizationUpdateSchema.pipe(28export const UpdateOrganizationPayloadSchema = Schema.Struct({
35 Schema.pick("name", "status"),29 name: OptionalTrimmedNonEmptyStringSchema,
36 Schema.partialWith({ exact: true }),30 status: Schema.optional(OrganizationStatusSchema),
37);31});
38 32
39export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type;33export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type;
40 34
packages/control-plane/src/api/organizations/http.ts+24−102
@@ -1,48 +1,17 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import type { OrganizationId } from "#schema";2import type { OrganizationId } from "#schema";
4 3
4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 Actor,6 createOrganization,
7 ActorForbiddenError,7 getOrganization,
8 ActorUnauthenticatedError,8 listOrganizations,
9 requirePermission,9 removeOrganization,
10 withPolicy,10 updateOrganization,
11} from "#domain";11} from "../../runtime/organizations-operations";
12 12
13import { ControlPlaneApi } from "../api";13import { ControlPlaneApi } from "../api";
14import { ControlPlaneActorResolver } from "../auth/actor-resolver";14import { withRequestActor } from "../http-auth";
15import {
16 ControlPlaneForbiddenError,
17 ControlPlaneUnauthorizedError,
18} from "../errors";
19import { ControlPlaneOrganizationsService } from "../service";
20
21const toForbiddenError = (
22 operation: string,
23 cause: ActorForbiddenError,
24): ControlPlaneForbiddenError =>
25 new ControlPlaneForbiddenError({
26 operation,
27 message: "Access denied",
28 details: `${cause.permission} on ${cause.scope}`,
29 });
30
31const toUnauthorizedError = (
32 operation: string,
33 cause: ActorUnauthenticatedError,
34): ControlPlaneUnauthorizedError =>
35 new ControlPlaneUnauthorizedError({
36 operation,
37 message: cause.message,
38 details: "Authentication required",
39 });
40
41const resolveActor = Effect.gen(function* () {
42 const actorResolver = yield* ControlPlaneActorResolver;
43 const request = yield* HttpServerRequest.HttpServerRequest;
44 return yield* actorResolver.resolveActor({ headers: request.headers });
45});
46 15
47const requireReadOrganizations = requirePermission({16const requireReadOrganizations = requirePermission({
48 permission: "organizations:read",17 permission: "organizations:read",
@@ -60,93 +29,46 @@ export const ControlPlaneOrganizationsLive = HttpApiBuilder.group( · 11 unmodified lines
60 (handlers) =>29 (handlers) =>
61 handlers30 handlers
62 .handle("list", () =>31 .handle("list", () =>
63 Effect.gen(function* () {32 withRequestActor("organizations.list", (actor) =>
64 const service = yield* ControlPlaneOrganizationsService;33 withPolicy(requireReadOrganizations)(
65 const actor = yield* resolveActor;34 listOrganizations({
66
67 return yield* withPolicy(requireReadOrganizations)(
68 service.listOrganizations({
69 accountId: actor.principal.accountId,35 accountId: actor.principal.accountId,
70 }),36 }),
71 ).pipe(Effect.provideService(Actor, actor));
72 }).pipe(
73 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
74 Effect.fail(toUnauthorizedError("organizations.list", cause)),
75 ),
76 Effect.catchTag("ActorForbiddenError", (cause) =>
77 Effect.fail(toForbiddenError("organizations.list", cause)),
78 ),37 ),
79 ),38 ),
80 )39 )
81 .handle("create", ({ payload }) =>40 .handle("create", ({ payload }) =>
82 Effect.gen(function* () {41 withRequestActor("organizations.create", (actor) =>
83 const service = yield* ControlPlaneOrganizationsService;42 createOrganization({
84 const actor = yield* resolveActor;
85
86 return yield* service.createOrganization({
87 payload,43 payload,
88 createdByAccountId: actor.principal.accountId,44 createdByAccountId: actor.principal.accountId,
89 });45 }),
90 }).pipe(
91 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
92 Effect.fail(toUnauthorizedError("organizations.create", cause)),
93 ),
94 ),46 ),
95 )47 )
96 .handle("get", ({ path }) =>48 .handle("get", ({ path }) =>
97 Effect.gen(function* () {49 withRequestActor("organizations.get", (actor) =>
98 const service = yield* ControlPlaneOrganizationsService;50 withPolicy(requireReadOrganizations)(
99 const actor = yield* resolveActor;51 getOrganization({
100
101 return yield* withPolicy(requireReadOrganizations)(
102 service.getOrganization({
103 organizationId: path.organizationId,52 organizationId: path.organizationId,
104 accountId: actor.principal.accountId,53 accountId: actor.principal.accountId,
105 }),54 }),
106 ).pipe(Effect.provideService(Actor, actor));
107 }).pipe(
108 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
109 Effect.fail(toUnauthorizedError("organizations.get", cause)),
110 ),
111 Effect.catchTag("ActorForbiddenError", (cause) =>
112 Effect.fail(toForbiddenError("organizations.get", cause)),
113 ),55 ),
114 ),56 ),
115 )57 )
116 .handle("update", ({ path, payload }) =>58 .handle("update", ({ path, payload }) =>
117 Effect.gen(function* () {59 withRequestActor("organizations.update", () =>
118 const service = yield* ControlPlaneOrganizationsService;60 withPolicy(requireManageOrganizations(path.organizationId))(
119 const actor = yield* resolveActor;61 updateOrganization({
120
121 return yield* withPolicy(requireManageOrganizations(path.organizationId))(
122 service.updateOrganization({
123 organizationId: path.organizationId,62 organizationId: path.organizationId,
124 payload,63 payload,
125 }),64 }),
126 ).pipe(Effect.provideService(Actor, actor));
127 }).pipe(
128 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
129 Effect.fail(toUnauthorizedError("organizations.update", cause)),
130 ),
131 Effect.catchTag("ActorForbiddenError", (cause) =>
132 Effect.fail(toForbiddenError("organizations.update", cause)),
133 ),65 ),
134 ),66 ),
135 )67 )
136 .handle("remove", ({ path }) =>68 .handle("remove", ({ path }) =>
137 Effect.gen(function* () {69 withRequestActor("organizations.remove", () =>
138 const service = yield* ControlPlaneOrganizationsService;70 withPolicy(requireManageOrganizations(path.organizationId))(
139 const actor = yield* resolveActor;71 removeOrganization({ organizationId: path.organizationId }),
140
141 return yield* withPolicy(requireManageOrganizations(path.organizationId))(
142 service.removeOrganization({ organizationId: path.organizationId }),
143 ).pipe(Effect.provideService(Actor, actor));
144 }).pipe(
145 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
146 Effect.fail(toUnauthorizedError("organizations.remove", cause)),
147 ),
148 Effect.catchTag("ActorForbiddenError", (cause) =>
149 Effect.fail(toForbiddenError("organizations.remove", cause)),
150 ),72 ),
151 ),73 ),
152 ),74 ),
packages/control-plane/src/api/payload-schemas.test.ts+86−0
@@ -0,0 +1,86 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Schema from "effect/Schema";
3
4import {
5 CreateExecutionPayloadSchema,
6} from "./executions/api";
7import {
8 CreateOrganizationPayloadSchema,
9 UpdateOrganizationPayloadSchema,
10} from "./organizations/api";
11import { CreatePolicyPayloadSchema } from "./policies/api";
12import {
13 CreateSourcePayloadSchema,
14 UpdateSourcePayloadSchema,
15} from "./sources/api";
16import { CreateWorkspacePayloadSchema } from "./workspaces/api";
17
18describe("control-plane payload schemas", () => {
19 it("normalizes trimmed strings at decode time", () => {
20 expect(
21 Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({
22 name: " Acme ",
23 slug: " acme ",
24 }),
25 ).toEqual({
26 name: "Acme",
27 slug: "acme",
28 });
29
30 expect(
31 Schema.decodeUnknownSync(CreateWorkspacePayloadSchema)({
32 name: " Primary ",
33 }),
34 ).toEqual({
35 name: "Primary",
36 });
37
38 expect(
39 Schema.decodeUnknownSync(CreateSourcePayloadSchema)({
40 name: " Github ",
41 kind: "openapi",
42 endpoint: " https://api.github.com ",
43 }),
44 ).toEqual({
45 name: "Github",
46 kind: "openapi",
47 endpoint: "https://api.github.com",
48 });
49
50 expect(
51 Schema.decodeUnknownSync(CreateExecutionPayloadSchema)({
52 code: " console.log('ok') ",
53 }),
54 ).toEqual({
55 code: "console.log('ok')",
56 });
57
58 expect(
59 Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({
60 resourcePattern: " source.github.* ",
61 }),
62 ).toEqual({
63 resourcePattern: "source.github.*",
64 });
65 });
66
67 it("rejects blank strings for normalized string fields", () => {
68 expect(() =>
69 Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({
70 name: " ",
71 })
72 ).toThrow();
73
74 expect(() =>
75 Schema.decodeUnknownSync(UpdateOrganizationPayloadSchema)({
76 name: " ",
77 })
78 ).toThrow();
79
80 expect(() =>
81 Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({
82 endpoint: " ",
83 })
84 ).toThrow();
85 });
86});
packages/control-plane/src/api/policies/api.ts+30−32
@@ -1,9 +1,12 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 AccountIdSchema,
3 PolicyIdSchema,4 PolicyIdSchema,
4 PolicyInsertSchema,5 PolicyApprovalModeSchema,
6 PolicyEffectSchema,
7 PolicyMatchTypeSchema,
8 PolicyResourceTypeSchema,
5 PolicySchema,9 PolicySchema,
6 PolicyUpdateSchema,
7 WorkspaceIdSchema,10 WorkspaceIdSchema,
8} from "#schema";11} from "#schema";
9import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
@@ -15,40 +18,35 @@ import { · 5 unmodified lines
15 ControlPlaneStorageError,18 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,19 ControlPlaneUnauthorizedError,
17} from "../errors";20} from "../errors";
21import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas";
18 22
19export const CreatePolicyPayloadSchema = PolicyInsertSchema.pipe(23export const CreatePolicyPayloadSchema = Schema.Struct({
20 Schema.pick(24 resourceType: Schema.optional(PolicyResourceTypeSchema),
21 "resourceType",25 resourcePattern: OptionalTrimmedNonEmptyStringSchema,
22 "resourcePattern",26 matchType: Schema.optional(PolicyMatchTypeSchema),
23 "matchType",27 effect: Schema.optional(PolicyEffectSchema),
24 "effect",28 approvalMode: Schema.optional(PolicyApprovalModeSchema),
25 "approvalMode",29 argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)),
26 "argumentConditionsJson",30 priority: Schema.optional(Schema.Number),
27 "priority",31 enabled: Schema.optional(Schema.Boolean),
28 "enabled",32 targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)),
29 "targetAccountId",33 clientId: Schema.optional(Schema.NullOr(Schema.String)),
30 "clientId",34});
31 ),
32 Schema.partialWith({ exact: true }),
33);
34 35
35export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type;36export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type;
36 37
37export const UpdatePolicyPayloadSchema = PolicyUpdateSchema.pipe(38export const UpdatePolicyPayloadSchema = Schema.Struct({
38 Schema.pick(39 resourceType: Schema.optional(PolicyResourceTypeSchema),
39 "resourceType",40 resourcePattern: OptionalTrimmedNonEmptyStringSchema,
40 "resourcePattern",41 matchType: Schema.optional(PolicyMatchTypeSchema),
41 "matchType",42 effect: Schema.optional(PolicyEffectSchema),
42 "effect",43 approvalMode: Schema.optional(PolicyApprovalModeSchema),
43 "approvalMode",44 argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)),
44 "argumentConditionsJson",45 priority: Schema.optional(Schema.Number),
45 "priority",46 enabled: Schema.optional(Schema.Boolean),
46 "enabled",47 targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)),
47 "targetAccountId",48 clientId: Schema.optional(Schema.NullOr(Schema.String)),
48 "clientId",49});
49 ),
50 Schema.partialWith({ exact: true }),
51);
52 50
53export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;51export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;
54 52
packages/control-plane/src/api/policies/http.ts+24−107
@@ -1,50 +1,17 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import type { WorkspaceId } from "#schema";2import type { WorkspaceId } from "#schema";
4 3
4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 Actor,6 createPolicy,
7 ActorForbiddenError,7 getPolicy,
8 ActorUnauthenticatedError,8 listPolicies,
9 requirePermission,9 removePolicy,
10 withPolicy,10 updatePolicy,
11} from "#domain";11} from "../../runtime/policies-operations";
12 12
13import { ControlPlaneApi } from "../api";13import { ControlPlaneApi } from "../api";
14import { ControlPlaneActorResolver } from "../auth/actor-resolver";14import { withWorkspaceRequestActor } from "../http-auth";
15import {
16 ControlPlaneForbiddenError,
17 ControlPlaneUnauthorizedError,
18} from "../errors";
19import { ControlPlanePoliciesService } from "../service";
20
21const toForbiddenError = (
22 operation: string,
23 cause: ActorForbiddenError,
24): ControlPlaneForbiddenError =>
25 new ControlPlaneForbiddenError({
26 operation,
27 message: "Access denied",
28 details: `${cause.permission} on ${cause.scope}`,
29 });
30
31const toUnauthorizedError = (
32 operation: string,
33 cause: ActorUnauthenticatedError,
34): ControlPlaneUnauthorizedError =>
35 new ControlPlaneUnauthorizedError({
36 operation,
37 message: cause.message,
38 details: "Authentication required",
39 });
40
41const resolveWorkspaceActor = (workspaceId: WorkspaceId) =>
42 Effect.gen(function* () {
43 const actorResolver = yield* ControlPlaneActorResolver;
44 const request = yield* HttpServerRequest.HttpServerRequest;
45
46 return yield* actorResolver.resolveWorkspaceActor({ workspaceId, headers: request.headers });
47 });
48 15
49const requireReadPolicies = (workspaceId: WorkspaceId) =>16const requireReadPolicies = (workspaceId: WorkspaceId) =>
50 requirePermission({17 requirePermission({
@@ -64,97 +31,47 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 13 unmodified lines
64 (handlers) =>31 (handlers) =>
65 handlers32 handlers
66 .handle("list", ({ path }) =>33 .handle("list", ({ path }) =>
67 Effect.gen(function* () {34 withWorkspaceRequestActor("policies.list", path.workspaceId, () =>
68 const service = yield* ControlPlanePoliciesService;35 withPolicy(requireReadPolicies(path.workspaceId))(
69 const actor = yield* resolveWorkspaceActor(path.workspaceId);36 listPolicies(path.workspaceId),
70
71 return yield* withPolicy(requireReadPolicies(path.workspaceId))(
72 service.listPolicies(path.workspaceId),
73 ).pipe(Effect.provideService(Actor, actor));
74 }).pipe(
75 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
76 Effect.fail(toUnauthorizedError("policies.list", cause)),
77 ),
78 Effect.catchTag("ActorForbiddenError", (cause) =>
79 Effect.fail(toForbiddenError("policies.list", cause)),
80 ),37 ),
81 ),38 ),
82 )39 )
83 .handle("create", ({ path, payload }) =>40 .handle("create", ({ path, payload }) =>
84 Effect.gen(function* () {41 withWorkspaceRequestActor("policies.create", path.workspaceId, () =>
85 const service = yield* ControlPlanePoliciesService;42 withPolicy(requireWritePolicies(path.workspaceId))(
86 const actor = yield* resolveWorkspaceActor(path.workspaceId);43 createPolicy({ workspaceId: path.workspaceId, payload }),
87
88 return yield* withPolicy(requireWritePolicies(path.workspaceId))(
89 service.createPolicy({ workspaceId: path.workspaceId, payload }),
90 ).pipe(Effect.provideService(Actor, actor));
91 }).pipe(
92 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
93 Effect.fail(toUnauthorizedError("policies.create", cause)),
94 ),
95 Effect.catchTag("ActorForbiddenError", (cause) =>
96 Effect.fail(toForbiddenError("policies.create", cause)),
97 ),44 ),
98 ),45 ),
99 )46 )
100 .handle("get", ({ path }) =>47 .handle("get", ({ path }) =>
101 Effect.gen(function* () {48 withWorkspaceRequestActor("policies.get", path.workspaceId, () =>
102 const service = yield* ControlPlanePoliciesService;49 withPolicy(requireReadPolicies(path.workspaceId))(
103 const actor = yield* resolveWorkspaceActor(path.workspaceId);50 getPolicy({
104
105 return yield* withPolicy(requireReadPolicies(path.workspaceId))(
106 service.getPolicy({
107 workspaceId: path.workspaceId,51 workspaceId: path.workspaceId,
108 policyId: path.policyId,52 policyId: path.policyId,
109 }),53 }),
110 ).pipe(Effect.provideService(Actor, actor));
111 }).pipe(
112 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
113 Effect.fail(toUnauthorizedError("policies.get", cause)),
114 ),
115 Effect.catchTag("ActorForbiddenError", (cause) =>
116 Effect.fail(toForbiddenError("policies.get", cause)),
117 ),54 ),
118 ),55 ),
119 )56 )
120 .handle("update", ({ path, payload }) =>57 .handle("update", ({ path, payload }) =>
121 Effect.gen(function* () {58 withWorkspaceRequestActor("policies.update", path.workspaceId, () =>
122 const service = yield* ControlPlanePoliciesService;59 withPolicy(requireWritePolicies(path.workspaceId))(
123 const actor = yield* resolveWorkspaceActor(path.workspaceId);60 updatePolicy({
124
125 return yield* withPolicy(requireWritePolicies(path.workspaceId))(
126 service.updatePolicy({
127 workspaceId: path.workspaceId,61 workspaceId: path.workspaceId,
128 policyId: path.policyId,62 policyId: path.policyId,
129 payload,63 payload,
130 }),64 }),
131 ).pipe(Effect.provideService(Actor, actor));
132 }).pipe(
133 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
134 Effect.fail(toUnauthorizedError("policies.update", cause)),
135 ),
136 Effect.catchTag("ActorForbiddenError", (cause) =>
137 Effect.fail(toForbiddenError("policies.update", cause)),
138 ),65 ),
139 ),66 ),
140 )67 )
141 .handle("remove", ({ path }) =>68 .handle("remove", ({ path }) =>
142 Effect.gen(function* () {69 withWorkspaceRequestActor("policies.remove", path.workspaceId, () =>
143 const service = yield* ControlPlanePoliciesService;70 withPolicy(requireWritePolicies(path.workspaceId))(
144 const actor = yield* resolveWorkspaceActor(path.workspaceId);71 removePolicy({
145
146 return yield* withPolicy(requireWritePolicies(path.workspaceId))(
147 service.removePolicy({
148 workspaceId: path.workspaceId,72 workspaceId: path.workspaceId,
149 policyId: path.policyId,73 policyId: path.policyId,
150 }),74 }),
151 ).pipe(Effect.provideService(Actor, actor));
152 }).pipe(
153 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
154 Effect.fail(toUnauthorizedError("policies.remove", cause)),
155 ),
156 Effect.catchTag("ActorForbiddenError", (cause) =>
157 Effect.fail(toForbiddenError("policies.remove", cause)),
158 ),75 ),
159 ),76 ),
160 ),77 ),
packages/control-plane/src/api/service.ts+0−343
@@ -1,343 +0,0 @@
1import type {
2 AccountId,
3 ExecutionEnvelope,
4 ExecutionId,
5 LocalInstallation,
6 Organization,
7 OrganizationId,
8 OrganizationMembership,
9 Policy,
10 PolicyId,
11 Source,
12 SourceId,
13 Workspace,
14 WorkspaceId,
15} from "#schema";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18
19import type {
20 CreateMembershipPayload,
21 UpdateMembershipPayload,
22} from "./memberships/api";
23import type {
24 CreateOrganizationPayload,
25 UpdateOrganizationPayload,
26} from "./organizations/api";
27import type {
28 CreatePolicyPayload,
29 UpdatePolicyPayload,
30} from "./policies/api";
31import type {
32 CreateSourcePayload,
33 UpdateSourcePayload,
34} from "./sources/api";
35import type {
36 CreateExecutionPayload,
37 ResumeExecutionPayload,
38} from "./executions/api";
39import type {
40 CreateWorkspacePayload,
41 UpdateWorkspacePayload,
42} from "./workspaces/api";
43import type {
44 ControlPlaneBadRequestError,
45 ControlPlaneNotFoundError,
46 ControlPlaneStorageError,
47} from "./errors";
48
49export type CreateOrganizationInput = {
50 payload: CreateOrganizationPayload;
51 createdByAccountId?: Organization["createdByAccountId"];
52};
53
54export type UpdateOrganizationInput = {
55 organizationId: OrganizationId;
56 payload: UpdateOrganizationPayload;
57};
58
59export type RemoveOrganizationInput = {
60 organizationId: OrganizationId;
61};
62
63export type CreateMembershipInput = {
64 organizationId: OrganizationId;
65 payload: CreateMembershipPayload;
66};
67
68export type UpdateMembershipInput = {
69 organizationId: OrganizationId;
70 accountId: AccountId;
71 payload: UpdateMembershipPayload;
72};
73
74export type RemoveMembershipInput = {
75 organizationId: OrganizationId;
76 accountId: AccountId;
77};
78
79export type CreateWorkspaceInput = {
80 organizationId: OrganizationId;
81 payload: CreateWorkspacePayload;
82 createdByAccountId?: Workspace["createdByAccountId"];
83};
84
85export type UpdateWorkspaceInput = {
86 workspaceId: WorkspaceId;
87 payload: UpdateWorkspacePayload;
88};
89
90export type RemoveWorkspaceInput = {
91 workspaceId: WorkspaceId;
92};
93
94export type CreateSourceInput = {
95 workspaceId: WorkspaceId;
96 payload: CreateSourcePayload;
97};
98
99export type GetSourceInput = {
100 workspaceId: WorkspaceId;
101 sourceId: SourceId;
102};
103
104export type UpdateSourceInput = {
105 workspaceId: WorkspaceId;
106 sourceId: SourceId;
107 payload: UpdateSourcePayload;
108};
109
110export type RemoveSourceInput = {
111 workspaceId: WorkspaceId;
112 sourceId: SourceId;
113};
114
115export type CompleteSourceAuthCallbackInput = {
116 state: string;
117 code?: string | null;
118 error?: string | null;
119 errorDescription?: string | null;
120};
121
122export type CreatePolicyInput = {
123 workspaceId: WorkspaceId;
124 payload: CreatePolicyPayload;
125};
126
127export type CreateExecutionInput = {
128 workspaceId: WorkspaceId;
129 payload: CreateExecutionPayload;
130 createdByAccountId: AccountId;
131};
132
133export type GetExecutionInput = {
134 workspaceId: WorkspaceId;
135 executionId: ExecutionId;
136};
137
138export type ResumeExecutionInput = {
139 workspaceId: WorkspaceId;
140 executionId: ExecutionId;
141 payload: ResumeExecutionPayload;
142 resumedByAccountId: AccountId;
143};
144
145export type GetPolicyInput = {
146 workspaceId: WorkspaceId;
147 policyId: PolicyId;
148};
149
150export type UpdatePolicyInput = {
151 workspaceId: WorkspaceId;
152 policyId: PolicyId;
153 payload: UpdatePolicyPayload;
154};
155
156export type RemovePolicyInput = {
157 workspaceId: WorkspaceId;
158 policyId: PolicyId;
159};
160
161export type ControlPlaneOrganizationsServiceShape = {
162 listOrganizations: (input: {
163 accountId: AccountId;
164 }) => Effect.Effect<ReadonlyArray<Organization>, ControlPlaneStorageError>;
165 createOrganization: (
166 input: CreateOrganizationInput,
167 ) => Effect.Effect<Organization, ControlPlaneBadRequestError | ControlPlaneStorageError>;
168 getOrganization: (input: {
169 organizationId: OrganizationId;
170 accountId: AccountId;
171 }) => Effect.Effect<Organization, ControlPlaneNotFoundError | ControlPlaneStorageError>;
172 updateOrganization: (
173 input: UpdateOrganizationInput,
174 ) => Effect.Effect<
175 Organization,
176 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
177 >;
178 removeOrganization: (
179 input: RemoveOrganizationInput,
180 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;
181};
182
183export type ControlPlaneMembershipsServiceShape = {
184 listMemberships: (
185 organizationId: OrganizationId,
186 ) => Effect.Effect<
187 ReadonlyArray<OrganizationMembership>,
188 ControlPlaneNotFoundError | ControlPlaneStorageError
189 >;
190 createMembership: (
191 input: CreateMembershipInput,
192 ) => Effect.Effect<
193 OrganizationMembership,
194 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
195 >;
196 updateMembership: (
197 input: UpdateMembershipInput,
198 ) => Effect.Effect<
199 OrganizationMembership,
200 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
201 >;
202 removeMembership: (
203 input: RemoveMembershipInput,
204 ) => Effect.Effect<{ removed: boolean }, ControlPlaneNotFoundError | ControlPlaneStorageError>;
205};
206
207export type ControlPlaneWorkspacesServiceShape = {
208 listWorkspaces: (
209 organizationId: OrganizationId,
210 ) => Effect.Effect<ReadonlyArray<Workspace>, ControlPlaneNotFoundError | ControlPlaneStorageError>;
211 createWorkspace: (
212 input: CreateWorkspaceInput,
213 ) => Effect.Effect<
214 Workspace,
215 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
216 >;
217 getWorkspace: (
218 workspaceId: WorkspaceId,
219 ) => Effect.Effect<Workspace, ControlPlaneNotFoundError | ControlPlaneStorageError>;
220 updateWorkspace: (
221 input: UpdateWorkspaceInput,
222 ) => Effect.Effect<
223 Workspace,
224 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
225 >;
226 removeWorkspace: (
227 input: RemoveWorkspaceInput,
228 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;
229};
230
231export type ControlPlaneSourcesServiceShape = {
232 listSources: (
233 workspaceId: WorkspaceId,
234 ) => Effect.Effect<ReadonlyArray<Source>, ControlPlaneStorageError>;
235 createSource: (
236 input: CreateSourceInput,
237 ) => Effect.Effect<Source, ControlPlaneBadRequestError | ControlPlaneStorageError>;
238 getSource: (
239 input: GetSourceInput,
240 ) => Effect.Effect<Source, ControlPlaneNotFoundError | ControlPlaneStorageError>;
241 updateSource: (
242 input: UpdateSourceInput,
243 ) => Effect.Effect<
244 Source,
245 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
246 >;
247 removeSource: (
248 input: RemoveSourceInput,
249 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;
250};
251
252export type ControlPlaneExecutionsServiceShape = {
253 createExecution: (
254 input: CreateExecutionInput,
255 ) => Effect.Effect<
256 ExecutionEnvelope,
257 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
258 >;
259 getExecution: (
260 input: GetExecutionInput,
261 ) => Effect.Effect<ExecutionEnvelope, ControlPlaneNotFoundError | ControlPlaneStorageError>;
262 resumeExecution: (
263 input: ResumeExecutionInput,
264 ) => Effect.Effect<
265 ExecutionEnvelope,
266 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
267 >;
268};
269
270export type ControlPlaneLocalServiceShape = {
271 getLocalInstallation: () => Effect.Effect<
272 LocalInstallation,
273 ControlPlaneNotFoundError | ControlPlaneStorageError
274 >;
275 completeSourceAuthCallback: (
276 input: CompleteSourceAuthCallbackInput,
277 ) => Effect.Effect<Source, ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError>;
278};
279
280export type ControlPlanePoliciesServiceShape = {
281 listPolicies: (
282 workspaceId: WorkspaceId,
283 ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>;
284 createPolicy: (
285 input: CreatePolicyInput,
286 ) => Effect.Effect<Policy, ControlPlaneBadRequestError | ControlPlaneStorageError>;
287 getPolicy: (
288 input: GetPolicyInput,
289 ) => Effect.Effect<Policy, ControlPlaneNotFoundError | ControlPlaneStorageError>;
290 updatePolicy: (
291 input: UpdatePolicyInput,
292 ) => Effect.Effect<
293 Policy,
294 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
295 >;
296 removePolicy: (
297 input: RemovePolicyInput,
298 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;
299};
300
301export class ControlPlaneOrganizationsService extends Context.Tag(
302 "#api/ControlPlaneOrganizationsService",
303)<ControlPlaneOrganizationsService, ControlPlaneOrganizationsServiceShape>() {
304}
305
306export class ControlPlaneMembershipsService extends Context.Tag(
307 "#api/ControlPlaneMembershipsService",
308)<ControlPlaneMembershipsService, ControlPlaneMembershipsServiceShape>() {
309}
310
311export class ControlPlaneWorkspacesService extends Context.Tag(
312 "#api/ControlPlaneWorkspacesService",
313)<ControlPlaneWorkspacesService, ControlPlaneWorkspacesServiceShape>() {
314}
315
316export class ControlPlaneSourcesService extends Context.Tag(
317 "#api/ControlPlaneSourcesService",
318)<ControlPlaneSourcesService, ControlPlaneSourcesServiceShape>() {
319}
320
321export class ControlPlaneExecutionsService extends Context.Tag(
322 "#api/ControlPlaneExecutionsService",
323)<ControlPlaneExecutionsService, ControlPlaneExecutionsServiceShape>() {
324}
325
326export class ControlPlaneLocalService extends Context.Tag(
327 "#api/ControlPlaneLocalService",
328)<ControlPlaneLocalService, ControlPlaneLocalServiceShape>() {
329}
330
331export class ControlPlanePoliciesService extends Context.Tag(
332 "#api/ControlPlanePoliciesService",
333)<ControlPlanePoliciesService, ControlPlanePoliciesServiceShape>() {
334}
335
336export type ControlPlaneApiServiceContext =
337 | ControlPlaneOrganizationsService
338 | ControlPlaneMembershipsService
339 | ControlPlaneWorkspacesService
340 | ControlPlaneSourcesService
341 | ControlPlaneExecutionsService
342 | ControlPlaneLocalService
343 | ControlPlanePoliciesService;
packages/control-plane/src/api/sources/api.ts+8−4
@@ -18,11 +18,15 @@ import { · 17 unmodified lines
18 ControlPlaneStorageError,18 ControlPlaneStorageError,
19 ControlPlaneUnauthorizedError,19 ControlPlaneUnauthorizedError,
20} from "../errors";20} from "../errors";
21import {
22 OptionalTrimmedNonEmptyStringSchema,
23 TrimmedNonEmptyStringSchema,
24} from "../string-schemas";
21 25
22const createSourcePayloadRequiredSchema = Schema.Struct({26const createSourcePayloadRequiredSchema = Schema.Struct({
23 name: Schema.String,27 name: TrimmedNonEmptyStringSchema,
24 kind: SourceKindSchema,28 kind: SourceKindSchema,
25 endpoint: Schema.String,29 endpoint: TrimmedNonEmptyStringSchema,
26});30});
27 31
28const createSourcePayloadOptionalSchema = Schema.Struct({32const createSourcePayloadOptionalSchema = Schema.Struct({
@@ -47,9 +51,9 @@ export const CreateSourcePayloadSchema = Schema.extend( · 18 unmodified lines
47export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type;51export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type;
48 52
49export const UpdateSourcePayloadSchema = Schema.Struct({53export const UpdateSourcePayloadSchema = Schema.Struct({
50 name: Schema.optional(Schema.String),54 name: OptionalTrimmedNonEmptyStringSchema,
51 kind: Schema.optional(SourceKindSchema),55 kind: Schema.optional(SourceKindSchema),
52 endpoint: Schema.optional(Schema.String),56 endpoint: OptionalTrimmedNonEmptyStringSchema,
53 status: Schema.optional(SourceStatusSchema),57 status: Schema.optional(SourceStatusSchema),
54 enabled: Schema.optional(Schema.Boolean),58 enabled: Schema.optional(Schema.Boolean),
55 namespace: Schema.optional(Schema.NullOr(Schema.String)),59 namespace: Schema.optional(Schema.NullOr(Schema.String)),
packages/control-plane/src/api/sources/http.ts+24−107
@@ -1,50 +1,17 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import type { WorkspaceId } from "#schema";2import type { WorkspaceId } from "#schema";
4 3
4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 Actor,6 createSource,
7 ActorForbiddenError,7 getSource,
8 ActorUnauthenticatedError,8 listSources,
9 requirePermission,9 removeSource,
10 withPolicy,10 updateSource,
11} from "#domain";11} from "../../runtime/sources-operations";
12 12
13import { ControlPlaneApi } from "../api";13import { ControlPlaneApi } from "../api";
14import { ControlPlaneActorResolver } from "../auth/actor-resolver";14import { withWorkspaceRequestActor } from "../http-auth";
15import {
16 ControlPlaneForbiddenError,
17 ControlPlaneUnauthorizedError,
18} from "../errors";
19import { ControlPlaneSourcesService } from "../service";
20
21const toForbiddenError = (
22 operation: string,
23 cause: ActorForbiddenError,
24): ControlPlaneForbiddenError =>
25 new ControlPlaneForbiddenError({
26 operation,
27 message: "Access denied",
28 details: `${cause.permission} on ${cause.scope}`,
29 });
30
31const toUnauthorizedError = (
32 operation: string,
33 cause: ActorUnauthenticatedError,
34): ControlPlaneUnauthorizedError =>
35 new ControlPlaneUnauthorizedError({
36 operation,
37 message: cause.message,
38 details: "Authentication required",
39 });
40
41const resolveWorkspaceActor = (workspaceId: WorkspaceId) =>
42 Effect.gen(function* () {
43 const actorResolver = yield* ControlPlaneActorResolver;
44 const request = yield* HttpServerRequest.HttpServerRequest;
45
46 return yield* actorResolver.resolveWorkspaceActor({ workspaceId, headers: request.headers });
47 });
48 15
49const requireReadSources = (workspaceId: WorkspaceId) =>16const requireReadSources = (workspaceId: WorkspaceId) =>
50 requirePermission({17 requirePermission({
@@ -64,97 +31,47 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 13 unmodified lines
64 (handlers) =>31 (handlers) =>
65 handlers32 handlers
66 .handle("list", ({ path }) =>33 .handle("list", ({ path }) =>
67 Effect.gen(function* () {34 withWorkspaceRequestActor("sources.list", path.workspaceId, () =>
68 const service = yield* ControlPlaneSourcesService;35 withPolicy(requireReadSources(path.workspaceId))(
69 const actor = yield* resolveWorkspaceActor(path.workspaceId);36 listSources(path.workspaceId),
70
71 return yield* withPolicy(requireReadSources(path.workspaceId))(
72 service.listSources(path.workspaceId),
73 ).pipe(Effect.provideService(Actor, actor));
74 }).pipe(
75 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
76 Effect.fail(toUnauthorizedError("sources.list", cause)),
77 ),
78 Effect.catchTag("ActorForbiddenError", (cause) =>
79 Effect.fail(toForbiddenError("sources.list", cause)),
80 ),37 ),
81 ),38 ),
82 )39 )
83 .handle("create", ({ path, payload }) =>40 .handle("create", ({ path, payload }) =>
84 Effect.gen(function* () {41 withWorkspaceRequestActor("sources.create", path.workspaceId, () =>
85 const service = yield* ControlPlaneSourcesService;42 withPolicy(requireWriteSources(path.workspaceId))(
86 const actor = yield* resolveWorkspaceActor(path.workspaceId);43 createSource({ workspaceId: path.workspaceId, payload }),
87
88 return yield* withPolicy(requireWriteSources(path.workspaceId))(
89 service.createSource({ workspaceId: path.workspaceId, payload }),
90 ).pipe(Effect.provideService(Actor, actor));
91 }).pipe(
92 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
93 Effect.fail(toUnauthorizedError("sources.create", cause)),
94 ),
95 Effect.catchTag("ActorForbiddenError", (cause) =>
96 Effect.fail(toForbiddenError("sources.create", cause)),
97 ),44 ),
98 ),45 ),
99 )46 )
100 .handle("get", ({ path }) =>47 .handle("get", ({ path }) =>
101 Effect.gen(function* () {48 withWorkspaceRequestActor("sources.get", path.workspaceId, () =>
102 const service = yield* ControlPlaneSourcesService;49 withPolicy(requireReadSources(path.workspaceId))(
103 const actor = yield* resolveWorkspaceActor(path.workspaceId);50 getSource({
104
105 return yield* withPolicy(requireReadSources(path.workspaceId))(
106 service.getSource({
107 workspaceId: path.workspaceId,51 workspaceId: path.workspaceId,
108 sourceId: path.sourceId,52 sourceId: path.sourceId,
109 }),53 }),
110 ).pipe(Effect.provideService(Actor, actor));
111 }).pipe(
112 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
113 Effect.fail(toUnauthorizedError("sources.get", cause)),
114 ),
115 Effect.catchTag("ActorForbiddenError", (cause) =>
116 Effect.fail(toForbiddenError("sources.get", cause)),
117 ),54 ),
118 ),55 ),
119 )56 )
120 .handle("update", ({ path, payload }) =>57 .handle("update", ({ path, payload }) =>
121 Effect.gen(function* () {58 withWorkspaceRequestActor("sources.update", path.workspaceId, () =>
122 const service = yield* ControlPlaneSourcesService;59 withPolicy(requireWriteSources(path.workspaceId))(
123 const actor = yield* resolveWorkspaceActor(path.workspaceId);60 updateSource({
124
125 return yield* withPolicy(requireWriteSources(path.workspaceId))(
126 service.updateSource({
127 workspaceId: path.workspaceId,61 workspaceId: path.workspaceId,
128 sourceId: path.sourceId,62 sourceId: path.sourceId,
129 payload,63 payload,
130 }),64 }),
131 ).pipe(Effect.provideService(Actor, actor));
132 }).pipe(
133 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
134 Effect.fail(toUnauthorizedError("sources.update", cause)),
135 ),
136 Effect.catchTag("ActorForbiddenError", (cause) =>
137 Effect.fail(toForbiddenError("sources.update", cause)),
138 ),65 ),
139 ),66 ),
140 )67 )
141 .handle("remove", ({ path }) =>68 .handle("remove", ({ path }) =>
142 Effect.gen(function* () {69 withWorkspaceRequestActor("sources.remove", path.workspaceId, () =>
143 const service = yield* ControlPlaneSourcesService;70 withPolicy(requireWriteSources(path.workspaceId))(
144 const actor = yield* resolveWorkspaceActor(path.workspaceId);71 removeSource({
145
146 return yield* withPolicy(requireWriteSources(path.workspaceId))(
147 service.removeSource({
148 workspaceId: path.workspaceId,72 workspaceId: path.workspaceId,
149 sourceId: path.sourceId,73 sourceId: path.sourceId,
150 }),74 }),
151 ).pipe(Effect.provideService(Actor, actor));
152 }).pipe(
153 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
154 Effect.fail(toUnauthorizedError("sources.remove", cause)),
155 ),
156 Effect.catchTag("ActorForbiddenError", (cause) =>
157 Effect.fail(toForbiddenError("sources.remove", cause)),
158 ),75 ),
159 ),76 ),
160 ),77 ),
packages/control-plane/src/api/string-schemas.ts+9−0
@@ -0,0 +1,9 @@
1import * as Schema from "effect/Schema";
2
3export const TrimmedNonEmptyStringSchema = Schema.Trim.pipe(
4 Schema.nonEmptyString(),
5);
6
7export const OptionalTrimmedNonEmptyStringSchema = Schema.optional(
8 TrimmedNonEmptyStringSchema,
9);
packages/control-plane/src/api/workspaces/api.ts+10−9
@@ -2,9 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
2import {2import {
3 OrganizationIdSchema,3 OrganizationIdSchema,
4 WorkspaceIdSchema,4 WorkspaceIdSchema,
5 WorkspaceInsertSchema,
6 WorkspaceSchema,5 WorkspaceSchema,
7 WorkspaceUpdateSchema,
8} from "#schema";6} from "#schema";
9import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
10 8
@@ -15,17 +13,20 @@ import { · 4 unmodified lines
15 ControlPlaneStorageError,13 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,14 ControlPlaneUnauthorizedError,
17} from "../errors";15} from "../errors";
16import {
17 OptionalTrimmedNonEmptyStringSchema,
18 TrimmedNonEmptyStringSchema,
19} from "../string-schemas";
18 20
19export const CreateWorkspacePayloadSchema = WorkspaceInsertSchema.pipe(21export const CreateWorkspacePayloadSchema = Schema.Struct({
20 Schema.pick("name"),22 name: TrimmedNonEmptyStringSchema,
21);23});
22 24
23export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type;25export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type;
24 26
25export const UpdateWorkspacePayloadSchema = WorkspaceUpdateSchema.pipe(27export const UpdateWorkspacePayloadSchema = Schema.Struct({
26 Schema.pick("name"),28 name: OptionalTrimmedNonEmptyStringSchema,
27 Schema.partialWith({ exact: true }),29});
28);
29 30
30export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type;31export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type;
31 32
packages/control-plane/src/api/workspaces/http.ts+26−114
@@ -1,56 +1,20 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import type { OrganizationId, WorkspaceId } from "#schema";2import type { OrganizationId, WorkspaceId } from "#schema";
4 3
4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 Actor,6 createWorkspace,
7 ActorForbiddenError,7 getWorkspace,
8 ActorUnauthenticatedError,8 listWorkspaces,
9 requirePermission,9 removeWorkspace,
10 withPolicy,10 updateWorkspace,
11} from "#domain";11} from "../../runtime/organizations-operations";
12 12
13import { ControlPlaneApi } from "../api";13import { ControlPlaneApi } from "../api";
14import { ControlPlaneActorResolver } from "../auth/actor-resolver";
15import {14import {
16 ControlPlaneForbiddenError,15 withRequestActor,
17 ControlPlaneUnauthorizedError,16 withWorkspaceRequestActor,
18} from "../errors";17} from "../http-auth";
19import { ControlPlaneWorkspacesService } from "../service";
20
21const toForbiddenError = (
22 operation: string,
23 cause: ActorForbiddenError,
24): ControlPlaneForbiddenError =>
25 new ControlPlaneForbiddenError({
26 operation,
27 message: "Access denied",
28 details: `${cause.permission} on ${cause.scope}`,
29 });
30
31const toUnauthorizedError = (
32 operation: string,
33 cause: ActorUnauthenticatedError,
34): ControlPlaneUnauthorizedError =>
35 new ControlPlaneUnauthorizedError({
36 operation,
37 message: cause.message,
38 details: "Authentication required",
39 });
40
41const resolveActor = Effect.gen(function* () {
42 const actorResolver = yield* ControlPlaneActorResolver;
43 const request = yield* HttpServerRequest.HttpServerRequest;
44 return yield* actorResolver.resolveActor({ headers: request.headers });
45});
46
47const resolveWorkspaceActor = (workspaceId: WorkspaceId) =>
48 Effect.gen(function* () {
49 const actorResolver = yield* ControlPlaneActorResolver;
50 const request = yield* HttpServerRequest.HttpServerRequest;
51
52 return yield* actorResolver.resolveWorkspaceActor({ workspaceId, headers: request.headers });
53 });
54 18
55const requireReadWorkspace = (workspaceId: WorkspaceId) =>19const requireReadWorkspace = (workspaceId: WorkspaceId) =>
56 requirePermission({20 requirePermission({
@@ -82,95 +46,43 @@ export const ControlPlaneWorkspacesLive = HttpApiBuilder.group( · 25 unmodified lines
82 (handlers) =>46 (handlers) =>
83 handlers47 handlers
84 .handle("list", ({ path }) =>48 .handle("list", ({ path }) =>
85 Effect.gen(function* () {49 withRequestActor("workspaces.list", () =>
86 const service = yield* ControlPlaneWorkspacesService;50 withPolicy(
87 const actor = yield* resolveActor;
88
89 return yield* withPolicy(
90 requireOrganizationWorkspaceRead(path.organizationId),51 requireOrganizationWorkspaceRead(path.organizationId),
91 )(service.listWorkspaces(path.organizationId)).pipe(52 )(listWorkspaces(path.organizationId))
92 Effect.provideService(Actor, actor),
93 );
94 }).pipe(
95 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
96 Effect.fail(toUnauthorizedError("workspaces.list", cause)),
97 ),
98 Effect.catchTag("ActorForbiddenError", (cause) =>
99 Effect.fail(toForbiddenError("workspaces.list", cause)),
100 ),
101 ),53 ),
102 )54 )
103 .handle("create", ({ path, payload }) =>55 .handle("create", ({ path, payload }) =>
104 Effect.gen(function* () {56 withRequestActor("workspaces.create", (actor) =>
105 const service = yield* ControlPlaneWorkspacesService;57 withPolicy(
106 const actor = yield* resolveActor;
107
108 return yield* withPolicy(
109 requireOrganizationWorkspaceManage(path.organizationId),58 requireOrganizationWorkspaceManage(path.organizationId),
110 )(59 )(
111 service.createWorkspace({60 createWorkspace({
112 organizationId: path.organizationId,61 organizationId: path.organizationId,
113 payload,62 payload,
114 createdByAccountId: actor.principal.accountId,63 createdByAccountId: actor.principal.accountId,
115 }),64 }),
116 ).pipe(Effect.provideService(Actor, actor));
117 }).pipe(
118 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
119 Effect.fail(toUnauthorizedError("workspaces.create", cause)),
120 ),
121 Effect.catchTag("ActorForbiddenError", (cause) =>
122 Effect.fail(toForbiddenError("workspaces.create", cause)),
123 ),65 ),
124 ),66 ),
125 )67 )
126 .handle("get", ({ path }) =>68 .handle("get", ({ path }) =>
127 Effect.gen(function* () {69 withWorkspaceRequestActor("workspaces.get", path.workspaceId, () =>
128 const service = yield* ControlPlaneWorkspacesService;70 withPolicy(requireReadWorkspace(path.workspaceId))(
129 const actor = yield* resolveWorkspaceActor(path.workspaceId);71 getWorkspace(path.workspaceId),
130
131 return yield* withPolicy(requireReadWorkspace(path.workspaceId))(
132 service.getWorkspace(path.workspaceId),
133 ).pipe(Effect.provideService(Actor, actor));
134 }).pipe(
135 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
136 Effect.fail(toUnauthorizedError("workspaces.get", cause)),
137 ),
138 Effect.catchTag("ActorForbiddenError", (cause) =>
139 Effect.fail(toForbiddenError("workspaces.get", cause)),
140 ),72 ),
141 ),73 ),
142 )74 )
143 .handle("update", ({ path, payload }) =>75 .handle("update", ({ path, payload }) =>
144 Effect.gen(function* () {76 withWorkspaceRequestActor("workspaces.update", path.workspaceId, () =>
145 const service = yield* ControlPlaneWorkspacesService;77 withPolicy(requireManageWorkspace(path.workspaceId))(
146 const actor = yield* resolveWorkspaceActor(path.workspaceId);78 updateWorkspace({ workspaceId: path.workspaceId, payload }),
147
148 return yield* withPolicy(requireManageWorkspace(path.workspaceId))(
149 service.updateWorkspace({ workspaceId: path.workspaceId, payload }),
150 ).pipe(Effect.provideService(Actor, actor));
151 }).pipe(
152 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
153 Effect.fail(toUnauthorizedError("workspaces.update", cause)),
154 ),
155 Effect.catchTag("ActorForbiddenError", (cause) =>
156 Effect.fail(toForbiddenError("workspaces.update", cause)),
157 ),79 ),
158 ),80 ),
159 )81 )
160 .handle("remove", ({ path }) =>82 .handle("remove", ({ path }) =>
161 Effect.gen(function* () {83 withWorkspaceRequestActor("workspaces.remove", path.workspaceId, () =>
162 const service = yield* ControlPlaneWorkspacesService;84 withPolicy(requireManageWorkspace(path.workspaceId))(
163 const actor = yield* resolveWorkspaceActor(path.workspaceId);85 removeWorkspace({ workspaceId: path.workspaceId }),
164
165 return yield* withPolicy(requireManageWorkspace(path.workspaceId))(
166 service.removeWorkspace({ workspaceId: path.workspaceId }),
167 ).pipe(Effect.provideService(Actor, actor));
168 }).pipe(
169 Effect.catchTag("ActorUnauthenticatedError", (cause) =>
170 Effect.fail(toUnauthorizedError("workspaces.remove", cause)),
171 ),
172 Effect.catchTag("ActorForbiddenError", (cause) =>
173 Effect.fail(toForbiddenError("workspaces.remove", cause)),
174 ),86 ),
175 ),87 ),
176 ),88 ),
packages/control-plane/src/runtime/execution-service.ts+259−280
@@ -1,18 +1,20 @@
1import type { ControlPlaneExecutionsServiceShape } from "#api";
2import {1import {
3 ControlPlaneBadRequestError,2 ControlPlaneBadRequestError,
4 ControlPlaneNotFoundError,3 ControlPlaneNotFoundError,
5 ControlPlaneStorageError,4 ControlPlaneStorageError,
6} from "#api";5} from "#api";
6import type {
7 CreateExecutionPayload,
8 ResumeExecutionPayload,
9} from "../api/executions/api";
7import type { ToolInvoker } from "@executor-v3/codemode-core";10import type { ToolInvoker } from "@executor-v3/codemode-core";
8import {
9 ControlPlanePersistenceError,
10 type SqlControlPlaneRows,
11} from "#persistence";
12import {11import {
13 ExecutionIdSchema,12 ExecutionIdSchema,
13 type AccountId,
14 type Execution,14 type Execution,
15 type ExecutionEnvelope,15 type ExecutionEnvelope,
16 type ExecutionId,
17 type WorkspaceId,
16} from "#schema";18} from "#schema";
17import * as Deferred from "effect/Deferred";19import * as Deferred from "effect/Deferred";
18import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
@@ -24,65 +26,25 @@ import { · 5 unmodified lines
24 ResumeUnsupportedError,26 ResumeUnsupportedError,
25} from "./execution-state";27} from "./execution-state";
26import {28import {
29 LiveExecutionManagerService,
27 type LiveExecutionManager,30 type LiveExecutionManager,
28} from "./live-execution";31} from "./live-execution";
32import {
33 asOperationErrors,
34 operationErrors,
35 type OperationErrorsLike,
36} from "./operation-errors";
37import {
38 ControlPlaneStore,
39 type ControlPlaneStoreShape,
40} from "./store";
41import { RuntimeExecutionResolverService } from "./workspace-execution-environment";
29 42
30const badRequest = (43const executionOps = {
31 operation: string,44 create: operationErrors("executions.create"),
32 message: string,45 get: operationErrors("executions.get"),
33 details: string,46 resume: operationErrors("executions.resume"),
34): ControlPlaneBadRequestError =>47} as const;
35 new ControlPlaneBadRequestError({
36 operation,
37 message,
38 details,
39 });
40
41const notFound = (
42 operation: string,
43 message: string,
44 details: string,
45): ControlPlaneNotFoundError =>
46 new ControlPlaneNotFoundError({
47 operation,
48 message,
49 details,
50 });
51
52const storageFromPersistence = (
53 operation: string,
54 error: ControlPlanePersistenceError,
55): ControlPlaneStorageError =>
56 new ControlPlaneStorageError({
57 operation,
58 message: error.message,
59 details: error.details ?? "Persistence operation failed",
60 });
61
62const mapStorageError = <A>(
63 operation: string,
64 effect: Effect.Effect<A, ControlPlanePersistenceError>,
65): Effect.Effect<A, ControlPlaneStorageError> =>
66 effect.pipe(Effect.mapError((error) => storageFromPersistence(operation, error)));
67
68const requireTrimmed = (
69 operation: string,
70 fieldName: string,
71 value: string,
72): Effect.Effect<string, ControlPlaneBadRequestError> => {
73 const trimmed = value.trim();
74 if (trimmed.length === 0) {
75 return Effect.fail(
76 badRequest(
77 operation,
78 `Invalid ${fieldName}`,
79 `${fieldName} must be a non-empty string`,
80 ),
81 );
82 }
83
84 return Effect.succeed(trimmed);
85};
86 48
87const serializeJson = (value: unknown): string | null => {49const serializeJson = (value: unknown): string | null => {
88 if (value === undefined) {50 if (value === undefined) {
@@ -133,23 +95,22 @@ const withExecutionInvocationContext = (input: { · 44 unmodified lines
133};95};
134 96
135const fetchExecution = (97const fetchExecution = (
136 rows: SqlControlPlaneRows,98 store: ControlPlaneStoreShape,
137 input: {99 input: {
138 workspaceId: Execution["workspaceId"];100 workspaceId: Execution["workspaceId"];
139 executionId: Execution["id"];101 executionId: Execution["id"];
140 operation: string;102 operation: OperationErrorsLike;
141 },103 },
142): Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError> =>104): Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
143 Effect.gen(function* () {105 Effect.gen(function* () {
144 const existing = yield* mapStorageError(106 const errors = asOperationErrors(input.operation);
145 input.operation,107 const existing = yield* errors.mapStorage(
146 rows.executions.getByWorkspaceAndId(input.workspaceId, input.executionId),108 store.executions.getByWorkspaceAndId(input.workspaceId, input.executionId),
147 );109 );
148 110
149 if (Option.isNone(existing)) {111 if (Option.isNone(existing)) {
150 return yield* Effect.fail(112 return yield* Effect.fail(
151 notFound(113 errors.notFound(
152 input.operation,
153 "Execution not found",114 "Execution not found",
154 `workspaceId=${input.workspaceId} executionId=${input.executionId}`,115 `workspaceId=${input.workspaceId} executionId=${input.executionId}`,
155 ),116 ),
@@ -160,18 +121,18 @@ const fetchExecution = ( · 4 unmodified lines
160 });121 });
161 122
162const fetchExecutionEnvelope = (123const fetchExecutionEnvelope = (
163 rows: SqlControlPlaneRows,124 store: ControlPlaneStoreShape,
164 input: {125 input: {
165 workspaceId: Execution["workspaceId"];126 workspaceId: Execution["workspaceId"];
166 executionId: Execution["id"];127 executionId: Execution["id"];
167 operation: string;128 operation: OperationErrorsLike;
168 },129 },
169): Effect.Effect<ExecutionEnvelope, ControlPlaneNotFoundError | ControlPlaneStorageError> =>130): Effect.Effect<ExecutionEnvelope, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
170 Effect.gen(function* () {131 Effect.gen(function* () {
171 const execution = yield* fetchExecution(rows, input);132 const errors = asOperationErrors(input.operation);
172 const pendingInteraction = yield* mapStorageError(133 const execution = yield* fetchExecution(store, input);
173 `${input.operation}.pending_interaction`,134 const pendingInteraction = yield* errors.child("pending_interaction").mapStorage(
174 rows.executionInteractions.getPendingByExecutionId(input.executionId),135 store.executionInteractions.getPendingByExecutionId(input.executionId),
175 );136 );
176 137
177 return {138 return {
@@ -180,224 +141,242 @@ const fetchExecutionEnvelope = ( · 2 unmodified lines
180 };141 };
181 });142 });
182 143
183export const createRuntimeExecutionsService = (144const createExecutionWithDependencies = (
184 rows: SqlControlPlaneRows,145 store: ControlPlaneStoreShape,
185 executionResolver: ResolveExecutionEnvironment,146 executionResolver: ResolveExecutionEnvironment,
186 liveExecutionManager: LiveExecutionManager,147 liveExecutionManager: LiveExecutionManager,
187): ControlPlaneExecutionsServiceShape => {148 input: {
188 const createExecution: ControlPlaneExecutionsServiceShape["createExecution"] = (149 workspaceId: WorkspaceId;
189 { workspaceId, payload, createdByAccountId },150 payload: CreateExecutionPayload;
190 ) =>151 createdByAccountId: AccountId;
191 Effect.gen(function* () {152 },
192 const code = yield* requireTrimmed("executions.create", "code", payload.code);153) =>
193 const now = Date.now();154 Effect.gen(function* () {
194 const execution: Execution = {155 const code = input.payload.code;
195 id: ExecutionIdSchema.make(`exec_${crypto.randomUUID()}`),156 const now = Date.now();
196 workspaceId,157 const execution: Execution = {
197 createdByAccountId,158 id: ExecutionIdSchema.make(`exec_${crypto.randomUUID()}`),
198 status: "pending",159 workspaceId: input.workspaceId,
199 code,160 createdByAccountId: input.createdByAccountId,
200 resultJson: null,161 status: "pending",
201 errorText: null,162 code,
202 logsJson: null,163 resultJson: null,
203 startedAt: null,164 errorText: null,
204 completedAt: null,165 logsJson: null,
205 createdAt: now,166 startedAt: null,
206 updatedAt: now,167 completedAt: null,
207 };168 createdAt: now,
208 169 updatedAt: now,
209 yield* mapStorageError(170 };
210 "executions.create.insert",171
211 rows.executions.insert(execution),172 yield* executionOps.create.child("insert").mapStorage(
212 );173 store.executions.insert(execution),
213 174 );
214 const running = yield* mapStorageError(175
215 "executions.create.mark_running",176 const running = yield* executionOps.create.child("mark_running").mapStorage(
216 rows.executions.update(execution.id, {177 store.executions.update(execution.id, {
217 status: "running",178 status: "running",
218 startedAt: now,179 startedAt: now,
219 updatedAt: now,180 updatedAt: now,
220 }),181 }),
221 );182 );
222 183
223 if (Option.isNone(running)) {184 if (Option.isNone(running)) {
224 return yield* Effect.fail(185 return yield* Effect.fail(
225 notFound(186 executionOps.create.notFound(
226 "executions.create",187 "Execution not found after insert",
227 "Execution not found after insert",188 `executionId=${execution.id}`,
228 `executionId=${execution.id}`,189 ),
229 ),190 );
230 );191 }
231 }192
232 193 const environment = yield* executionResolver({
233 const environment = yield* executionResolver({194 workspaceId: input.workspaceId,
234 workspaceId,195 accountId: input.createdByAccountId,
235 accountId: createdByAccountId,196 executionId: execution.id,
236 executionId: execution.id,197 onElicitation: liveExecutionManager.createOnElicitation({
237 onElicitation: liveExecutionManager.createOnElicitation({198 rows: store,
238 rows,199 executionId: execution.id,
239 executionId: execution.id,200 }),
240 }),201 }).pipe(
241 }).pipe(202 Effect.mapError((error) =>
242 Effect.mapError((error) =>203 executionOps.create.child("environment").unknownStorage(
243 new ControlPlaneStorageError({204 error,
244 operation: "executions.create.environment",205 "Execution environment resolution failed",
245 message: error instanceof Error ? error.message : String(error),206 ),
246 details: "Execution environment resolution failed",207 ),
247 }),208 );
248 ),209
249 );210 const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id);
250 211 const toolInvoker = withExecutionInvocationContext({
251 const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id);212 executionId: execution.id,
252 const toolInvoker = withExecutionInvocationContext({213 toolInvoker: environment.toolInvoker,
253 executionId: execution.id,214 });
254 toolInvoker: environment.toolInvoker,215
255 });216 yield* Effect.sync(() => {
256 217 Effect.runFork(
257 yield* Effect.sync(() => {218 environment.executor.execute(code, toolInvoker).pipe(
258 Effect.runFork(219 Effect.flatMap((outcome) => {
259 environment.executor.execute(code, toolInvoker).pipe(220 const completedAt = Date.now();
260 Effect.flatMap((outcome) => {221 return executionOps.create.child("complete").mapStorage(
261 const completedAt = Date.now();222 store.executions.update(execution.id, {
262 return mapStorageError(223 status: outcome.error ? "failed" : "completed",
263 "executions.create.complete",224 resultJson: serializeJson(outcome.result),
264 rows.executions.update(execution.id, {225 errorText: outcome.error ?? null,
265 status: outcome.error ? "failed" : "completed",226 logsJson: serializeJson(outcome.logs ?? null),
266 resultJson: serializeJson(outcome.result),227 completedAt,
267 errorText: outcome.error ?? null,228 updatedAt: completedAt,
268 logsJson: serializeJson(outcome.logs ?? null),
269 completedAt,
270 updatedAt: completedAt,
271 }),
272 ).pipe(
273 Effect.flatMap((updated) =>
274 Option.isNone(updated)
275 ? Effect.fail(
276 notFound(
277 "executions.create",
278 "Execution not found after completion",
279 `executionId=${execution.id}`,
280 ),
281 )
282 : Effect.succeed(updated.value),
283 ),
284 Effect.flatMap((updated) =>
285 liveExecutionManager.finishRun({
286 executionId: execution.id,
287 state: updated.status === "completed" ? "completed" : "failed",
288 }).pipe(Effect.as(updated)),
289 ),
290 );
291 }),229 }),
292 Effect.catchAll((error) => {230 ).pipe(
293 const completedAt = Date.now();231 Effect.flatMap((updated) =>
294 return mapStorageError(232 Option.isNone(updated)
295 "executions.create.complete_error",233 ? Effect.fail(
296 rows.executions.update(execution.id, {234 executionOps.create.notFound(
297 status: "failed",235 "Execution not found after completion",
298 errorText: error instanceof Error ? error.message : String(error),236 `executionId=${execution.id}`,
299 completedAt,237 ),
300 updatedAt: completedAt,238 )
301 }),239 : Effect.succeed(updated.value),
302 ).pipe(240 ),
303 Effect.zipRight(241 Effect.flatMap((updated) =>
304 liveExecutionManager.finishRun({242 liveExecutionManager.finishRun({
305 executionId: execution.id,243 executionId: execution.id,
306 state: "failed",244 state: updated.status === "completed" ? "completed" : "failed",
307 }),245 }).pipe(Effect.as(updated)),
308 ),246 ),
309 Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)),247 );
310 );248 }),
249 Effect.catchAll((error) => {
250 const completedAt = Date.now();
251 return executionOps.create.child("complete_error").mapStorage(
252 store.executions.update(execution.id, {
253 status: "failed",
254 errorText: error instanceof Error ? error.message : String(error),
255 completedAt,
256 updatedAt: completedAt,
311 }),257 }),
312 ),258 ).pipe(
313 );259 Effect.zipRight(
314 });260 liveExecutionManager.finishRun({
261 executionId: execution.id,
262 state: "failed",
263 }),
264 ),
265 Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)),
266 );
267 }),
268 ),
269 );
270 });
315 271
316 yield* Deferred.await(nextState);272 yield* Deferred.await(nextState);
317 273
318 return yield* fetchExecutionEnvelope(rows, {274 return yield* fetchExecutionEnvelope(store, {
319 workspaceId,275 workspaceId: input.workspaceId,
320 executionId: execution.id,276 executionId: execution.id,
321 operation: "executions.create",277 operation: executionOps.create,
322 });278 });
323 });279 });
324 280
325 return {281export const createExecution = (input: {
326 createExecution,282 workspaceId: WorkspaceId;
327 getExecution: ({ workspaceId, executionId }) =>283 payload: CreateExecutionPayload;
328 fetchExecutionEnvelope(rows, {284 createdByAccountId: AccountId;
329 workspaceId,285}) =>
330 executionId,286 Effect.gen(function* () {
331 operation: "executions.get",287 const store = yield* ControlPlaneStore;
332 }),288 const executionResolver = yield* RuntimeExecutionResolverService;
289 const liveExecutionManager = yield* LiveExecutionManagerService;
290
291 return yield* createExecutionWithDependencies(
292 store,
293 executionResolver,
294 liveExecutionManager,
295 input,
296 );
297 });
333 298
334 resumeExecution: ({ workspaceId, executionId, payload }) =>299export const getExecution = (input: {
335 Effect.gen(function* () {300 workspaceId: WorkspaceId;
336 const existing = yield* fetchExecutionEnvelope(rows, {301 executionId: ExecutionId;
337 workspaceId,302}) =>
338 executionId,303 Effect.flatMap(ControlPlaneStore, (store) =>
339 operation: "executions.resume",304 fetchExecutionEnvelope(store, {
340 });305 workspaceId: input.workspaceId,
341 306 executionId: input.executionId,
342 if (existing.execution.status !== "waiting_for_interaction") {307 operation: executionOps.get,
343 return yield* Effect.fail(308 })
344 badRequest(309 );
345 "executions.resume",310
346 "Execution is not waiting for interaction",311export const resumeExecution = (input: {
347 `executionId=${executionId} status=${existing.execution.status}`,312 workspaceId: WorkspaceId;
348 ),313 executionId: ExecutionId;
349 );314 payload: ResumeExecutionPayload;
350 }315 resumedByAccountId: AccountId;
351 316}) =>
352 const responseJson = payload.responseJson;317 Effect.gen(function* () {
353 const response =318 const store = yield* ControlPlaneStore;
354 responseJson === undefined319 const liveExecutionManager = yield* LiveExecutionManagerService;
355 ? { action: "accept" as const }320
356 : yield* Effect.try({321 const existing = yield* fetchExecutionEnvelope(store, {
357 try: () => JSON.parse(responseJson),322 workspaceId: input.workspaceId,
358 catch: (error) =>323 executionId: input.executionId,
359 badRequest(324 operation: "executions.resume",
360 "executions.resume",325 });
326
327 if (existing.execution.status !== "waiting_for_interaction") {
328 return yield* Effect.fail(
329 executionOps.resume.badRequest(
330 "Execution is not waiting for interaction",
331 `executionId=${input.executionId} status=${existing.execution.status}`,
332 ),
333 );
334 }
335
336 const responseJson = input.payload.responseJson;
337 const response =
338 responseJson === undefined
339 ? { action: "accept" as const }
340 : yield* Effect.try({
341 try: () => JSON.parse(responseJson),
342 catch: (error) =>
343 executionOps.resume.badRequest(
344 "Invalid responseJson",
345 error instanceof Error ? error.message : String(error),
346 ),
347 }).pipe(
348 Effect.flatMap((decoded) =>
349 decodeElicitationResponse(decoded).pipe(
350 Effect.mapError((error) =>
351 executionOps.resume.badRequest(
361 "Invalid responseJson",352 "Invalid responseJson",
362 error instanceof Error ? error.message : String(error),353 String(error),
363 ),
364 }).pipe(
365 Effect.flatMap((decoded) =>
366 decodeElicitationResponse(decoded).pipe(
367 Effect.mapError((error) =>
368 badRequest(
369 "executions.resume",
370 "Invalid responseJson",
371 String(error),
372 ),
373 ),
374 ),354 ),
375 ),355 ),
376 );356 ),
377
378 const nextState = yield* liveExecutionManager.registerStateWaiter(executionId);
379 const resumed = yield* liveExecutionManager.resolveInteraction({
380 executionId,
381 response,
382 });
383
384 if (!resumed) {
385 return yield* Effect.fail(
386 badRequest(
387 "executions.resume",
388 "Resume is unavailable for this execution",
389 `executionId=${executionId} mode=${new ResumeUnsupportedError({ executionId })._tag}`,
390 ),357 ),
391 );358 );
392 }
393 359
394 yield* Deferred.await(nextState);360 const nextState = yield* liveExecutionManager.registerStateWaiter(input.executionId);
361 const resumed = yield* liveExecutionManager.resolveInteraction({
362 executionId: input.executionId,
363 response,
364 });
395 365
396 return yield* fetchExecutionEnvelope(rows, {366 if (!resumed) {
397 workspaceId,367 return yield* Effect.fail(
398 executionId,368 executionOps.resume.badRequest(
399 operation: "executions.resume",369 "Resume is unavailable for this execution",
400 });370 `executionId=${input.executionId} mode=${new ResumeUnsupportedError({ executionId: input.executionId })._tag}`,
401 }),371 ),
402 };372 );
403};373 }
374
375 yield* Deferred.await(nextState);
376
377 return yield* fetchExecutionEnvelope(store, {
378 workspaceId: input.workspaceId,
379 executionId: input.executionId,
380 operation: executionOps.resume,
381 });
382 });
packages/control-plane/src/runtime/index.ts+46−120
@@ -5,23 +5,9 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
5import * as Scope from "effect/Scope";5import * as Scope from "effect/Scope";
6 6
7import {7import {
8 type ControlPlaneApiServiceContext,8 type ControlPlaneApiRuntimeContext,
9 ControlPlaneActorResolver,9 ControlPlaneActorResolver,
10 type ControlPlaneActorResolverShape,10 type ControlPlaneActorResolverShape,
11 ControlPlaneExecutionsService,
12 type ControlPlaneExecutionsServiceShape,
13 ControlPlaneLocalService,
14 type ControlPlaneLocalServiceShape,
15 ControlPlaneMembershipsService,
16 type ControlPlaneMembershipsServiceShape,
17 ControlPlaneOrganizationsService,
18 type ControlPlaneOrganizationsServiceShape,
19 ControlPlanePoliciesService,
20 type ControlPlanePoliciesServiceShape,
21 ControlPlaneSourcesService,
22 type ControlPlaneSourcesServiceShape,
23 ControlPlaneWorkspacesService,
24 type ControlPlaneWorkspacesServiceShape,
25} from "#api";11} from "#api";
26import {12import {
27 SqlControlPlanePersistenceLive,13 SqlControlPlanePersistenceLive,
@@ -31,31 +17,31 @@ import { · 3 unmodified lines
31 type CreateSqlRuntimeOptions,17 type CreateSqlRuntimeOptions,
32 type SqlControlPlanePersistence,18 type SqlControlPlanePersistence,
33} from "#persistence";19} from "#persistence";
34
35import type { LocalInstallation } from "#schema";20import type { LocalInstallation } from "#schema";
21
36import {22import {
37 ControlPlaneAuthHeaders,23 ControlPlaneAuthHeaders,
38 RuntimeActorResolverLive,24 RuntimeActorResolverLive,
39 createHeaderActorResolver,25 createHeaderActorResolver,
40} from "./actor-resolver";26} from "./actor-resolver";
41import {27import { type ResolveExecutionEnvironment } from "./execution-state";
42 type ResolveExecutionEnvironment,
43} from "./execution-state";
44import {28import {
45 LiveExecutionManagerLive,29 LiveExecutionManagerLive,
30 LiveExecutionManagerService,
46} from "./live-execution";31} from "./live-execution";
32import { getOrProvisionLocalInstallation } from "./local-installation";
47import {33import {
48 getOrProvisionLocalInstallation,34 ControlPlaneStore,
49} from "./local-installation";35 ControlPlaneStoreLive,
36} from "./store";
50import {37import {
51 RuntimeSourceAuthServiceLive,38 RuntimeSourceAuthServiceLive,
39 RuntimeSourceAuthServiceTag,
52 type ResolveSecretMaterial,40 type ResolveSecretMaterial,
53} from "./source-auth-service";41} from "./source-auth-service";
54import {
55 RuntimeControlPlaneApiServicesLive,
56} from "./services";
57import {42import {
58 RuntimeExecutionResolverLive,43 RuntimeExecutionResolverLive,
44 RuntimeExecutionResolverService,
59} from "./workspace-execution-environment";45} from "./workspace-execution-environment";
60 46
61export {47export {
@@ -67,6 +53,7 @@ export * from "./execution-state"; · 5 unmodified lines
67export * from "./live-execution";53export * from "./live-execution";
68export * from "./local-installation";54export * from "./local-installation";
69export * from "./source-auth-service";55export * from "./source-auth-service";
56export * from "./store";
70export * from "./workspace-execution-environment";57export * from "./workspace-execution-environment";
71 58
72export type RuntimeControlPlaneOptions = {59export type RuntimeControlPlaneOptions = {
@@ -76,10 +63,6 @@ export type RuntimeControlPlaneOptions = { · 3 unmodified lines
76 getLocalServerBaseUrl?: () => string | undefined;63 getLocalServerBaseUrl?: () => string | undefined;
77};64};
78 65
79export type RuntimeControlPlaneInput = RuntimeControlPlaneOptions & {
80 persistence: SqlControlPlanePersistence;
81};
82
83const detailsFromCause = (cause: unknown): string =>66const detailsFromCause = (cause: unknown): string =>
84 cause instanceof Error ? cause.message : String(cause);67 cause instanceof Error ? cause.message : String(cause);
85 68
@@ -96,23 +79,31 @@ const toLocalInstallationBootstrapError = ( · 10 unmodified lines
96const closeScope = (scope: Scope.CloseableScope) =>79const closeScope = (scope: Scope.CloseableScope) =>
97 Scope.close(scope, Exit.void).pipe(Effect.orDie);80 Scope.close(scope, Exit.void).pipe(Effect.orDie);
98 81
99const createRuntimeServicesLayer = (input: {82export type RuntimeControlPlaneLayer = Layer.Layer<
100 organizationsService: ControlPlaneOrganizationsServiceShape;83 ControlPlaneApiRuntimeContext,
101 membershipsService: ControlPlaneMembershipsServiceShape;84 never,
102 workspacesService: ControlPlaneWorkspacesServiceShape;85 never
103 sourcesService: ControlPlaneSourcesServiceShape;86>;
104 policiesService: ControlPlanePoliciesServiceShape;87
105 localService: ControlPlaneLocalServiceShape;88const runtimeContextTags = [
106 executionsService: ControlPlaneExecutionsServiceShape;89 ControlPlaneActorResolver,
107}): Layer.Layer<ControlPlaneApiServiceContext, never, never> =>90 ControlPlaneStore,
108 Layer.mergeAll(91 LiveExecutionManagerService,
109 Layer.succeed(ControlPlaneOrganizationsService, input.organizationsService),92 RuntimeSourceAuthServiceTag,
110 Layer.succeed(ControlPlaneMembershipsService, input.membershipsService),93 RuntimeExecutionResolverService,
111 Layer.succeed(ControlPlaneWorkspacesService, input.workspacesService),94] as const;
112 Layer.succeed(ControlPlaneSourcesService, input.sourcesService),95
113 Layer.succeed(ControlPlanePoliciesService, input.policiesService),96const createRuntimeLayerFromContext = (
114 Layer.succeed(ControlPlaneLocalService, input.localService),97 context: Context.Context<
115 Layer.succeed(ControlPlaneExecutionsService, input.executionsService),98 ControlPlaneActorResolver
99 | ControlPlaneStore
100 | LiveExecutionManagerService
101 | RuntimeSourceAuthServiceTag
102 | RuntimeExecutionResolverService
103 >,
104): RuntimeControlPlaneLayer =>
105 Layer.succeedContext(
106 context.pipe(Context.pick(...runtimeContextTags)),
116 );107 );
117 108
118export const createRuntimeControlPlaneLayer = (109export const createRuntimeControlPlaneLayer = (
@@ -130,67 +121,20 @@ export const createRuntimeControlPlaneLayer = ( · 11 unmodified lines
130 }).pipe(121 }).pipe(
131 Layer.provide(sourceAuthLayer),122 Layer.provide(sourceAuthLayer),
132 );123 );
133 const runtimeDependenciesLayer = Layer.mergeAll(
134 liveExecutionManagerLayer,
135 sourceAuthLayer,
136 executionResolverLayer,
137 );
138 const apiServicesLayer = RuntimeControlPlaneApiServicesLive.pipe(
139 Layer.provide(runtimeDependenciesLayer),
140 );
141 124
142 return Layer.mergeAll(125 return Layer.mergeAll(
143 apiServicesLayer,126 ControlPlaneStoreLive,
144 RuntimeActorResolverLive(options.actorResolver),127 RuntimeActorResolverLive(options.actorResolver),
145 runtimeDependenciesLayer,128 liveExecutionManagerLayer,
129 sourceAuthLayer,
130 executionResolverLayer,
146 );131 );
147};132};
148 133
149export const createRuntimeControlPlane = (
150 input: RuntimeControlPlaneInput,
151): Effect.Effect<{
152 serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, never, never>;
153 actorResolver: ControlPlaneActorResolverShape;
154}> =>
155 Effect.gen(function* () {
156 const actorResolver = yield* ControlPlaneActorResolver;
157 const organizationsService = yield* ControlPlaneOrganizationsService;
158 const membershipsService = yield* ControlPlaneMembershipsService;
159 const workspacesService = yield* ControlPlaneWorkspacesService;
160 const sourcesService = yield* ControlPlaneSourcesService;
161 const policiesService = yield* ControlPlanePoliciesService;
162 const localService = yield* ControlPlaneLocalService;
163 const executionsService = yield* ControlPlaneExecutionsService;
164 const serviceLayer = createRuntimeServicesLayer({
165 organizationsService,
166 membershipsService,
167 workspacesService,
168 sourcesService,
169 policiesService,
170 localService,
171 executionsService,
172 });
173
174 return {
175 serviceLayer,
176 actorResolver,
177 };
178 }).pipe(
179 Effect.provide(
180 createRuntimeControlPlaneLayer(input).pipe(
181 Layer.provide(SqlControlPlaneRowsLive),
182 Layer.provide(
183 Layer.succeed(SqlControlPlanePersistenceService, input.persistence),
184 ),
185 ),
186 ),
187 );
188
189export type SqlControlPlaneRuntime = {134export type SqlControlPlaneRuntime = {
190 persistence: SqlControlPlanePersistence;135 persistence: SqlControlPlanePersistence;
191 localInstallation: LocalInstallation;136 localInstallation: LocalInstallation;
192 serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, never, never>;137 runtimeLayer: RuntimeControlPlaneLayer;
193 actorResolver: ControlPlaneActorResolverShape;
194 close: () => Promise<void>;138 close: () => Promise<void>;
195};139};
196 140
@@ -217,27 +161,10 @@ export const createSqlControlPlaneRuntime = ( · 20 unmodified lines
217 );161 );
218 162
219 const persistence = Context.get(context, SqlControlPlanePersistenceService);163 const persistence = Context.get(context, SqlControlPlanePersistenceService);
220 const actorResolver = Context.get(context, ControlPlaneActorResolver);164 const store = Context.get(context, ControlPlaneStore);
221 const organizationsService = Context.get(context, ControlPlaneOrganizationsService);165 const concreteRuntimeLayer = createRuntimeLayerFromContext(context);
222 const membershipsService = Context.get(context, ControlPlaneMembershipsService);166
223 const workspacesService = Context.get(context, ControlPlaneWorkspacesService);167 const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe(
224 const sourcesService = Context.get(context, ControlPlaneSourcesService);
225 const policiesService = Context.get(context, ControlPlanePoliciesService);
226 const localService = Context.get(context, ControlPlaneLocalService);
227 const executionsService = Context.get(context, ControlPlaneExecutionsService);
228 const serviceLayer = createRuntimeServicesLayer({
229 organizationsService,
230 membershipsService,
231 workspacesService,
232 sourcesService,
233 policiesService,
234 localService,
235 executionsService,
236 });
237
238 const localInstallation = yield* getOrProvisionLocalInstallation(
239 persistence.rows,
240 ).pipe(
241 Effect.mapError(toLocalInstallationBootstrapError),168 Effect.mapError(toLocalInstallationBootstrapError),
242 Effect.catchAll((error) =>169 Effect.catchAll((error) =>
243 closeScope(scope).pipe(170 closeScope(scope).pipe(
@@ -248,8 +175,7 @@ export const createSqlControlPlaneRuntime = ( · 4 unmodified lines
248 return {175 return {
249 persistence,176 persistence,
250 localInstallation,177 localInstallation,
251 serviceLayer,178 runtimeLayer: concreteRuntimeLayer,
252 actorResolver,
253 close: () => Effect.runPromise(Scope.close(scope, Exit.void)),179 close: () => Effect.runPromise(Scope.close(scope, Exit.void)),
254 };180 };
255 });181 });
packages/control-plane/src/runtime/local-operations.ts+60−0
@@ -0,0 +1,60 @@
1import { ControlPlanePersistenceError } from "#persistence";
2import * as Effect from "effect/Effect";
3
4import { loadLocalInstallation } from "./local-installation";
5import {
6 operationErrors,
7} from "./operation-errors";
8import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
9import { ControlPlaneStore } from "./store";
10
11const localOps = {
12 installation: operationErrors("local.installation.get"),
13 oauthCallback: operationErrors("local.oauth.callback"),
14} as const;
15
16export const getLocalInstallation = () =>
17 Effect.gen(function* () {
18 const store = yield* ControlPlaneStore;
19
20 const installation = yield* loadLocalInstallation(store).pipe(
21 Effect.mapError((error) =>
22 error instanceof ControlPlanePersistenceError
23 ? localOps.installation.storage(error)
24 : localOps.installation.unknownStorage(
25 error,
26 "Failed loading local installation",
27 ),
28 ),
29 );
30
31 if (installation === null) {
32 return yield* Effect.fail(
33 localOps.installation.notFound(
34 "Local installation not found",
35 "No local installation has been provisioned",
36 ),
37 );
38 }
39
40 return installation;
41 });
42
43export const completeSourceAuthCallback = (input: {
44 state: string;
45 code?: string | null;
46 error?: string | null;
47 errorDescription?: string | null;
48}) =>
49 Effect.gen(function* () {
50 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
51
52 return yield* sourceAuthService.completeSourceAuthCallback(input).pipe(
53 Effect.mapError((error) =>
54 localOps.oauthCallback.unknownStorage(
55 error,
56 "Failed completing source auth callback",
57 ),
58 ),
59 );
60 });
packages/control-plane/src/runtime/operation-errors.ts+99−0
@@ -0,0 +1,99 @@
1import {
2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,
5} from "#api";
6import { ControlPlanePersistenceError } from "#persistence";
7import * as Effect from "effect/Effect";
8
9const unknownPersistenceError = (
10 operation: string,
11 cause: unknown,
12 details: string,
13): ControlPlanePersistenceError =>
14 new ControlPlanePersistenceError({
15 operation,
16 message: cause instanceof Error ? cause.message : String(cause),
17 details,
18 code: null,
19 constraint: null,
20 table: null,
21 kind: "unknown",
22 cause,
23 });
24
25export type OperationErrors<TOperation extends string = string> = {
26 readonly operation: TOperation;
27 readonly child: <TSuffix extends string>(
28 suffix: TSuffix,
29 ) => OperationErrors<`${TOperation}.${TSuffix}`>;
30 readonly badRequest: (
31 message: string,
32 details: string,
33 ) => ControlPlaneBadRequestError;
34 readonly notFound: (
35 message: string,
36 details: string,
37 ) => ControlPlaneNotFoundError;
38 readonly storage: (
39 error: ControlPlanePersistenceError,
40 ) => ControlPlaneStorageError;
41 readonly unknownPersistence: (
42 cause: unknown,
43 details: string,
44 ) => ControlPlanePersistenceError;
45 readonly unknownStorage: (
46 cause: unknown,
47 details: string,
48 ) => ControlPlaneStorageError;
49 readonly mapStorage: <A>(
50 effect: Effect.Effect<A, ControlPlanePersistenceError>,
51 ) => Effect.Effect<A, ControlPlaneStorageError>;
52};
53
54export type OperationErrorsLike = OperationErrors | string;
55
56export const operationErrors = <TOperation extends string>(
57 operation: TOperation,
58): OperationErrors<TOperation> => {
59 const self: OperationErrors<TOperation> = {
60 operation,
61 child: (suffix) =>
62 operationErrors(`${operation}.${suffix}` as `${TOperation}.${typeof suffix}`),
63 badRequest: (message, details) =>
64 new ControlPlaneBadRequestError({
65 operation,
66 message,
67 details,
68 }),
69 notFound: (message, details) =>
70 new ControlPlaneNotFoundError({
71 operation,
72 message,
73 details,
74 }),
75 storage: (error) =>
76 new ControlPlaneStorageError({
77 operation,
78 message: error.message,
79 details: error.details ?? "Persistence operation failed",
80 }),
81 unknownPersistence: (cause, details) =>
82 unknownPersistenceError(operation, cause, details),
83 unknownStorage: (cause, details) =>
84 self.storage(self.unknownPersistence(cause, details)),
85 mapStorage: (effect) =>
86 effect.pipe(
87 Effect.mapError(self.storage),
88 ),
89 };
90
91 return self;
92};
93
94export const asOperationErrors = (
95 errors: OperationErrorsLike,
96): OperationErrors =>
97 typeof errors === "string"
98 ? operationErrors(errors)
99 : errors;
packages/control-plane/src/runtime/operations-shared.ts+102−0
@@ -0,0 +1,102 @@
1import {
2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,
5} from "#api";
6import { ControlPlanePersistenceError } from "#persistence";
7import type { Organization } from "#schema";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10
11import {
12 asOperationErrors,
13 operationErrors,
14 type OperationErrorsLike,
15} from "./operation-errors";
16import type { ControlPlaneStoreShape } from "./store";
17import { slugify } from "./slug";
18
19export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
20
21const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>
22 error.kind === "unique_violation";
23
24export const mapPersistenceError = <A>(
25 operation: OperationErrorsLike,
26 effect: Effect.Effect<A, ControlPlanePersistenceError>,
27): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> =>
28 effect.pipe(
29 Effect.mapError((error) => {
30 const errors = asOperationErrors(operation);
31 return isUniqueViolation(error)
32 ? errors.badRequest("Unique constraint violation", error.details ?? "duplicate key")
33 : errors.storage(error);
34 }),
35 );
36
37export const parseJsonString = (
38 operation: OperationErrorsLike,
39 fieldName: string,
40 value: string,
41): Effect.Effect<string, ControlPlaneBadRequestError> =>
42 Effect.try({
43 try: () => {
44 JSON.parse(value);
45 return value;
46 },
47 catch: () => {
48 const errors = asOperationErrors(operation);
49 return errors.badRequest(
50 `Invalid ${fieldName}`,
51 `${fieldName} must be valid JSON`,
52 );
53 },
54 });
55
56export const ensureUniqueOrganizationSlug = (
57 store: ControlPlaneStoreShape,
58 baseName: string,
59 operation = operationErrors("organizations.create"),
60): Effect.Effect<string, ControlPlaneStorageError> =>
61 Effect.gen(function* () {
62 const normalized = slugify(baseName);
63 const seed = normalized.length > 0 ? normalized : "item";
64
65 let counter = 0;
66 while (true) {
67 const candidate = counter === 0 ? seed : `${seed}-${counter + 1}`;
68
69 const existing = yield* operation.child("slug_lookup").mapStorage(
70 store.organizations.getBySlug(candidate as Organization["slug"]),
71 );
72
73 if (Option.isNone(existing)) {
74 return candidate;
75 }
76
77 counter += 1;
78 }
79 });
80
81export const ensureOrganizationExists = (
82 store: ControlPlaneStoreShape,
83 operation: OperationErrorsLike,
84 organizationId: Organization["id"],
85): Effect.Effect<Organization, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
86 Effect.gen(function* () {
87 const errors = asOperationErrors(operation);
88 const organization = yield* errors.child("organization_lookup").mapStorage(
89 store.organizations.getById(organizationId),
90 );
91
92 if (Option.isNone(organization)) {
93 return yield* Effect.fail(
94 errors.notFound(
95 "Organization not found",
96 `organizationId=${organizationId}`,
97 ),
98 );
99 }
100
101 return organization.value;
102 });
packages/control-plane/src/runtime/organizations-operations.ts+434−0
@@ -0,0 +1,434 @@
1import type {
2 CreateMembershipPayload,
3 UpdateMembershipPayload,
4} from "../api/memberships/api";
5import type {
6 CreateOrganizationPayload,
7 UpdateOrganizationPayload,
8} from "../api/organizations/api";
9import type {
10 CreateWorkspacePayload,
11 UpdateWorkspacePayload,
12} from "../api/workspaces/api";
13import {
14 OrganizationIdSchema,
15 OrganizationMemberIdSchema,
16 WorkspaceIdSchema,
17 type AccountId,
18 type Organization,
19 type OrganizationId,
20 type OrganizationMembership,
21 type Workspace,
22 type WorkspaceId,
23} from "#schema";
24import * as Effect from "effect/Effect";
25import * as Option from "effect/Option";
26
27import {
28 ensureOrganizationExists,
29 ensureUniqueOrganizationSlug,
30 mapPersistenceError,
31 type Mutable,
32} from "./operations-shared";
33import {
34 operationErrors,
35} from "./operation-errors";
36import {
37 ControlPlaneStore,
38 type ControlPlaneStoreShape,
39} from "./store";
40
41const organizationOps = {
42 list: operationErrors("organizations.list"),
43 create: operationErrors("organizations.create"),
44 get: operationErrors("organizations.get"),
45 update: operationErrors("organizations.update"),
46 remove: operationErrors("organizations.remove"),
47} as const;
48
49const membershipOps = {
50 list: operationErrors("memberships.list"),
51 create: operationErrors("memberships.create"),
52 update: operationErrors("memberships.update"),
53 remove: operationErrors("memberships.remove"),
54} as const;
55
56const workspaceOps = {
57 list: operationErrors("workspaces.list"),
58 create: operationErrors("workspaces.create"),
59 get: operationErrors("workspaces.get"),
60 update: operationErrors("workspaces.update"),
61 remove: operationErrors("workspaces.remove"),
62} as const;
63
64const listOrganizationsWithStore = (
65 store: ControlPlaneStoreShape,
66 input: { accountId: AccountId },
67) =>
68 Effect.gen(function* () {
69 const memberships = yield* organizationOps.list.child("memberships").mapStorage(
70 store.organizationMemberships.listByAccountId(input.accountId),
71 );
72
73 const activeOrganizationIds = Array.from(
74 new Set(
75 memberships
76 .filter((membership) => membership.status === "active")
77 .map((membership) => membership.organizationId),
78 ),
79 );
80
81 const organizations = yield* Effect.forEach(activeOrganizationIds, (organizationId) =>
82 organizationOps.list.child("organization").mapStorage(
83 store.organizations.getById(organizationId),
84 ).pipe(
85 Effect.map((result) => (Option.isSome(result) ? result.value : null)),
86 ));
87
88 return organizations.filter((organization): organization is Organization => organization !== null);
89 });
90
91export const listOrganizations = (input: { accountId: AccountId }) =>
92 Effect.flatMap(ControlPlaneStore, (store) => listOrganizationsWithStore(store, input));
93
94export const createOrganization = (input: {
95 payload: CreateOrganizationPayload;
96 createdByAccountId?: Organization["createdByAccountId"];
97}) =>
98 Effect.flatMap(ControlPlaneStore, (store) =>
99 Effect.gen(function* () {
100 const name = input.payload.name;
101 const now = Date.now();
102
103 const slug = input.payload.slug
104 ? input.payload.slug
105 : yield* ensureUniqueOrganizationSlug(store, name, organizationOps.create);
106
107 const organization: Organization = {
108 id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
109 slug,
110 name,
111 status: "active",
112 createdByAccountId: input.createdByAccountId ?? null,
113 createdAt: now,
114 updatedAt: now,
115 };
116
117 const ownerMembership: OrganizationMembership | null = input.createdByAccountId
118 ? {
119 id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
120 organizationId: organization.id,
121 accountId: input.createdByAccountId,
122 role: "owner",
123 status: "active",
124 billable: true,
125 invitedByAccountId: null,
126 joinedAt: now,
127 createdAt: now,
128 updatedAt: now,
129 }
130 : null;
131
132 yield* mapPersistenceError(
133 organizationOps.create,
134 store.organizations.insertWithOwnerMembership(organization, ownerMembership),
135 );
136
137 return organization;
138 }));
139
140export const getOrganization = (input: {
141 organizationId: OrganizationId;
142 accountId: AccountId;
143}) =>
144 Effect.flatMap(ControlPlaneStore, (store) =>
145 Effect.gen(function* () {
146 const membership = yield* organizationOps.get.child("membership").mapStorage(
147 store.organizationMemberships.getByOrganizationAndAccount(
148 input.organizationId,
149 input.accountId,
150 ),
151 );
152
153 if (Option.isNone(membership) || membership.value.status !== "active") {
154 return yield* Effect.fail(
155 organizationOps.get.notFound(
156 "Organization not found",
157 `organizationId=${input.organizationId}`,
158 ),
159 );
160 }
161
162 const existing = yield* organizationOps.get.mapStorage(
163 store.organizations.getById(input.organizationId),
164 );
165
166 if (Option.isNone(existing)) {
167 return yield* Effect.fail(
168 organizationOps.get.notFound(
169 "Organization not found",
170 `organizationId=${input.organizationId}`,
171 ),
172 );
173 }
174
175 return existing.value;
176 }));
177
178export const updateOrganization = (input: {
179 organizationId: OrganizationId;
180 payload: UpdateOrganizationPayload;
181}) =>
182 Effect.flatMap(ControlPlaneStore, (store) =>
183 Effect.gen(function* () {
184 const patch: Partial<Omit<Mutable<Organization>, "id" | "createdAt">> = {
185 updatedAt: Date.now(),
186 };
187
188 if (input.payload.name !== undefined) {
189 patch.name = input.payload.name;
190 }
191 if (input.payload.status !== undefined) {
192 patch.status = input.payload.status;
193 }
194
195 const updated = yield* mapPersistenceError(
196 organizationOps.update,
197 store.organizations.update(input.organizationId, patch),
198 );
199
200 if (Option.isNone(updated)) {
201 return yield* Effect.fail(
202 organizationOps.update.notFound(
203 "Organization not found",
204 `organizationId=${input.organizationId}`,
205 ),
206 );
207 }
208
209 return updated.value;
210 }));
211
212export const removeOrganization = (input: { organizationId: OrganizationId }) =>
213 Effect.flatMap(ControlPlaneStore, (store) =>
214 organizationOps.remove.mapStorage(
215 store.organizations.removeTreeById(input.organizationId),
216 ).pipe(Effect.map((removed) => ({ removed })))
217 );
218
219export const listMemberships = (organizationId: OrganizationId) =>
220 Effect.flatMap(ControlPlaneStore, (store) =>
221 Effect.gen(function* () {
222 yield* ensureOrganizationExists(store, membershipOps.list, organizationId);
223
224 return yield* membershipOps.list.mapStorage(
225 store.organizationMemberships.listByOrganizationId(organizationId),
226 );
227 }));
228
229export const createMembership = (input: {
230 organizationId: OrganizationId;
231 payload: CreateMembershipPayload;
232}) =>
233 Effect.flatMap(ControlPlaneStore, (store) =>
234 Effect.gen(function* () {
235 yield* ensureOrganizationExists(store, membershipOps.create, input.organizationId);
236
237 const now = Date.now();
238 const membership: OrganizationMembership = {
239 id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
240 organizationId: input.organizationId,
241 accountId: input.payload.accountId,
242 role: input.payload.role,
243 status: input.payload.status ?? "active",
244 billable: input.payload.billable ?? true,
245 invitedByAccountId: input.payload.invitedByAccountId ?? null,
246 joinedAt: (input.payload.status ?? "active") === "active" ? now : null,
247 createdAt: now,
248 updatedAt: now,
249 };
250
251 yield* mapPersistenceError(
252 membershipOps.create,
253 store.organizationMemberships.upsert(membership),
254 );
255
256 const stored = yield* membershipOps.create.mapStorage(
257 store.organizationMemberships.getByOrganizationAndAccount(
258 input.organizationId,
259 input.payload.accountId,
260 ),
261 );
262 if (Option.isNone(stored)) {
263 return yield* Effect.fail(
264 membershipOps.create.badRequest(
265 "Membership was not persisted",
266 `organizationId=${input.organizationId} accountId=${input.payload.accountId}`,
267 ),
268 );
269 }
270
271 return stored.value;
272 }));
273
274export const updateMembership = (input: {
275 organizationId: OrganizationId;
276 accountId: AccountId;
277 payload: UpdateMembershipPayload;
278}) =>
279 Effect.flatMap(ControlPlaneStore, (store) =>
280 Effect.gen(function* () {
281 yield* ensureOrganizationExists(store, membershipOps.update, input.organizationId);
282
283 const existing = yield* membershipOps.update.mapStorage(
284 store.organizationMemberships.getByOrganizationAndAccount(
285 input.organizationId,
286 input.accountId,
287 ),
288 );
289
290 if (Option.isNone(existing)) {
291 return yield* Effect.fail(
292 membershipOps.update.badRequest(
293 "Membership not found",
294 `organizationId=${input.organizationId} accountId=${input.accountId}`,
295 ),
296 );
297 }
298
299 const current = existing.value;
300 const now = Date.now();
301 const next: OrganizationMembership = {
302 ...current,
303 role: input.payload.role ?? current.role,
304 status: input.payload.status ?? current.status,
305 billable: input.payload.billable ?? current.billable,
306 joinedAt:
307 (input.payload.status ?? current.status) === "active"
308 ? (current.joinedAt ?? now)
309 : current.joinedAt,
310 updatedAt: now,
311 };
312
313 yield* mapPersistenceError(
314 membershipOps.update,
315 store.organizationMemberships.upsert(next),
316 );
317
318 return next;
319 }));
320
321export const removeMembership = (input: {
322 organizationId: OrganizationId;
323 accountId: AccountId;
324}) =>
325 Effect.flatMap(ControlPlaneStore, (store) =>
326 Effect.gen(function* () {
327 yield* ensureOrganizationExists(store, membershipOps.remove, input.organizationId);
328
329 const removed = yield* membershipOps.remove.mapStorage(
330 store.organizationMemberships.removeByOrganizationAndAccount(
331 input.organizationId,
332 input.accountId,
333 ),
334 );
335
336 return { removed };
337 }));
338
339export const listWorkspaces = (organizationId: OrganizationId) =>
340 Effect.flatMap(ControlPlaneStore, (store) =>
341 Effect.gen(function* () {
342 yield* ensureOrganizationExists(store, workspaceOps.list, organizationId);
343
344 return yield* workspaceOps.list.mapStorage(
345 store.workspaces.listByOrganizationId(organizationId),
346 );
347 }));
348
349export const createWorkspace = (input: {
350 organizationId: OrganizationId;
351 payload: CreateWorkspacePayload;
352 createdByAccountId?: Workspace["createdByAccountId"];
353}) =>
354 Effect.flatMap(ControlPlaneStore, (store) =>
355 Effect.gen(function* () {
356 yield* ensureOrganizationExists(store, workspaceOps.create, input.organizationId);
357 const name = input.payload.name;
358 const now = Date.now();
359
360 const workspace: Workspace = {
361 id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
362 organizationId: input.organizationId,
363 name,
364 createdByAccountId: input.createdByAccountId ?? null,
365 createdAt: now,
366 updatedAt: now,
367 };
368
369 yield* mapPersistenceError(
370 workspaceOps.create,
371 store.workspaces.insert(workspace),
372 );
373
374 return workspace;
375 }));
376
377export const getWorkspace = (workspaceId: WorkspaceId) =>
378 Effect.flatMap(ControlPlaneStore, (store) =>
379 Effect.gen(function* () {
380 const existing = yield* workspaceOps.get.mapStorage(
381 store.workspaces.getById(workspaceId),
382 );
383
384 if (Option.isNone(existing)) {
385 return yield* Effect.fail(
386 workspaceOps.get.notFound(
387 "Workspace not found",
388 `workspaceId=${workspaceId}`,
389 ),
390 );
391 }
392
393 return existing.value;
394 }));
395
396export const updateWorkspace = (input: {
397 workspaceId: WorkspaceId;
398 payload: UpdateWorkspacePayload;
399}) =>
400 Effect.flatMap(ControlPlaneStore, (store) =>
401 Effect.gen(function* () {
402 const patch: Partial<
403 Omit<Mutable<Workspace>, "id" | "organizationId" | "createdAt">
404 > = {
405 updatedAt: Date.now(),
406 };
407
408 if (input.payload.name !== undefined) {
409 patch.name = input.payload.name;
410 }
411
412 const updated = yield* mapPersistenceError(
413 workspaceOps.update,
414 store.workspaces.update(input.workspaceId, patch),
415 );
416
417 if (Option.isNone(updated)) {
418 return yield* Effect.fail(
419 workspaceOps.update.notFound(
420 "Workspace not found",
421 `workspaceId=${input.workspaceId}`,
422 ),
423 );
424 }
425
426 return updated.value;
427 }));
428
429export const removeWorkspace = (input: { workspaceId: WorkspaceId }) =>
430 Effect.flatMap(ControlPlaneStore, (store) =>
431 workspaceOps.remove.mapStorage(
432 store.workspaces.removeById(input.workspaceId),
433 ).pipe(Effect.map((removed) => ({ removed })))
434 );
packages/control-plane/src/runtime/policies-operations.ts+197−0
@@ -0,0 +1,197 @@
1import type {
2 CreatePolicyPayload,
3 UpdatePolicyPayload,
4} from "../api/policies/api";
5import {
6 PolicyIdSchema,
7 type Policy,
8 type PolicyId,
9 type WorkspaceId,
10} from "#schema";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13
14import {
15 mapPersistenceError,
16 parseJsonString,
17 type Mutable,
18} from "./operations-shared";
19import {
20 operationErrors,
21} from "./operation-errors";
22import { ControlPlaneStore } from "./store";
23
24const policyOps = {
25 list: operationErrors("policies.list"),
26 create: operationErrors("policies.create"),
27 get: operationErrors("policies.get"),
28 update: operationErrors("policies.update"),
29 remove: operationErrors("policies.remove"),
30} as const;
31
32export const listPolicies = (workspaceId: WorkspaceId) =>
33 Effect.flatMap(ControlPlaneStore, (store) =>
34 policyOps.list.mapStorage(
35 store.policies.listByWorkspaceId(workspaceId),
36 )
37 );
38
39export const createPolicy = (input: {
40 workspaceId: WorkspaceId;
41 payload: CreatePolicyPayload;
42}) =>
43 Effect.flatMap(ControlPlaneStore, (store) =>
44 Effect.gen(function* () {
45 const now = Date.now();
46
47 const policy: Policy = {
48 id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),
49 workspaceId: input.workspaceId,
50 targetAccountId: input.payload.targetAccountId ?? null,
51 clientId: input.payload.clientId ?? null,
52 resourceType: input.payload.resourceType ?? "tool_path",
53 resourcePattern: input.payload.resourcePattern ?? "*",
54 matchType: input.payload.matchType ?? "glob",
55 effect: input.payload.effect ?? "allow",
56 approvalMode: input.payload.approvalMode ?? "auto",
57 argumentConditionsJson: input.payload.argumentConditionsJson ?? null,
58 priority: input.payload.priority ?? 0,
59 enabled: input.payload.enabled ?? true,
60 createdAt: now,
61 updatedAt: now,
62 };
63
64 if (policy.argumentConditionsJson !== null) {
65 yield* parseJsonString(
66 policyOps.create,
67 "argumentConditionsJson",
68 policy.argumentConditionsJson,
69 );
70 }
71
72 yield* mapPersistenceError(
73 policyOps.create,
74 store.policies.insert(policy),
75 );
76
77 return policy;
78 }));
79
80export const getPolicy = (input: {
81 workspaceId: WorkspaceId;
82 policyId: PolicyId;
83}) =>
84 Effect.flatMap(ControlPlaneStore, (store) =>
85 Effect.gen(function* () {
86 const existing = yield* policyOps.get.mapStorage(
87 store.policies.getById(input.policyId),
88 );
89
90 if (Option.isNone(existing) || existing.value.workspaceId !== input.workspaceId) {
91 return yield* Effect.fail(
92 policyOps.get.notFound(
93 "Policy not found",
94 `workspaceId=${input.workspaceId} policyId=${input.policyId}`,
95 ),
96 );
97 }
98
99 return existing.value;
100 }));
101
102export const updatePolicy = (input: {
103 workspaceId: WorkspaceId;
104 policyId: PolicyId;
105 payload: UpdatePolicyPayload;
106}) =>
107 Effect.flatMap(ControlPlaneStore, (store) =>
108 Effect.gen(function* () {
109 const existing = yield* policyOps.update.mapStorage(
110 store.policies.getById(input.policyId),
111 );
112 if (Option.isNone(existing) || existing.value.workspaceId !== input.workspaceId) {
113 return yield* Effect.fail(
114 policyOps.update.notFound(
115 "Policy not found",
116 `workspaceId=${input.workspaceId} policyId=${input.policyId}`,
117 ),
118 );
119 }
120
121 const patch: Partial<Omit<Mutable<Policy>, "id" | "workspaceId" | "createdAt">> = {
122 updatedAt: Date.now(),
123 };
124
125 if (input.payload.targetAccountId !== undefined) {
126 patch.targetAccountId = input.payload.targetAccountId;
127 }
128 if (input.payload.clientId !== undefined) {
129 patch.clientId = input.payload.clientId;
130 }
131 if (input.payload.resourceType !== undefined) {
132 patch.resourceType = input.payload.resourceType;
133 }
134 if (input.payload.resourcePattern !== undefined) {
135 patch.resourcePattern = input.payload.resourcePattern;
136 }
137 if (input.payload.matchType !== undefined) {
138 patch.matchType = input.payload.matchType;
139 }
140 if (input.payload.effect !== undefined) {
141 patch.effect = input.payload.effect;
142 }
143 if (input.payload.approvalMode !== undefined) {
144 patch.approvalMode = input.payload.approvalMode;
145 }
146 if (input.payload.argumentConditionsJson !== undefined) {
147 if (input.payload.argumentConditionsJson !== null) {
148 yield* parseJsonString(
149 policyOps.update,
150 "argumentConditionsJson",
151 input.payload.argumentConditionsJson,
152 );
153 }
154 patch.argumentConditionsJson = input.payload.argumentConditionsJson;
155 }
156 if (input.payload.priority !== undefined) {
157 patch.priority = input.payload.priority;
158 }
159 if (input.payload.enabled !== undefined) {
160 patch.enabled = input.payload.enabled;
161 }
162
163 const updated = yield* mapPersistenceError(
164 policyOps.update,
165 store.policies.update(input.policyId, patch),
166 );
167 if (Option.isNone(updated)) {
168 return yield* Effect.fail(
169 policyOps.update.notFound(
170 "Policy not found",
171 `workspaceId=${input.workspaceId} policyId=${input.policyId}`,
172 ),
173 );
174 }
175
176 return updated.value;
177 }));
178
179export const removePolicy = (input: {
180 workspaceId: WorkspaceId;
181 policyId: PolicyId;
182}) =>
183 Effect.flatMap(ControlPlaneStore, (store) =>
184 Effect.gen(function* () {
185 const existing = yield* policyOps.remove.mapStorage(
186 store.policies.getById(input.policyId),
187 );
188 if (Option.isNone(existing) || existing.value.workspaceId !== input.workspaceId) {
189 return { removed: false };
190 }
191
192 const removed = yield* policyOps.remove.mapStorage(
193 store.policies.removeById(input.policyId),
194 );
195
196 return { removed };
197 }));
packages/control-plane/src/runtime/services.ts+0−1083
@@ -1,1083 +0,0 @@
1import {
2 ControlPlaneExecutionsService,
3 type ControlPlaneExecutionsServiceShape,
4 ControlPlaneLocalService,
5 type ControlPlaneLocalServiceShape,
6 ControlPlaneMembershipsService,
7 type ControlPlaneMembershipsServiceShape,
8 ControlPlaneOrganizationsService,
9 type ControlPlaneOrganizationsServiceShape,
10 ControlPlanePoliciesService,
11 type ControlPlanePoliciesServiceShape,
12 ControlPlaneSourcesService,
13 type ControlPlaneSourcesServiceShape,
14 ControlPlaneWorkspacesService,
15 type ControlPlaneWorkspacesServiceShape,
16 ControlPlaneBadRequestError,
17 ControlPlaneNotFoundError,
18 ControlPlaneStorageError,
19} from "#api";
20import {
21 ControlPlanePersistenceError,
22 SqlControlPlaneRowsService,
23 type SqlControlPlaneRows,
24} from "#persistence";
25import type {
26 Organization,
27 OrganizationMembership,
28 Policy,
29 Source,
30 SourceId,
31 SourceCredentialBinding,
32 Workspace,
33} from "#schema";
34import {
35 OrganizationIdSchema,
36 OrganizationMemberIdSchema,
37 PolicyIdSchema,
38 SourceIdSchema,
39 WorkspaceIdSchema,
40} from "#schema";
41import * as Effect from "effect/Effect";
42import * as Layer from "effect/Layer";
43import * as Option from "effect/Option";
44
45import {
46 LiveExecutionManagerService,
47} from "./live-execution";
48import { createRuntimeExecutionsService } from "./execution-service";
49import { loadLocalInstallation } from "./local-installation";
50import {
51 RuntimeSourceAuthServiceTag,
52 type RuntimeSourceAuthService,
53} from "./source-auth-service";
54import {
55 createSourceFromPayload,
56 projectSourceFromStorage,
57 projectSourcesFromStorage,
58 splitSourceForStorage,
59 updateSourceFromPayload,
60} from "./source-definitions";
61import { slugify } from "./slug";
62import { RuntimeExecutionResolverService } from "./workspace-execution-environment";
63
64type Mutable<T> = { -readonly [K in keyof T]: T[K] };
65
66const badRequest = (
67 operation: string,
68 message: string,
69 details: string,
70): ControlPlaneBadRequestError =>
71 new ControlPlaneBadRequestError({
72 operation,
73 message,
74 details,
75 });
76
77const notFound = (
78 operation: string,
79 message: string,
80 details: string,
81): ControlPlaneNotFoundError =>
82 new ControlPlaneNotFoundError({
83 operation,
84 message,
85 details,
86 });
87
88const storageFromPersistence = (
89 operation: string,
90 error: ControlPlanePersistenceError,
91): ControlPlaneStorageError =>
92 new ControlPlaneStorageError({
93 operation,
94 message: error.message,
95 details: error.details ?? "Persistence operation failed",
96 });
97
98const toUnknownPersistenceError = (
99 operation: string,
100 cause: unknown,
101 details: string,
102): ControlPlanePersistenceError =>
103 new ControlPlanePersistenceError({
104 operation,
105 message: cause instanceof Error ? cause.message : String(cause),
106 details,
107 code: null,
108 constraint: null,
109 table: null,
110 kind: "unknown",
111 cause,
112 });
113
114const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>
115 error.kind === "unique_violation";
116
117const mapPersistenceError = <A>(
118 operation: string,
119 effect: Effect.Effect<A, ControlPlanePersistenceError>,
120): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> =>
121 effect.pipe(
122 Effect.mapError((error) =>
123 isUniqueViolation(error)
124 ? badRequest(operation, "Unique constraint violation", error.details ?? "duplicate key")
125 : storageFromPersistence(operation, error),
126 ),
127 );
128
129const mapStorageError = <A>(
130 operation: string,
131 effect: Effect.Effect<A, ControlPlanePersistenceError>,
132): Effect.Effect<A, ControlPlaneStorageError> =>
133 effect.pipe(
134 Effect.mapError((error) => storageFromPersistence(operation, error)),
135 );
136
137const requireTrimmed = (
138 operation: string,
139 fieldName: string,
140 value: string,
141): Effect.Effect<string, ControlPlaneBadRequestError> => {
142 const trimmed = value.trim();
143 if (trimmed.length === 0) {
144 return Effect.fail(
145 badRequest(
146 operation,
147 `Invalid ${fieldName}`,
148 `${fieldName} must be a non-empty string`,
149 ),
150 );
151 }
152
153 return Effect.succeed(trimmed);
154};
155
156const parseJsonString = (
157 operation: string,
158 fieldName: string,
159 value: string,
160): Effect.Effect<string, ControlPlaneBadRequestError> =>
161 Effect.try({
162 try: () => {
163 JSON.parse(value);
164 return value;
165 },
166 catch: () =>
167 badRequest(operation, `Invalid ${fieldName}`, `${fieldName} must be valid JSON`),
168 });
169
170const ensureUniqueOrganizationSlug = (
171 rows: SqlControlPlaneRows,
172 baseName: string,
173): Effect.Effect<string, ControlPlaneStorageError> =>
174 Effect.gen(function* () {
175 const normalized = slugify(baseName);
176 const seed = normalized.length > 0 ? normalized : "item";
177
178 let counter = 0;
179 while (true) {
180 const candidate = counter === 0 ? seed : `${seed}-${counter + 1}`;
181
182 const existing = yield* mapStorageError(
183 "organizations.create.slug_lookup",
184 rows.organizations.getBySlug(candidate as Organization["slug"]),
185 );
186
187 if (Option.isNone(existing)) {
188 return candidate;
189 }
190
191 counter += 1;
192 }
193 });
194
195const ensureOrganizationExists = (
196 rows: SqlControlPlaneRows,
197 operation: string,
198 organizationId: Organization["id"],
199): Effect.Effect<Organization, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
200 Effect.gen(function* () {
201 const organization = yield* mapStorageError(
202 `${operation}.organization_lookup`,
203 rows.organizations.getById(organizationId),
204 );
205
206 if (Option.isNone(organization)) {
207 return yield* Effect.fail(
208 notFound(operation, "Organization not found", `organizationId=${organizationId}`),
209 );
210 }
211
212 return organization.value;
213 });
214
215const createOrganizationsService = (
216 rows: SqlControlPlaneRows,
217): ControlPlaneOrganizationsServiceShape => ({
218 listOrganizations: ({ accountId }) =>
219 Effect.gen(function* () {
220 const memberships = yield* mapStorageError(
221 "organizations.list.memberships",
222 rows.organizationMemberships.listByAccountId(accountId),
223 );
224
225 const activeOrganizationIds = Array.from(
226 new Set(
227 memberships
228 .filter((membership) => membership.status === "active")
229 .map((membership) => membership.organizationId),
230 ),
231 );
232
233 const organizations = yield* Effect.forEach(activeOrganizationIds, (organizationId) =>
234 mapStorageError(
235 "organizations.list.organization",
236 rows.organizations.getById(organizationId),
237 ).pipe(
238 Effect.map((result) => (Option.isSome(result) ? result.value : null)),
239 ));
240
241 return organizations.filter((organization): organization is Organization => organization !== null);
242 }),
243
244 createOrganization: ({ payload, createdByAccountId }) =>
245 Effect.gen(function* () {
246 const name = yield* requireTrimmed(
247 "organizations.create",
248 "name",
249 payload.name,
250 );
251 const now = Date.now();
252
253 const slug = payload.slug
254 ? yield* requireTrimmed("organizations.create", "slug", payload.slug)
255 : yield* ensureUniqueOrganizationSlug(rows, name);
256
257 const organization: Organization = {
258 id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
259 slug,
260 name,
261 status: "active",
262 createdByAccountId: createdByAccountId ?? null,
263 createdAt: now,
264 updatedAt: now,
265 };
266
267 const ownerMembership: OrganizationMembership | null = createdByAccountId
268 ? {
269 id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
270 organizationId: organization.id,
271 accountId: createdByAccountId,
272 role: "owner",
273 status: "active",
274 billable: true,
275 invitedByAccountId: null,
276 joinedAt: now,
277 createdAt: now,
278 updatedAt: now,
279 }
280 : null;
281
282 yield* mapPersistenceError(
283 "organizations.create",
284 rows.organizations.insertWithOwnerMembership(organization, ownerMembership),
285 );
286
287 return organization;
288 }),
289
290 getOrganization: ({ organizationId, accountId }) =>
291 Effect.gen(function* () {
292 const membership = yield* mapStorageError(
293 "organizations.get.membership",
294 rows.organizationMemberships.getByOrganizationAndAccount(organizationId, accountId),
295 );
296
297 if (Option.isNone(membership) || membership.value.status !== "active") {
298 return yield* Effect.fail(
299 notFound(
300 "organizations.get",
301 "Organization not found",
302 `organizationId=${organizationId}`,
303 ),
304 );
305 }
306
307 const existing = yield* mapStorageError(
308 "organizations.get",
309 rows.organizations.getById(organizationId),
310 );
311
312 if (Option.isNone(existing)) {
313 return yield* Effect.fail(
314 notFound(
315 "organizations.get",
316 "Organization not found",
317 `organizationId=${organizationId}`,
318 ),
319 );
320 }
321
322 return existing.value;
323 }),
324
325 updateOrganization: ({ organizationId, payload }) =>
326 Effect.gen(function* () {
327 const patch: Partial<Omit<Mutable<Organization>, "id" | "createdAt">> = {
328 updatedAt: Date.now(),
329 };
330
331 if (payload.name !== undefined) {
332 patch.name = yield* requireTrimmed(
333 "organizations.update",
334 "name",
335 payload.name,
336 );
337 }
338 if (payload.status !== undefined) {
339 patch.status = payload.status;
340 }
341
342 const updated = yield* mapPersistenceError(
343 "organizations.update",
344 rows.organizations.update(organizationId, patch),
345 );
346
347 if (Option.isNone(updated)) {
348 return yield* Effect.fail(
349 notFound(
350 "organizations.update",
351 "Organization not found",
352 `organizationId=${organizationId}`,
353 ),
354 );
355 }
356
357 return updated.value;
358 }),
359
360 removeOrganization: ({ organizationId }) =>
361 mapStorageError(
362 "organizations.remove",
363 rows.organizations.removeTreeById(organizationId),
364 ).pipe(Effect.map((removed) => ({ removed }))),
365 });
366
367const createMembershipsService = (
368 rows: SqlControlPlaneRows,
369): ControlPlaneMembershipsServiceShape => ({
370 listMemberships: (organizationId) =>
371 Effect.gen(function* () {
372 yield* ensureOrganizationExists(rows, "memberships.list", organizationId);
373
374 return yield* mapStorageError(
375 "memberships.list",
376 rows.organizationMemberships.listByOrganizationId(organizationId),
377 );
378 }),
379
380 createMembership: ({ organizationId, payload }) =>
381 Effect.gen(function* () {
382 yield* ensureOrganizationExists(rows, "memberships.create", organizationId);
383
384 const now = Date.now();
385 const membership: OrganizationMembership = {
386 id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
387 organizationId,
388 accountId: payload.accountId,
389 role: payload.role,
390 status: payload.status ?? "active",
391 billable: payload.billable ?? true,
392 invitedByAccountId: payload.invitedByAccountId ?? null,
393 joinedAt: (payload.status ?? "active") === "active" ? now : null,
394 createdAt: now,
395 updatedAt: now,
396 };
397
398 yield* mapPersistenceError(
399 "memberships.create",
400 rows.organizationMemberships.upsert(membership),
401 );
402
403 const stored = yield* mapStorageError(
404 "memberships.create",
405 rows.organizationMemberships.getByOrganizationAndAccount(
406 organizationId,
407 payload.accountId,
408 ),
409 );
410 if (Option.isNone(stored)) {
411 return yield* Effect.fail(
412 new ControlPlaneStorageError({
413 operation: "memberships.create",
414 message: "Membership was not persisted",
415 details: `organizationId=${organizationId} accountId=${payload.accountId}`,
416 }),
417 );
418 }
419
420 return stored.value;
421 }),
422
423 updateMembership: ({ organizationId, accountId, payload }) =>
424 Effect.gen(function* () {
425 yield* ensureOrganizationExists(rows, "memberships.update", organizationId);
426
427 const existing = yield* mapStorageError(
428 "memberships.update",
429 rows.organizationMemberships.getByOrganizationAndAccount(
430 organizationId,
431 accountId,
432 ),
433 );
434
435 if (Option.isNone(existing)) {
436 return yield* Effect.fail(
437 badRequest(
438 "memberships.update",
439 "Membership not found",
440 `organizationId=${organizationId} accountId=${accountId}`,
441 ),
442 );
443 }
444
445 const current = existing.value;
446 const now = Date.now();
447 const next: OrganizationMembership = {
448 ...current,
449 role: payload.role ?? current.role,
450 status: payload.status ?? current.status,
451 billable: payload.billable ?? current.billable,
452 joinedAt:
453 (payload.status ?? current.status) === "active"
454 ? (current.joinedAt ?? now)
455 : current.joinedAt,
456 updatedAt: now,
457 };
458
459 yield* mapPersistenceError(
460 "memberships.update",
461 rows.organizationMemberships.upsert(next),
462 );
463
464 return next;
465 }),
466
467 removeMembership: ({ organizationId, accountId }) =>
468 Effect.gen(function* () {
469 yield* ensureOrganizationExists(rows, "memberships.remove", organizationId);
470
471 const removed = yield* mapStorageError(
472 "memberships.remove",
473 rows.organizationMemberships.removeByOrganizationAndAccount(
474 organizationId,
475 accountId,
476 ),
477 );
478
479 return { removed };
480 }),
481 });
482
483const createWorkspacesService = (
484 rows: SqlControlPlaneRows,
485): ControlPlaneWorkspacesServiceShape => ({
486 listWorkspaces: (organizationId) =>
487 Effect.gen(function* () {
488 yield* ensureOrganizationExists(rows, "workspaces.list", organizationId);
489
490 return yield* mapStorageError(
491 "workspaces.list",
492 rows.workspaces.listByOrganizationId(organizationId),
493 );
494 }),
495
496 createWorkspace: ({ organizationId, payload, createdByAccountId }) =>
497 Effect.gen(function* () {
498 yield* ensureOrganizationExists(rows, "workspaces.create", organizationId);
499
500 const name = yield* requireTrimmed(
501 "workspaces.create",
502 "name",
503 payload.name,
504 );
505 const now = Date.now();
506
507 const workspace: Workspace = {
508 id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
509 organizationId,
510 name,
511 createdByAccountId: createdByAccountId ?? null,
512 createdAt: now,
513 updatedAt: now,
514 };
515
516 yield* mapPersistenceError(
517 "workspaces.create",
518 rows.workspaces.insert(workspace),
519 );
520
521 return workspace;
522 }),
523
524 getWorkspace: (workspaceId) =>
525 Effect.gen(function* () {
526 const existing = yield* mapStorageError(
527 "workspaces.get",
528 rows.workspaces.getById(workspaceId),
529 );
530
531 if (Option.isNone(existing)) {
532 return yield* Effect.fail(
533 notFound(
534 "workspaces.get",
535 "Workspace not found",
536 `workspaceId=${workspaceId}`,
537 ),
538 );
539 }
540
541 return existing.value;
542 }),
543
544 updateWorkspace: ({ workspaceId, payload }) =>
545 Effect.gen(function* () {
546 const patch: Partial<
547 Omit<Mutable<Workspace>, "id" | "organizationId" | "createdAt">
548 > = {
549 updatedAt: Date.now(),
550 };
551
552 if (payload.name !== undefined) {
553 patch.name = yield* requireTrimmed(
554 "workspaces.update",
555 "name",
556 payload.name,
557 );
558 }
559
560 const updated = yield* mapPersistenceError(
561 "workspaces.update",
562 rows.workspaces.update(workspaceId, patch),
563 );
564
565 if (Option.isNone(updated)) {
566 return yield* Effect.fail(
567 notFound(
568 "workspaces.update",
569 "Workspace not found",
570 `workspaceId=${workspaceId}`,
571 ),
572 );
573 }
574
575 return updated.value;
576 }),
577
578 removeWorkspace: ({ workspaceId }) =>
579 mapStorageError(
580 "workspaces.remove",
581 rows.workspaces.removeById(workspaceId),
582 ).pipe(Effect.map((removed) => ({ removed }))),
583 });
584
585const createSourcesService = (
586 rows: SqlControlPlaneRows,
587): ControlPlaneSourcesServiceShape => ({
588 listSources: (workspaceId) =>
589 Effect.gen(function* () {
590 const sourceRecords = yield* mapStorageError(
591 "sources.list.records",
592 rows.sources.listByWorkspaceId(workspaceId),
593 );
594 const credentialBindings = yield* mapStorageError(
595 "sources.list.bindings",
596 rows.sourceCredentialBindings.listByWorkspaceId(workspaceId),
597 );
598
599 return yield* projectSourcesFromStorage({
600 sourceRecords,
601 credentialBindings,
602 }).pipe(
603 Effect.mapError((error) =>
604 storageFromPersistence(
605 "sources.list",
606 toUnknownPersistenceError(
607 "sources.list",
608 error,
609 "Failed projecting stored sources",
610 ),
611 ),
612 ),
613 );
614 }),
615
616 createSource: ({ workspaceId, payload }) =>
617 Effect.gen(function* () {
618 const name = yield* requireTrimmed(
619 "sources.create",
620 "name",
621 payload.name,
622 );
623 const endpoint = yield* requireTrimmed(
624 "sources.create",
625 "endpoint",
626 payload.endpoint,
627 );
628 const now = Date.now();
629
630 const source = yield* createSourceFromPayload({
631 workspaceId,
632 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
633 payload: {
634 ...payload,
635 name,
636 endpoint,
637 },
638 now,
639 }).pipe(
640 Effect.mapError((cause) =>
641 badRequest(
642 "sources.create",
643 "Invalid source definition",
644 cause instanceof Error ? cause.message : String(cause),
645 ),
646 ),
647 );
648
649 const { sourceRecord, credentialBinding } = splitSourceForStorage({
650 source,
651 });
652
653 yield* mapPersistenceError("sources.create.source", rows.sources.insert(sourceRecord));
654 if (credentialBinding !== null) {
655 yield* mapPersistenceError(
656 "sources.create.binding",
657 rows.sourceCredentialBindings.upsert(credentialBinding),
658 );
659 }
660
661 return source;
662 }),
663
664 getSource: ({ workspaceId, sourceId }) =>
665 Effect.gen(function* () {
666 const existing = yield* mapStorageError(
667 "sources.get",
668 rows.sources.getByWorkspaceAndId(workspaceId, sourceId),
669 );
670
671 if (Option.isNone(existing)) {
672 return yield* Effect.fail(
673 notFound(
674 "sources.get",
675 "Source not found",
676 `workspaceId=${workspaceId} sourceId=${sourceId}`,
677 ),
678 );
679 }
680
681 const credentialBinding = yield* mapStorageError(
682 "sources.get.binding",
683 rows.sourceCredentialBindings.getByWorkspaceAndSourceId(workspaceId, sourceId),
684 );
685
686 return yield* projectSourceFromStorage({
687 sourceRecord: existing.value,
688 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
689 }).pipe(
690 Effect.mapError((cause) =>
691 storageFromPersistence(
692 "sources.get",
693 toUnknownPersistenceError(
694 "sources.get",
695 cause,
696 "Failed projecting stored source",
697 ),
698 ),
699 ),
700 );
701 }),
702
703 updateSource: ({ workspaceId, sourceId, payload }) =>
704 Effect.gen(function* () {
705 const existing = yield* mapStorageError(
706 "sources.update.existing",
707 rows.sources.getByWorkspaceAndId(workspaceId, sourceId),
708 );
709
710 if (Option.isNone(existing)) {
711 return yield* Effect.fail(
712 notFound(
713 "sources.update",
714 "Source not found",
715 `workspaceId=${workspaceId} sourceId=${sourceId}`,
716 ),
717 );
718 }
719
720 const existingBinding = yield* mapStorageError(
721 "sources.update.binding",
722 rows.sourceCredentialBindings.getByWorkspaceAndSourceId(workspaceId, sourceId),
723 );
724
725 const existingSource = yield* projectSourceFromStorage({
726 sourceRecord: existing.value,
727 credentialBinding: Option.isSome(existingBinding) ? existingBinding.value : null,
728 }).pipe(
729 Effect.mapError((cause) =>
730 storageFromPersistence(
731 "sources.update",
732 toUnknownPersistenceError(
733 "sources.update",
734 cause,
735 "Failed projecting stored source",
736 ),
737 ),
738 ),
739 );
740
741 const normalizedPayload = {
742 ...payload,
743 ...(payload.name !== undefined
744 ? {
745 name: yield* requireTrimmed("sources.update", "name", payload.name),
746 }
747 : {}),
748 ...(payload.endpoint !== undefined
749 ? {
750 endpoint: yield* requireTrimmed(
751 "sources.update",
752 "endpoint",
753 payload.endpoint,
754 ),
755 }
756 : {}),
757 };
758
759 const updatedSource = yield* updateSourceFromPayload({
760 source: existingSource,
761 payload: normalizedPayload,
762 now: Date.now(),
763 }).pipe(
764 Effect.mapError((cause) =>
765 badRequest(
766 "sources.update",
767 "Invalid source definition",
768 cause instanceof Error ? cause.message : String(cause),
769 ),
770 ),
771 );
772
773 const { sourceRecord, credentialBinding } = splitSourceForStorage({
774 source: updatedSource,
775 });
776
777 const stored = yield* mapPersistenceError(
778 "sources.update.source",
779 rows.sources.update(workspaceId, sourceId, {
780 ...sourceRecord,
781 updatedAt: updatedSource.updatedAt,
782 }),
783 );
784
785 if (Option.isNone(stored)) {
786 return yield* Effect.fail(
787 notFound(
788 "sources.update",
789 "Source not found",
790 `workspaceId=${workspaceId} sourceId=${sourceId}`,
791 ),
792 );
793 }
794
795 if (credentialBinding === null) {
796 yield* mapStorageError(
797 "sources.update.binding.remove",
798 rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(workspaceId, sourceId),
799 );
800 } else {
801 yield* mapPersistenceError(
802 "sources.update.binding",
803 rows.sourceCredentialBindings.upsert(credentialBinding),
804 );
805 }
806
807 return updatedSource;
808 }),
809
810 removeSource: ({ workspaceId, sourceId }) =>
811 mapStorageError(
812 "sources.remove",
813 rows.sources.removeByWorkspaceAndId(workspaceId, sourceId),
814 ).pipe(Effect.map((removed) => ({ removed }))),
815 });
816
817const createPoliciesService = (
818 rows: SqlControlPlaneRows,
819): ControlPlanePoliciesServiceShape => ({
820 listPolicies: (workspaceId) =>
821 mapStorageError(
822 "policies.list",
823 rows.policies.listByWorkspaceId(workspaceId),
824 ),
825
826 createPolicy: ({ workspaceId, payload }) =>
827 Effect.gen(function* () {
828 const now = Date.now();
829
830 const policy: Policy = {
831 id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),
832 workspaceId,
833 targetAccountId: payload.targetAccountId ?? null,
834 clientId: payload.clientId ?? null,
835 resourceType: payload.resourceType ?? "tool_path",
836 resourcePattern:
837 payload.resourcePattern && payload.resourcePattern.trim().length > 0
838 ? payload.resourcePattern.trim()
839 : "*",
840 matchType: payload.matchType ?? "glob",
841 effect: payload.effect ?? "allow",
842 approvalMode: payload.approvalMode ?? "auto",
843 argumentConditionsJson: payload.argumentConditionsJson ?? null,
844 priority: payload.priority ?? 0,
845 enabled: payload.enabled ?? true,
846 createdAt: now,
847 updatedAt: now,
848 };
849
850 if (policy.argumentConditionsJson !== null) {
851 yield* parseJsonString(
852 "policies.create",
853 "argumentConditionsJson",
854 policy.argumentConditionsJson,
855 );
856 }
857
858 yield* mapPersistenceError(
859 "policies.create",
860 rows.policies.insert(policy),
861 );
862
863 return policy;
864 }),
865
866 getPolicy: ({ workspaceId, policyId }) =>
867 Effect.gen(function* () {
868 const existing = yield* mapStorageError(
869 "policies.get",
870 rows.policies.getById(policyId),
871 );
872
873 if (Option.isNone(existing) || existing.value.workspaceId !== workspaceId) {
874 return yield* Effect.fail(
875 notFound(
876 "policies.get",
877 "Policy not found",
878 `workspaceId=${workspaceId} policyId=${policyId}`,
879 ),
880 );
881 }
882
883 return existing.value;
884 }),
885
886 updatePolicy: ({ workspaceId, policyId, payload }) =>
887 Effect.gen(function* () {
888 const existing = yield* mapStorageError(
889 "policies.update",
890 rows.policies.getById(policyId),
891 );
892 if (Option.isNone(existing) || existing.value.workspaceId !== workspaceId) {
893 return yield* Effect.fail(
894 notFound(
895 "policies.update",
896 "Policy not found",
897 `workspaceId=${workspaceId} policyId=${policyId}`,
898 ),
899 );
900 }
901
902 const patch: Partial<Omit<Mutable<Policy>, "id" | "workspaceId" | "createdAt">> = {
903 updatedAt: Date.now(),
904 };
905
906 if (payload.targetAccountId !== undefined) {
907 patch.targetAccountId = payload.targetAccountId;
908 }
909 if (payload.clientId !== undefined) {
910 patch.clientId = payload.clientId;
911 }
912 if (payload.resourceType !== undefined) {
913 patch.resourceType = payload.resourceType;
914 }
915 if (payload.resourcePattern !== undefined) {
916 patch.resourcePattern = payload.resourcePattern;
917 }
918 if (payload.matchType !== undefined) {
919 patch.matchType = payload.matchType;
920 }
921 if (payload.effect !== undefined) {
922 patch.effect = payload.effect;
923 }
924 if (payload.approvalMode !== undefined) {
925 patch.approvalMode = payload.approvalMode;
926 }
927 if (payload.argumentConditionsJson !== undefined) {
928 if (payload.argumentConditionsJson !== null) {
929 yield* parseJsonString(
930 "policies.update",
931 "argumentConditionsJson",
932 payload.argumentConditionsJson,
933 );
934 }
935 patch.argumentConditionsJson = payload.argumentConditionsJson;
936 }
937 if (payload.priority !== undefined) {
938 patch.priority = payload.priority;
939 }
940 if (payload.enabled !== undefined) {
941 patch.enabled = payload.enabled;
942 }
943
944 const updated = yield* mapPersistenceError(
945 "policies.update",
946 rows.policies.update(policyId, patch),
947 );
948 if (Option.isNone(updated)) {
949 return yield* Effect.fail(
950 notFound(
951 "policies.update",
952 "Policy not found",
953 `workspaceId=${workspaceId} policyId=${policyId}`,
954 ),
955 );
956 }
957
958 return updated.value;
959 }),
960
961 removePolicy: ({ workspaceId, policyId }) =>
962 Effect.gen(function* () {
963 const existing = yield* mapStorageError(
964 "policies.remove",
965 rows.policies.getById(policyId),
966 );
967 if (Option.isNone(existing) || existing.value.workspaceId !== workspaceId) {
968 return { removed: false };
969 }
970
971 const removed = yield* mapStorageError(
972 "policies.remove",
973 rows.policies.removeById(policyId),
974 );
975
976 return { removed };
977 }),
978 });
979
980const createLocalService = (
981 rows: SqlControlPlaneRows,
982 sourceAuthService: RuntimeSourceAuthService,
983): ControlPlaneLocalServiceShape => ({
984 getLocalInstallation: () =>
985 Effect.gen(function* () {
986 const installation = yield* loadLocalInstallation(rows).pipe(
987 Effect.mapError((error) =>
988 error instanceof ControlPlanePersistenceError
989 ? storageFromPersistence("local.installation.get", error)
990 : new ControlPlaneStorageError({
991 operation: "local.installation.get",
992 message: error instanceof Error ? error.message : String(error),
993 details: "Failed loading local installation",
994 }),
995 ),
996 );
997
998 if (installation === null) {
999 return yield* Effect.fail(
1000 notFound(
1001 "local.installation.get",
1002 "Local installation not found",
1003 "No local installation has been provisioned",
1004 ),
1005 );
1006 }
1007
1008 return installation;
1009 }),
1010
1011 completeSourceAuthCallback: (input) =>
1012 Effect.gen(function* () {
1013 return yield* sourceAuthService.completeSourceAuthCallback(input).pipe(
1014 Effect.mapError((error) =>
1015 new ControlPlaneStorageError({
1016 operation: "local.oauth.callback",
1017 message: error instanceof Error ? error.message : String(error),
1018 details: "Failed completing source auth callback",
1019 }),
1020 ),
1021 );
1022 }),
1023 });
1024
1025const RuntimeOrganizationsServiceLive = Layer.effect(
1026 ControlPlaneOrganizationsService,
1027 Effect.map(SqlControlPlaneRowsService, createOrganizationsService),
1028);
1029
1030const RuntimeMembershipsServiceLive = Layer.effect(
1031 ControlPlaneMembershipsService,
1032 Effect.map(SqlControlPlaneRowsService, createMembershipsService),
1033);
1034
1035const RuntimeWorkspacesServiceLive = Layer.effect(
1036 ControlPlaneWorkspacesService,
1037 Effect.map(SqlControlPlaneRowsService, createWorkspacesService),
1038);
1039
1040const RuntimeSourcesServiceLive = Layer.effect(
1041 ControlPlaneSourcesService,
1042 Effect.map(SqlControlPlaneRowsService, createSourcesService),
1043);
1044
1045const RuntimePoliciesServiceLive = Layer.effect(
1046 ControlPlanePoliciesService,
1047 Effect.map(SqlControlPlaneRowsService, createPoliciesService),
1048);
1049
1050const RuntimeLocalServiceLive = Layer.effect(
1051 ControlPlaneLocalService,
1052 Effect.gen(function* () {
1053 const rows = yield* SqlControlPlaneRowsService;
1054 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
1055
1056 return createLocalService(rows, sourceAuthService);
1057 }),
1058);
1059
1060const RuntimeExecutionsServiceLive = Layer.effect(
1061 ControlPlaneExecutionsService,
1062 Effect.gen(function* () {
1063 const rows = yield* SqlControlPlaneRowsService;
1064 const executionResolver = yield* RuntimeExecutionResolverService;
1065 const liveExecutionManager = yield* LiveExecutionManagerService;
1066
1067 return createRuntimeExecutionsService(
1068 rows,
1069 executionResolver,
1070 liveExecutionManager,
1071 );
1072 }),
1073);
1074
1075export const RuntimeControlPlaneApiServicesLive = Layer.mergeAll(
1076 RuntimeOrganizationsServiceLive,
1077 RuntimeMembershipsServiceLive,
1078 RuntimeWorkspacesServiceLive,
1079 RuntimeSourcesServiceLive,
1080 RuntimePoliciesServiceLive,
1081 RuntimeLocalServiceLive,
1082 RuntimeExecutionsServiceLive,
1083);
packages/control-plane/src/runtime/sources-operations.ts+236−0
@@ -0,0 +1,236 @@
1import type {
2 CreateSourcePayload,
3 UpdateSourcePayload,
4} from "../api/sources/api";
5import {
6 SourceIdSchema,
7 type SourceId,
8 type WorkspaceId,
9} from "#schema";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12
13import {
14 createSourceFromPayload,
15 projectSourceFromStorage,
16 projectSourcesFromStorage,
17 splitSourceForStorage,
18 updateSourceFromPayload,
19} from "./source-definitions";
20import {
21 mapPersistenceError,
22} from "./operations-shared";
23import {
24 operationErrors,
25} from "./operation-errors";
26import { ControlPlaneStore } from "./store";
27
28const sourceOps = {
29 list: operationErrors("sources.list"),
30 create: operationErrors("sources.create"),
31 get: operationErrors("sources.get"),
32 update: operationErrors("sources.update"),
33 remove: operationErrors("sources.remove"),
34} as const;
35
36export const listSources = (workspaceId: WorkspaceId) =>
37 Effect.flatMap(ControlPlaneStore, (store) =>
38 Effect.gen(function* () {
39 const sourceRecords = yield* sourceOps.list.child("records").mapStorage(
40 store.sources.listByWorkspaceId(workspaceId),
41 );
42 const credentialBindings = yield* sourceOps.list.child("bindings").mapStorage(
43 store.sourceCredentialBindings.listByWorkspaceId(workspaceId),
44 );
45
46 return yield* projectSourcesFromStorage({
47 sourceRecords,
48 credentialBindings,
49 }).pipe(
50 Effect.mapError((error) =>
51 sourceOps.list.unknownStorage(
52 error,
53 "Failed projecting stored sources",
54 ),
55 ),
56 );
57 }));
58
59export const createSource = (input: {
60 workspaceId: WorkspaceId;
61 payload: CreateSourcePayload;
62}) =>
63 Effect.flatMap(ControlPlaneStore, (store) =>
64 Effect.gen(function* () {
65 const now = Date.now();
66
67 const source = yield* createSourceFromPayload({
68 workspaceId: input.workspaceId,
69 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
70 payload: input.payload,
71 now,
72 }).pipe(
73 Effect.mapError((cause) =>
74 sourceOps.create.badRequest(
75 "Invalid source definition",
76 cause instanceof Error ? cause.message : String(cause),
77 ),
78 ),
79 );
80
81 const { sourceRecord, credentialBinding } = splitSourceForStorage({
82 source,
83 });
84
85 yield* mapPersistenceError(
86 sourceOps.create.child("source"),
87 store.sources.insert(sourceRecord),
88 );
89 if (credentialBinding !== null) {
90 yield* mapPersistenceError(
91 sourceOps.create.child("binding"),
92 store.sourceCredentialBindings.upsert(credentialBinding),
93 );
94 }
95
96 return source;
97 }));
98
99export const getSource = (input: {
100 workspaceId: WorkspaceId;
101 sourceId: SourceId;
102}) =>
103 Effect.flatMap(ControlPlaneStore, (store) =>
104 Effect.gen(function* () {
105 const existing = yield* sourceOps.get.mapStorage(
106 store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
107 );
108
109 if (Option.isNone(existing)) {
110 return yield* Effect.fail(
111 sourceOps.get.notFound(
112 "Source not found",
113 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
114 ),
115 );
116 }
117
118 const credentialBinding = yield* sourceOps.get.child("binding").mapStorage(
119 store.sourceCredentialBindings.getByWorkspaceAndSourceId(
120 input.workspaceId,
121 input.sourceId,
122 ),
123 );
124
125 return yield* projectSourceFromStorage({
126 sourceRecord: existing.value,
127 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
128 }).pipe(
129 Effect.mapError((cause) =>
130 sourceOps.get.unknownStorage(
131 cause,
132 "Failed projecting stored source",
133 ),
134 ),
135 );
136 }));
137
138export const updateSource = (input: {
139 workspaceId: WorkspaceId;
140 sourceId: SourceId;
141 payload: UpdateSourcePayload;
142}) =>
143 Effect.flatMap(ControlPlaneStore, (store) =>
144 Effect.gen(function* () {
145 const existing = yield* sourceOps.update.child("existing").mapStorage(
146 store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
147 );
148
149 if (Option.isNone(existing)) {
150 return yield* Effect.fail(
151 sourceOps.update.notFound(
152 "Source not found",
153 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
154 ),
155 );
156 }
157
158 const existingBinding = yield* sourceOps.update.child("binding").mapStorage(
159 store.sourceCredentialBindings.getByWorkspaceAndSourceId(
160 input.workspaceId,
161 input.sourceId,
162 ),
163 );
164
165 const existingSource = yield* projectSourceFromStorage({
166 sourceRecord: existing.value,
167 credentialBinding: Option.isSome(existingBinding) ? existingBinding.value : null,
168 }).pipe(
169 Effect.mapError((cause) =>
170 sourceOps.update.unknownStorage(
171 cause,
172 "Failed projecting stored source",
173 ),
174 ),
175 );
176
177 const updatedSource = yield* updateSourceFromPayload({
178 source: existingSource,
179 payload: input.payload,
180 now: Date.now(),
181 }).pipe(
182 Effect.mapError((cause) =>
183 sourceOps.update.badRequest(
184 "Invalid source definition",
185 cause instanceof Error ? cause.message : String(cause),
186 ),
187 ),
188 );
189
190 const { sourceRecord, credentialBinding } = splitSourceForStorage({
191 source: updatedSource,
192 });
193
194 const stored = yield* mapPersistenceError(
195 sourceOps.update.child("source"),
196 store.sources.update(input.workspaceId, input.sourceId, {
197 ...sourceRecord,
198 updatedAt: updatedSource.updatedAt,
199 }),
200 );
201
202 if (Option.isNone(stored)) {
203 return yield* Effect.fail(
204 sourceOps.update.notFound(
205 "Source not found",
206 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
207 ),
208 );
209 }
210
211 if (credentialBinding === null) {
212 yield* sourceOps.update.child("binding.remove").mapStorage(
213 store.sourceCredentialBindings.removeByWorkspaceAndSourceId(
214 input.workspaceId,
215 input.sourceId,
216 ),
217 );
218 } else {
219 yield* mapPersistenceError(
220 sourceOps.update.child("binding"),
221 store.sourceCredentialBindings.upsert(credentialBinding),
222 );
223 }
224
225 return updatedSource;
226 }));
227
228export const removeSource = (input: {
229 workspaceId: WorkspaceId;
230 sourceId: SourceId;
231}) =>
232 Effect.flatMap(ControlPlaneStore, (store) =>
233 sourceOps.remove.mapStorage(
234 store.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId),
235 ).pipe(Effect.map((removed) => ({ removed })))
236 );
packages/control-plane/src/runtime/store.ts+15−0
@@ -0,0 +1,15 @@
1import { type SqlControlPlaneRows, SqlControlPlaneRowsService } from "#persistence";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6export type ControlPlaneStoreShape = SqlControlPlaneRows;
7
8export class ControlPlaneStore extends Context.Tag(
9 "#runtime/ControlPlaneStore",
10)<ControlPlaneStore, ControlPlaneStoreShape>() {}
11
12export const ControlPlaneStoreLive = Layer.effect(
13 ControlPlaneStore,
14 Effect.map(SqlControlPlaneRowsService, (rows) => rows),
15);
packages/control-plane/src/runtime/test-http-client.ts+1−6
@@ -9,7 +9,6 @@ import * as Effect from "effect/Effect"; · 8 unmodified lines
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10 10
11import {11import {
12 ControlPlaneActorResolver,
13 ControlPlaneApi,12 ControlPlaneApi,
14 createControlPlaneApiLayer,13 createControlPlaneApiLayer,
15} from "#api";14} from "#api";
@@ -20,11 +19,7 @@ import { · 4 unmodified lines
20} from "./index";19} from "./index";
21 20
22const createClientLayer = (runtime: SqlControlPlaneRuntime) => {21const createClientLayer = (runtime: SqlControlPlaneRuntime) => {
23 const actorResolverLayer = Layer.succeed(22 const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer);
24 ControlPlaneActorResolver,
25 runtime.actorResolver,
26 );
27 const apiLayer = createControlPlaneApiLayer(runtime.serviceLayer, actorResolverLayer);
28 23
29 return HttpApiBuilder.serve().pipe(24 return HttpApiBuilder.serve().pipe(
30 Layer.provide(apiLayer),25 Layer.provide(apiLayer),