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
55 import { HttpApiBuilder, HttpServer } from "@effect/platform";
66 import { NodeHttpServer } from "@effect/platform-node";
77 import {
8− ControlPlaneActorResolver,
98 createControlPlaneApiLayer,
109 createSqlControlPlaneRuntime,
1110 type ResolveExecutionEnvironment,
@@ -46,11 +45,7 @@ const createControlPlaneServerLayer = (input: { · 34 unmodified lines
4645 host: string;
4746 port: number;
4847 }) => {
49− const actorResolverLayer = Layer.succeed(
50− ControlPlaneActorResolver,
51− input.runtime.actorResolver,
52− );
53− const apiLayer = createControlPlaneApiLayer(input.runtime.serviceLayer, actorResolverLayer);
48+ const apiLayer = createControlPlaneApiLayer(input.runtime.runtimeLayer);
5449
5550 return HttpApiBuilder.serve().pipe(
5651 Layer.provide(apiLayer),
packages/control-plane/src/api/executions/api.ts+2−1
@@ -13,9 +13,10 @@ import { · 12 unmodified lines
1313 ControlPlaneStorageError,
1414 ControlPlaneUnauthorizedError,
1515 } from "../errors";
16+import { TrimmedNonEmptyStringSchema } from "../string-schemas";
1617
1718 export const CreateExecutionPayloadSchema = Schema.Struct({
18− code: Schema.String,
19+ code: TrimmedNonEmptyStringSchema,
1920 });
2021
2122 export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type;
packages/control-plane/src/api/executions/http.ts+16−81
@@ -1,50 +1,15 @@
1−import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2−import * as Effect from "effect/Effect";
1+import { HttpApiBuilder } from "@effect/platform";
32 import type { WorkspaceId } from "#schema";
43
4+import { requirePermission, withPolicy } from "#domain";
55 import {
6− Actor,
7− ActorForbiddenError,
8− ActorUnauthenticatedError,
9− requirePermission,
10− withPolicy,
11−} from "#domain";
6+ createExecution,
7+ getExecution,
8+ resumeExecution,
9+} from "../../runtime/execution-service";
1210
1311 import { ControlPlaneApi } from "../api";
14−import { ControlPlaneActorResolver } from "../auth/actor-resolver";
15−import {
16− ControlPlaneForbiddenError,
17− ControlPlaneUnauthorizedError,
18−} from "../errors";
19−import { ControlPlaneExecutionsService } from "../service";
20−
21−const 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−
31−const toUnauthorizedError = (
32− operation: string,
33− cause: ActorUnauthenticatedError,
34−): ControlPlaneUnauthorizedError =>
35− new ControlPlaneUnauthorizedError({
36− operation,
37− message: cause.message,
38− details: "Authentication required",
39− });
40−
41−const 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− });
12+import { withWorkspaceRequestActor } from "../http-auth";
4813
4914 const requireExecuteWorkspace = (workspaceId: WorkspaceId) =>
5015 requirePermission({
@@ -58,65 +23,35 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 7 unmodified lines
5823 (handlers) =>
5924 handlers
6025 .handle("create", ({ path, payload }) =>
61− Effect.gen(function* () {
62− const service = yield* ControlPlaneExecutionsService;
63− const actor = yield* resolveWorkspaceActor(path.workspaceId);
64−
65− return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))(
66− service.createExecution({
26+ withWorkspaceRequestActor("executions.create", path.workspaceId, (actor) =>
27+ withPolicy(requireExecuteWorkspace(path.workspaceId))(
28+ createExecution({
6729 workspaceId: path.workspaceId,
6830 payload,
6931 createdByAccountId: actor.principal.accountId,
7032 }),
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)),
7833 ),
7934 ),
8035 )
8136 .handle("get", ({ path }) =>
82− Effect.gen(function* () {
83− const service = yield* ControlPlaneExecutionsService;
84− const actor = yield* resolveWorkspaceActor(path.workspaceId);
85−
86− return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))(
87− service.getExecution({
37+ withWorkspaceRequestActor("executions.get", path.workspaceId, () =>
38+ withPolicy(requireExecuteWorkspace(path.workspaceId))(
39+ getExecution({
8840 workspaceId: path.workspaceId,
8941 executionId: path.executionId,
9042 }),
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)),
9843 ),
9944 ),
10045 )
10146 .handle("resume", ({ path, payload }) =>
102− Effect.gen(function* () {
103− const service = yield* ControlPlaneExecutionsService;
104− const actor = yield* resolveWorkspaceActor(path.workspaceId);
105−
106− return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))(
107− service.resumeExecution({
47+ withWorkspaceRequestActor("executions.resume", path.workspaceId, (actor) =>
48+ withPolicy(requireExecuteWorkspace(path.workspaceId))(
49+ resumeExecution({
10850 workspaceId: path.workspaceId,
10951 executionId: path.executionId,
11052 payload,
11153 resumedByAccountId: actor.principal.accountId,
11254 }),
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)),
12055 ),
12156 ),
12257 ),
packages/control-plane/src/api/http-auth.ts+98−0
@@ -0,0 +1,98 @@
1+import { HttpServerRequest } from "@effect/platform";
2+import type { WorkspaceId } from "#schema";
3+import {
4+ Actor,
5+ ActorForbiddenError,
6+ type ActorShape,
7+ ActorUnauthenticatedError,
8+} from "#domain";
9+import * as Effect from "effect/Effect";
10+
11+import { ControlPlaneActorResolver } from "./auth/actor-resolver";
12+import {
13+ ControlPlaneForbiddenError,
14+ ControlPlaneUnauthorizedError,
15+} from "./errors";
16+
17+const requestHeaders = Effect.map(
18+ HttpServerRequest.HttpServerRequest,
19+ (request) => request.headers,
20+);
21+
22+const readString = (value: unknown, fallback: string): string =>
23+ typeof value === "string" && value.length > 0 ? value : fallback;
24+
25+const 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+
35+const 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+
45+export const resolveRequestActor = Effect.flatMap(
46+ ControlPlaneActorResolver,
47+ (actorResolver) =>
48+ Effect.flatMap(requestHeaders, (headers) => actorResolver.resolveActor({ headers })),
49+);
50+
51+export const resolveWorkspaceRequestActor = (workspaceId: WorkspaceId) =>
52+ Effect.flatMap(ControlPlaneActorResolver, (actorResolver) =>
53+ Effect.flatMap(requestHeaders, (headers) =>
54+ actorResolver.resolveWorkspaceActor({ workspaceId, headers })
55+ ));
56+
57+const provideActor = <A, E, R>(
58+ actor: ActorShape,
59+ effect: Effect.Effect<A, E, R>,
60+) => effect.pipe(Effect.provideService(Actor, actor));
61+
62+export 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+
75+export 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+
87+export 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
1111 import { ControlPlaneMembershipsLive } from "./memberships/http";
1212 import { ControlPlaneOrganizationsLive } from "./organizations/http";
1313 import { ControlPlanePoliciesLive } from "./policies/http";
14−import { type ControlPlaneApiServiceContext } from "./service";
1514 import { ControlPlaneSourcesLive } from "./sources/http";
1615 import { ControlPlaneWorkspacesLive } from "./workspaces/http";
1716
@@ -25,13 +24,19 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines
2524 Layer.provide(ControlPlaneExecutionsLive),
2625 );
2726
28−export const createControlPlaneApiLayer = <EService, EResolver>(
29− serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, EService, never>,
30− actorResolverLayer: Layer.Layer<ControlPlaneActorResolver, EResolver, never>,
27+export type ControlPlaneApiRuntimeContext = Layer.Layer.Context<typeof ControlPlaneApiLive>;
28+
29+export type BuiltControlPlaneApiLayer = Layer.Layer<
30+ Layer.Layer.Success<typeof ControlPlaneApiLive>,
31+ Layer.Layer.Error<typeof ControlPlaneApiLive>,
32+ never
33+>;
34+
35+export const createControlPlaneApiLayer = <ERuntime>(
36+ runtimeLayer: Layer.Layer<ControlPlaneApiRuntimeContext, ERuntime, never>,
3137 ) =>
3238 ControlPlaneApiLive.pipe(
33− Layer.provide(serviceLayer),
34− Layer.provide(actorResolverLayer),
39+ Layer.provide(runtimeLayer),
3540 );
3641
3742 export const ControlPlaneActorResolverLive = (
packages/control-plane/src/api/index.ts+2−35
@@ -13,43 +13,10 @@ export { · 12 unmodified lines
1313 ControlPlaneUnauthorizedError,
1414 } from "./errors";
1515
16−export {
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−
5116 export {
5217 ControlPlaneApiLive,
18+ type ControlPlaneApiRuntimeContext,
19+ type BuiltControlPlaneApiLayer,
5320 ControlPlaneActorResolverLive,
5421 createControlPlaneApiLayer,
5522 } from "./http";
packages/control-plane/src/api/local/http.ts+6−7
@@ -1,8 +1,11 @@
11 import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
22 import * as Effect from "effect/Effect";
3+import {
4+ completeSourceAuthCallback,
5+ getLocalInstallation,
6+} from "../../runtime/local-operations";
37
48 import { ControlPlaneApi } from "../api";
5−import { ControlPlaneLocalService } from "../service";
69
710 export const ControlPlaneLocalLive = HttpApiBuilder.group(
811 ControlPlaneApi,
@@ -10,18 +13,14 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 1 unmodified line
1013 (handlers) =>
1114 handlers
1215 .handle("installation", () =>
13− Effect.gen(function* () {
14− const service = yield* ControlPlaneLocalService;
15− return yield* service.getLocalInstallation();
16− }),
16+ getLocalInstallation(),
1717 )
1818 .handle("oauthCallback", () =>
1919 Effect.gen(function* () {
20− const service = yield* ControlPlaneLocalService;
2120 const request = yield* HttpServerRequest.HttpServerRequest;
2221 const requestUrl = new URL(request.url, "http://127.0.0.1");
2322
24− const source = yield* service.completeSourceAuthCallback({
23+ const source = yield* completeSourceAuthCallback({
2524 state: requestUrl.searchParams.get("state") ?? "",
2625 code: requestUrl.searchParams.get("code"),
2726 error: requestUrl.searchParams.get("error"),
packages/control-plane/src/api/memberships/http.ts+20−92
@@ -1,48 +1,16 @@
1−import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2−import * as Effect from "effect/Effect";
1+import { HttpApiBuilder } from "@effect/platform";
32 import type { OrganizationId } from "#schema";
43
4+import { requirePermission, withPolicy } from "#domain";
55 import {
6− Actor,
7− ActorForbiddenError,
8− ActorUnauthenticatedError,
9− requirePermission,
10− withPolicy,
11−} from "#domain";
6+ createMembership,
7+ listMemberships,
8+ removeMembership,
9+ updateMembership,
10+} from "../../runtime/organizations-operations";
1211
1312 import { ControlPlaneApi } from "../api";
14−import { ControlPlaneActorResolver } from "../auth/actor-resolver";
15−import {
16− ControlPlaneForbiddenError,
17− ControlPlaneUnauthorizedError,
18−} from "../errors";
19−import { ControlPlaneMembershipsService } from "../service";
20−
21−const 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−
31−const toUnauthorizedError = (
32− operation: string,
33− cause: ActorUnauthenticatedError,
34−): ControlPlaneUnauthorizedError =>
35− new ControlPlaneUnauthorizedError({
36− operation,
37− message: cause.message,
38− details: "Authentication required",
39− });
40−
41−const resolveActor = Effect.gen(function* () {
42− const actorResolver = yield* ControlPlaneActorResolver;
43− const request = yield* HttpServerRequest.HttpServerRequest;
44− return yield* actorResolver.resolveActor({ headers: request.headers });
45−});
13+import { withRequestActor } from "../http-auth";
4614
4715 const requireReadMemberships = (organizationId: OrganizationId) =>
4816 requirePermission({
@@ -62,77 +30,37 @@ export const ControlPlaneMembershipsLive = HttpApiBuilder.group( · 13 unmodified lines
6230 (handlers) =>
6331 handlers
6432 .handle("list", ({ path }) =>
65− Effect.gen(function* () {
66− const service = yield* ControlPlaneMembershipsService;
67− const actor = yield* resolveActor;
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)),
33+ withRequestActor("memberships.list", () =>
34+ withPolicy(requireReadMemberships(path.organizationId))(
35+ listMemberships(path.organizationId),
7836 ),
7937 ),
8038 )
8139 .handle("create", ({ path, payload }) =>
82− Effect.gen(function* () {
83− const service = yield* ControlPlaneMembershipsService;
84− const actor = yield* resolveActor;
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)),
40+ withRequestActor("memberships.create", () =>
41+ withPolicy(requireWriteMemberships(path.organizationId))(
42+ createMembership({ organizationId: path.organizationId, payload }),
9543 ),
9644 ),
9745 )
9846 .handle("update", ({ path, payload }) =>
99− Effect.gen(function* () {
100− const service = yield* ControlPlaneMembershipsService;
101− const actor = yield* resolveActor;
102−
103− return yield* withPolicy(requireWriteMemberships(path.organizationId))(
104− service.updateMembership({
47+ withRequestActor("memberships.update", () =>
48+ withPolicy(requireWriteMemberships(path.organizationId))(
49+ updateMembership({
10550 organizationId: path.organizationId,
10651 accountId: path.accountId,
10752 payload,
10853 }),
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)),
11654 ),
11755 ),
11856 )
11957 .handle("remove", ({ path }) =>
120− Effect.gen(function* () {
121− const service = yield* ControlPlaneMembershipsService;
122− const actor = yield* resolveActor;
123−
124− return yield* withPolicy(requireWriteMemberships(path.organizationId))(
125− service.removeMembership({
58+ withRequestActor("memberships.remove", () =>
59+ withPolicy(requireWriteMemberships(path.organizationId))(
60+ removeMembership({
12661 organizationId: path.organizationId,
12762 accountId: path.accountId,
12863 }),
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)),
13664 ),
13765 ),
13866 ),
packages/control-plane/src/api/organizations/api.ts+13−19
@@ -1,9 +1,8 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import {
33 OrganizationIdSchema,
4− OrganizationInsertSchema,
4+ OrganizationStatusSchema,
55 OrganizationSchema,
6− OrganizationUpdateSchema,
76 } from "#schema";
87 import * as Schema from "effect/Schema";
98
@@ -14,27 +13,22 @@ import { · 4 unmodified lines
1413 ControlPlaneStorageError,
1514 ControlPlaneUnauthorizedError,
1615 } from "../errors";
16+import {
17+ OptionalTrimmedNonEmptyStringSchema,
18+ TrimmedNonEmptyStringSchema,
19+} from "../string-schemas";
1720
18−const createOrganizationPayloadRequiredSchema = OrganizationInsertSchema.pipe(
19− Schema.pick("name"),
20−);
21−
22−const createOrganizationPayloadOptionalSchema = OrganizationInsertSchema.pipe(
23− Schema.pick("slug"),
24− Schema.partialWith({ exact: true }),
25−);
26−
27−export const CreateOrganizationPayloadSchema = Schema.extend(
28− createOrganizationPayloadRequiredSchema,
29− createOrganizationPayloadOptionalSchema,
30−);
21+export const CreateOrganizationPayloadSchema = Schema.Struct({
22+ name: TrimmedNonEmptyStringSchema,
23+ slug: OptionalTrimmedNonEmptyStringSchema,
24+});
3125
3226 export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type;
3327
34−export const UpdateOrganizationPayloadSchema = OrganizationUpdateSchema.pipe(
35− Schema.pick("name", "status"),
36− Schema.partialWith({ exact: true }),
37−);
28+export const UpdateOrganizationPayloadSchema = Schema.Struct({
29+ name: OptionalTrimmedNonEmptyStringSchema,
30+ status: Schema.optional(OrganizationStatusSchema),
31+});
3832
3933 export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type;
4034
packages/control-plane/src/api/organizations/http.ts+24−102
@@ -1,48 +1,17 @@
1−import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2−import * as Effect from "effect/Effect";
1+import { HttpApiBuilder } from "@effect/platform";
32 import type { OrganizationId } from "#schema";
43
4+import { requirePermission, withPolicy } from "#domain";
55 import {
6− Actor,
7− ActorForbiddenError,
8− ActorUnauthenticatedError,
9− requirePermission,
10− withPolicy,
11−} from "#domain";
6+ createOrganization,
7+ getOrganization,
8+ listOrganizations,
9+ removeOrganization,
10+ updateOrganization,
11+} from "../../runtime/organizations-operations";
1212
1313 import { ControlPlaneApi } from "../api";
14−import { ControlPlaneActorResolver } from "../auth/actor-resolver";
15−import {
16− ControlPlaneForbiddenError,
17− ControlPlaneUnauthorizedError,
18−} from "../errors";
19−import { ControlPlaneOrganizationsService } from "../service";
20−
21−const 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−
31−const toUnauthorizedError = (
32− operation: string,
33− cause: ActorUnauthenticatedError,
34−): ControlPlaneUnauthorizedError =>
35− new ControlPlaneUnauthorizedError({
36− operation,
37− message: cause.message,
38− details: "Authentication required",
39− });
40−
41−const resolveActor = Effect.gen(function* () {
42− const actorResolver = yield* ControlPlaneActorResolver;
43− const request = yield* HttpServerRequest.HttpServerRequest;
44− return yield* actorResolver.resolveActor({ headers: request.headers });
45−});
14+import { withRequestActor } from "../http-auth";
4615
4716 const requireReadOrganizations = requirePermission({
4817 permission: "organizations:read",
@@ -60,93 +29,46 @@ export const ControlPlaneOrganizationsLive = HttpApiBuilder.group( · 11 unmodified lines
6029 (handlers) =>
6130 handlers
6231 .handle("list", () =>
63− Effect.gen(function* () {
64− const service = yield* ControlPlaneOrganizationsService;
65− const actor = yield* resolveActor;
66−
67− return yield* withPolicy(requireReadOrganizations)(
68− service.listOrganizations({
32+ withRequestActor("organizations.list", (actor) =>
33+ withPolicy(requireReadOrganizations)(
34+ listOrganizations({
6935 accountId: actor.principal.accountId,
7036 }),
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)),
7837 ),
7938 ),
8039 )
8140 .handle("create", ({ payload }) =>
82− Effect.gen(function* () {
83− const service = yield* ControlPlaneOrganizationsService;
84− const actor = yield* resolveActor;
85−
86− return yield* service.createOrganization({
41+ withRequestActor("organizations.create", (actor) =>
42+ createOrganization({
8743 payload,
8844 createdByAccountId: actor.principal.accountId,
89− });
90− }).pipe(
91− Effect.catchTag("ActorUnauthenticatedError", (cause) =>
92− Effect.fail(toUnauthorizedError("organizations.create", cause)),
93− ),
45+ }),
9446 ),
9547 )
9648 .handle("get", ({ path }) =>
97− Effect.gen(function* () {
98− const service = yield* ControlPlaneOrganizationsService;
99− const actor = yield* resolveActor;
100−
101− return yield* withPolicy(requireReadOrganizations)(
102− service.getOrganization({
49+ withRequestActor("organizations.get", (actor) =>
50+ withPolicy(requireReadOrganizations)(
51+ getOrganization({
10352 organizationId: path.organizationId,
10453 accountId: actor.principal.accountId,
10554 }),
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)),
11355 ),
11456 ),
11557 )
11658 .handle("update", ({ path, payload }) =>
117− Effect.gen(function* () {
118− const service = yield* ControlPlaneOrganizationsService;
119− const actor = yield* resolveActor;
120−
121− return yield* withPolicy(requireManageOrganizations(path.organizationId))(
122− service.updateOrganization({
59+ withRequestActor("organizations.update", () =>
60+ withPolicy(requireManageOrganizations(path.organizationId))(
61+ updateOrganization({
12362 organizationId: path.organizationId,
12463 payload,
12564 }),
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)),
13365 ),
13466 ),
13567 )
13668 .handle("remove", ({ path }) =>
137− Effect.gen(function* () {
138− const service = yield* ControlPlaneOrganizationsService;
139− const actor = yield* resolveActor;
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)),
69+ withRequestActor("organizations.remove", () =>
70+ withPolicy(requireManageOrganizations(path.organizationId))(
71+ removeOrganization({ organizationId: path.organizationId }),
15072 ),
15173 ),
15274 ),
packages/control-plane/src/api/payload-schemas.test.ts+86−0
@@ -0,0 +1,86 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Schema from "effect/Schema";
3+
4+import {
5+ CreateExecutionPayloadSchema,
6+} from "./executions/api";
7+import {
8+ CreateOrganizationPayloadSchema,
9+ UpdateOrganizationPayloadSchema,
10+} from "./organizations/api";
11+import { CreatePolicyPayloadSchema } from "./policies/api";
12+import {
13+ CreateSourcePayloadSchema,
14+ UpdateSourcePayloadSchema,
15+} from "./sources/api";
16+import { CreateWorkspacePayloadSchema } from "./workspaces/api";
17+
18+describe("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 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import {
3+ AccountIdSchema,
34 PolicyIdSchema,
4− PolicyInsertSchema,
5+ PolicyApprovalModeSchema,
6+ PolicyEffectSchema,
7+ PolicyMatchTypeSchema,
8+ PolicyResourceTypeSchema,
59 PolicySchema,
6− PolicyUpdateSchema,
710 WorkspaceIdSchema,
811 } from "#schema";
912 import * as Schema from "effect/Schema";
@@ -15,40 +18,35 @@ import { · 5 unmodified lines
1518 ControlPlaneStorageError,
1619 ControlPlaneUnauthorizedError,
1720 } from "../errors";
21+import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas";
1822
19−export const CreatePolicyPayloadSchema = PolicyInsertSchema.pipe(
20− Schema.pick(
21− "resourceType",
22− "resourcePattern",
23− "matchType",
24− "effect",
25− "approvalMode",
26− "argumentConditionsJson",
27− "priority",
28− "enabled",
29− "targetAccountId",
30− "clientId",
31− ),
32− Schema.partialWith({ exact: true }),
33−);
23+export const CreatePolicyPayloadSchema = Schema.Struct({
24+ resourceType: Schema.optional(PolicyResourceTypeSchema),
25+ resourcePattern: OptionalTrimmedNonEmptyStringSchema,
26+ matchType: Schema.optional(PolicyMatchTypeSchema),
27+ effect: Schema.optional(PolicyEffectSchema),
28+ approvalMode: Schema.optional(PolicyApprovalModeSchema),
29+ argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)),
30+ priority: Schema.optional(Schema.Number),
31+ enabled: Schema.optional(Schema.Boolean),
32+ targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)),
33+ clientId: Schema.optional(Schema.NullOr(Schema.String)),
34+});
3435
3536 export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type;
3637
37−export const UpdatePolicyPayloadSchema = PolicyUpdateSchema.pipe(
38− Schema.pick(
39− "resourceType",
40− "resourcePattern",
41− "matchType",
42− "effect",
43− "approvalMode",
44− "argumentConditionsJson",
45− "priority",
46− "enabled",
47− "targetAccountId",
48− "clientId",
49− ),
50− Schema.partialWith({ exact: true }),
51−);
38+export const UpdatePolicyPayloadSchema = Schema.Struct({
39+ resourceType: Schema.optional(PolicyResourceTypeSchema),
40+ resourcePattern: OptionalTrimmedNonEmptyStringSchema,
41+ matchType: Schema.optional(PolicyMatchTypeSchema),
42+ effect: Schema.optional(PolicyEffectSchema),
43+ approvalMode: Schema.optional(PolicyApprovalModeSchema),
44+ argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)),
45+ priority: Schema.optional(Schema.Number),
46+ enabled: Schema.optional(Schema.Boolean),
47+ targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)),
48+ clientId: Schema.optional(Schema.NullOr(Schema.String)),
49+});
5250
5351 export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;
5452
packages/control-plane/src/api/policies/http.ts+24−107
@@ -1,50 +1,17 @@
1−import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2−import * as Effect from "effect/Effect";
1+import { HttpApiBuilder } from "@effect/platform";
32 import type { WorkspaceId } from "#schema";
43
4+import { requirePermission, withPolicy } from "#domain";
55 import {
6− Actor,
7− ActorForbiddenError,
8− ActorUnauthenticatedError,
9− requirePermission,
10− withPolicy,
11−} from "#domain";
6+ createPolicy,
7+ getPolicy,
8+ listPolicies,
9+ removePolicy,
10+ updatePolicy,
11+} from "../../runtime/policies-operations";
1212
1313 import { ControlPlaneApi } from "../api";
14−import { ControlPlaneActorResolver } from "../auth/actor-resolver";
15−import {
16− ControlPlaneForbiddenError,
17− ControlPlaneUnauthorizedError,
18−} from "../errors";
19−import { ControlPlanePoliciesService } from "../service";
20−
21−const 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−
31−const toUnauthorizedError = (
32− operation: string,
33− cause: ActorUnauthenticatedError,
34−): ControlPlaneUnauthorizedError =>
35− new ControlPlaneUnauthorizedError({
36− operation,
37− message: cause.message,
38− details: "Authentication required",
39− });
40−
41−const 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− });
14+import { withWorkspaceRequestActor } from "../http-auth";
4815
4916 const requireReadPolicies = (workspaceId: WorkspaceId) =>
5017 requirePermission({
@@ -64,97 +31,47 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 13 unmodified lines
6431 (handlers) =>
6532 handlers
6633 .handle("list", ({ path }) =>
67− Effect.gen(function* () {
68− const service = yield* ControlPlanePoliciesService;
69− const actor = yield* resolveWorkspaceActor(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)),
34+ withWorkspaceRequestActor("policies.list", path.workspaceId, () =>
35+ withPolicy(requireReadPolicies(path.workspaceId))(
36+ listPolicies(path.workspaceId),
8037 ),
8138 ),
8239 )
8340 .handle("create", ({ path, payload }) =>
84− Effect.gen(function* () {
85− const service = yield* ControlPlanePoliciesService;
86− const actor = yield* resolveWorkspaceActor(path.workspaceId);
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)),
41+ withWorkspaceRequestActor("policies.create", path.workspaceId, () =>
42+ withPolicy(requireWritePolicies(path.workspaceId))(
43+ createPolicy({ workspaceId: path.workspaceId, payload }),
9744 ),
9845 ),
9946 )
10047 .handle("get", ({ path }) =>
101− Effect.gen(function* () {
102− const service = yield* ControlPlanePoliciesService;
103− const actor = yield* resolveWorkspaceActor(path.workspaceId);
104−
105− return yield* withPolicy(requireReadPolicies(path.workspaceId))(
106− service.getPolicy({
48+ withWorkspaceRequestActor("policies.get", path.workspaceId, () =>
49+ withPolicy(requireReadPolicies(path.workspaceId))(
50+ getPolicy({
10751 workspaceId: path.workspaceId,
10852 policyId: path.policyId,
10953 }),
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)),
11754 ),
11855 ),
11956 )
12057 .handle("update", ({ path, payload }) =>
121− Effect.gen(function* () {
122− const service = yield* ControlPlanePoliciesService;
123− const actor = yield* resolveWorkspaceActor(path.workspaceId);
124−
125− return yield* withPolicy(requireWritePolicies(path.workspaceId))(
126− service.updatePolicy({
58+ withWorkspaceRequestActor("policies.update", path.workspaceId, () =>
59+ withPolicy(requireWritePolicies(path.workspaceId))(
60+ updatePolicy({
12761 workspaceId: path.workspaceId,
12862 policyId: path.policyId,
12963 payload,
13064 }),
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)),
13865 ),
13966 ),
14067 )
14168 .handle("remove", ({ path }) =>
142− Effect.gen(function* () {
143− const service = yield* ControlPlanePoliciesService;
144− const actor = yield* resolveWorkspaceActor(path.workspaceId);
145−
146− return yield* withPolicy(requireWritePolicies(path.workspaceId))(
147− service.removePolicy({
69+ withWorkspaceRequestActor("policies.remove", path.workspaceId, () =>
70+ withPolicy(requireWritePolicies(path.workspaceId))(
71+ removePolicy({
14872 workspaceId: path.workspaceId,
14973 policyId: path.policyId,
15074 }),
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)),
15875 ),
15976 ),
16077 ),
packages/control-plane/src/api/service.ts+0−343
@@ -1,343 +0,0 @@
1−import 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";
16−import * as Context from "effect/Context";
17−import * as Effect from "effect/Effect";
18−
19−import type {
20− CreateMembershipPayload,
21− UpdateMembershipPayload,
22−} from "./memberships/api";
23−import type {
24− CreateOrganizationPayload,
25− UpdateOrganizationPayload,
26−} from "./organizations/api";
27−import type {
28− CreatePolicyPayload,
29− UpdatePolicyPayload,
30−} from "./policies/api";
31−import type {
32− CreateSourcePayload,
33− UpdateSourcePayload,
34−} from "./sources/api";
35−import type {
36− CreateExecutionPayload,
37− ResumeExecutionPayload,
38−} from "./executions/api";
39−import type {
40− CreateWorkspacePayload,
41− UpdateWorkspacePayload,
42−} from "./workspaces/api";
43−import type {
44− ControlPlaneBadRequestError,
45− ControlPlaneNotFoundError,
46− ControlPlaneStorageError,
47−} from "./errors";
48−
49−export type CreateOrganizationInput = {
50− payload: CreateOrganizationPayload;
51− createdByAccountId?: Organization["createdByAccountId"];
52−};
53−
54−export type UpdateOrganizationInput = {
55− organizationId: OrganizationId;
56− payload: UpdateOrganizationPayload;
57−};
58−
59−export type RemoveOrganizationInput = {
60− organizationId: OrganizationId;
61−};
62−
63−export type CreateMembershipInput = {
64− organizationId: OrganizationId;
65− payload: CreateMembershipPayload;
66−};
67−
68−export type UpdateMembershipInput = {
69− organizationId: OrganizationId;
70− accountId: AccountId;
71− payload: UpdateMembershipPayload;
72−};
73−
74−export type RemoveMembershipInput = {
75− organizationId: OrganizationId;
76− accountId: AccountId;
77−};
78−
79−export type CreateWorkspaceInput = {
80− organizationId: OrganizationId;
81− payload: CreateWorkspacePayload;
82− createdByAccountId?: Workspace["createdByAccountId"];
83−};
84−
85−export type UpdateWorkspaceInput = {
86− workspaceId: WorkspaceId;
87− payload: UpdateWorkspacePayload;
88−};
89−
90−export type RemoveWorkspaceInput = {
91− workspaceId: WorkspaceId;
92−};
93−
94−export type CreateSourceInput = {
95− workspaceId: WorkspaceId;
96− payload: CreateSourcePayload;
97−};
98−
99−export type GetSourceInput = {
100− workspaceId: WorkspaceId;
101− sourceId: SourceId;
102−};
103−
104−export type UpdateSourceInput = {
105− workspaceId: WorkspaceId;
106− sourceId: SourceId;
107− payload: UpdateSourcePayload;
108−};
109−
110−export type RemoveSourceInput = {
111− workspaceId: WorkspaceId;
112− sourceId: SourceId;
113−};
114−
115−export type CompleteSourceAuthCallbackInput = {
116− state: string;
117− code?: string | null;
118− error?: string | null;
119− errorDescription?: string | null;
120−};
121−
122−export type CreatePolicyInput = {
123− workspaceId: WorkspaceId;
124− payload: CreatePolicyPayload;
125−};
126−
127−export type CreateExecutionInput = {
128− workspaceId: WorkspaceId;
129− payload: CreateExecutionPayload;
130− createdByAccountId: AccountId;
131−};
132−
133−export type GetExecutionInput = {
134− workspaceId: WorkspaceId;
135− executionId: ExecutionId;
136−};
137−
138−export type ResumeExecutionInput = {
139− workspaceId: WorkspaceId;
140− executionId: ExecutionId;
141− payload: ResumeExecutionPayload;
142− resumedByAccountId: AccountId;
143−};
144−
145−export type GetPolicyInput = {
146− workspaceId: WorkspaceId;
147− policyId: PolicyId;
148−};
149−
150−export type UpdatePolicyInput = {
151− workspaceId: WorkspaceId;
152− policyId: PolicyId;
153− payload: UpdatePolicyPayload;
154−};
155−
156−export type RemovePolicyInput = {
157− workspaceId: WorkspaceId;
158− policyId: PolicyId;
159−};
160−
161−export 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−
183−export 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−
207−export 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−
231−export 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−
252−export 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−
270−export 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−
280−export 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−
301−export class ControlPlaneOrganizationsService extends Context.Tag(
302− "#api/ControlPlaneOrganizationsService",
303−)<ControlPlaneOrganizationsService, ControlPlaneOrganizationsServiceShape>() {
304−}
305−
306−export class ControlPlaneMembershipsService extends Context.Tag(
307− "#api/ControlPlaneMembershipsService",
308−)<ControlPlaneMembershipsService, ControlPlaneMembershipsServiceShape>() {
309−}
310−
311−export class ControlPlaneWorkspacesService extends Context.Tag(
312− "#api/ControlPlaneWorkspacesService",
313−)<ControlPlaneWorkspacesService, ControlPlaneWorkspacesServiceShape>() {
314−}
315−
316−export class ControlPlaneSourcesService extends Context.Tag(
317− "#api/ControlPlaneSourcesService",
318−)<ControlPlaneSourcesService, ControlPlaneSourcesServiceShape>() {
319−}
320−
321−export class ControlPlaneExecutionsService extends Context.Tag(
322− "#api/ControlPlaneExecutionsService",
323−)<ControlPlaneExecutionsService, ControlPlaneExecutionsServiceShape>() {
324−}
325−
326−export class ControlPlaneLocalService extends Context.Tag(
327− "#api/ControlPlaneLocalService",
328−)<ControlPlaneLocalService, ControlPlaneLocalServiceShape>() {
329−}
330−
331−export class ControlPlanePoliciesService extends Context.Tag(
332− "#api/ControlPlanePoliciesService",
333−)<ControlPlanePoliciesService, ControlPlanePoliciesServiceShape>() {
334−}
335−
336−export 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
1818 ControlPlaneStorageError,
1919 ControlPlaneUnauthorizedError,
2020 } from "../errors";
21+import {
22+ OptionalTrimmedNonEmptyStringSchema,
23+ TrimmedNonEmptyStringSchema,
24+} from "../string-schemas";
2125
2226 const createSourcePayloadRequiredSchema = Schema.Struct({
23− name: Schema.String,
27+ name: TrimmedNonEmptyStringSchema,
2428 kind: SourceKindSchema,
25− endpoint: Schema.String,
29+ endpoint: TrimmedNonEmptyStringSchema,
2630 });
2731
2832 const createSourcePayloadOptionalSchema = Schema.Struct({
@@ -47,9 +51,9 @@ export const CreateSourcePayloadSchema = Schema.extend( · 18 unmodified lines
4751 export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type;
4852
4953 export const UpdateSourcePayloadSchema = Schema.Struct({
50− name: Schema.optional(Schema.String),
54+ name: OptionalTrimmedNonEmptyStringSchema,
5155 kind: Schema.optional(SourceKindSchema),
52− endpoint: Schema.optional(Schema.String),
56+ endpoint: OptionalTrimmedNonEmptyStringSchema,
5357 status: Schema.optional(SourceStatusSchema),
5458 enabled: Schema.optional(Schema.Boolean),
5559 namespace: Schema.optional(Schema.NullOr(Schema.String)),
packages/control-plane/src/api/sources/http.ts+24−107
@@ -1,50 +1,17 @@
1−import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2−import * as Effect from "effect/Effect";
1+import { HttpApiBuilder } from "@effect/platform";
32 import type { WorkspaceId } from "#schema";
43
4+import { requirePermission, withPolicy } from "#domain";
55 import {
6− Actor,
7− ActorForbiddenError,
8− ActorUnauthenticatedError,
9− requirePermission,
10− withPolicy,
11−} from "#domain";
6+ createSource,
7+ getSource,
8+ listSources,
9+ removeSource,
10+ updateSource,
11+} from "../../runtime/sources-operations";
1212
1313 import { ControlPlaneApi } from "../api";
14−import { ControlPlaneActorResolver } from "../auth/actor-resolver";
15−import {
16− ControlPlaneForbiddenError,
17− ControlPlaneUnauthorizedError,
18−} from "../errors";
19−import { ControlPlaneSourcesService } from "../service";
20−
21−const 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−
31−const toUnauthorizedError = (
32− operation: string,
33− cause: ActorUnauthenticatedError,
34−): ControlPlaneUnauthorizedError =>
35− new ControlPlaneUnauthorizedError({
36− operation,
37− message: cause.message,
38− details: "Authentication required",
39− });
40−
41−const 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− });
14+import { withWorkspaceRequestActor } from "../http-auth";
4815
4916 const requireReadSources = (workspaceId: WorkspaceId) =>
5017 requirePermission({
@@ -64,97 +31,47 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 13 unmodified lines
6431 (handlers) =>
6532 handlers
6633 .handle("list", ({ path }) =>
67− Effect.gen(function* () {
68− const service = yield* ControlPlaneSourcesService;
69− const actor = yield* resolveWorkspaceActor(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)),
34+ withWorkspaceRequestActor("sources.list", path.workspaceId, () =>
35+ withPolicy(requireReadSources(path.workspaceId))(
36+ listSources(path.workspaceId),
8037 ),
8138 ),
8239 )
8340 .handle("create", ({ path, payload }) =>
84− Effect.gen(function* () {
85− const service = yield* ControlPlaneSourcesService;
86− const actor = yield* resolveWorkspaceActor(path.workspaceId);
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)),
41+ withWorkspaceRequestActor("sources.create", path.workspaceId, () =>
42+ withPolicy(requireWriteSources(path.workspaceId))(
43+ createSource({ workspaceId: path.workspaceId, payload }),
9744 ),
9845 ),
9946 )
10047 .handle("get", ({ path }) =>
101− Effect.gen(function* () {
102− const service = yield* ControlPlaneSourcesService;
103− const actor = yield* resolveWorkspaceActor(path.workspaceId);
104−
105− return yield* withPolicy(requireReadSources(path.workspaceId))(
106− service.getSource({
48+ withWorkspaceRequestActor("sources.get", path.workspaceId, () =>
49+ withPolicy(requireReadSources(path.workspaceId))(
50+ getSource({
10751 workspaceId: path.workspaceId,
10852 sourceId: path.sourceId,
10953 }),
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)),
11754 ),
11855 ),
11956 )
12057 .handle("update", ({ path, payload }) =>
121− Effect.gen(function* () {
122− const service = yield* ControlPlaneSourcesService;
123− const actor = yield* resolveWorkspaceActor(path.workspaceId);
124−
125− return yield* withPolicy(requireWriteSources(path.workspaceId))(
126− service.updateSource({
58+ withWorkspaceRequestActor("sources.update", path.workspaceId, () =>
59+ withPolicy(requireWriteSources(path.workspaceId))(
60+ updateSource({
12761 workspaceId: path.workspaceId,
12862 sourceId: path.sourceId,
12963 payload,
13064 }),
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)),
13865 ),
13966 ),
14067 )
14168 .handle("remove", ({ path }) =>
142− Effect.gen(function* () {
143− const service = yield* ControlPlaneSourcesService;
144− const actor = yield* resolveWorkspaceActor(path.workspaceId);
145−
146− return yield* withPolicy(requireWriteSources(path.workspaceId))(
147− service.removeSource({
69+ withWorkspaceRequestActor("sources.remove", path.workspaceId, () =>
70+ withPolicy(requireWriteSources(path.workspaceId))(
71+ removeSource({
14872 workspaceId: path.workspaceId,
14973 sourceId: path.sourceId,
15074 }),
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)),
15875 ),
15976 ),
16077 ),
packages/control-plane/src/api/string-schemas.ts+9−0
@@ -0,0 +1,9 @@
1+import * as Schema from "effect/Schema";
2+
3+export const TrimmedNonEmptyStringSchema = Schema.Trim.pipe(
4+ Schema.nonEmptyString(),
5+);
6+
7+export 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
22 import {
33 OrganizationIdSchema,
44 WorkspaceIdSchema,
5− WorkspaceInsertSchema,
65 WorkspaceSchema,
7− WorkspaceUpdateSchema,
86 } from "#schema";
97 import * as Schema from "effect/Schema";
108
@@ -15,17 +13,20 @@ import { · 4 unmodified lines
1513 ControlPlaneStorageError,
1614 ControlPlaneUnauthorizedError,
1715 } from "../errors";
16+import {
17+ OptionalTrimmedNonEmptyStringSchema,
18+ TrimmedNonEmptyStringSchema,
19+} from "../string-schemas";
1820
19−export const CreateWorkspacePayloadSchema = WorkspaceInsertSchema.pipe(
20− Schema.pick("name"),
21−);
21+export const CreateWorkspacePayloadSchema = Schema.Struct({
22+ name: TrimmedNonEmptyStringSchema,
23+});
2224
2325 export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type;
2426
25−export const UpdateWorkspacePayloadSchema = WorkspaceUpdateSchema.pipe(
26− Schema.pick("name"),
27− Schema.partialWith({ exact: true }),
28−);
27+export const UpdateWorkspacePayloadSchema = Schema.Struct({
28+ name: OptionalTrimmedNonEmptyStringSchema,
29+});
2930
3031 export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type;
3132
packages/control-plane/src/api/workspaces/http.ts+26−114
@@ -1,56 +1,20 @@
1−import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2−import * as Effect from "effect/Effect";
1+import { HttpApiBuilder } from "@effect/platform";
32 import type { OrganizationId, WorkspaceId } from "#schema";
43
4+import { requirePermission, withPolicy } from "#domain";
55 import {
6− Actor,
7− ActorForbiddenError,
8− ActorUnauthenticatedError,
9− requirePermission,
10− withPolicy,
11−} from "#domain";
6+ createWorkspace,
7+ getWorkspace,
8+ listWorkspaces,
9+ removeWorkspace,
10+ updateWorkspace,
11+} from "../../runtime/organizations-operations";
1212
1313 import { ControlPlaneApi } from "../api";
14−import { ControlPlaneActorResolver } from "../auth/actor-resolver";
1514 import {
16− ControlPlaneForbiddenError,
17− ControlPlaneUnauthorizedError,
18−} from "../errors";
19−import { ControlPlaneWorkspacesService } from "../service";
20−
21−const 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−
31−const toUnauthorizedError = (
32− operation: string,
33− cause: ActorUnauthenticatedError,
34−): ControlPlaneUnauthorizedError =>
35− new ControlPlaneUnauthorizedError({
36− operation,
37− message: cause.message,
38− details: "Authentication required",
39− });
40−
41−const 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−
47−const 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− });
15+ withRequestActor,
16+ withWorkspaceRequestActor,
17+} from "../http-auth";
5418
5519 const requireReadWorkspace = (workspaceId: WorkspaceId) =>
5620 requirePermission({
@@ -82,95 +46,43 @@ export const ControlPlaneWorkspacesLive = HttpApiBuilder.group( · 25 unmodified lines
8246 (handlers) =>
8347 handlers
8448 .handle("list", ({ path }) =>
85− Effect.gen(function* () {
86− const service = yield* ControlPlaneWorkspacesService;
87− const actor = yield* resolveActor;
88−
89− return yield* withPolicy(
49+ withRequestActor("workspaces.list", () =>
50+ withPolicy(
9051 requireOrganizationWorkspaceRead(path.organizationId),
91− )(service.listWorkspaces(path.organizationId)).pipe(
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− ),
52+ )(listWorkspaces(path.organizationId))
10153 ),
10254 )
10355 .handle("create", ({ path, payload }) =>
104− Effect.gen(function* () {
105− const service = yield* ControlPlaneWorkspacesService;
106− const actor = yield* resolveActor;
107−
108− return yield* withPolicy(
56+ withRequestActor("workspaces.create", (actor) =>
57+ withPolicy(
10958 requireOrganizationWorkspaceManage(path.organizationId),
11059 )(
111− service.createWorkspace({
60+ createWorkspace({
11261 organizationId: path.organizationId,
11362 payload,
11463 createdByAccountId: actor.principal.accountId,
11564 }),
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)),
12365 ),
12466 ),
12567 )
12668 .handle("get", ({ path }) =>
127− Effect.gen(function* () {
128− const service = yield* ControlPlaneWorkspacesService;
129− const actor = yield* resolveWorkspaceActor(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)),
69+ withWorkspaceRequestActor("workspaces.get", path.workspaceId, () =>
70+ withPolicy(requireReadWorkspace(path.workspaceId))(
71+ getWorkspace(path.workspaceId),
14072 ),
14173 ),
14274 )
14375 .handle("update", ({ path, payload }) =>
144− Effect.gen(function* () {
145− const service = yield* ControlPlaneWorkspacesService;
146− const actor = yield* resolveWorkspaceActor(path.workspaceId);
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)),
76+ withWorkspaceRequestActor("workspaces.update", path.workspaceId, () =>
77+ withPolicy(requireManageWorkspace(path.workspaceId))(
78+ updateWorkspace({ workspaceId: path.workspaceId, payload }),
15779 ),
15880 ),
15981 )
16082 .handle("remove", ({ path }) =>
161− Effect.gen(function* () {
162− const service = yield* ControlPlaneWorkspacesService;
163− const actor = yield* resolveWorkspaceActor(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)),
83+ withWorkspaceRequestActor("workspaces.remove", path.workspaceId, () =>
84+ withPolicy(requireManageWorkspace(path.workspaceId))(
85+ removeWorkspace({ workspaceId: path.workspaceId }),
17486 ),
17587 ),
17688 ),
packages/control-plane/src/runtime/execution-service.ts+259−280
@@ -1,18 +1,20 @@
1−import type { ControlPlaneExecutionsServiceShape } from "#api";
21 import {
32 ControlPlaneBadRequestError,
43 ControlPlaneNotFoundError,
54 ControlPlaneStorageError,
65 } from "#api";
6+import type {
7+ CreateExecutionPayload,
8+ ResumeExecutionPayload,
9+} from "../api/executions/api";
710 import type { ToolInvoker } from "@executor-v3/codemode-core";
8−import {
9− ControlPlanePersistenceError,
10− type SqlControlPlaneRows,
11−} from "#persistence";
1211 import {
1312 ExecutionIdSchema,
13+ type AccountId,
1414 type Execution,
1515 type ExecutionEnvelope,
16+ type ExecutionId,
17+ type WorkspaceId,
1618 } from "#schema";
1719 import * as Deferred from "effect/Deferred";
1820 import * as Effect from "effect/Effect";
@@ -24,65 +26,25 @@ import { · 5 unmodified lines
2426 ResumeUnsupportedError,
2527 } from "./execution-state";
2628 import {
29+ LiveExecutionManagerService,
2730 type LiveExecutionManager,
2831 } from "./live-execution";
32+import {
33+ asOperationErrors,
34+ operationErrors,
35+ type OperationErrorsLike,
36+} from "./operation-errors";
37+import {
38+ ControlPlaneStore,
39+ type ControlPlaneStoreShape,
40+} from "./store";
41+import { RuntimeExecutionResolverService } from "./workspace-execution-environment";
2942
30−const badRequest = (
31− operation: string,
32− message: string,
33− details: string,
34−): ControlPlaneBadRequestError =>
35− new ControlPlaneBadRequestError({
36− operation,
37− message,
38− details,
39− });
40−
41−const notFound = (
42− operation: string,
43− message: string,
44− details: string,
45−): ControlPlaneNotFoundError =>
46− new ControlPlaneNotFoundError({
47− operation,
48− message,
49− details,
50− });
51−
52−const 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−
62−const 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−
68−const 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−};
43+const executionOps = {
44+ create: operationErrors("executions.create"),
45+ get: operationErrors("executions.get"),
46+ resume: operationErrors("executions.resume"),
47+} as const;
8648
8749 const serializeJson = (value: unknown): string | null => {
8850 if (value === undefined) {
@@ -133,23 +95,22 @@ const withExecutionInvocationContext = (input: { · 44 unmodified lines
13395 };
13496
13597 const fetchExecution = (
136− rows: SqlControlPlaneRows,
98+ store: ControlPlaneStoreShape,
13799 input: {
138100 workspaceId: Execution["workspaceId"];
139101 executionId: Execution["id"];
140− operation: string;
102+ operation: OperationErrorsLike;
141103 },
142104 ): Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
143105 Effect.gen(function* () {
144− const existing = yield* mapStorageError(
145− input.operation,
146− rows.executions.getByWorkspaceAndId(input.workspaceId, input.executionId),
106+ const errors = asOperationErrors(input.operation);
107+ const existing = yield* errors.mapStorage(
108+ store.executions.getByWorkspaceAndId(input.workspaceId, input.executionId),
147109 );
148110
149111 if (Option.isNone(existing)) {
150112 return yield* Effect.fail(
151− notFound(
152− input.operation,
113+ errors.notFound(
153114 "Execution not found",
154115 `workspaceId=${input.workspaceId} executionId=${input.executionId}`,
155116 ),
@@ -160,18 +121,18 @@ const fetchExecution = ( · 4 unmodified lines
160121 });
161122
162123 const fetchExecutionEnvelope = (
163− rows: SqlControlPlaneRows,
124+ store: ControlPlaneStoreShape,
164125 input: {
165126 workspaceId: Execution["workspaceId"];
166127 executionId: Execution["id"];
167− operation: string;
128+ operation: OperationErrorsLike;
168129 },
169130 ): Effect.Effect<ExecutionEnvelope, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
170131 Effect.gen(function* () {
171− const execution = yield* fetchExecution(rows, input);
172− const pendingInteraction = yield* mapStorageError(
173− `${input.operation}.pending_interaction`,
174− rows.executionInteractions.getPendingByExecutionId(input.executionId),
132+ const errors = asOperationErrors(input.operation);
133+ const execution = yield* fetchExecution(store, input);
134+ const pendingInteraction = yield* errors.child("pending_interaction").mapStorage(
135+ store.executionInteractions.getPendingByExecutionId(input.executionId),
175136 );
176137
177138 return {
@@ -180,224 +141,242 @@ const fetchExecutionEnvelope = ( · 2 unmodified lines
180141 };
181142 });
182143
183−export const createRuntimeExecutionsService = (
184− rows: SqlControlPlaneRows,
144+const createExecutionWithDependencies = (
145+ store: ControlPlaneStoreShape,
185146 executionResolver: ResolveExecutionEnvironment,
186147 liveExecutionManager: LiveExecutionManager,
187−): ControlPlaneExecutionsServiceShape => {
188− const createExecution: ControlPlaneExecutionsServiceShape["createExecution"] = (
189− { workspaceId, payload, createdByAccountId },
190− ) =>
191− Effect.gen(function* () {
192− const code = yield* requireTrimmed("executions.create", "code", payload.code);
193− const now = Date.now();
194− const execution: Execution = {
195− id: ExecutionIdSchema.make(`exec_${crypto.randomUUID()}`),
196− workspaceId,
197− createdByAccountId,
198− status: "pending",
199− code,
200− resultJson: null,
201− errorText: null,
202− logsJson: null,
203− startedAt: null,
204− completedAt: null,
205− createdAt: now,
206− updatedAt: now,
207− };
208−
209− yield* mapStorageError(
210− "executions.create.insert",
211− rows.executions.insert(execution),
212− );
213−
214− const running = yield* mapStorageError(
215− "executions.create.mark_running",
216− rows.executions.update(execution.id, {
217− status: "running",
218− startedAt: now,
219− updatedAt: now,
220− }),
221− );
222−
223− if (Option.isNone(running)) {
224− return yield* Effect.fail(
225− notFound(
226− "executions.create",
227− "Execution not found after insert",
228− `executionId=${execution.id}`,
229− ),
230− );
231− }
232−
233− const environment = yield* executionResolver({
234− workspaceId,
235− accountId: createdByAccountId,
236− executionId: execution.id,
237− onElicitation: liveExecutionManager.createOnElicitation({
238− rows,
239− executionId: execution.id,
240− }),
241− }).pipe(
242− Effect.mapError((error) =>
243− new ControlPlaneStorageError({
244− operation: "executions.create.environment",
245− message: error instanceof Error ? error.message : String(error),
246− details: "Execution environment resolution failed",
247− }),
248− ),
249− );
250−
251− const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id);
252− const toolInvoker = withExecutionInvocationContext({
253− executionId: execution.id,
254− toolInvoker: environment.toolInvoker,
255− });
256−
257− yield* Effect.sync(() => {
258− Effect.runFork(
259− environment.executor.execute(code, toolInvoker).pipe(
260− Effect.flatMap((outcome) => {
261− const completedAt = Date.now();
262− return mapStorageError(
263− "executions.create.complete",
264− rows.executions.update(execution.id, {
265− status: outcome.error ? "failed" : "completed",
266− resultJson: serializeJson(outcome.result),
267− errorText: outcome.error ?? null,
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− );
148+ input: {
149+ workspaceId: WorkspaceId;
150+ payload: CreateExecutionPayload;
151+ createdByAccountId: AccountId;
152+ },
153+) =>
154+ Effect.gen(function* () {
155+ const code = input.payload.code;
156+ const now = Date.now();
157+ const execution: Execution = {
158+ id: ExecutionIdSchema.make(`exec_${crypto.randomUUID()}`),
159+ workspaceId: input.workspaceId,
160+ createdByAccountId: input.createdByAccountId,
161+ status: "pending",
162+ code,
163+ resultJson: null,
164+ errorText: null,
165+ logsJson: null,
166+ startedAt: null,
167+ completedAt: null,
168+ createdAt: now,
169+ updatedAt: now,
170+ };
171+
172+ yield* executionOps.create.child("insert").mapStorage(
173+ store.executions.insert(execution),
174+ );
175+
176+ const running = yield* executionOps.create.child("mark_running").mapStorage(
177+ store.executions.update(execution.id, {
178+ status: "running",
179+ startedAt: now,
180+ updatedAt: now,
181+ }),
182+ );
183+
184+ if (Option.isNone(running)) {
185+ return yield* Effect.fail(
186+ executionOps.create.notFound(
187+ "Execution not found after insert",
188+ `executionId=${execution.id}`,
189+ ),
190+ );
191+ }
192+
193+ const environment = yield* executionResolver({
194+ workspaceId: input.workspaceId,
195+ accountId: input.createdByAccountId,
196+ executionId: execution.id,
197+ onElicitation: liveExecutionManager.createOnElicitation({
198+ rows: store,
199+ executionId: execution.id,
200+ }),
201+ }).pipe(
202+ Effect.mapError((error) =>
203+ executionOps.create.child("environment").unknownStorage(
204+ error,
205+ "Execution environment resolution failed",
206+ ),
207+ ),
208+ );
209+
210+ const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id);
211+ const toolInvoker = withExecutionInvocationContext({
212+ executionId: execution.id,
213+ toolInvoker: environment.toolInvoker,
214+ });
215+
216+ yield* Effect.sync(() => {
217+ Effect.runFork(
218+ environment.executor.execute(code, toolInvoker).pipe(
219+ Effect.flatMap((outcome) => {
220+ const completedAt = Date.now();
221+ return executionOps.create.child("complete").mapStorage(
222+ store.executions.update(execution.id, {
223+ status: outcome.error ? "failed" : "completed",
224+ resultJson: serializeJson(outcome.result),
225+ errorText: outcome.error ?? null,
226+ logsJson: serializeJson(outcome.logs ?? null),
227+ completedAt,
228+ updatedAt: completedAt,
291229 }),
292− Effect.catchAll((error) => {
293− const completedAt = Date.now();
294− return mapStorageError(
295− "executions.create.complete_error",
296− rows.executions.update(execution.id, {
297− status: "failed",
298− errorText: error instanceof Error ? error.message : String(error),
299− completedAt,
300− updatedAt: completedAt,
301− }),
302− ).pipe(
303− Effect.zipRight(
304− liveExecutionManager.finishRun({
305− executionId: execution.id,
306− state: "failed",
307− }),
308− ),
309− Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)),
310− );
230+ ).pipe(
231+ Effect.flatMap((updated) =>
232+ Option.isNone(updated)
233+ ? Effect.fail(
234+ executionOps.create.notFound(
235+ "Execution not found after completion",
236+ `executionId=${execution.id}`,
237+ ),
238+ )
239+ : Effect.succeed(updated.value),
240+ ),
241+ Effect.flatMap((updated) =>
242+ liveExecutionManager.finishRun({
243+ executionId: execution.id,
244+ state: updated.status === "completed" ? "completed" : "failed",
245+ }).pipe(Effect.as(updated)),
246+ ),
247+ );
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,
311257 }),
312− ),
313− );
314− });
258+ ).pipe(
259+ Effect.zipRight(
260+ liveExecutionManager.finishRun({
261+ executionId: execution.id,
262+ state: "failed",
263+ }),
264+ ),
265+ Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)),
266+ );
267+ }),
268+ ),
269+ );
270+ });
315271
316− yield* Deferred.await(nextState);
272+ yield* Deferred.await(nextState);
317273
318− return yield* fetchExecutionEnvelope(rows, {
319− workspaceId,
320− executionId: execution.id,
321− operation: "executions.create",
322− });
323− });
274+ return yield* fetchExecutionEnvelope(store, {
275+ workspaceId: input.workspaceId,
276+ executionId: execution.id,
277+ operation: executionOps.create,
278+ });
279+ });
324280
325− return {
326− createExecution,
327− getExecution: ({ workspaceId, executionId }) =>
328− fetchExecutionEnvelope(rows, {
329− workspaceId,
330− executionId,
331− operation: "executions.get",
332− }),
281+export const createExecution = (input: {
282+ workspaceId: WorkspaceId;
283+ payload: CreateExecutionPayload;
284+ createdByAccountId: AccountId;
285+}) =>
286+ Effect.gen(function* () {
287+ const store = yield* ControlPlaneStore;
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+ });
333298
334− resumeExecution: ({ workspaceId, executionId, payload }) =>
335− Effect.gen(function* () {
336− const existing = yield* fetchExecutionEnvelope(rows, {
337− workspaceId,
338− executionId,
339− operation: "executions.resume",
340− });
341−
342− if (existing.execution.status !== "waiting_for_interaction") {
343− return yield* Effect.fail(
344− badRequest(
345− "executions.resume",
346− "Execution is not waiting for interaction",
347− `executionId=${executionId} status=${existing.execution.status}`,
348− ),
349− );
350− }
351−
352− const responseJson = payload.responseJson;
353− const response =
354− responseJson === undefined
355− ? { action: "accept" as const }
356− : yield* Effect.try({
357− try: () => JSON.parse(responseJson),
358− catch: (error) =>
359− badRequest(
360− "executions.resume",
299+export const getExecution = (input: {
300+ workspaceId: WorkspaceId;
301+ executionId: ExecutionId;
302+}) =>
303+ Effect.flatMap(ControlPlaneStore, (store) =>
304+ fetchExecutionEnvelope(store, {
305+ workspaceId: input.workspaceId,
306+ executionId: input.executionId,
307+ operation: executionOps.get,
308+ })
309+ );
310+
311+export const resumeExecution = (input: {
312+ workspaceId: WorkspaceId;
313+ executionId: ExecutionId;
314+ payload: ResumeExecutionPayload;
315+ resumedByAccountId: AccountId;
316+}) =>
317+ Effect.gen(function* () {
318+ const store = yield* ControlPlaneStore;
319+ const liveExecutionManager = yield* LiveExecutionManagerService;
320+
321+ const existing = yield* fetchExecutionEnvelope(store, {
322+ workspaceId: input.workspaceId,
323+ executionId: input.executionId,
324+ operation: "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(
361352 "Invalid responseJson",
362− error instanceof Error ? error.message : 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− ),
353+ String(error),
374354 ),
375355 ),
376− );
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}`,
356+ ),
390357 ),
391358 );
392− }
393359
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+ });
395365
396− return yield* fetchExecutionEnvelope(rows, {
397− workspaceId,
398− executionId,
399− operation: "executions.resume",
400− });
401− }),
402− };
403−};
366+ if (!resumed) {
367+ return yield* Effect.fail(
368+ executionOps.resume.badRequest(
369+ "Resume is unavailable for this execution",
370+ `executionId=${input.executionId} mode=${new ResumeUnsupportedError({ executionId: input.executionId })._tag}`,
371+ ),
372+ );
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
55 import * as Scope from "effect/Scope";
66
77 import {
8− type ControlPlaneApiServiceContext,
8+ type ControlPlaneApiRuntimeContext,
99 ControlPlaneActorResolver,
1010 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,
2511 } from "#api";
2612 import {
2713 SqlControlPlanePersistenceLive,
@@ -31,31 +17,31 @@ import { · 3 unmodified lines
3117 type CreateSqlRuntimeOptions,
3218 type SqlControlPlanePersistence,
3319 } from "#persistence";
34−
3520 import type { LocalInstallation } from "#schema";
21+
3622 import {
3723 ControlPlaneAuthHeaders,
3824 RuntimeActorResolverLive,
3925 createHeaderActorResolver,
4026 } from "./actor-resolver";
41−import {
42− type ResolveExecutionEnvironment,
43−} from "./execution-state";
27+import { type ResolveExecutionEnvironment } from "./execution-state";
4428 import {
4529 LiveExecutionManagerLive,
30+ LiveExecutionManagerService,
4631 } from "./live-execution";
32+import { getOrProvisionLocalInstallation } from "./local-installation";
4733 import {
48− getOrProvisionLocalInstallation,
49−} from "./local-installation";
34+ ControlPlaneStore,
35+ ControlPlaneStoreLive,
36+} from "./store";
5037 import {
5138 RuntimeSourceAuthServiceLive,
39+ RuntimeSourceAuthServiceTag,
5240 type ResolveSecretMaterial,
5341 } from "./source-auth-service";
54−import {
55− RuntimeControlPlaneApiServicesLive,
56−} from "./services";
5742 import {
5843 RuntimeExecutionResolverLive,
44+ RuntimeExecutionResolverService,
5945 } from "./workspace-execution-environment";
6046
6147 export {
@@ -67,6 +53,7 @@ export * from "./execution-state"; · 5 unmodified lines
6753 export * from "./live-execution";
6854 export * from "./local-installation";
6955 export * from "./source-auth-service";
56+export * from "./store";
7057 export * from "./workspace-execution-environment";
7158
7259 export type RuntimeControlPlaneOptions = {
@@ -76,10 +63,6 @@ export type RuntimeControlPlaneOptions = { · 3 unmodified lines
7663 getLocalServerBaseUrl?: () => string | undefined;
7764 };
7865
79−export type RuntimeControlPlaneInput = RuntimeControlPlaneOptions & {
80− persistence: SqlControlPlanePersistence;
81−};
82−
8366 const detailsFromCause = (cause: unknown): string =>
8467 cause instanceof Error ? cause.message : String(cause);
8568
@@ -96,23 +79,31 @@ const toLocalInstallationBootstrapError = ( · 10 unmodified lines
9679 const closeScope = (scope: Scope.CloseableScope) =>
9780 Scope.close(scope, Exit.void).pipe(Effect.orDie);
9881
99−const createRuntimeServicesLayer = (input: {
100− organizationsService: ControlPlaneOrganizationsServiceShape;
101− membershipsService: ControlPlaneMembershipsServiceShape;
102− workspacesService: ControlPlaneWorkspacesServiceShape;
103− sourcesService: ControlPlaneSourcesServiceShape;
104− policiesService: ControlPlanePoliciesServiceShape;
105− localService: ControlPlaneLocalServiceShape;
106− executionsService: ControlPlaneExecutionsServiceShape;
107−}): Layer.Layer<ControlPlaneApiServiceContext, never, never> =>
108− Layer.mergeAll(
109− Layer.succeed(ControlPlaneOrganizationsService, input.organizationsService),
110− Layer.succeed(ControlPlaneMembershipsService, input.membershipsService),
111− Layer.succeed(ControlPlaneWorkspacesService, input.workspacesService),
112− Layer.succeed(ControlPlaneSourcesService, input.sourcesService),
113− Layer.succeed(ControlPlanePoliciesService, input.policiesService),
114− Layer.succeed(ControlPlaneLocalService, input.localService),
115− Layer.succeed(ControlPlaneExecutionsService, input.executionsService),
82+export type RuntimeControlPlaneLayer = Layer.Layer<
83+ ControlPlaneApiRuntimeContext,
84+ never,
85+ never
86+>;
87+
88+const runtimeContextTags = [
89+ ControlPlaneActorResolver,
90+ ControlPlaneStore,
91+ LiveExecutionManagerService,
92+ RuntimeSourceAuthServiceTag,
93+ RuntimeExecutionResolverService,
94+] as const;
95+
96+const createRuntimeLayerFromContext = (
97+ context: Context.Context<
98+ ControlPlaneActorResolver
99+ | ControlPlaneStore
100+ | LiveExecutionManagerService
101+ | RuntimeSourceAuthServiceTag
102+ | RuntimeExecutionResolverService
103+ >,
104+): RuntimeControlPlaneLayer =>
105+ Layer.succeedContext(
106+ context.pipe(Context.pick(...runtimeContextTags)),
116107 );
117108
118109 export const createRuntimeControlPlaneLayer = (
@@ -130,67 +121,20 @@ export const createRuntimeControlPlaneLayer = ( · 11 unmodified lines
130121 }).pipe(
131122 Layer.provide(sourceAuthLayer),
132123 );
133− const runtimeDependenciesLayer = Layer.mergeAll(
134− liveExecutionManagerLayer,
135− sourceAuthLayer,
136− executionResolverLayer,
137− );
138− const apiServicesLayer = RuntimeControlPlaneApiServicesLive.pipe(
139− Layer.provide(runtimeDependenciesLayer),
140− );
141124
142125 return Layer.mergeAll(
143− apiServicesLayer,
126+ ControlPlaneStoreLive,
144127 RuntimeActorResolverLive(options.actorResolver),
145− runtimeDependenciesLayer,
128+ liveExecutionManagerLayer,
129+ sourceAuthLayer,
130+ executionResolverLayer,
146131 );
147132 };
148133
149−export 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−
189134 export type SqlControlPlaneRuntime = {
190135 persistence: SqlControlPlanePersistence;
191136 localInstallation: LocalInstallation;
192− serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, never, never>;
193− actorResolver: ControlPlaneActorResolverShape;
137+ runtimeLayer: RuntimeControlPlaneLayer;
194138 close: () => Promise<void>;
195139 };
196140
@@ -217,27 +161,10 @@ export const createSqlControlPlaneRuntime = ( · 20 unmodified lines
217161 );
218162
219163 const persistence = Context.get(context, SqlControlPlanePersistenceService);
220− const actorResolver = Context.get(context, ControlPlaneActorResolver);
221− const organizationsService = Context.get(context, ControlPlaneOrganizationsService);
222− const membershipsService = Context.get(context, ControlPlaneMembershipsService);
223− const workspacesService = Context.get(context, ControlPlaneWorkspacesService);
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(
164+ const store = Context.get(context, ControlPlaneStore);
165+ const concreteRuntimeLayer = createRuntimeLayerFromContext(context);
166+
167+ const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe(
241168 Effect.mapError(toLocalInstallationBootstrapError),
242169 Effect.catchAll((error) =>
243170 closeScope(scope).pipe(
@@ -248,8 +175,7 @@ export const createSqlControlPlaneRuntime = ( · 4 unmodified lines
248175 return {
249176 persistence,
250177 localInstallation,
251− serviceLayer,
252− actorResolver,
178+ runtimeLayer: concreteRuntimeLayer,
253179 close: () => Effect.runPromise(Scope.close(scope, Exit.void)),
254180 };
255181 });
packages/control-plane/src/runtime/local-operations.ts+60−0
@@ -0,0 +1,60 @@
1+import { ControlPlanePersistenceError } from "#persistence";
2+import * as Effect from "effect/Effect";
3+
4+import { loadLocalInstallation } from "./local-installation";
5+import {
6+ operationErrors,
7+} from "./operation-errors";
8+import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
9+import { ControlPlaneStore } from "./store";
10+
11+const localOps = {
12+ installation: operationErrors("local.installation.get"),
13+ oauthCallback: operationErrors("local.oauth.callback"),
14+} as const;
15+
16+export 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+
43+export 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 @@
1+import {
2+ ControlPlaneBadRequestError,
3+ ControlPlaneNotFoundError,
4+ ControlPlaneStorageError,
5+} from "#api";
6+import { ControlPlanePersistenceError } from "#persistence";
7+import * as Effect from "effect/Effect";
8+
9+const 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+
25+export 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+
54+export type OperationErrorsLike = OperationErrors | string;
55+
56+export 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+
94+export 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 @@
1+import {
2+ ControlPlaneBadRequestError,
3+ ControlPlaneNotFoundError,
4+ ControlPlaneStorageError,
5+} from "#api";
6+import { ControlPlanePersistenceError } from "#persistence";
7+import type { Organization } from "#schema";
8+import * as Effect from "effect/Effect";
9+import * as Option from "effect/Option";
10+
11+import {
12+ asOperationErrors,
13+ operationErrors,
14+ type OperationErrorsLike,
15+} from "./operation-errors";
16+import type { ControlPlaneStoreShape } from "./store";
17+import { slugify } from "./slug";
18+
19+export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
20+
21+const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>
22+ error.kind === "unique_violation";
23+
24+export 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+
37+export 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+
56+export 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+
81+export 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 @@
1+import type {
2+ CreateMembershipPayload,
3+ UpdateMembershipPayload,
4+} from "../api/memberships/api";
5+import type {
6+ CreateOrganizationPayload,
7+ UpdateOrganizationPayload,
8+} from "../api/organizations/api";
9+import type {
10+ CreateWorkspacePayload,
11+ UpdateWorkspacePayload,
12+} from "../api/workspaces/api";
13+import {
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";
24+import * as Effect from "effect/Effect";
25+import * as Option from "effect/Option";
26+
27+import {
28+ ensureOrganizationExists,
29+ ensureUniqueOrganizationSlug,
30+ mapPersistenceError,
31+ type Mutable,
32+} from "./operations-shared";
33+import {
34+ operationErrors,
35+} from "./operation-errors";
36+import {
37+ ControlPlaneStore,
38+ type ControlPlaneStoreShape,
39+} from "./store";
40+
41+const 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+
49+const 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+
56+const 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+
64+const 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+
91+export const listOrganizations = (input: { accountId: AccountId }) =>
92+ Effect.flatMap(ControlPlaneStore, (store) => listOrganizationsWithStore(store, input));
93+
94+export 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+
140+export 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+
178+export 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+
212+export 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+
219+export 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+
229+export 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+
274+export 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+
321+export 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+
339+export 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+
349+export 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+
377+export 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+
396+export 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+
429+export 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 @@
1+import type {
2+ CreatePolicyPayload,
3+ UpdatePolicyPayload,
4+} from "../api/policies/api";
5+import {
6+ PolicyIdSchema,
7+ type Policy,
8+ type PolicyId,
9+ type WorkspaceId,
10+} from "#schema";
11+import * as Effect from "effect/Effect";
12+import * as Option from "effect/Option";
13+
14+import {
15+ mapPersistenceError,
16+ parseJsonString,
17+ type Mutable,
18+} from "./operations-shared";
19+import {
20+ operationErrors,
21+} from "./operation-errors";
22+import { ControlPlaneStore } from "./store";
23+
24+const 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+
32+export const listPolicies = (workspaceId: WorkspaceId) =>
33+ Effect.flatMap(ControlPlaneStore, (store) =>
34+ policyOps.list.mapStorage(
35+ store.policies.listByWorkspaceId(workspaceId),
36+ )
37+ );
38+
39+export 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+
80+export 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+
102+export 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+
179+export 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 @@
1−import {
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";
20−import {
21− ControlPlanePersistenceError,
22− SqlControlPlaneRowsService,
23− type SqlControlPlaneRows,
24−} from "#persistence";
25−import type {
26− Organization,
27− OrganizationMembership,
28− Policy,
29− Source,
30− SourceId,
31− SourceCredentialBinding,
32− Workspace,
33−} from "#schema";
34−import {
35− OrganizationIdSchema,
36− OrganizationMemberIdSchema,
37− PolicyIdSchema,
38− SourceIdSchema,
39− WorkspaceIdSchema,
40−} from "#schema";
41−import * as Effect from "effect/Effect";
42−import * as Layer from "effect/Layer";
43−import * as Option from "effect/Option";
44−
45−import {
46− LiveExecutionManagerService,
47−} from "./live-execution";
48−import { createRuntimeExecutionsService } from "./execution-service";
49−import { loadLocalInstallation } from "./local-installation";
50−import {
51− RuntimeSourceAuthServiceTag,
52− type RuntimeSourceAuthService,
53−} from "./source-auth-service";
54−import {
55− createSourceFromPayload,
56− projectSourceFromStorage,
57− projectSourcesFromStorage,
58− splitSourceForStorage,
59− updateSourceFromPayload,
60−} from "./source-definitions";
61−import { slugify } from "./slug";
62−import { RuntimeExecutionResolverService } from "./workspace-execution-environment";
63−
64−type Mutable<T> = { -readonly [K in keyof T]: T[K] };
65−
66−const badRequest = (
67− operation: string,
68− message: string,
69− details: string,
70−): ControlPlaneBadRequestError =>
71− new ControlPlaneBadRequestError({
72− operation,
73− message,
74− details,
75− });
76−
77−const notFound = (
78− operation: string,
79− message: string,
80− details: string,
81−): ControlPlaneNotFoundError =>
82− new ControlPlaneNotFoundError({
83− operation,
84− message,
85− details,
86− });
87−
88−const 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−
98−const 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−
114−const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>
115− error.kind === "unique_violation";
116−
117−const 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−
129−const 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−
137−const 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−
156−const 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−
170−const 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−
195−const 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−
215−const 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−
367−const 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−
483−const 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−
585−const 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−
817−const 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−
980−const 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−
1025−const RuntimeOrganizationsServiceLive = Layer.effect(
1026− ControlPlaneOrganizationsService,
1027− Effect.map(SqlControlPlaneRowsService, createOrganizationsService),
1028−);
1029−
1030−const RuntimeMembershipsServiceLive = Layer.effect(
1031− ControlPlaneMembershipsService,
1032− Effect.map(SqlControlPlaneRowsService, createMembershipsService),
1033−);
1034−
1035−const RuntimeWorkspacesServiceLive = Layer.effect(
1036− ControlPlaneWorkspacesService,
1037− Effect.map(SqlControlPlaneRowsService, createWorkspacesService),
1038−);
1039−
1040−const RuntimeSourcesServiceLive = Layer.effect(
1041− ControlPlaneSourcesService,
1042− Effect.map(SqlControlPlaneRowsService, createSourcesService),
1043−);
1044−
1045−const RuntimePoliciesServiceLive = Layer.effect(
1046− ControlPlanePoliciesService,
1047− Effect.map(SqlControlPlaneRowsService, createPoliciesService),
1048−);
1049−
1050−const 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−
1060−const 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−
1075−export 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 @@
1+import type {
2+ CreateSourcePayload,
3+ UpdateSourcePayload,
4+} from "../api/sources/api";
5+import {
6+ SourceIdSchema,
7+ type SourceId,
8+ type WorkspaceId,
9+} from "#schema";
10+import * as Effect from "effect/Effect";
11+import * as Option from "effect/Option";
12+
13+import {
14+ createSourceFromPayload,
15+ projectSourceFromStorage,
16+ projectSourcesFromStorage,
17+ splitSourceForStorage,
18+ updateSourceFromPayload,
19+} from "./source-definitions";
20+import {
21+ mapPersistenceError,
22+} from "./operations-shared";
23+import {
24+ operationErrors,
25+} from "./operation-errors";
26+import { ControlPlaneStore } from "./store";
27+
28+const 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+
36+export 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+
59+export 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+
99+export 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+
138+export 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+
228+export 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 @@
1+import { type SqlControlPlaneRows, SqlControlPlaneRowsService } from "#persistence";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+
6+export type ControlPlaneStoreShape = SqlControlPlaneRows;
7+
8+export class ControlPlaneStore extends Context.Tag(
9+ "#runtime/ControlPlaneStore",
10+)<ControlPlaneStore, ControlPlaneStoreShape>() {}
11+
12+export 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
99 import * as Layer from "effect/Layer";
1010
1111 import {
12− ControlPlaneActorResolver,
1312 ControlPlaneApi,
1413 createControlPlaneApiLayer,
1514 } from "#api";
@@ -20,11 +19,7 @@ import { · 4 unmodified lines
2019 } from "./index";
2120
2221 const createClientLayer = (runtime: SqlControlPlaneRuntime) => {
23− const actorResolverLayer = Layer.succeed(
24− ControlPlaneActorResolver,
25− runtime.actorResolver,
26− );
27− const apiLayer = createControlPlaneApiLayer(runtime.serviceLayer, actorResolverLayer);
22+ const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer);
2823
2924 return HttpApiBuilder.serve().pipe(
3025 Layer.provide(apiLayer),