1import { DEFAULT_COMPATIBILITY_DATE } from "@alchemy.run/cloudflare-runtime/core/internal/constants";
2import { Redacted } from "effect";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";
6import * as Etag from "effect/http/Etag";
7import * as HttpPlatform from "effect/http/HttpPlatform";
8import * as HttpRouter from "effect/http/HttpRouter";
9import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
10import * as HttpApiError from "effect/http-api/HttpApiError";
11import crypto from "node:crypto";
12import { RuntimeContext } from "../../RuntimeContext.ts";
13import {
14 BearerTokenValidator,
15 StateApi,
16 StateAuthLive,
17} from "../../State/HttpStateApi.ts";
18import { ReadSecret } from "../SecretsStore/ReadSecret.ts";
19import { ReadSecretBinding } from "../SecretsStore/ReadSecretBinding.ts";
20import { Worker } from "../Workers/Worker.ts";
21import Store from "./Store.ts";
22import { AuthToken } from "./Token.ts";
23
24export const STATE_STORE_SCRIPT_NAME = "alchemy-state-store" as const;
25
26/**
27 * Version of the deployed Cloudflare State Store worker contract.
28 *
29 * Bump this whenever the wire format or runtime behaviour of the
30 * worker changes in a way that an older deployed copy can no longer
31 * satisfy. Clients query `/version` on the deployed worker and
32 * compare against this constant; a mismatch (or 404) triggers a
33 * forced redeploy via the bootstrap flow.
34 */
35export const STATE_STORE_VERSION = 7 as const;
36
37/**
38 * Hard-coded OTLP/HTTP endpoints. Point at the public ingest relay
39 * defined in `stacks/otel.ts` (bound to `otel.alchemy.run`), which
40 * forwards to Axiom with the bearer token attached server-side. Hard-
41 * coded on purpose: the worker has no env-var plumbing and the relay
42 * is account-level infra that lives outside any single deploy.
43 */
44// const OTEL_TRACES_URL = "https://otel.alchemy.run/v1/traces";
45// const OTEL_METRICS_URL = "https://otel.alchemy.run/v1/metrics";
46// const OTEL_LOGS_URL = "https://otel.alchemy.run/v1/logs";
47
48/**
49 * OTLP traces + metrics + logs Layer for the state-store worker.
50 * Mirrors the CLI's `TelemetryLive` so every signal the worker emits
51 * (`Effect.withSpan`, runtime metrics, `Effect.log*`) ships to the
52 * same Axiom datasets as deploy-time CLI signals. Resource attributes
53 * brand each signal with the worker's service name + contract version
54 * so they're easy to slice in Axiom.
55 */
56// const otelResource = {
57// serviceName: STATE_STORE_SCRIPT_NAME,
58// serviceVersion: String(STATE_STORE_VERSION),
59// attributes: {
60// "alchemy.state_store.script_name": STATE_STORE_SCRIPT_NAME,
61// "alchemy.state_store.version": STATE_STORE_VERSION,
62// },
63// } as const;
64
65// const TelemetryLive = Layer.mergeAll(
66// OtlpTracer.layer({
67// url: OTEL_TRACES_URL,
68// resource: otelResource,
69// exportInterval: "1 second",
70// }),
71// OtlpMetrics.layer({
72// url: OTEL_METRICS_URL,
73// resource: otelResource,
74// exportInterval: "1 second",
75// }),
76// // Replace (don't merge with) the default stdout logger so worker logs
77// // ship to Axiom instead of just `wrangler tail`.
78// OtlpLogger.layer({
79// url: OTEL_LOGS_URL,
80// resource: otelResource,
81// exportInterval: "1 second",
82// mergeWithExisting: false,
83// }),
84// ).pipe(
85// Layer.provide(OtlpSerialization.layerJson),
86// Layer.provide(FetchHttpClient.layer),
87// );
88
89/**
90 * Path on disk to *this* file, used as the worker's bundling entry.
91 *
92 * When running from source (e.g. dev / monorepo), `import.meta.url` points
93 * at `Api.ts` and we can use it directly. When the alchemy CLI is run from
94 * its published `bin/alchemy.js` bundle, this module is inlined into the
95 * CLI bundle and `import.meta.url` resolves to `bin/alchemy.js` — which
96 * has no `default` export and breaks the worker bundler with
97 * `[MISSING_EXPORT] "default" is not exported by "bin/alchemy.js"`.
98 *
99 * In the bundled case, fall back to the published source file shipped
100 * alongside the CLI under `../src/Cloudflare/StateStore/Api.ts`.
101 */
102export default Worker(
103 "Api",
104 {
105 name: STATE_STORE_SCRIPT_NAME,
106 main: import.meta.url,
107 workersDev: true,
108 compatibility: {
109 date: DEFAULT_COMPATIBILITY_DATE,
110 },
111 },
112 Effect.gen(function* () {
113 const remoteSecret = yield* ReadSecret(AuthToken);
114 const store = yield* Store;
115
116 const bearerTokenValidator = Layer.succeed(
117 BearerTokenValidator,
118 BearerTokenValidator.of({
119 validate: Effect.fn(function* (token) {
120 const expected = yield* remoteSecret
121 .get()
122 .pipe(Effect.orDie, Effect.provide(RuntimeContext.phantom));
123 return !!expected &&
124 timingSafeEqual(token.trim(), Redacted.value(expected).trim())
125 ? yield* Effect.void
126 : yield* new HttpApiError.Unauthorized();
127 }),
128 }),
129 );
130
131 const versionApi = HttpApiBuilder.group(StateApi, "version", (handlers) =>
132 handlers.handle("getVersion", () =>
133 Effect.succeed({ version: STATE_STORE_VERSION }).pipe(
134 Effect.withSpan("state_store.getVersion", {
135 attributes: { "alchemy.state_store.op": "getVersion" },
136 }),
137 ),
138 ),
139 );
140
141 const stateApi = HttpApiBuilder.group(StateApi, "state", (handlers) =>
142 handlers
143 .handle("listStacks", () =>
144 store
145 .getByName(Store.ROOT_DO_NAME)
146 .listStacks()
147 .pipe(
148 Effect.withSpan("state_store.listStacks", {
149 attributes: { "alchemy.state_store.op": "listStacks" },
150 }),
151 ),
152 )
153 .handle("listStages", ({ params }) =>
154 store
155 .getByName(params.stack)
156 .listStages()
157 .pipe(
158 Effect.withSpan("state_store.listStages", {
159 attributes: {
160 "alchemy.state_store.op": "listStages",
161 "alchemy.state_store.stack": params.stack,
162 },
163 }),
164 ),
165 )
166 .handle("listResources", ({ params }) =>
167 store
168 .getByName(params.stack)
169 .listResources({ stage: params.stage })
170 .pipe(
171 Effect.withSpan("state_store.listResources", {
172 attributes: {
173 "alchemy.state_store.op": "listResources",
174 "alchemy.state_store.stack": params.stack,
175 "alchemy.state_store.stage": params.stage,
176 },
177 }),
178 ),
179 )
180 .handle("getState", ({ params }) => {
181 const fqn = decodeURIComponent(params.fqn);
182 return store
183 .getByName(params.stack)
184 .get({ stage: params.stage, fqn })
185 .pipe(
186 Effect.withSpan("state_store.getState", {
187 attributes: {
188 "alchemy.state_store.op": "getState",
189 "alchemy.state_store.stack": params.stack,
190 "alchemy.state_store.stage": params.stage,
191 "alchemy.state_store.fqn": fqn,
192 },
193 }),
194 );
195 })
196 .handle("setState", ({ params, payload }) => {
197 const fqn = decodeURIComponent(params.fqn);
198 return store
199 .getByName(params.stack)
200 .set({ stage: params.stage, fqn, value: payload as any })
201 .pipe(
202 Effect.tap(() =>
203 store
204 .getByName(Store.ROOT_DO_NAME)
205 .registerStack({ stack: params.stack }),
206 ),
207 Effect.withSpan("state_store.setState", {
208 attributes: {
209 "alchemy.state_store.op": "setState",
210 "alchemy.state_store.stack": params.stack,
211 "alchemy.state_store.stage": params.stage,
212 "alchemy.state_store.fqn": fqn,
213 },
214 }),
215 );
216 })
217 .handle("deleteState", ({ params }) => {
218 const fqn = decodeURIComponent(params.fqn);
219 // The DO method is `remove`, not `delete` — `delete` is
220 // reserved by Cloudflare's RPC stub proxy.
221 return store
222 .getByName(params.stack)
223 .remove({ stage: params.stage, fqn })
224 .pipe(
225 Effect.asVoid,
226 Effect.withSpan("state_store.deleteState", {
227 attributes: {
228 "alchemy.state_store.op": "deleteState",
229 "alchemy.state_store.stack": params.stack,
230 "alchemy.state_store.stage": params.stage,
231 "alchemy.state_store.fqn": fqn,
232 },
233 }),
234 );
235 })
236 .handle("getReplacedResources", ({ params }) =>
237 store
238 .getByName(params.stack)
239 .getReplacedResources({ stage: params.stage })
240 .pipe(
241 Effect.withSpan("state_store.getReplacedResources", {
242 attributes: {
243 "alchemy.state_store.op": "getReplacedResources",
244 "alchemy.state_store.stack": params.stack,
245 "alchemy.state_store.stage": params.stage,
246 },
247 }),
248 ),
249 )
250 .handle("getStackOutput", ({ params }) =>
251 store
252 .getByName(params.stack)
253 .getOutput({ stage: params.stage })
254 .pipe(
255 Effect.withSpan("state_store.getStackOutput", {
256 attributes: {
257 "alchemy.state_store.op": "getStackOutput",
258 "alchemy.state_store.stack": params.stack,
259 "alchemy.state_store.stage": params.stage,
260 },
261 }),
262 ),
263 )
264 .handle("setStackOutput", ({ params, payload }) =>
265 store
266 .getByName(params.stack)
267 .setOutput({ stage: params.stage, value: payload as any })
268 .pipe(
269 Effect.tap(() =>
270 store
271 .getByName(Store.ROOT_DO_NAME)
272 .registerStack({ stack: params.stack }),
273 ),
274 Effect.withSpan("state_store.setStackOutput", {
275 attributes: {
276 "alchemy.state_store.op": "setStackOutput",
277 "alchemy.state_store.stack": params.stack,
278 "alchemy.state_store.stage": params.stage,
279 },
280 }),
281 ),
282 )
283 .handle("deleteStack", ({ params, query }) =>
284 store
285 .getByName(params.stack)
286 .deleteStack(
287 query.stage === undefined ? {} : { stage: query.stage },
288 )
289 .pipe(
290 Effect.flatMap(() =>
291 query.stage === undefined
292 ? store
293 .getByName(Store.ROOT_DO_NAME)
294 .unregisterStack({ stack: params.stack })
295 : Effect.void,
296 ),
297 Effect.asVoid,
298 Effect.withSpan("state_store.deleteStack", {
299 attributes: {
300 "alchemy.state_store.op": "deleteStack",
301 "alchemy.state_store.stack": params.stack,
302 "alchemy.state_store.stage": query.stage ?? "",
303 "alchemy.state_store.scope":
304 query.stage === undefined ? "stack" : "stage",
305 },
306 }),
307 ),
308 ),
309 );
310
311 return {
312 fetch: HttpApiBuilder.layer(StateApi).pipe(
313 Layer.provide(stateApi),
314 Layer.provide(versionApi),
315 Layer.provide(StateAuthLive),
316 Layer.provide(bearerTokenValidator),
317 // The state-store worker never serves files, so HttpPlatform's
318 // file-response surface is stubbed.
319 Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
320 HttpRouter.toHttpEffect,
321 // Effect.provide(TelemetryLive),
322 ),
323 };
324 }).pipe(Effect.provide(Layer.mergeAll(ReadSecretBinding))),
325);
326
327/**
328 * Stub `HttpPlatform` for the worker. The state-store API never
329 * issues file responses, so both surface methods die if invoked. Lets
330 * us avoid pulling in a `FileSystem` dependency that workers don't
331 * have.
332 */
333const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, {
334 platform: "web",
335 // Advertises no algorithms, so the compression middleware never engages
336 // and compressResponse is never called.
337 compression: {
338 algorithms: new Set<HttpPlatform.CompressionAlgorithm>(),
339 compressResponse: (response) => Effect.succeed(response),
340 },
341 fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"),
342 fileWebResponse: () =>
343 Effect.die("HttpPlatform.fileWebResponse not supported"),
344});
345
346/**
347 * Timing-safe string comparison using the Workers runtime's built-in
348 * `crypto.subtle.timingSafeEqual`.
349 *
350 * @see https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/
351 */
352const timingSafeEqual = (a: string, b: string): boolean => {
353 const encoder = new TextEncoder();
354 const aBytes = encoder.encode(a);
355 const bBytes = encoder.encode(b);
356 if (aBytes.byteLength !== bBytes.byteLength) return false;
357 // @ts-expect-error - TODO(sam)
358 return crypto.subtle.timingSafeEqual(aBytes, bBytes);
359};