1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import * as Schema from "effect/Schema";
6import * as HttpApi from "effect/http-api/HttpApi";
7import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
8import * as HttpApiError from "effect/http-api/HttpApiError";
9import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
10import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware";
11import * as HttpApiSchema from "effect/http-api/HttpApiSchema";
12import * as HttpApiSecurity from "effect/http-api/HttpApiSecurity";
13
14/**
15 * Resource-state payload shape on the wire.
16 *
17 * Pinned to JSON encoding via {@link HttpApiSchema.asJson} so that both
18 * sides of the API agree on `Content-Type: application/json`. With a
19 * bare `Schema.Any` the server's payload-decoder map is keyed off the
20 * default content type only — any client request that arrives with a
21 * differently-shaped body (or a transport that drops the
22 * `Content-Type` header entirely) falls into the `payloadBy.get(
23 * contentType)` miss branch in `HttpApiBuilder.decodePayload` and
24 * surfaces as a confusing `415 Unsupported Media Type`. Annotating
25 * the schema makes the encoding explicit on both endpoints and the
26 * client encoder, so the wire format is unambiguous.
27 */
28export const ResourceStateSchema = Schema.Any.pipe(HttpApiSchema.asJson());
29
30export class BearerTokenValidator extends Context.Service<
31 BearerTokenValidator,
32 {
33 readonly validate: (
34 token: string,
35 ) => Effect.Effect<void, HttpApiError.Unauthorized>;
36 }
37>()("alchemy/State/BearerTokenValidator") {}
38
39export class StateAuth extends HttpApiMiddleware.Service<
40 StateAuth,
41 { requires: BearerTokenValidator }
42>()("alchemy/State/StateAuth", {
43 security: {
44 bearer: HttpApiSecurity.bearer,
45 },
46 error: HttpApiError.UnauthorizedNoContent,
47}) {}
48
49export const StateAuthLive: Layer.Layer<
50 StateAuth,
51 never,
52 BearerTokenValidator
53> = Layer.effect(
54 StateAuth,
55 Effect.gen(function* () {
56 const validator = yield* BearerTokenValidator;
57 return {
58 bearer: (httpEffect, { credential }) =>
59 validator
60 .validate(Redacted.value(credential))
61 .pipe(Effect.flatMap(() => httpEffect)),
62 };
63 }),
64);
65
66/** `stack` path segment for nested REST resources. */
67const StackParams = Schema.Struct({
68 stack: Schema.String,
69});
70
71/** Optional stage selector for stack deletion. */
72const OptionalStageQuery = Schema.Struct({
73 stage: Schema.optional(Schema.String),
74});
75
76/** `(stack, stage)` path segments shared by stage-scoped endpoints. */
77const StackStage = Schema.Struct({
78 stack: Schema.String,
79 stage: Schema.String,
80});
81
82/** `(stack, stage, fqn)` path segments for a single resource. */
83const ResourceKey = Schema.Struct({
84 stack: Schema.String,
85 stage: Schema.String,
86 fqn: Schema.String,
87});
88
89export const ListStacks = HttpApiEndpoint.get("listStacks", "/state/stacks", {
90 success: Schema.Array(Schema.String),
91});
92
93export const ListStages = HttpApiEndpoint.get(
94 "listStages",
95 "/state/stacks/:stack/stages",
96 {
97 params: StackParams,
98 success: Schema.Array(Schema.String),
99 },
100);
101
102export const ListResources = HttpApiEndpoint.get(
103 "listResources",
104 "/state/stacks/:stack/stages/:stage/resources",
105 {
106 params: StackStage,
107 success: Schema.Array(Schema.String),
108 },
109);
110
111export const GetState = HttpApiEndpoint.get(
112 "getState",
113 "/state/stacks/:stack/stages/:stage/resources/:fqn",
114 {
115 params: ResourceKey,
116 success: Schema.UndefinedOr(ResourceStateSchema),
117 },
118);
119
120export const SetState = HttpApiEndpoint.put(
121 "setState",
122 "/state/stacks/:stack/stages/:stage/resources/:fqn",
123 {
124 params: ResourceKey,
125 payload: ResourceStateSchema,
126 success: ResourceStateSchema,
127 },
128);
129
130export const DeleteState = HttpApiEndpoint.delete(
131 "deleteState",
132 "/state/stacks/:stack/stages/:stage/resources/:fqn",
133 {
134 params: ResourceKey,
135 success: HttpApiSchema.NoContent,
136 },
137);
138
139export const DeleteStack = HttpApiEndpoint.delete(
140 "deleteStack",
141 "/state/stacks/:stack",
142 {
143 params: StackParams,
144 query: OptionalStageQuery,
145 success: HttpApiSchema.NoContent,
146 },
147);
148
149export const GetReplacedResources = HttpApiEndpoint.get(
150 "getReplacedResources",
151 "/state/stacks/:stack/stages/:stage/replaced-resources",
152 {
153 params: StackStage,
154 success: Schema.Array(ResourceStateSchema),
155 },
156);
157
158export const GetStackOutput = HttpApiEndpoint.get(
159 "getStackOutput",
160 "/state/stacks/:stack/stages/:stage/output",
161 {
162 params: StackStage,
163 success: Schema.UndefinedOr(ResourceStateSchema),
164 },
165);
166
167export const SetStackOutput = HttpApiEndpoint.put(
168 "setStackOutput",
169 "/state/stacks/:stack/stages/:stage/output",
170 {
171 params: StackStage,
172 payload: ResourceStateSchema,
173 success: ResourceStateSchema,
174 },
175);
176
177/**
178 * Version of the State Store wire / behavioural contract.
179 *
180 * Bump this whenever the wire format or runtime behaviour of an HTTP
181 * state-store changes in a way that an older deployed copy can no
182 * longer satisfy. Clients query `/version` on the deployed worker and
183 * compare against this constant; a mismatch (or 404) triggers a
184 * forced redeploy via the bootstrap flow.
185 */
186export const STATE_STORE_VERSION = 5 as const;
187
188/** Response shape for the unauthenticated `/version` probe. */
189export const VersionResponse = Schema.Struct({
190 version: Schema.Number,
191});
192
193/**
194 * Unauthenticated probe so clients can detect a stale (or absent)
195 * deployed worker without holding a valid bearer token. The returned
196 * version is bumped whenever the wire / behavioural contract changes
197 * in a way that requires a redeploy.
198 */
199export const GetVersion = HttpApiEndpoint.get("getVersion", "/version", {
200 success: VersionResponse,
201});
202
203export class StateGroup extends HttpApiGroup.make("state")
204 .add(ListStacks)
205 .add(ListStages)
206 .add(ListResources)
207 .add(GetState)
208 .add(SetState)
209 .add(DeleteState)
210 .add(GetReplacedResources)
211 .add(DeleteStack)
212 .add(GetStackOutput)
213 .add(SetStackOutput)
214 .middleware(StateAuth) {}
215
216export class VersionGroup extends HttpApiGroup.make("version").add(
217 GetVersion,
218) {}
219
220export class StateApi extends HttpApi.make("alchemy-state")
221 .add(StateGroup)
222 .add(VersionGroup) {}