Unified · Split
| @@ -5,7 +5,6 @@ import { dirname } from "node:path"; · 4 unmodified lines | |||
| 5 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; | 5 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 6 | import { NodeHttpServer } from "@effect/platform-node"; | 6 | import { NodeHttpServer } from "@effect/platform-node"; |
| 7 | import { | 7 | import { |
| 8 | ControlPlaneActorResolver, | ||
| 9 | createControlPlaneApiLayer, | 8 | createControlPlaneApiLayer, |
| 10 | createSqlControlPlaneRuntime, | 9 | createSqlControlPlaneRuntime, |
| 11 | type ResolveExecutionEnvironment, | 10 | type ResolveExecutionEnvironment, |
| @@ -46,11 +45,7 @@ const createControlPlaneServerLayer = (input: { · 34 unmodified lines | |||
| 46 | host: string; | 45 | host: string; |
| 47 | port: number; | 46 | port: number; |
| 48 | }) => { | 47 | }) => { |
| 49 | const actorResolverLayer = Layer.succeed( | 48 | const apiLayer = createControlPlaneApiLayer(input.runtime.runtimeLayer); |
| 50 | ControlPlaneActorResolver, | ||
| 51 | input.runtime.actorResolver, | ||
| 52 | ); | ||
| 53 | const apiLayer = createControlPlaneApiLayer(input.runtime.serviceLayer, actorResolverLayer); | ||
| 54 | 49 | ||
| 55 | return HttpApiBuilder.serve().pipe( | 50 | return HttpApiBuilder.serve().pipe( |
| 56 | Layer.provide(apiLayer), | 51 | Layer.provide(apiLayer), |
| @@ -13,9 +13,10 @@ import { · 12 unmodified lines | |||
| 13 | ControlPlaneStorageError, | 13 | ControlPlaneStorageError, |
| 14 | ControlPlaneUnauthorizedError, | 14 | ControlPlaneUnauthorizedError, |
| 15 | } from "../errors"; | 15 | } from "../errors"; |
| 16 | import { TrimmedNonEmptyStringSchema } from "../string-schemas"; | ||
| 16 | 17 | ||
| 17 | export const CreateExecutionPayloadSchema = Schema.Struct({ | 18 | export const CreateExecutionPayloadSchema = Schema.Struct({ |
| 18 | code: Schema.String, | 19 | code: TrimmedNonEmptyStringSchema, |
| 19 | }); | 20 | }); |
| 20 | 21 | ||
| 21 | export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type; | 22 | export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type; |
| @@ -1,50 +1,15 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type { WorkspaceId } from "#schema"; | 2 | import type { WorkspaceId } from "#schema"; |
| 4 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 5 | import { |
| 6 | Actor, | 6 | createExecution, |
| 7 | ActorForbiddenError, | 7 | getExecution, |
| 8 | ActorUnauthenticatedError, | 8 | resumeExecution, |
| 9 | requirePermission, | 9 | } from "../../runtime/execution-service"; |
| 10 | withPolicy, | ||
| 11 | } from "#domain"; | ||
| 12 | 10 | ||
| 13 | import { ControlPlaneApi } from "../api"; | 11 | import { ControlPlaneApi } from "../api"; |
| 14 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | 12 | import { withWorkspaceRequestActor } from "../http-auth"; |
| 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 | }); | ||
| 48 | 13 | ||
| 49 | const requireExecuteWorkspace = (workspaceId: WorkspaceId) => | 14 | const requireExecuteWorkspace = (workspaceId: WorkspaceId) => |
| 50 | requirePermission({ | 15 | requirePermission({ |
| @@ -58,65 +23,35 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 7 unmodified lines | |||
| 58 | (handlers) => | 23 | (handlers) => |
| 59 | handlers | 24 | handlers |
| 60 | .handle("create", ({ path, payload }) => | 25 | .handle("create", ({ path, payload }) => |
| 61 | Effect.gen(function* () { | 26 | withWorkspaceRequestActor("executions.create", path.workspaceId, (actor) => |
| 62 | const service = yield* ControlPlaneExecutionsService; | 27 | withPolicy(requireExecuteWorkspace(path.workspaceId))( |
| 63 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 28 | createExecution({ |
| 64 | |||
| 65 | return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))( | ||
| 66 | service.createExecution({ | ||
| 67 | workspaceId: path.workspaceId, | 29 | workspaceId: path.workspaceId, |
| 68 | payload, | 30 | payload, |
| 69 | createdByAccountId: actor.principal.accountId, | 31 | createdByAccountId: actor.principal.accountId, |
| 70 | }), | 32 | }), |
| 71 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 72 | }).pipe( | ||
| 73 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 74 | Effect.fail(toUnauthorizedError("executions.create", cause)), | ||
| 75 | ), | ||
| 76 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 77 | Effect.fail(toForbiddenError("executions.create", cause)), | ||
| 78 | ), | 33 | ), |
| 79 | ), | 34 | ), |
| 80 | ) | 35 | ) |
| 81 | .handle("get", ({ path }) => | 36 | .handle("get", ({ path }) => |
| 82 | Effect.gen(function* () { | 37 | withWorkspaceRequestActor("executions.get", path.workspaceId, () => |
| 83 | const service = yield* ControlPlaneExecutionsService; | 38 | withPolicy(requireExecuteWorkspace(path.workspaceId))( |
| 84 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 39 | getExecution({ |
| 85 | |||
| 86 | return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))( | ||
| 87 | service.getExecution({ | ||
| 88 | workspaceId: path.workspaceId, | 40 | workspaceId: path.workspaceId, |
| 89 | executionId: path.executionId, | 41 | executionId: path.executionId, |
| 90 | }), | 42 | }), |
| 91 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 92 | }).pipe( | ||
| 93 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 94 | Effect.fail(toUnauthorizedError("executions.get", cause)), | ||
| 95 | ), | ||
| 96 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 97 | Effect.fail(toForbiddenError("executions.get", cause)), | ||
| 98 | ), | 43 | ), |
| 99 | ), | 44 | ), |
| 100 | ) | 45 | ) |
| 101 | .handle("resume", ({ path, payload }) => | 46 | .handle("resume", ({ path, payload }) => |
| 102 | Effect.gen(function* () { | 47 | withWorkspaceRequestActor("executions.resume", path.workspaceId, (actor) => |
| 103 | const service = yield* ControlPlaneExecutionsService; | 48 | withPolicy(requireExecuteWorkspace(path.workspaceId))( |
| 104 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 49 | resumeExecution({ |
| 105 | |||
| 106 | return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))( | ||
| 107 | service.resumeExecution({ | ||
| 108 | workspaceId: path.workspaceId, | 50 | workspaceId: path.workspaceId, |
| 109 | executionId: path.executionId, | 51 | executionId: path.executionId, |
| 110 | payload, | 52 | payload, |
| 111 | resumedByAccountId: actor.principal.accountId, | 53 | resumedByAccountId: actor.principal.accountId, |
| 112 | }), | 54 | }), |
| 113 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 114 | }).pipe( | ||
| 115 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 116 | Effect.fail(toUnauthorizedError("executions.resume", cause)), | ||
| 117 | ), | ||
| 118 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 119 | Effect.fail(toForbiddenError("executions.resume", cause)), | ||
| 120 | ), | 55 | ), |
| 121 | ), | 56 | ), |
| 122 | ), | 57 | ), |
| @@ -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 | ); | ||
| @@ -11,7 +11,6 @@ import { ControlPlaneLocalLive } from "./local/http"; · 10 unmodified lines | |||
| 11 | import { ControlPlaneMembershipsLive } from "./memberships/http"; | 11 | import { ControlPlaneMembershipsLive } from "./memberships/http"; |
| 12 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; | 12 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; |
| 13 | import { ControlPlanePoliciesLive } from "./policies/http"; | 13 | import { ControlPlanePoliciesLive } from "./policies/http"; |
| 14 | import { type ControlPlaneApiServiceContext } from "./service"; | ||
| 15 | import { ControlPlaneSourcesLive } from "./sources/http"; | 14 | import { ControlPlaneSourcesLive } from "./sources/http"; |
| 16 | import { ControlPlaneWorkspacesLive } from "./workspaces/http"; | 15 | import { ControlPlaneWorkspacesLive } from "./workspaces/http"; |
| 17 | 16 | ||
| @@ -25,13 +24,19 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines | |||
| 25 | Layer.provide(ControlPlaneExecutionsLive), | 24 | Layer.provide(ControlPlaneExecutionsLive), |
| 26 | ); | 25 | ); |
| 27 | 26 | ||
| 28 | export const createControlPlaneApiLayer = <EService, EResolver>( | 27 | export type ControlPlaneApiRuntimeContext = Layer.Layer.Context<typeof ControlPlaneApiLive>; |
| 29 | serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, EService, never>, | 28 | |
| 30 | actorResolverLayer: Layer.Layer<ControlPlaneActorResolver, EResolver, never>, | 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>, | ||
| 31 | ) => | 37 | ) => |
| 32 | ControlPlaneApiLive.pipe( | 38 | ControlPlaneApiLive.pipe( |
| 33 | Layer.provide(serviceLayer), | 39 | Layer.provide(runtimeLayer), |
| 34 | Layer.provide(actorResolverLayer), | ||
| 35 | ); | 40 | ); |
| 36 | 41 | ||
| 37 | export const ControlPlaneActorResolverLive = ( | 42 | export const ControlPlaneActorResolverLive = ( |
| @@ -13,43 +13,10 @@ export { · 12 unmodified lines | |||
| 13 | ControlPlaneUnauthorizedError, | 13 | ControlPlaneUnauthorizedError, |
| 14 | } from "./errors"; | 14 | } from "./errors"; |
| 15 | 15 | ||
| 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 | |||
| 51 | export { | 16 | export { |
| 52 | ControlPlaneApiLive, | 17 | ControlPlaneApiLive, |
| 18 | type ControlPlaneApiRuntimeContext, | ||
| 19 | type BuiltControlPlaneApiLayer, | ||
| 53 | ControlPlaneActorResolverLive, | 20 | ControlPlaneActorResolverLive, |
| 54 | createControlPlaneApiLayer, | 21 | createControlPlaneApiLayer, |
| 55 | } from "./http"; | 22 | } from "./http"; |
| @@ -1,8 +1,11 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import { | ||
| 4 | completeSourceAuthCallback, | ||
| 5 | getLocalInstallation, | ||
| 6 | } from "../../runtime/local-operations"; | ||
| 3 | 7 | ||
| 4 | import { ControlPlaneApi } from "../api"; | 8 | import { ControlPlaneApi } from "../api"; |
| 5 | import { ControlPlaneLocalService } from "../service"; | ||
| 6 | 9 | ||
| 7 | export const ControlPlaneLocalLive = HttpApiBuilder.group( | 10 | export const ControlPlaneLocalLive = HttpApiBuilder.group( |
| 8 | ControlPlaneApi, | 11 | ControlPlaneApi, |
| @@ -10,18 +13,14 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 1 unmodified line | |||
| 10 | (handlers) => | 13 | (handlers) => |
| 11 | handlers | 14 | handlers |
| 12 | .handle("installation", () => | 15 | .handle("installation", () => |
| 13 | Effect.gen(function* () { | 16 | getLocalInstallation(), |
| 14 | const service = yield* ControlPlaneLocalService; | ||
| 15 | return yield* service.getLocalInstallation(); | ||
| 16 | }), | ||
| 17 | ) | 17 | ) |
| 18 | .handle("oauthCallback", () => | 18 | .handle("oauthCallback", () => |
| 19 | Effect.gen(function* () { | 19 | Effect.gen(function* () { |
| 20 | const service = yield* ControlPlaneLocalService; | ||
| 21 | const request = yield* HttpServerRequest.HttpServerRequest; | 20 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 22 | const requestUrl = new URL(request.url, "http://127.0.0.1"); | 21 | const requestUrl = new URL(request.url, "http://127.0.0.1"); |
| 23 | 22 | ||
| 24 | const source = yield* service.completeSourceAuthCallback({ | 23 | const source = yield* completeSourceAuthCallback({ |
| 25 | state: requestUrl.searchParams.get("state") ?? "", | 24 | state: requestUrl.searchParams.get("state") ?? "", |
| 26 | code: requestUrl.searchParams.get("code"), | 25 | code: requestUrl.searchParams.get("code"), |
| 27 | error: requestUrl.searchParams.get("error"), | 26 | error: requestUrl.searchParams.get("error"), |
| @@ -1,48 +1,16 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type { OrganizationId } from "#schema"; | 2 | import type { OrganizationId } from "#schema"; |
| 4 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 5 | import { |
| 6 | Actor, | 6 | createMembership, |
| 7 | ActorForbiddenError, | 7 | listMemberships, |
| 8 | ActorUnauthenticatedError, | 8 | removeMembership, |
| 9 | requirePermission, | 9 | updateMembership, |
| 10 | withPolicy, | 10 | } from "../../runtime/organizations-operations"; |
| 11 | } from "#domain"; | ||
| 12 | 11 | ||
| 13 | import { ControlPlaneApi } from "../api"; | 12 | import { ControlPlaneApi } from "../api"; |
| 14 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | 13 | import { withRequestActor } from "../http-auth"; |
| 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 | }); | ||
| 46 | 14 | ||
| 47 | const requireReadMemberships = (organizationId: OrganizationId) => | 15 | const requireReadMemberships = (organizationId: OrganizationId) => |
| 48 | requirePermission({ | 16 | requirePermission({ |
| @@ -62,77 +30,37 @@ export const ControlPlaneMembershipsLive = HttpApiBuilder.group( · 13 unmodified lines | |||
| 62 | (handlers) => | 30 | (handlers) => |
| 63 | handlers | 31 | handlers |
| 64 | .handle("list", ({ path }) => | 32 | .handle("list", ({ path }) => |
| 65 | Effect.gen(function* () { | 33 | withRequestActor("memberships.list", () => |
| 66 | const service = yield* ControlPlaneMembershipsService; | 34 | withPolicy(requireReadMemberships(path.organizationId))( |
| 67 | const actor = yield* resolveActor; | 35 | listMemberships(path.organizationId), |
| 68 | |||
| 69 | return yield* withPolicy(requireReadMemberships(path.organizationId))( | ||
| 70 | service.listMemberships(path.organizationId), | ||
| 71 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 72 | }).pipe( | ||
| 73 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 74 | Effect.fail(toUnauthorizedError("memberships.list", cause)), | ||
| 75 | ), | ||
| 76 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 77 | Effect.fail(toForbiddenError("memberships.list", cause)), | ||
| 78 | ), | 36 | ), |
| 79 | ), | 37 | ), |
| 80 | ) | 38 | ) |
| 81 | .handle("create", ({ path, payload }) => | 39 | .handle("create", ({ path, payload }) => |
| 82 | Effect.gen(function* () { | 40 | withRequestActor("memberships.create", () => |
| 83 | const service = yield* ControlPlaneMembershipsService; | 41 | withPolicy(requireWriteMemberships(path.organizationId))( |
| 84 | const actor = yield* resolveActor; | 42 | createMembership({ organizationId: path.organizationId, payload }), |
| 85 | |||
| 86 | return yield* withPolicy(requireWriteMemberships(path.organizationId))( | ||
| 87 | service.createMembership({ organizationId: path.organizationId, payload }), | ||
| 88 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 89 | }).pipe( | ||
| 90 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 91 | Effect.fail(toUnauthorizedError("memberships.create", cause)), | ||
| 92 | ), | ||
| 93 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 94 | Effect.fail(toForbiddenError("memberships.create", cause)), | ||
| 95 | ), | 43 | ), |
| 96 | ), | 44 | ), |
| 97 | ) | 45 | ) |
| 98 | .handle("update", ({ path, payload }) => | 46 | .handle("update", ({ path, payload }) => |
| 99 | Effect.gen(function* () { | 47 | withRequestActor("memberships.update", () => |
| 100 | const service = yield* ControlPlaneMembershipsService; | 48 | withPolicy(requireWriteMemberships(path.organizationId))( |
| 101 | const actor = yield* resolveActor; | 49 | updateMembership({ |
| 102 | |||
| 103 | return yield* withPolicy(requireWriteMemberships(path.organizationId))( | ||
| 104 | service.updateMembership({ | ||
| 105 | organizationId: path.organizationId, | 50 | organizationId: path.organizationId, |
| 106 | accountId: path.accountId, | 51 | accountId: path.accountId, |
| 107 | payload, | 52 | payload, |
| 108 | }), | 53 | }), |
| 109 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 110 | }).pipe( | ||
| 111 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 112 | Effect.fail(toUnauthorizedError("memberships.update", cause)), | ||
| 113 | ), | ||
| 114 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 115 | Effect.fail(toForbiddenError("memberships.update", cause)), | ||
| 116 | ), | 54 | ), |
| 117 | ), | 55 | ), |
| 118 | ) | 56 | ) |
| 119 | .handle("remove", ({ path }) => | 57 | .handle("remove", ({ path }) => |
| 120 | Effect.gen(function* () { | 58 | withRequestActor("memberships.remove", () => |
| 121 | const service = yield* ControlPlaneMembershipsService; | 59 | withPolicy(requireWriteMemberships(path.organizationId))( |
| 122 | const actor = yield* resolveActor; | 60 | removeMembership({ |
| 123 | |||
| 124 | return yield* withPolicy(requireWriteMemberships(path.organizationId))( | ||
| 125 | service.removeMembership({ | ||
| 126 | organizationId: path.organizationId, | 61 | organizationId: path.organizationId, |
| 127 | accountId: path.accountId, | 62 | accountId: path.accountId, |
| 128 | }), | 63 | }), |
| 129 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 130 | }).pipe( | ||
| 131 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 132 | Effect.fail(toUnauthorizedError("memberships.remove", cause)), | ||
| 133 | ), | ||
| 134 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 135 | Effect.fail(toForbiddenError("memberships.remove", cause)), | ||
| 136 | ), | 64 | ), |
| 137 | ), | 65 | ), |
| 138 | ), | 66 | ), |
| @@ -1,9 +1,8 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 3 | OrganizationIdSchema, | 3 | OrganizationIdSchema, |
| 4 | OrganizationInsertSchema, | 4 | OrganizationStatusSchema, |
| 5 | OrganizationSchema, | 5 | OrganizationSchema, |
| 6 | OrganizationUpdateSchema, | ||
| 7 | } from "#schema"; | 6 | } from "#schema"; |
| 8 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 9 | 8 | ||
| @@ -14,27 +13,22 @@ import { · 4 unmodified lines | |||
| 14 | ControlPlaneStorageError, | 13 | ControlPlaneStorageError, |
| 15 | ControlPlaneUnauthorizedError, | 14 | ControlPlaneUnauthorizedError, |
| 16 | } from "../errors"; | 15 | } from "../errors"; |
| 16 | import { | ||
| 17 | OptionalTrimmedNonEmptyStringSchema, | ||
| 18 | TrimmedNonEmptyStringSchema, | ||
| 19 | } from "../string-schemas"; | ||
| 17 | 20 | ||
| 18 | const createOrganizationPayloadRequiredSchema = OrganizationInsertSchema.pipe( | 21 | export const CreateOrganizationPayloadSchema = Schema.Struct({ |
| 19 | Schema.pick("name"), | 22 | name: TrimmedNonEmptyStringSchema, |
| 20 | ); | 23 | slug: OptionalTrimmedNonEmptyStringSchema, |
| 21 | 24 | }); | |
| 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 | ); | ||
| 31 | 25 | ||
| 32 | export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type; | 26 | export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type; |
| 33 | 27 | ||
| 34 | export const UpdateOrganizationPayloadSchema = OrganizationUpdateSchema.pipe( | 28 | export const UpdateOrganizationPayloadSchema = Schema.Struct({ |
| 35 | Schema.pick("name", "status"), | 29 | name: OptionalTrimmedNonEmptyStringSchema, |
| 36 | Schema.partialWith({ exact: true }), | 30 | status: Schema.optional(OrganizationStatusSchema), |
| 37 | ); | 31 | }); |
| 38 | 32 | ||
| 39 | export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type; | 33 | export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type; |
| 40 | 34 | ||
| @@ -1,48 +1,17 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type { OrganizationId } from "#schema"; | 2 | import type { OrganizationId } from "#schema"; |
| 4 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 5 | import { |
| 6 | Actor, | 6 | createOrganization, |
| 7 | ActorForbiddenError, | 7 | getOrganization, |
| 8 | ActorUnauthenticatedError, | 8 | listOrganizations, |
| 9 | requirePermission, | 9 | removeOrganization, |
| 10 | withPolicy, | 10 | updateOrganization, |
| 11 | } from "#domain"; | 11 | } from "../../runtime/organizations-operations"; |
| 12 | 12 | ||
| 13 | import { ControlPlaneApi } from "../api"; | 13 | import { ControlPlaneApi } from "../api"; |
| 14 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | 14 | import { withRequestActor } from "../http-auth"; |
| 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 | }); | ||
| 46 | 15 | ||
| 47 | const requireReadOrganizations = requirePermission({ | 16 | const requireReadOrganizations = requirePermission({ |
| 48 | permission: "organizations:read", | 17 | permission: "organizations:read", |
| @@ -60,93 +29,46 @@ export const ControlPlaneOrganizationsLive = HttpApiBuilder.group( · 11 unmodified lines | |||
| 60 | (handlers) => | 29 | (handlers) => |
| 61 | handlers | 30 | handlers |
| 62 | .handle("list", () => | 31 | .handle("list", () => |
| 63 | Effect.gen(function* () { | 32 | withRequestActor("organizations.list", (actor) => |
| 64 | const service = yield* ControlPlaneOrganizationsService; | 33 | withPolicy(requireReadOrganizations)( |
| 65 | const actor = yield* resolveActor; | 34 | listOrganizations({ |
| 66 | |||
| 67 | return yield* withPolicy(requireReadOrganizations)( | ||
| 68 | service.listOrganizations({ | ||
| 69 | accountId: actor.principal.accountId, | 35 | accountId: actor.principal.accountId, |
| 70 | }), | 36 | }), |
| 71 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 72 | }).pipe( | ||
| 73 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 74 | Effect.fail(toUnauthorizedError("organizations.list", cause)), | ||
| 75 | ), | ||
| 76 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 77 | Effect.fail(toForbiddenError("organizations.list", cause)), | ||
| 78 | ), | 37 | ), |
| 79 | ), | 38 | ), |
| 80 | ) | 39 | ) |
| 81 | .handle("create", ({ payload }) => | 40 | .handle("create", ({ payload }) => |
| 82 | Effect.gen(function* () { | 41 | withRequestActor("organizations.create", (actor) => |
| 83 | const service = yield* ControlPlaneOrganizationsService; | 42 | createOrganization({ |
| 84 | const actor = yield* resolveActor; | ||
| 85 | |||
| 86 | return yield* service.createOrganization({ | ||
| 87 | payload, | 43 | payload, |
| 88 | createdByAccountId: actor.principal.accountId, | 44 | createdByAccountId: actor.principal.accountId, |
| 89 | }); | 45 | }), |
| 90 | }).pipe( | ||
| 91 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 92 | Effect.fail(toUnauthorizedError("organizations.create", cause)), | ||
| 93 | ), | ||
| 94 | ), | 46 | ), |
| 95 | ) | 47 | ) |
| 96 | .handle("get", ({ path }) => | 48 | .handle("get", ({ path }) => |
| 97 | Effect.gen(function* () { | 49 | withRequestActor("organizations.get", (actor) => |
| 98 | const service = yield* ControlPlaneOrganizationsService; | 50 | withPolicy(requireReadOrganizations)( |
| 99 | const actor = yield* resolveActor; | 51 | getOrganization({ |
| 100 | |||
| 101 | return yield* withPolicy(requireReadOrganizations)( | ||
| 102 | service.getOrganization({ | ||
| 103 | organizationId: path.organizationId, | 52 | organizationId: path.organizationId, |
| 104 | accountId: actor.principal.accountId, | 53 | accountId: actor.principal.accountId, |
| 105 | }), | 54 | }), |
| 106 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 107 | }).pipe( | ||
| 108 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 109 | Effect.fail(toUnauthorizedError("organizations.get", cause)), | ||
| 110 | ), | ||
| 111 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 112 | Effect.fail(toForbiddenError("organizations.get", cause)), | ||
| 113 | ), | 55 | ), |
| 114 | ), | 56 | ), |
| 115 | ) | 57 | ) |
| 116 | .handle("update", ({ path, payload }) => | 58 | .handle("update", ({ path, payload }) => |
| 117 | Effect.gen(function* () { | 59 | withRequestActor("organizations.update", () => |
| 118 | const service = yield* ControlPlaneOrganizationsService; | 60 | withPolicy(requireManageOrganizations(path.organizationId))( |
| 119 | const actor = yield* resolveActor; | 61 | updateOrganization({ |
| 120 | |||
| 121 | return yield* withPolicy(requireManageOrganizations(path.organizationId))( | ||
| 122 | service.updateOrganization({ | ||
| 123 | organizationId: path.organizationId, | 62 | organizationId: path.organizationId, |
| 124 | payload, | 63 | payload, |
| 125 | }), | 64 | }), |
| 126 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 127 | }).pipe( | ||
| 128 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 129 | Effect.fail(toUnauthorizedError("organizations.update", cause)), | ||
| 130 | ), | ||
| 131 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 132 | Effect.fail(toForbiddenError("organizations.update", cause)), | ||
| 133 | ), | 65 | ), |
| 134 | ), | 66 | ), |
| 135 | ) | 67 | ) |
| 136 | .handle("remove", ({ path }) => | 68 | .handle("remove", ({ path }) => |
| 137 | Effect.gen(function* () { | 69 | withRequestActor("organizations.remove", () => |
| 138 | const service = yield* ControlPlaneOrganizationsService; | 70 | withPolicy(requireManageOrganizations(path.organizationId))( |
| 139 | const actor = yield* resolveActor; | 71 | removeOrganization({ organizationId: path.organizationId }), |
| 140 | |||
| 141 | return yield* withPolicy(requireManageOrganizations(path.organizationId))( | ||
| 142 | service.removeOrganization({ organizationId: path.organizationId }), | ||
| 143 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 144 | }).pipe( | ||
| 145 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 146 | Effect.fail(toUnauthorizedError("organizations.remove", cause)), | ||
| 147 | ), | ||
| 148 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 149 | Effect.fail(toForbiddenError("organizations.remove", cause)), | ||
| 150 | ), | 72 | ), |
| 151 | ), | 73 | ), |
| 152 | ), | 74 | ), |
| @@ -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 | }); | ||
| @@ -1,9 +1,12 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 3 | AccountIdSchema, | ||
| 3 | PolicyIdSchema, | 4 | PolicyIdSchema, |
| 4 | PolicyInsertSchema, | 5 | PolicyApprovalModeSchema, |
| 6 | PolicyEffectSchema, | ||
| 7 | PolicyMatchTypeSchema, | ||
| 8 | PolicyResourceTypeSchema, | ||
| 5 | PolicySchema, | 9 | PolicySchema, |
| 6 | PolicyUpdateSchema, | ||
| 7 | WorkspaceIdSchema, | 10 | WorkspaceIdSchema, |
| 8 | } from "#schema"; | 11 | } from "#schema"; |
| 9 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| @@ -15,40 +18,35 @@ import { · 5 unmodified lines | |||
| 15 | ControlPlaneStorageError, | 18 | ControlPlaneStorageError, |
| 16 | ControlPlaneUnauthorizedError, | 19 | ControlPlaneUnauthorizedError, |
| 17 | } from "../errors"; | 20 | } from "../errors"; |
| 21 | import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas"; | ||
| 18 | 22 | ||
| 19 | export const CreatePolicyPayloadSchema = PolicyInsertSchema.pipe( | 23 | export const CreatePolicyPayloadSchema = Schema.Struct({ |
| 20 | Schema.pick( | 24 | resourceType: Schema.optional(PolicyResourceTypeSchema), |
| 21 | "resourceType", | 25 | resourcePattern: OptionalTrimmedNonEmptyStringSchema, |
| 22 | "resourcePattern", | 26 | matchType: Schema.optional(PolicyMatchTypeSchema), |
| 23 | "matchType", | 27 | effect: Schema.optional(PolicyEffectSchema), |
| 24 | "effect", | 28 | approvalMode: Schema.optional(PolicyApprovalModeSchema), |
| 25 | "approvalMode", | 29 | argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)), |
| 26 | "argumentConditionsJson", | 30 | priority: Schema.optional(Schema.Number), |
| 27 | "priority", | 31 | enabled: Schema.optional(Schema.Boolean), |
| 28 | "enabled", | 32 | targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)), |
| 29 | "targetAccountId", | 33 | clientId: Schema.optional(Schema.NullOr(Schema.String)), |
| 30 | "clientId", | 34 | }); |
| 31 | ), | ||
| 32 | Schema.partialWith({ exact: true }), | ||
| 33 | ); | ||
| 34 | 35 | ||
| 35 | export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type; | 36 | export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type; |
| 36 | 37 | ||
| 37 | export const UpdatePolicyPayloadSchema = PolicyUpdateSchema.pipe( | 38 | export const UpdatePolicyPayloadSchema = Schema.Struct({ |
| 38 | Schema.pick( | 39 | resourceType: Schema.optional(PolicyResourceTypeSchema), |
| 39 | "resourceType", | 40 | resourcePattern: OptionalTrimmedNonEmptyStringSchema, |
| 40 | "resourcePattern", | 41 | matchType: Schema.optional(PolicyMatchTypeSchema), |
| 41 | "matchType", | 42 | effect: Schema.optional(PolicyEffectSchema), |
| 42 | "effect", | 43 | approvalMode: Schema.optional(PolicyApprovalModeSchema), |
| 43 | "approvalMode", | 44 | argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)), |
| 44 | "argumentConditionsJson", | 45 | priority: Schema.optional(Schema.Number), |
| 45 | "priority", | 46 | enabled: Schema.optional(Schema.Boolean), |
| 46 | "enabled", | 47 | targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)), |
| 47 | "targetAccountId", | 48 | clientId: Schema.optional(Schema.NullOr(Schema.String)), |
| 48 | "clientId", | 49 | }); |
| 49 | ), | ||
| 50 | Schema.partialWith({ exact: true }), | ||
| 51 | ); | ||
| 52 | 50 | ||
| 53 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; | 51 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; |
| 54 | 52 | ||
| @@ -1,50 +1,17 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type { WorkspaceId } from "#schema"; | 2 | import type { WorkspaceId } from "#schema"; |
| 4 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 5 | import { |
| 6 | Actor, | 6 | createPolicy, |
| 7 | ActorForbiddenError, | 7 | getPolicy, |
| 8 | ActorUnauthenticatedError, | 8 | listPolicies, |
| 9 | requirePermission, | 9 | removePolicy, |
| 10 | withPolicy, | 10 | updatePolicy, |
| 11 | } from "#domain"; | 11 | } from "../../runtime/policies-operations"; |
| 12 | 12 | ||
| 13 | import { ControlPlaneApi } from "../api"; | 13 | import { ControlPlaneApi } from "../api"; |
| 14 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | 14 | import { withWorkspaceRequestActor } from "../http-auth"; |
| 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 | }); | ||
| 48 | 15 | ||
| 49 | const requireReadPolicies = (workspaceId: WorkspaceId) => | 16 | const requireReadPolicies = (workspaceId: WorkspaceId) => |
| 50 | requirePermission({ | 17 | requirePermission({ |
| @@ -64,97 +31,47 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 13 unmodified lines | |||
| 64 | (handlers) => | 31 | (handlers) => |
| 65 | handlers | 32 | handlers |
| 66 | .handle("list", ({ path }) => | 33 | .handle("list", ({ path }) => |
| 67 | Effect.gen(function* () { | 34 | withWorkspaceRequestActor("policies.list", path.workspaceId, () => |
| 68 | const service = yield* ControlPlanePoliciesService; | 35 | withPolicy(requireReadPolicies(path.workspaceId))( |
| 69 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 36 | listPolicies(path.workspaceId), |
| 70 | |||
| 71 | return yield* withPolicy(requireReadPolicies(path.workspaceId))( | ||
| 72 | service.listPolicies(path.workspaceId), | ||
| 73 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 74 | }).pipe( | ||
| 75 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 76 | Effect.fail(toUnauthorizedError("policies.list", cause)), | ||
| 77 | ), | ||
| 78 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 79 | Effect.fail(toForbiddenError("policies.list", cause)), | ||
| 80 | ), | 37 | ), |
| 81 | ), | 38 | ), |
| 82 | ) | 39 | ) |
| 83 | .handle("create", ({ path, payload }) => | 40 | .handle("create", ({ path, payload }) => |
| 84 | Effect.gen(function* () { | 41 | withWorkspaceRequestActor("policies.create", path.workspaceId, () => |
| 85 | const service = yield* ControlPlanePoliciesService; | 42 | withPolicy(requireWritePolicies(path.workspaceId))( |
| 86 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 43 | createPolicy({ workspaceId: path.workspaceId, payload }), |
| 87 | |||
| 88 | return yield* withPolicy(requireWritePolicies(path.workspaceId))( | ||
| 89 | service.createPolicy({ workspaceId: path.workspaceId, payload }), | ||
| 90 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 91 | }).pipe( | ||
| 92 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 93 | Effect.fail(toUnauthorizedError("policies.create", cause)), | ||
| 94 | ), | ||
| 95 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 96 | Effect.fail(toForbiddenError("policies.create", cause)), | ||
| 97 | ), | 44 | ), |
| 98 | ), | 45 | ), |
| 99 | ) | 46 | ) |
| 100 | .handle("get", ({ path }) => | 47 | .handle("get", ({ path }) => |
| 101 | Effect.gen(function* () { | 48 | withWorkspaceRequestActor("policies.get", path.workspaceId, () => |
| 102 | const service = yield* ControlPlanePoliciesService; | 49 | withPolicy(requireReadPolicies(path.workspaceId))( |
| 103 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 50 | getPolicy({ |
| 104 | |||
| 105 | return yield* withPolicy(requireReadPolicies(path.workspaceId))( | ||
| 106 | service.getPolicy({ | ||
| 107 | workspaceId: path.workspaceId, | 51 | workspaceId: path.workspaceId, |
| 108 | policyId: path.policyId, | 52 | policyId: path.policyId, |
| 109 | }), | 53 | }), |
| 110 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 111 | }).pipe( | ||
| 112 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 113 | Effect.fail(toUnauthorizedError("policies.get", cause)), | ||
| 114 | ), | ||
| 115 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 116 | Effect.fail(toForbiddenError("policies.get", cause)), | ||
| 117 | ), | 54 | ), |
| 118 | ), | 55 | ), |
| 119 | ) | 56 | ) |
| 120 | .handle("update", ({ path, payload }) => | 57 | .handle("update", ({ path, payload }) => |
| 121 | Effect.gen(function* () { | 58 | withWorkspaceRequestActor("policies.update", path.workspaceId, () => |
| 122 | const service = yield* ControlPlanePoliciesService; | 59 | withPolicy(requireWritePolicies(path.workspaceId))( |
| 123 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 60 | updatePolicy({ |
| 124 | |||
| 125 | return yield* withPolicy(requireWritePolicies(path.workspaceId))( | ||
| 126 | service.updatePolicy({ | ||
| 127 | workspaceId: path.workspaceId, | 61 | workspaceId: path.workspaceId, |
| 128 | policyId: path.policyId, | 62 | policyId: path.policyId, |
| 129 | payload, | 63 | payload, |
| 130 | }), | 64 | }), |
| 131 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 132 | }).pipe( | ||
| 133 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 134 | Effect.fail(toUnauthorizedError("policies.update", cause)), | ||
| 135 | ), | ||
| 136 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 137 | Effect.fail(toForbiddenError("policies.update", cause)), | ||
| 138 | ), | 65 | ), |
| 139 | ), | 66 | ), |
| 140 | ) | 67 | ) |
| 141 | .handle("remove", ({ path }) => | 68 | .handle("remove", ({ path }) => |
| 142 | Effect.gen(function* () { | 69 | withWorkspaceRequestActor("policies.remove", path.workspaceId, () => |
| 143 | const service = yield* ControlPlanePoliciesService; | 70 | withPolicy(requireWritePolicies(path.workspaceId))( |
| 144 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 71 | removePolicy({ |
| 145 | |||
| 146 | return yield* withPolicy(requireWritePolicies(path.workspaceId))( | ||
| 147 | service.removePolicy({ | ||
| 148 | workspaceId: path.workspaceId, | 72 | workspaceId: path.workspaceId, |
| 149 | policyId: path.policyId, | 73 | policyId: path.policyId, |
| 150 | }), | 74 | }), |
| 151 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 152 | }).pipe( | ||
| 153 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 154 | Effect.fail(toUnauthorizedError("policies.remove", cause)), | ||
| 155 | ), | ||
| 156 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 157 | Effect.fail(toForbiddenError("policies.remove", cause)), | ||
| 158 | ), | 75 | ), |
| 159 | ), | 76 | ), |
| 160 | ), | 77 | ), |
| @@ -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; | ||
| @@ -18,11 +18,15 @@ import { · 17 unmodified lines | |||
| 18 | ControlPlaneStorageError, | 18 | ControlPlaneStorageError, |
| 19 | ControlPlaneUnauthorizedError, | 19 | ControlPlaneUnauthorizedError, |
| 20 | } from "../errors"; | 20 | } from "../errors"; |
| 21 | import { | ||
| 22 | OptionalTrimmedNonEmptyStringSchema, | ||
| 23 | TrimmedNonEmptyStringSchema, | ||
| 24 | } from "../string-schemas"; | ||
| 21 | 25 | ||
| 22 | const createSourcePayloadRequiredSchema = Schema.Struct({ | 26 | const createSourcePayloadRequiredSchema = Schema.Struct({ |
| 23 | name: Schema.String, | 27 | name: TrimmedNonEmptyStringSchema, |
| 24 | kind: SourceKindSchema, | 28 | kind: SourceKindSchema, |
| 25 | endpoint: Schema.String, | 29 | endpoint: TrimmedNonEmptyStringSchema, |
| 26 | }); | 30 | }); |
| 27 | 31 | ||
| 28 | const createSourcePayloadOptionalSchema = Schema.Struct({ | 32 | const createSourcePayloadOptionalSchema = Schema.Struct({ |
| @@ -47,9 +51,9 @@ export const CreateSourcePayloadSchema = Schema.extend( · 18 unmodified lines | |||
| 47 | export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type; | 51 | export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type; |
| 48 | 52 | ||
| 49 | export const UpdateSourcePayloadSchema = Schema.Struct({ | 53 | export const UpdateSourcePayloadSchema = Schema.Struct({ |
| 50 | name: Schema.optional(Schema.String), | 54 | name: OptionalTrimmedNonEmptyStringSchema, |
| 51 | kind: Schema.optional(SourceKindSchema), | 55 | kind: Schema.optional(SourceKindSchema), |
| 52 | endpoint: Schema.optional(Schema.String), | 56 | endpoint: OptionalTrimmedNonEmptyStringSchema, |
| 53 | status: Schema.optional(SourceStatusSchema), | 57 | status: Schema.optional(SourceStatusSchema), |
| 54 | enabled: Schema.optional(Schema.Boolean), | 58 | enabled: Schema.optional(Schema.Boolean), |
| 55 | namespace: Schema.optional(Schema.NullOr(Schema.String)), | 59 | namespace: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -1,50 +1,17 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type { WorkspaceId } from "#schema"; | 2 | import type { WorkspaceId } from "#schema"; |
| 4 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 5 | import { |
| 6 | Actor, | 6 | createSource, |
| 7 | ActorForbiddenError, | 7 | getSource, |
| 8 | ActorUnauthenticatedError, | 8 | listSources, |
| 9 | requirePermission, | 9 | removeSource, |
| 10 | withPolicy, | 10 | updateSource, |
| 11 | } from "#domain"; | 11 | } from "../../runtime/sources-operations"; |
| 12 | 12 | ||
| 13 | import { ControlPlaneApi } from "../api"; | 13 | import { ControlPlaneApi } from "../api"; |
| 14 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | 14 | import { withWorkspaceRequestActor } from "../http-auth"; |
| 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 | }); | ||
| 48 | 15 | ||
| 49 | const requireReadSources = (workspaceId: WorkspaceId) => | 16 | const requireReadSources = (workspaceId: WorkspaceId) => |
| 50 | requirePermission({ | 17 | requirePermission({ |
| @@ -64,97 +31,47 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 13 unmodified lines | |||
| 64 | (handlers) => | 31 | (handlers) => |
| 65 | handlers | 32 | handlers |
| 66 | .handle("list", ({ path }) => | 33 | .handle("list", ({ path }) => |
| 67 | Effect.gen(function* () { | 34 | withWorkspaceRequestActor("sources.list", path.workspaceId, () => |
| 68 | const service = yield* ControlPlaneSourcesService; | 35 | withPolicy(requireReadSources(path.workspaceId))( |
| 69 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 36 | listSources(path.workspaceId), |
| 70 | |||
| 71 | return yield* withPolicy(requireReadSources(path.workspaceId))( | ||
| 72 | service.listSources(path.workspaceId), | ||
| 73 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 74 | }).pipe( | ||
| 75 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 76 | Effect.fail(toUnauthorizedError("sources.list", cause)), | ||
| 77 | ), | ||
| 78 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 79 | Effect.fail(toForbiddenError("sources.list", cause)), | ||
| 80 | ), | 37 | ), |
| 81 | ), | 38 | ), |
| 82 | ) | 39 | ) |
| 83 | .handle("create", ({ path, payload }) => | 40 | .handle("create", ({ path, payload }) => |
| 84 | Effect.gen(function* () { | 41 | withWorkspaceRequestActor("sources.create", path.workspaceId, () => |
| 85 | const service = yield* ControlPlaneSourcesService; | 42 | withPolicy(requireWriteSources(path.workspaceId))( |
| 86 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 43 | createSource({ workspaceId: path.workspaceId, payload }), |
| 87 | |||
| 88 | return yield* withPolicy(requireWriteSources(path.workspaceId))( | ||
| 89 | service.createSource({ workspaceId: path.workspaceId, payload }), | ||
| 90 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 91 | }).pipe( | ||
| 92 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 93 | Effect.fail(toUnauthorizedError("sources.create", cause)), | ||
| 94 | ), | ||
| 95 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 96 | Effect.fail(toForbiddenError("sources.create", cause)), | ||
| 97 | ), | 44 | ), |
| 98 | ), | 45 | ), |
| 99 | ) | 46 | ) |
| 100 | .handle("get", ({ path }) => | 47 | .handle("get", ({ path }) => |
| 101 | Effect.gen(function* () { | 48 | withWorkspaceRequestActor("sources.get", path.workspaceId, () => |
| 102 | const service = yield* ControlPlaneSourcesService; | 49 | withPolicy(requireReadSources(path.workspaceId))( |
| 103 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 50 | getSource({ |
| 104 | |||
| 105 | return yield* withPolicy(requireReadSources(path.workspaceId))( | ||
| 106 | service.getSource({ | ||
| 107 | workspaceId: path.workspaceId, | 51 | workspaceId: path.workspaceId, |
| 108 | sourceId: path.sourceId, | 52 | sourceId: path.sourceId, |
| 109 | }), | 53 | }), |
| 110 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 111 | }).pipe( | ||
| 112 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 113 | Effect.fail(toUnauthorizedError("sources.get", cause)), | ||
| 114 | ), | ||
| 115 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 116 | Effect.fail(toForbiddenError("sources.get", cause)), | ||
| 117 | ), | 54 | ), |
| 118 | ), | 55 | ), |
| 119 | ) | 56 | ) |
| 120 | .handle("update", ({ path, payload }) => | 57 | .handle("update", ({ path, payload }) => |
| 121 | Effect.gen(function* () { | 58 | withWorkspaceRequestActor("sources.update", path.workspaceId, () => |
| 122 | const service = yield* ControlPlaneSourcesService; | 59 | withPolicy(requireWriteSources(path.workspaceId))( |
| 123 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 60 | updateSource({ |
| 124 | |||
| 125 | return yield* withPolicy(requireWriteSources(path.workspaceId))( | ||
| 126 | service.updateSource({ | ||
| 127 | workspaceId: path.workspaceId, | 61 | workspaceId: path.workspaceId, |
| 128 | sourceId: path.sourceId, | 62 | sourceId: path.sourceId, |
| 129 | payload, | 63 | payload, |
| 130 | }), | 64 | }), |
| 131 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 132 | }).pipe( | ||
| 133 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 134 | Effect.fail(toUnauthorizedError("sources.update", cause)), | ||
| 135 | ), | ||
| 136 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 137 | Effect.fail(toForbiddenError("sources.update", cause)), | ||
| 138 | ), | 65 | ), |
| 139 | ), | 66 | ), |
| 140 | ) | 67 | ) |
| 141 | .handle("remove", ({ path }) => | 68 | .handle("remove", ({ path }) => |
| 142 | Effect.gen(function* () { | 69 | withWorkspaceRequestActor("sources.remove", path.workspaceId, () => |
| 143 | const service = yield* ControlPlaneSourcesService; | 70 | withPolicy(requireWriteSources(path.workspaceId))( |
| 144 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 71 | removeSource({ |
| 145 | |||
| 146 | return yield* withPolicy(requireWriteSources(path.workspaceId))( | ||
| 147 | service.removeSource({ | ||
| 148 | workspaceId: path.workspaceId, | 72 | workspaceId: path.workspaceId, |
| 149 | sourceId: path.sourceId, | 73 | sourceId: path.sourceId, |
| 150 | }), | 74 | }), |
| 151 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 152 | }).pipe( | ||
| 153 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 154 | Effect.fail(toUnauthorizedError("sources.remove", cause)), | ||
| 155 | ), | ||
| 156 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 157 | Effect.fail(toForbiddenError("sources.remove", cause)), | ||
| 158 | ), | 75 | ), |
| 159 | ), | 76 | ), |
| 160 | ), | 77 | ), |
| @@ -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 | ); | ||
| @@ -2,9 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | |||
| 2 | import { | 2 | import { |
| 3 | OrganizationIdSchema, | 3 | OrganizationIdSchema, |
| 4 | WorkspaceIdSchema, | 4 | WorkspaceIdSchema, |
| 5 | WorkspaceInsertSchema, | ||
| 6 | WorkspaceSchema, | 5 | WorkspaceSchema, |
| 7 | WorkspaceUpdateSchema, | ||
| 8 | } from "#schema"; | 6 | } from "#schema"; |
| 9 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 10 | 8 | ||
| @@ -15,17 +13,20 @@ import { · 4 unmodified lines | |||
| 15 | ControlPlaneStorageError, | 13 | ControlPlaneStorageError, |
| 16 | ControlPlaneUnauthorizedError, | 14 | ControlPlaneUnauthorizedError, |
| 17 | } from "../errors"; | 15 | } from "../errors"; |
| 16 | import { | ||
| 17 | OptionalTrimmedNonEmptyStringSchema, | ||
| 18 | TrimmedNonEmptyStringSchema, | ||
| 19 | } from "../string-schemas"; | ||
| 18 | 20 | ||
| 19 | export const CreateWorkspacePayloadSchema = WorkspaceInsertSchema.pipe( | 21 | export const CreateWorkspacePayloadSchema = Schema.Struct({ |
| 20 | Schema.pick("name"), | 22 | name: TrimmedNonEmptyStringSchema, |
| 21 | ); | 23 | }); |
| 22 | 24 | ||
| 23 | export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type; | 25 | export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type; |
| 24 | 26 | ||
| 25 | export const UpdateWorkspacePayloadSchema = WorkspaceUpdateSchema.pipe( | 27 | export const UpdateWorkspacePayloadSchema = Schema.Struct({ |
| 26 | Schema.pick("name"), | 28 | name: OptionalTrimmedNonEmptyStringSchema, |
| 27 | Schema.partialWith({ exact: true }), | 29 | }); |
| 28 | ); | ||
| 29 | 30 | ||
| 30 | export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type; | 31 | export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type; |
| 31 | 32 | ||
| @@ -1,56 +1,20 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type { OrganizationId, WorkspaceId } from "#schema"; | 2 | import type { OrganizationId, WorkspaceId } from "#schema"; |
| 4 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 5 | import { |
| 6 | Actor, | 6 | createWorkspace, |
| 7 | ActorForbiddenError, | 7 | getWorkspace, |
| 8 | ActorUnauthenticatedError, | 8 | listWorkspaces, |
| 9 | requirePermission, | 9 | removeWorkspace, |
| 10 | withPolicy, | 10 | updateWorkspace, |
| 11 | } from "#domain"; | 11 | } from "../../runtime/organizations-operations"; |
| 12 | 12 | ||
| 13 | import { ControlPlaneApi } from "../api"; | 13 | import { ControlPlaneApi } from "../api"; |
| 14 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 15 | import { | 14 | import { |
| 16 | ControlPlaneForbiddenError, | 15 | withRequestActor, |
| 17 | ControlPlaneUnauthorizedError, | 16 | withWorkspaceRequestActor, |
| 18 | } from "../errors"; | 17 | } from "../http-auth"; |
| 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 | }); | ||
| 54 | 18 | ||
| 55 | const requireReadWorkspace = (workspaceId: WorkspaceId) => | 19 | const requireReadWorkspace = (workspaceId: WorkspaceId) => |
| 56 | requirePermission({ | 20 | requirePermission({ |
| @@ -82,95 +46,43 @@ export const ControlPlaneWorkspacesLive = HttpApiBuilder.group( · 25 unmodified lines | |||
| 82 | (handlers) => | 46 | (handlers) => |
| 83 | handlers | 47 | handlers |
| 84 | .handle("list", ({ path }) => | 48 | .handle("list", ({ path }) => |
| 85 | Effect.gen(function* () { | 49 | withRequestActor("workspaces.list", () => |
| 86 | const service = yield* ControlPlaneWorkspacesService; | 50 | withPolicy( |
| 87 | const actor = yield* resolveActor; | ||
| 88 | |||
| 89 | return yield* withPolicy( | ||
| 90 | requireOrganizationWorkspaceRead(path.organizationId), | 51 | requireOrganizationWorkspaceRead(path.organizationId), |
| 91 | )(service.listWorkspaces(path.organizationId)).pipe( | 52 | )(listWorkspaces(path.organizationId)) |
| 92 | Effect.provideService(Actor, actor), | ||
| 93 | ); | ||
| 94 | }).pipe( | ||
| 95 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 96 | Effect.fail(toUnauthorizedError("workspaces.list", cause)), | ||
| 97 | ), | ||
| 98 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 99 | Effect.fail(toForbiddenError("workspaces.list", cause)), | ||
| 100 | ), | ||
| 101 | ), | 53 | ), |
| 102 | ) | 54 | ) |
| 103 | .handle("create", ({ path, payload }) => | 55 | .handle("create", ({ path, payload }) => |
| 104 | Effect.gen(function* () { | 56 | withRequestActor("workspaces.create", (actor) => |
| 105 | const service = yield* ControlPlaneWorkspacesService; | 57 | withPolicy( |
| 106 | const actor = yield* resolveActor; | ||
| 107 | |||
| 108 | return yield* withPolicy( | ||
| 109 | requireOrganizationWorkspaceManage(path.organizationId), | 58 | requireOrganizationWorkspaceManage(path.organizationId), |
| 110 | )( | 59 | )( |
| 111 | service.createWorkspace({ | 60 | createWorkspace({ |
| 112 | organizationId: path.organizationId, | 61 | organizationId: path.organizationId, |
| 113 | payload, | 62 | payload, |
| 114 | createdByAccountId: actor.principal.accountId, | 63 | createdByAccountId: actor.principal.accountId, |
| 115 | }), | 64 | }), |
| 116 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 117 | }).pipe( | ||
| 118 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 119 | Effect.fail(toUnauthorizedError("workspaces.create", cause)), | ||
| 120 | ), | ||
| 121 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 122 | Effect.fail(toForbiddenError("workspaces.create", cause)), | ||
| 123 | ), | 65 | ), |
| 124 | ), | 66 | ), |
| 125 | ) | 67 | ) |
| 126 | .handle("get", ({ path }) => | 68 | .handle("get", ({ path }) => |
| 127 | Effect.gen(function* () { | 69 | withWorkspaceRequestActor("workspaces.get", path.workspaceId, () => |
| 128 | const service = yield* ControlPlaneWorkspacesService; | 70 | withPolicy(requireReadWorkspace(path.workspaceId))( |
| 129 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 71 | getWorkspace(path.workspaceId), |
| 130 | |||
| 131 | return yield* withPolicy(requireReadWorkspace(path.workspaceId))( | ||
| 132 | service.getWorkspace(path.workspaceId), | ||
| 133 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 134 | }).pipe( | ||
| 135 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 136 | Effect.fail(toUnauthorizedError("workspaces.get", cause)), | ||
| 137 | ), | ||
| 138 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 139 | Effect.fail(toForbiddenError("workspaces.get", cause)), | ||
| 140 | ), | 72 | ), |
| 141 | ), | 73 | ), |
| 142 | ) | 74 | ) |
| 143 | .handle("update", ({ path, payload }) => | 75 | .handle("update", ({ path, payload }) => |
| 144 | Effect.gen(function* () { | 76 | withWorkspaceRequestActor("workspaces.update", path.workspaceId, () => |
| 145 | const service = yield* ControlPlaneWorkspacesService; | 77 | withPolicy(requireManageWorkspace(path.workspaceId))( |
| 146 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 78 | updateWorkspace({ workspaceId: path.workspaceId, payload }), |
| 147 | |||
| 148 | return yield* withPolicy(requireManageWorkspace(path.workspaceId))( | ||
| 149 | service.updateWorkspace({ workspaceId: path.workspaceId, payload }), | ||
| 150 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 151 | }).pipe( | ||
| 152 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 153 | Effect.fail(toUnauthorizedError("workspaces.update", cause)), | ||
| 154 | ), | ||
| 155 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 156 | Effect.fail(toForbiddenError("workspaces.update", cause)), | ||
| 157 | ), | 79 | ), |
| 158 | ), | 80 | ), |
| 159 | ) | 81 | ) |
| 160 | .handle("remove", ({ path }) => | 82 | .handle("remove", ({ path }) => |
| 161 | Effect.gen(function* () { | 83 | withWorkspaceRequestActor("workspaces.remove", path.workspaceId, () => |
| 162 | const service = yield* ControlPlaneWorkspacesService; | 84 | withPolicy(requireManageWorkspace(path.workspaceId))( |
| 163 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | 85 | removeWorkspace({ workspaceId: path.workspaceId }), |
| 164 | |||
| 165 | return yield* withPolicy(requireManageWorkspace(path.workspaceId))( | ||
| 166 | service.removeWorkspace({ workspaceId: path.workspaceId }), | ||
| 167 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 168 | }).pipe( | ||
| 169 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 170 | Effect.fail(toUnauthorizedError("workspaces.remove", cause)), | ||
| 171 | ), | ||
| 172 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 173 | Effect.fail(toForbiddenError("workspaces.remove", cause)), | ||
| 174 | ), | 86 | ), |
| 175 | ), | 87 | ), |
| 176 | ), | 88 | ), |
| @@ -1,18 +1,20 @@ | |||
| 1 | import type { ControlPlaneExecutionsServiceShape } from "#api"; | ||
| 2 | import { | 1 | import { |
| 3 | ControlPlaneBadRequestError, | 2 | ControlPlaneBadRequestError, |
| 4 | ControlPlaneNotFoundError, | 3 | ControlPlaneNotFoundError, |
| 5 | ControlPlaneStorageError, | 4 | ControlPlaneStorageError, |
| 6 | } from "#api"; | 5 | } from "#api"; |
| 6 | import type { | ||
| 7 | CreateExecutionPayload, | ||
| 8 | ResumeExecutionPayload, | ||
| 9 | } from "../api/executions/api"; | ||
| 7 | import type { ToolInvoker } from "@executor-v3/codemode-core"; | 10 | import type { ToolInvoker } from "@executor-v3/codemode-core"; |
| 8 | import { | ||
| 9 | ControlPlanePersistenceError, | ||
| 10 | type SqlControlPlaneRows, | ||
| 11 | } from "#persistence"; | ||
| 12 | import { | 11 | import { |
| 13 | ExecutionIdSchema, | 12 | ExecutionIdSchema, |
| 13 | type AccountId, | ||
| 14 | type Execution, | 14 | type Execution, |
| 15 | type ExecutionEnvelope, | 15 | type ExecutionEnvelope, |
| 16 | type ExecutionId, | ||
| 17 | type WorkspaceId, | ||
| 16 | } from "#schema"; | 18 | } from "#schema"; |
| 17 | import * as Deferred from "effect/Deferred"; | 19 | import * as Deferred from "effect/Deferred"; |
| 18 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| @@ -24,65 +26,25 @@ import { · 5 unmodified lines | |||
| 24 | ResumeUnsupportedError, | 26 | ResumeUnsupportedError, |
| 25 | } from "./execution-state"; | 27 | } from "./execution-state"; |
| 26 | import { | 28 | import { |
| 29 | LiveExecutionManagerService, | ||
| 27 | type LiveExecutionManager, | 30 | type LiveExecutionManager, |
| 28 | } from "./live-execution"; | 31 | } 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"; | ||
| 29 | 42 | ||
| 30 | const badRequest = ( | 43 | const executionOps = { |
| 31 | operation: string, | 44 | create: operationErrors("executions.create"), |
| 32 | message: string, | 45 | get: operationErrors("executions.get"), |
| 33 | details: string, | 46 | resume: operationErrors("executions.resume"), |
| 34 | ): ControlPlaneBadRequestError => | 47 | } as const; |
| 35 | new ControlPlaneBadRequestError({ | ||
| 36 | operation, | ||
| 37 | message, | ||
| 38 | details, | ||
| 39 | }); | ||
| 40 | |||
| 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 | }; | ||
| 86 | 48 | ||
| 87 | const serializeJson = (value: unknown): string | null => { | 49 | const serializeJson = (value: unknown): string | null => { |
| 88 | if (value === undefined) { | 50 | if (value === undefined) { |
| @@ -133,23 +95,22 @@ const withExecutionInvocationContext = (input: { · 44 unmodified lines | |||
| 133 | }; | 95 | }; |
| 134 | 96 | ||
| 135 | const fetchExecution = ( | 97 | const fetchExecution = ( |
| 136 | rows: SqlControlPlaneRows, | 98 | store: ControlPlaneStoreShape, |
| 137 | input: { | 99 | input: { |
| 138 | workspaceId: Execution["workspaceId"]; | 100 | workspaceId: Execution["workspaceId"]; |
| 139 | executionId: Execution["id"]; | 101 | executionId: Execution["id"]; |
| 140 | operation: string; | 102 | operation: OperationErrorsLike; |
| 141 | }, | 103 | }, |
| 142 | ): Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError> => | 104 | ): Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError> => |
| 143 | Effect.gen(function* () { | 105 | Effect.gen(function* () { |
| 144 | const existing = yield* mapStorageError( | 106 | const errors = asOperationErrors(input.operation); |
| 145 | input.operation, | 107 | const existing = yield* errors.mapStorage( |
| 146 | rows.executions.getByWorkspaceAndId(input.workspaceId, input.executionId), | 108 | store.executions.getByWorkspaceAndId(input.workspaceId, input.executionId), |
| 147 | ); | 109 | ); |
| 148 | 110 | ||
| 149 | if (Option.isNone(existing)) { | 111 | if (Option.isNone(existing)) { |
| 150 | return yield* Effect.fail( | 112 | return yield* Effect.fail( |
| 151 | notFound( | 113 | errors.notFound( |
| 152 | input.operation, | ||
| 153 | "Execution not found", | 114 | "Execution not found", |
| 154 | `workspaceId=${input.workspaceId} executionId=${input.executionId}`, | 115 | `workspaceId=${input.workspaceId} executionId=${input.executionId}`, |
| 155 | ), | 116 | ), |
| @@ -160,18 +121,18 @@ const fetchExecution = ( · 4 unmodified lines | |||
| 160 | }); | 121 | }); |
| 161 | 122 | ||
| 162 | const fetchExecutionEnvelope = ( | 123 | const fetchExecutionEnvelope = ( |
| 163 | rows: SqlControlPlaneRows, | 124 | store: ControlPlaneStoreShape, |
| 164 | input: { | 125 | input: { |
| 165 | workspaceId: Execution["workspaceId"]; | 126 | workspaceId: Execution["workspaceId"]; |
| 166 | executionId: Execution["id"]; | 127 | executionId: Execution["id"]; |
| 167 | operation: string; | 128 | operation: OperationErrorsLike; |
| 168 | }, | 129 | }, |
| 169 | ): Effect.Effect<ExecutionEnvelope, ControlPlaneNotFoundError | ControlPlaneStorageError> => | 130 | ): Effect.Effect<ExecutionEnvelope, ControlPlaneNotFoundError | ControlPlaneStorageError> => |
| 170 | Effect.gen(function* () { | 131 | Effect.gen(function* () { |
| 171 | const execution = yield* fetchExecution(rows, input); | 132 | const errors = asOperationErrors(input.operation); |
| 172 | const pendingInteraction = yield* mapStorageError( | 133 | const execution = yield* fetchExecution(store, input); |
| 173 | `${input.operation}.pending_interaction`, | 134 | const pendingInteraction = yield* errors.child("pending_interaction").mapStorage( |
| 174 | rows.executionInteractions.getPendingByExecutionId(input.executionId), | 135 | store.executionInteractions.getPendingByExecutionId(input.executionId), |
| 175 | ); | 136 | ); |
| 176 | 137 | ||
| 177 | return { | 138 | return { |
| @@ -180,224 +141,242 @@ const fetchExecutionEnvelope = ( · 2 unmodified lines | |||
| 180 | }; | 141 | }; |
| 181 | }); | 142 | }); |
| 182 | 143 | ||
| 183 | export const createRuntimeExecutionsService = ( | 144 | const createExecutionWithDependencies = ( |
| 184 | rows: SqlControlPlaneRows, | 145 | store: ControlPlaneStoreShape, |
| 185 | executionResolver: ResolveExecutionEnvironment, | 146 | executionResolver: ResolveExecutionEnvironment, |
| 186 | liveExecutionManager: LiveExecutionManager, | 147 | liveExecutionManager: LiveExecutionManager, |
| 187 | ): ControlPlaneExecutionsServiceShape => { | 148 | input: { |
| 188 | const createExecution: ControlPlaneExecutionsServiceShape["createExecution"] = ( | 149 | workspaceId: WorkspaceId; |
| 189 | { workspaceId, payload, createdByAccountId }, | 150 | payload: CreateExecutionPayload; |
| 190 | ) => | 151 | createdByAccountId: AccountId; |
| 191 | Effect.gen(function* () { | 152 | }, |
| 192 | const code = yield* requireTrimmed("executions.create", "code", payload.code); | 153 | ) => |
| 193 | const now = Date.now(); | 154 | Effect.gen(function* () { |
| 194 | const execution: Execution = { | 155 | const code = input.payload.code; |
| 195 | id: ExecutionIdSchema.make(`exec_${crypto.randomUUID()}`), | 156 | const now = Date.now(); |
| 196 | workspaceId, | 157 | const execution: Execution = { |
| 197 | createdByAccountId, | 158 | id: ExecutionIdSchema.make(`exec_${crypto.randomUUID()}`), |
| 198 | status: "pending", | 159 | workspaceId: input.workspaceId, |
| 199 | code, | 160 | createdByAccountId: input.createdByAccountId, |
| 200 | resultJson: null, | 161 | status: "pending", |
| 201 | errorText: null, | 162 | code, |
| 202 | logsJson: null, | 163 | resultJson: null, |
| 203 | startedAt: null, | 164 | errorText: null, |
| 204 | completedAt: null, | 165 | logsJson: null, |
| 205 | createdAt: now, | 166 | startedAt: null, |
| 206 | updatedAt: now, | 167 | completedAt: null, |
| 207 | }; | 168 | createdAt: now, |
| 208 | 169 | updatedAt: now, | |
| 209 | yield* mapStorageError( | 170 | }; |
| 210 | "executions.create.insert", | 171 | |
| 211 | rows.executions.insert(execution), | 172 | yield* executionOps.create.child("insert").mapStorage( |
| 212 | ); | 173 | store.executions.insert(execution), |
| 213 | 174 | ); | |
| 214 | const running = yield* mapStorageError( | 175 | |
| 215 | "executions.create.mark_running", | 176 | const running = yield* executionOps.create.child("mark_running").mapStorage( |
| 216 | rows.executions.update(execution.id, { | 177 | store.executions.update(execution.id, { |
| 217 | status: "running", | 178 | status: "running", |
| 218 | startedAt: now, | 179 | startedAt: now, |
| 219 | updatedAt: now, | 180 | updatedAt: now, |
| 220 | }), | 181 | }), |
| 221 | ); | 182 | ); |
| 222 | 183 | ||
| 223 | if (Option.isNone(running)) { | 184 | if (Option.isNone(running)) { |
| 224 | return yield* Effect.fail( | 185 | return yield* Effect.fail( |
| 225 | notFound( | 186 | executionOps.create.notFound( |
| 226 | "executions.create", | 187 | "Execution not found after insert", |
| 227 | "Execution not found after insert", | 188 | `executionId=${execution.id}`, |
| 228 | `executionId=${execution.id}`, | 189 | ), |
| 229 | ), | 190 | ); |
| 230 | ); | 191 | } |
| 231 | } | 192 | |
| 232 | 193 | const environment = yield* executionResolver({ | |
| 233 | const environment = yield* executionResolver({ | 194 | workspaceId: input.workspaceId, |
| 234 | workspaceId, | 195 | accountId: input.createdByAccountId, |
| 235 | accountId: createdByAccountId, | 196 | executionId: execution.id, |
| 236 | executionId: execution.id, | 197 | onElicitation: liveExecutionManager.createOnElicitation({ |
| 237 | onElicitation: liveExecutionManager.createOnElicitation({ | 198 | rows: store, |
| 238 | rows, | 199 | executionId: execution.id, |
| 239 | executionId: execution.id, | 200 | }), |
| 240 | }), | 201 | }).pipe( |
| 241 | }).pipe( | 202 | Effect.mapError((error) => |
| 242 | Effect.mapError((error) => | 203 | executionOps.create.child("environment").unknownStorage( |
| 243 | new ControlPlaneStorageError({ | 204 | error, |
| 244 | operation: "executions.create.environment", | 205 | "Execution environment resolution failed", |
| 245 | message: error instanceof Error ? error.message : String(error), | 206 | ), |
| 246 | details: "Execution environment resolution failed", | 207 | ), |
| 247 | }), | 208 | ); |
| 248 | ), | 209 | |
| 249 | ); | 210 | const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id); |
| 250 | 211 | const toolInvoker = withExecutionInvocationContext({ | |
| 251 | const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id); | 212 | executionId: execution.id, |
| 252 | const toolInvoker = withExecutionInvocationContext({ | 213 | toolInvoker: environment.toolInvoker, |
| 253 | executionId: execution.id, | 214 | }); |
| 254 | toolInvoker: environment.toolInvoker, | 215 | |
| 255 | }); | 216 | yield* Effect.sync(() => { |
| 256 | 217 | Effect.runFork( | |
| 257 | yield* Effect.sync(() => { | 218 | environment.executor.execute(code, toolInvoker).pipe( |
| 258 | Effect.runFork( | 219 | Effect.flatMap((outcome) => { |
| 259 | environment.executor.execute(code, toolInvoker).pipe( | 220 | const completedAt = Date.now(); |
| 260 | Effect.flatMap((outcome) => { | 221 | return executionOps.create.child("complete").mapStorage( |
| 261 | const completedAt = Date.now(); | 222 | store.executions.update(execution.id, { |
| 262 | return mapStorageError( | 223 | status: outcome.error ? "failed" : "completed", |
| 263 | "executions.create.complete", | 224 | resultJson: serializeJson(outcome.result), |
| 264 | rows.executions.update(execution.id, { | 225 | errorText: outcome.error ?? null, |
| 265 | status: outcome.error ? "failed" : "completed", | 226 | logsJson: serializeJson(outcome.logs ?? null), |
| 266 | resultJson: serializeJson(outcome.result), | 227 | completedAt, |
| 267 | errorText: outcome.error ?? null, | 228 | updatedAt: completedAt, |
| 268 | logsJson: serializeJson(outcome.logs ?? null), | ||
| 269 | completedAt, | ||
| 270 | updatedAt: completedAt, | ||
| 271 | }), | ||
| 272 | ).pipe( | ||
| 273 | Effect.flatMap((updated) => | ||
| 274 | Option.isNone(updated) | ||
| 275 | ? Effect.fail( | ||
| 276 | notFound( | ||
| 277 | "executions.create", | ||
| 278 | "Execution not found after completion", | ||
| 279 | `executionId=${execution.id}`, | ||
| 280 | ), | ||
| 281 | ) | ||
| 282 | : Effect.succeed(updated.value), | ||
| 283 | ), | ||
| 284 | Effect.flatMap((updated) => | ||
| 285 | liveExecutionManager.finishRun({ | ||
| 286 | executionId: execution.id, | ||
| 287 | state: updated.status === "completed" ? "completed" : "failed", | ||
| 288 | }).pipe(Effect.as(updated)), | ||
| 289 | ), | ||
| 290 | ); | ||
| 291 | }), | 229 | }), |
| 292 | Effect.catchAll((error) => { | 230 | ).pipe( |
| 293 | const completedAt = Date.now(); | 231 | Effect.flatMap((updated) => |
| 294 | return mapStorageError( | 232 | Option.isNone(updated) |
| 295 | "executions.create.complete_error", | 233 | ? Effect.fail( |
| 296 | rows.executions.update(execution.id, { | 234 | executionOps.create.notFound( |
| 297 | status: "failed", | 235 | "Execution not found after completion", |
| 298 | errorText: error instanceof Error ? error.message : String(error), | 236 | `executionId=${execution.id}`, |
| 299 | completedAt, | 237 | ), |
| 300 | updatedAt: completedAt, | 238 | ) |
| 301 | }), | 239 | : Effect.succeed(updated.value), |
| 302 | ).pipe( | 240 | ), |
| 303 | Effect.zipRight( | 241 | Effect.flatMap((updated) => |
| 304 | liveExecutionManager.finishRun({ | 242 | liveExecutionManager.finishRun({ |
| 305 | executionId: execution.id, | 243 | executionId: execution.id, |
| 306 | state: "failed", | 244 | state: updated.status === "completed" ? "completed" : "failed", |
| 307 | }), | 245 | }).pipe(Effect.as(updated)), |
| 308 | ), | 246 | ), |
| 309 | Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)), | 247 | ); |
| 310 | ); | 248 | }), |
| 249 | Effect.catchAll((error) => { | ||
| 250 | const completedAt = Date.now(); | ||
| 251 | return executionOps.create.child("complete_error").mapStorage( | ||
| 252 | store.executions.update(execution.id, { | ||
| 253 | status: "failed", | ||
| 254 | errorText: error instanceof Error ? error.message : String(error), | ||
| 255 | completedAt, | ||
| 256 | updatedAt: completedAt, | ||
| 311 | }), | 257 | }), |
| 312 | ), | 258 | ).pipe( |
| 313 | ); | 259 | Effect.zipRight( |
| 314 | }); | 260 | liveExecutionManager.finishRun({ |
| 261 | executionId: execution.id, | ||
| 262 | state: "failed", | ||
| 263 | }), | ||
| 264 | ), | ||
| 265 | Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)), | ||
| 266 | ); | ||
| 267 | }), | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | }); | ||
| 315 | 271 | ||
| 316 | yield* Deferred.await(nextState); | 272 | yield* Deferred.await(nextState); |
| 317 | 273 | ||
| 318 | return yield* fetchExecutionEnvelope(rows, { | 274 | return yield* fetchExecutionEnvelope(store, { |
| 319 | workspaceId, | 275 | workspaceId: input.workspaceId, |
| 320 | executionId: execution.id, | 276 | executionId: execution.id, |
| 321 | operation: "executions.create", | 277 | operation: executionOps.create, |
| 322 | }); | 278 | }); |
| 323 | }); | 279 | }); |
| 324 | 280 | ||
| 325 | return { | 281 | export const createExecution = (input: { |
| 326 | createExecution, | 282 | workspaceId: WorkspaceId; |
| 327 | getExecution: ({ workspaceId, executionId }) => | 283 | payload: CreateExecutionPayload; |
| 328 | fetchExecutionEnvelope(rows, { | 284 | createdByAccountId: AccountId; |
| 329 | workspaceId, | 285 | }) => |
| 330 | executionId, | 286 | Effect.gen(function* () { |
| 331 | operation: "executions.get", | 287 | const store = yield* ControlPlaneStore; |
| 332 | }), | 288 | const executionResolver = yield* RuntimeExecutionResolverService; |
| 289 | const liveExecutionManager = yield* LiveExecutionManagerService; | ||
| 290 | |||
| 291 | return yield* createExecutionWithDependencies( | ||
| 292 | store, | ||
| 293 | executionResolver, | ||
| 294 | liveExecutionManager, | ||
| 295 | input, | ||
| 296 | ); | ||
| 297 | }); | ||
| 333 | 298 | ||
| 334 | resumeExecution: ({ workspaceId, executionId, payload }) => | 299 | export const getExecution = (input: { |
| 335 | Effect.gen(function* () { | 300 | workspaceId: WorkspaceId; |
| 336 | const existing = yield* fetchExecutionEnvelope(rows, { | 301 | executionId: ExecutionId; |
| 337 | workspaceId, | 302 | }) => |
| 338 | executionId, | 303 | Effect.flatMap(ControlPlaneStore, (store) => |
| 339 | operation: "executions.resume", | 304 | fetchExecutionEnvelope(store, { |
| 340 | }); | 305 | workspaceId: input.workspaceId, |
| 341 | 306 | executionId: input.executionId, | |
| 342 | if (existing.execution.status !== "waiting_for_interaction") { | 307 | operation: executionOps.get, |
| 343 | return yield* Effect.fail( | 308 | }) |
| 344 | badRequest( | 309 | ); |
| 345 | "executions.resume", | 310 | |
| 346 | "Execution is not waiting for interaction", | 311 | export const resumeExecution = (input: { |
| 347 | `executionId=${executionId} status=${existing.execution.status}`, | 312 | workspaceId: WorkspaceId; |
| 348 | ), | 313 | executionId: ExecutionId; |
| 349 | ); | 314 | payload: ResumeExecutionPayload; |
| 350 | } | 315 | resumedByAccountId: AccountId; |
| 351 | 316 | }) => | |
| 352 | const responseJson = payload.responseJson; | 317 | Effect.gen(function* () { |
| 353 | const response = | 318 | const store = yield* ControlPlaneStore; |
| 354 | responseJson === undefined | 319 | const liveExecutionManager = yield* LiveExecutionManagerService; |
| 355 | ? { action: "accept" as const } | 320 | |
| 356 | : yield* Effect.try({ | 321 | const existing = yield* fetchExecutionEnvelope(store, { |
| 357 | try: () => JSON.parse(responseJson), | 322 | workspaceId: input.workspaceId, |
| 358 | catch: (error) => | 323 | executionId: input.executionId, |
| 359 | badRequest( | 324 | operation: "executions.resume", |
| 360 | "executions.resume", | 325 | }); |
| 326 | |||
| 327 | if (existing.execution.status !== "waiting_for_interaction") { | ||
| 328 | return yield* Effect.fail( | ||
| 329 | executionOps.resume.badRequest( | ||
| 330 | "Execution is not waiting for interaction", | ||
| 331 | `executionId=${input.executionId} status=${existing.execution.status}`, | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | |||
| 336 | const responseJson = input.payload.responseJson; | ||
| 337 | const response = | ||
| 338 | responseJson === undefined | ||
| 339 | ? { action: "accept" as const } | ||
| 340 | : yield* Effect.try({ | ||
| 341 | try: () => JSON.parse(responseJson), | ||
| 342 | catch: (error) => | ||
| 343 | executionOps.resume.badRequest( | ||
| 344 | "Invalid responseJson", | ||
| 345 | error instanceof Error ? error.message : String(error), | ||
| 346 | ), | ||
| 347 | }).pipe( | ||
| 348 | Effect.flatMap((decoded) => | ||
| 349 | decodeElicitationResponse(decoded).pipe( | ||
| 350 | Effect.mapError((error) => | ||
| 351 | executionOps.resume.badRequest( | ||
| 361 | "Invalid responseJson", | 352 | "Invalid responseJson", |
| 362 | error instanceof Error ? error.message : String(error), | 353 | String(error), |
| 363 | ), | ||
| 364 | }).pipe( | ||
| 365 | Effect.flatMap((decoded) => | ||
| 366 | decodeElicitationResponse(decoded).pipe( | ||
| 367 | Effect.mapError((error) => | ||
| 368 | badRequest( | ||
| 369 | "executions.resume", | ||
| 370 | "Invalid responseJson", | ||
| 371 | String(error), | ||
| 372 | ), | ||
| 373 | ), | ||
| 374 | ), | 354 | ), |
| 375 | ), | 355 | ), |
| 376 | ); | 356 | ), |
| 377 | |||
| 378 | const nextState = yield* liveExecutionManager.registerStateWaiter(executionId); | ||
| 379 | const resumed = yield* liveExecutionManager.resolveInteraction({ | ||
| 380 | executionId, | ||
| 381 | response, | ||
| 382 | }); | ||
| 383 | |||
| 384 | if (!resumed) { | ||
| 385 | return yield* Effect.fail( | ||
| 386 | badRequest( | ||
| 387 | "executions.resume", | ||
| 388 | "Resume is unavailable for this execution", | ||
| 389 | `executionId=${executionId} mode=${new ResumeUnsupportedError({ executionId })._tag}`, | ||
| 390 | ), | 357 | ), |
| 391 | ); | 358 | ); |
| 392 | } | ||
| 393 | 359 | ||
| 394 | yield* Deferred.await(nextState); | 360 | const nextState = yield* liveExecutionManager.registerStateWaiter(input.executionId); |
| 361 | const resumed = yield* liveExecutionManager.resolveInteraction({ | ||
| 362 | executionId: input.executionId, | ||
| 363 | response, | ||
| 364 | }); | ||
| 395 | 365 | ||
| 396 | return yield* fetchExecutionEnvelope(rows, { | 366 | if (!resumed) { |
| 397 | workspaceId, | 367 | return yield* Effect.fail( |
| 398 | executionId, | 368 | executionOps.resume.badRequest( |
| 399 | operation: "executions.resume", | 369 | "Resume is unavailable for this execution", |
| 400 | }); | 370 | `executionId=${input.executionId} mode=${new ResumeUnsupportedError({ executionId: input.executionId })._tag}`, |
| 401 | }), | 371 | ), |
| 402 | }; | 372 | ); |
| 403 | }; | 373 | } |
| 374 | |||
| 375 | yield* Deferred.await(nextState); | ||
| 376 | |||
| 377 | return yield* fetchExecutionEnvelope(store, { | ||
| 378 | workspaceId: input.workspaceId, | ||
| 379 | executionId: input.executionId, | ||
| 380 | operation: executionOps.resume, | ||
| 381 | }); | ||
| 382 | }); | ||
| @@ -5,23 +5,9 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | |||
| 5 | import * as Scope from "effect/Scope"; | 5 | import * as Scope from "effect/Scope"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | type ControlPlaneApiServiceContext, | 8 | type ControlPlaneApiRuntimeContext, |
| 9 | ControlPlaneActorResolver, | 9 | ControlPlaneActorResolver, |
| 10 | type ControlPlaneActorResolverShape, | 10 | type ControlPlaneActorResolverShape, |
| 11 | ControlPlaneExecutionsService, | ||
| 12 | type ControlPlaneExecutionsServiceShape, | ||
| 13 | ControlPlaneLocalService, | ||
| 14 | type ControlPlaneLocalServiceShape, | ||
| 15 | ControlPlaneMembershipsService, | ||
| 16 | type ControlPlaneMembershipsServiceShape, | ||
| 17 | ControlPlaneOrganizationsService, | ||
| 18 | type ControlPlaneOrganizationsServiceShape, | ||
| 19 | ControlPlanePoliciesService, | ||
| 20 | type ControlPlanePoliciesServiceShape, | ||
| 21 | ControlPlaneSourcesService, | ||
| 22 | type ControlPlaneSourcesServiceShape, | ||
| 23 | ControlPlaneWorkspacesService, | ||
| 24 | type ControlPlaneWorkspacesServiceShape, | ||
| 25 | } from "#api"; | 11 | } from "#api"; |
| 26 | import { | 12 | import { |
| 27 | SqlControlPlanePersistenceLive, | 13 | SqlControlPlanePersistenceLive, |
| @@ -31,31 +17,31 @@ import { · 3 unmodified lines | |||
| 31 | type CreateSqlRuntimeOptions, | 17 | type CreateSqlRuntimeOptions, |
| 32 | type SqlControlPlanePersistence, | 18 | type SqlControlPlanePersistence, |
| 33 | } from "#persistence"; | 19 | } from "#persistence"; |
| 34 | |||
| 35 | import type { LocalInstallation } from "#schema"; | 20 | import type { LocalInstallation } from "#schema"; |
| 21 | |||
| 36 | import { | 22 | import { |
| 37 | ControlPlaneAuthHeaders, | 23 | ControlPlaneAuthHeaders, |
| 38 | RuntimeActorResolverLive, | 24 | RuntimeActorResolverLive, |
| 39 | createHeaderActorResolver, | 25 | createHeaderActorResolver, |
| 40 | } from "./actor-resolver"; | 26 | } from "./actor-resolver"; |
| 41 | import { | 27 | import { type ResolveExecutionEnvironment } from "./execution-state"; |
| 42 | type ResolveExecutionEnvironment, | ||
| 43 | } from "./execution-state"; | ||
| 44 | import { | 28 | import { |
| 45 | LiveExecutionManagerLive, | 29 | LiveExecutionManagerLive, |
| 30 | LiveExecutionManagerService, | ||
| 46 | } from "./live-execution"; | 31 | } from "./live-execution"; |
| 32 | import { getOrProvisionLocalInstallation } from "./local-installation"; | ||
| 47 | import { | 33 | import { |
| 48 | getOrProvisionLocalInstallation, | 34 | ControlPlaneStore, |
| 49 | } from "./local-installation"; | 35 | ControlPlaneStoreLive, |
| 36 | } from "./store"; | ||
| 50 | import { | 37 | import { |
| 51 | RuntimeSourceAuthServiceLive, | 38 | RuntimeSourceAuthServiceLive, |
| 39 | RuntimeSourceAuthServiceTag, | ||
| 52 | type ResolveSecretMaterial, | 40 | type ResolveSecretMaterial, |
| 53 | } from "./source-auth-service"; | 41 | } from "./source-auth-service"; |
| 54 | import { | ||
| 55 | RuntimeControlPlaneApiServicesLive, | ||
| 56 | } from "./services"; | ||
| 57 | import { | 42 | import { |
| 58 | RuntimeExecutionResolverLive, | 43 | RuntimeExecutionResolverLive, |
| 44 | RuntimeExecutionResolverService, | ||
| 59 | } from "./workspace-execution-environment"; | 45 | } from "./workspace-execution-environment"; |
| 60 | 46 | ||
| 61 | export { | 47 | export { |
| @@ -67,6 +53,7 @@ export * from "./execution-state"; · 5 unmodified lines | |||
| 67 | export * from "./live-execution"; | 53 | export * from "./live-execution"; |
| 68 | export * from "./local-installation"; | 54 | export * from "./local-installation"; |
| 69 | export * from "./source-auth-service"; | 55 | export * from "./source-auth-service"; |
| 56 | export * from "./store"; | ||
| 70 | export * from "./workspace-execution-environment"; | 57 | export * from "./workspace-execution-environment"; |
| 71 | 58 | ||
| 72 | export type RuntimeControlPlaneOptions = { | 59 | export type RuntimeControlPlaneOptions = { |
| @@ -76,10 +63,6 @@ export type RuntimeControlPlaneOptions = { · 3 unmodified lines | |||
| 76 | getLocalServerBaseUrl?: () => string | undefined; | 63 | getLocalServerBaseUrl?: () => string | undefined; |
| 77 | }; | 64 | }; |
| 78 | 65 | ||
| 79 | export type RuntimeControlPlaneInput = RuntimeControlPlaneOptions & { | ||
| 80 | persistence: SqlControlPlanePersistence; | ||
| 81 | }; | ||
| 82 | |||
| 83 | const detailsFromCause = (cause: unknown): string => | 66 | const detailsFromCause = (cause: unknown): string => |
| 84 | cause instanceof Error ? cause.message : String(cause); | 67 | cause instanceof Error ? cause.message : String(cause); |
| 85 | 68 | ||
| @@ -96,23 +79,31 @@ const toLocalInstallationBootstrapError = ( · 10 unmodified lines | |||
| 96 | const closeScope = (scope: Scope.CloseableScope) => | 79 | const closeScope = (scope: Scope.CloseableScope) => |
| 97 | Scope.close(scope, Exit.void).pipe(Effect.orDie); | 80 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 98 | 81 | ||
| 99 | const createRuntimeServicesLayer = (input: { | 82 | export type RuntimeControlPlaneLayer = Layer.Layer< |
| 100 | organizationsService: ControlPlaneOrganizationsServiceShape; | 83 | ControlPlaneApiRuntimeContext, |
| 101 | membershipsService: ControlPlaneMembershipsServiceShape; | 84 | never, |
| 102 | workspacesService: ControlPlaneWorkspacesServiceShape; | 85 | never |
| 103 | sourcesService: ControlPlaneSourcesServiceShape; | 86 | >; |
| 104 | policiesService: ControlPlanePoliciesServiceShape; | 87 | |
| 105 | localService: ControlPlaneLocalServiceShape; | 88 | const runtimeContextTags = [ |
| 106 | executionsService: ControlPlaneExecutionsServiceShape; | 89 | ControlPlaneActorResolver, |
| 107 | }): Layer.Layer<ControlPlaneApiServiceContext, never, never> => | 90 | ControlPlaneStore, |
| 108 | Layer.mergeAll( | 91 | LiveExecutionManagerService, |
| 109 | Layer.succeed(ControlPlaneOrganizationsService, input.organizationsService), | 92 | RuntimeSourceAuthServiceTag, |
| 110 | Layer.succeed(ControlPlaneMembershipsService, input.membershipsService), | 93 | RuntimeExecutionResolverService, |
| 111 | Layer.succeed(ControlPlaneWorkspacesService, input.workspacesService), | 94 | ] as const; |
| 112 | Layer.succeed(ControlPlaneSourcesService, input.sourcesService), | 95 | |
| 113 | Layer.succeed(ControlPlanePoliciesService, input.policiesService), | 96 | const createRuntimeLayerFromContext = ( |
| 114 | Layer.succeed(ControlPlaneLocalService, input.localService), | 97 | context: Context.Context< |
| 115 | Layer.succeed(ControlPlaneExecutionsService, input.executionsService), | 98 | ControlPlaneActorResolver |
| 99 | | ControlPlaneStore | ||
| 100 | | LiveExecutionManagerService | ||
| 101 | | RuntimeSourceAuthServiceTag | ||
| 102 | | RuntimeExecutionResolverService | ||
| 103 | >, | ||
| 104 | ): RuntimeControlPlaneLayer => | ||
| 105 | Layer.succeedContext( | ||
| 106 | context.pipe(Context.pick(...runtimeContextTags)), | ||
| 116 | ); | 107 | ); |
| 117 | 108 | ||
| 118 | export const createRuntimeControlPlaneLayer = ( | 109 | export const createRuntimeControlPlaneLayer = ( |
| @@ -130,67 +121,20 @@ export const createRuntimeControlPlaneLayer = ( · 11 unmodified lines | |||
| 130 | }).pipe( | 121 | }).pipe( |
| 131 | Layer.provide(sourceAuthLayer), | 122 | Layer.provide(sourceAuthLayer), |
| 132 | ); | 123 | ); |
| 133 | const runtimeDependenciesLayer = Layer.mergeAll( | ||
| 134 | liveExecutionManagerLayer, | ||
| 135 | sourceAuthLayer, | ||
| 136 | executionResolverLayer, | ||
| 137 | ); | ||
| 138 | const apiServicesLayer = RuntimeControlPlaneApiServicesLive.pipe( | ||
| 139 | Layer.provide(runtimeDependenciesLayer), | ||
| 140 | ); | ||
| 141 | 124 | ||
| 142 | return Layer.mergeAll( | 125 | return Layer.mergeAll( |
| 143 | apiServicesLayer, | 126 | ControlPlaneStoreLive, |
| 144 | RuntimeActorResolverLive(options.actorResolver), | 127 | RuntimeActorResolverLive(options.actorResolver), |
| 145 | runtimeDependenciesLayer, | 128 | liveExecutionManagerLayer, |
| 129 | sourceAuthLayer, | ||
| 130 | executionResolverLayer, | ||
| 146 | ); | 131 | ); |
| 147 | }; | 132 | }; |
| 148 | 133 | ||
| 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 | |||
| 189 | export type SqlControlPlaneRuntime = { | 134 | export type SqlControlPlaneRuntime = { |
| 190 | persistence: SqlControlPlanePersistence; | 135 | persistence: SqlControlPlanePersistence; |
| 191 | localInstallation: LocalInstallation; | 136 | localInstallation: LocalInstallation; |
| 192 | serviceLayer: Layer.Layer<ControlPlaneApiServiceContext, never, never>; | 137 | runtimeLayer: RuntimeControlPlaneLayer; |
| 193 | actorResolver: ControlPlaneActorResolverShape; | ||
| 194 | close: () => Promise<void>; | 138 | close: () => Promise<void>; |
| 195 | }; | 139 | }; |
| 196 | 140 | ||
| @@ -217,27 +161,10 @@ export const createSqlControlPlaneRuntime = ( · 20 unmodified lines | |||
| 217 | ); | 161 | ); |
| 218 | 162 | ||
| 219 | const persistence = Context.get(context, SqlControlPlanePersistenceService); | 163 | const persistence = Context.get(context, SqlControlPlanePersistenceService); |
| 220 | const actorResolver = Context.get(context, ControlPlaneActorResolver); | 164 | const store = Context.get(context, ControlPlaneStore); |
| 221 | const organizationsService = Context.get(context, ControlPlaneOrganizationsService); | 165 | const concreteRuntimeLayer = createRuntimeLayerFromContext(context); |
| 222 | const membershipsService = Context.get(context, ControlPlaneMembershipsService); | 166 | |
| 223 | const workspacesService = Context.get(context, ControlPlaneWorkspacesService); | 167 | const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe( |
| 224 | const sourcesService = Context.get(context, ControlPlaneSourcesService); | ||
| 225 | const policiesService = Context.get(context, ControlPlanePoliciesService); | ||
| 226 | const localService = Context.get(context, ControlPlaneLocalService); | ||
| 227 | const executionsService = Context.get(context, ControlPlaneExecutionsService); | ||
| 228 | const serviceLayer = createRuntimeServicesLayer({ | ||
| 229 | organizationsService, | ||
| 230 | membershipsService, | ||
| 231 | workspacesService, | ||
| 232 | sourcesService, | ||
| 233 | policiesService, | ||
| 234 | localService, | ||
| 235 | executionsService, | ||
| 236 | }); | ||
| 237 | |||
| 238 | const localInstallation = yield* getOrProvisionLocalInstallation( | ||
| 239 | persistence.rows, | ||
| 240 | ).pipe( | ||
| 241 | Effect.mapError(toLocalInstallationBootstrapError), | 168 | Effect.mapError(toLocalInstallationBootstrapError), |
| 242 | Effect.catchAll((error) => | 169 | Effect.catchAll((error) => |
| 243 | closeScope(scope).pipe( | 170 | closeScope(scope).pipe( |
| @@ -248,8 +175,7 @@ export const createSqlControlPlaneRuntime = ( · 4 unmodified lines | |||
| 248 | return { | 175 | return { |
| 249 | persistence, | 176 | persistence, |
| 250 | localInstallation, | 177 | localInstallation, |
| 251 | serviceLayer, | 178 | runtimeLayer: concreteRuntimeLayer, |
| 252 | actorResolver, | ||
| 253 | close: () => Effect.runPromise(Scope.close(scope, Exit.void)), | 179 | close: () => Effect.runPromise(Scope.close(scope, Exit.void)), |
| 254 | }; | 180 | }; |
| 255 | }); | 181 | }); |
| @@ -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 | }); | ||
| @@ -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; | ||
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -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 | })); | ||
| @@ -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 | ); | ||
| @@ -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 | ); | ||
| @@ -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 | ); | ||
| @@ -9,7 +9,6 @@ import * as Effect from "effect/Effect"; · 8 unmodified lines | |||
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 12 | ControlPlaneActorResolver, | ||
| 13 | ControlPlaneApi, | 12 | ControlPlaneApi, |
| 14 | createControlPlaneApiLayer, | 13 | createControlPlaneApiLayer, |
| 15 | } from "#api"; | 14 | } from "#api"; |
| @@ -20,11 +19,7 @@ import { · 4 unmodified lines | |||
| 20 | } from "./index"; | 19 | } from "./index"; |
| 21 | 20 | ||
| 22 | const createClientLayer = (runtime: SqlControlPlaneRuntime) => { | 21 | const createClientLayer = (runtime: SqlControlPlaneRuntime) => { |
| 23 | const actorResolverLayer = Layer.succeed( | 22 | const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer); |
| 24 | ControlPlaneActorResolver, | ||
| 25 | runtime.actorResolver, | ||
| 26 | ); | ||
| 27 | const apiLayer = createControlPlaneApiLayer(runtime.serviceLayer, actorResolverLayer); | ||
| 28 | 23 | ||
| 29 | return HttpApiBuilder.serve().pipe( | 24 | return HttpApiBuilder.serve().pipe( |
| 30 | Layer.provide(apiLayer), | 25 | Layer.provide(apiLayer), |