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