1import * as SecretsStore from "@distilled.cloud/cloudflare/secrets-store";
2import * as workers from "@distilled.cloud/cloudflare/workers";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import * as HttpApiClient from "effect/http-api/HttpApiClient";
11import crypto from "node:crypto";
12
13import * as Config from "effect/Config";
14import * as Option from "effect/Option";
15import { adopt } from "../../AdoptPolicy.ts";
16import { AlchemyContext } from "../../AlchemyContext.ts";
17import { AuthError } from "../../Auth/AuthProvider.ts";
18import { CredentialsStore } from "../../Auth/Credentials.ts";
19import { currentProfileName } from "../../Auth/Profile.ts";
20import * as Cloudflare from "../../Cloudflare/Providers.ts";
21import { deploy } from "../../Deploy.ts";
22import * as Output from "../../Output.ts";
23import { RandomProvider } from "../../Random.ts";
24import * as Alchemy from "../../Stack.ts";
25import { Progress } from "../../Report.ts";
26import { StateApi } from "../../State/HttpStateApi.ts";
27import {
28 checkHttpStateStoreAuth,
29 makeHttpStateStore,
30 type HttpStateStoreCredentials,
31} from "../../State/HttpStateStore.ts";
32import { makeLocalState } from "../../State/LocalState.ts";
33import {
34 State,
35 type StateService,
36 type StateStoreError,
37} from "../../State/State.ts";
38import {
39 recordStateStoreInit,
40 recordStateStoreOp,
41} from "../../Telemetry/Metrics.ts";
42import * as Interaction from "../../Interaction.ts";
43import * as Access from "../Access.ts";
44import * as CloudflareEnvironment from "../CloudflareEnvironment.ts";
45import { EdgeSessionError, createEdgeSession } from "../EdgeSession.ts";
46import Api, { STATE_STORE_SCRIPT_NAME, STATE_STORE_VERSION } from "./Api.ts";
47import {
48 CREDENTIALS_FILE,
49 StoredStateStoreCredentials,
50 isStateStoreCredentialsStale,
51} from "./CredentialsFile.ts";
52import {
53 AuthToken,
54 AuthTokenSecretName,
55 EncryptionKeySecretName,
56 TokenValue,
57} from "./Token.ts";
58
59const CI = Config.Boolean("CI").pipe(Config.withDefault(false));
60
61export const state = () =>
62 Layer.effect(
63 State,
64 Effect.gen(function* () {
65 const isCI = yield* CI;
66 const scriptName = STATE_STORE_SCRIPT_NAME;
67 const profileName = yield* currentProfileName;
68 const localStage = `${profileName}_${scriptName}`;
69 const credStore = yield* CredentialsStore;
70 // `deploy --yes` flows in here (via AlchemyContext.updateStateStore) to
71 // auto-accept an out-of-date state store upgrade instead of prompting.
72 // Optional so callers that don't provide AlchemyContext keep the prompt.
73 const autoUpdateStateStore =
74 Option.getOrUndefined(yield* Effect.serviceOption(AlchemyContext))
75 ?.updateStateStore ?? false;
76 const context = yield* Effect.context<Effect.Services<typeof init>>();
77
78 const init = Effect.gen(function* () {
79 if (yield* hasLocalStack(localStage)) {
80 // if there's still a local stack, then we need to finish the bootstrap
81 // TODO(sam): what if the local stack was
82 const task = (yield* Interaction.Interaction).task;
83 return yield* task(
84 {
85 label: `Resuming Cloudflare State Store '${scriptName}' deployment`,
86 },
87 deployWithLocalState({
88 scriptName,
89 profileName,
90 isCI,
91 force: false,
92 }),
93 ).pipe(withStateBootstrapEvents(scriptName));
94 }
95
96 const ensureLatest = ({
97 url,
98 authToken,
99 }: {
100 url: string;
101 authToken: string;
102 }) =>
103 Effect.gen(function* () {
104 const { matches, expected, observed } =
105 yield* checkStateStoreVersion(url);
106
107 if (observed === undefined) {
108 const shouldDeploy =
109 autoUpdateStateStore ||
110 (yield* Interaction.accessors.prompt.confirm({
111 message: `Cloudflare State Store '${scriptName}' is not available. Do you want to deploy it?`,
112 // Deploying is the constructive happy path, not destructive
113 // — keep default-yes despite the prompt-wide default-no.
114 initialValue: true,
115 confirmLabel: "Deploy",
116 cancelLabel: "Cancel",
117 }));
118 if (shouldDeploy) {
119 return yield* bootstrap({
120 workerName: scriptName,
121 profile: profileName,
122 });
123 } else {
124 return yield* Effect.die(new Interaction.TerminalCancelled());
125 }
126 }
127
128 const httpState = yield* ensureAccess({ url, authToken });
129 if (matches) {
130 return httpState;
131 }
132
133 // The store is out of date. Upgrade it in place.
134 const upgrade = Effect.gen(function* () {
135 const interaction = yield* Interaction.Interaction;
136 return yield* interaction.task(
137 {
138 label: `Updating Cloudflare State Store '${scriptName}'`,
139 detail: `v${observed ?? "unknown"} → v${expected}`,
140 },
141 Effect.gen(function* () {
142 const stateStoreOptions = yield* deployStateStore({
143 stage: scriptName,
144 state: httpState,
145 force: false,
146 });
147 return yield* makeCloudflareStateStore(stateStoreOptions);
148 }),
149 );
150 }).pipe(withStateBootstrapEvents(scriptName));
151
152 if (autoUpdateStateStore) {
153 // `--yes`: upgrade automatically (also unblocks CI).
154 return yield* upgrade;
155 } else if (isCI) {
156 return yield* Effect.die(
157 new AuthError({
158 message:
159 `Cloudflare State store is out of date ` +
160 `(expected v${expected}, observed v${observed ?? "unknown"}). ` +
161 `Run 'alchemy provider cloudflare bootstrap --profile <your-ci-profile>' to upgrade it first, or pass --yes.`,
162 }),
163 );
164 } else {
165 const shouldDeploy = yield* Interaction.accessors.prompt.confirm({
166 message:
167 `Cloudflare State Store '${scriptName}' is out of date ` +
168 `(expected v${expected}, observed v${observed ?? "unknown"})`,
169 // Upgrading is the constructive happy path — default-yes.
170 initialValue: true,
171 confirmLabel: "Upgrade",
172 cancelLabel: "Cancel",
173 });
174 if (shouldDeploy) {
175 return yield* upgrade;
176 } else {
177 return yield* Effect.die(new Interaction.TerminalCancelled());
178 }
179 }
180 });
181
182 const ensureAccess = (credentials: HttpStateStoreCredentials) =>
183 Effect.gen(function* () {
184 const isAuth = yield* checkHttpStateStoreAuth(credentials);
185 if (!isAuth) {
186 // our token is wrong, force a refresh
187 const credentials = yield* loginWithCloudflare(profileName, true);
188 if (!(yield* checkHttpStateStoreAuth(credentials))) {
189 return yield* Effect.die(
190 new AuthError({
191 message: `Cloudflare State store authentication failed, after refreshing credentials.`,
192 }),
193 );
194 }
195 return yield* makeCloudflareStateStore(credentials);
196 }
197 return yield* makeCloudflareStateStore(credentials);
198 });
199
200 const { accountId } =
201 yield* yield* CloudflareEnvironment.CloudflareEnvironment;
202
203 const credentials = yield* credStore.read(
204 profileName,
205 CREDENTIALS_FILE,
206 StoredStateStoreCredentials,
207 );
208 if (credentials) {
209 // The cached `url`/`authToken` are minted per-account (the `url`
210 // encodes the account via its workers.dev subdomain). If the
211 // active account changed since they were written — or the file
212 // predates the `accountId` field — trusting the cache would
213 // silently read/write state in the wrong account, so discard it
214 // and fall through to re-derivation from the current account.
215 if (isStateStoreCredentialsStale(credentials, accountId)) {
216 yield* Interaction.accessors.output.info(
217 `Cloudflare State Store credentials were minted for a different ` +
218 `Cloudflare account; re-deriving for the current account.`,
219 );
220 yield* credStore
221 .delete(profileName, CREDENTIALS_FILE)
222 .pipe(Effect.ignore);
223 } else {
224 return yield* ensureLatest(credentials);
225 }
226 }
227 if (yield* isStateStoreServing(accountId)) {
228 return yield* ensureLatest(
229 yield* loginWithCloudflare(profileName, false),
230 );
231 } else if (autoUpdateStateStore) {
232 // `--yes`: deploy the missing state store automatically (also in CI).
233 return yield* bootstrap();
234 } else if (isCI) {
235 return yield* Effect.die(
236 new AuthError({
237 message: `Cloudflare State store not found. Run 'alchemy provider cloudflare bootstrap --profile <your-ci-profile>' to deploy it first, or pass --yes.`,
238 }),
239 );
240 } else {
241 const confirm = Interaction.accessors.prompt.confirm;
242 return yield* confirm({
243 message:
244 "Cloudflare State Store not found. Do you want to deploy it?",
245 // Deploying is the constructive happy path — default-yes.
246 initialValue: true,
247 }).pipe(
248 Effect.flatMap((shouldDeploy) =>
249 shouldDeploy
250 ? bootstrap()
251 : Effect.die(new Interaction.TerminalCancelled()),
252 ),
253 );
254 }
255 }).pipe(recordStateStoreInit, Effect.orDie);
256
257 return yield* Effect.cached(init.pipe(Effect.provideContext(context)));
258 }),
259 ).pipe(
260 // The Cloudflare API foundation shared with `providers()` —
261 // credentials, environment, auth/access, profile + credential
262 // store, and the same blanket retry policy. Without the retry
263 // policy the init-time subdomain/script/secrets probes run on the
264 // SDK default and give up early under Cloudflare rate limiting.
265 // `provide` (not `provideMerge`) so the distilled Retry tag stays
266 // out of this layer's public type.
267 Layer.provide(Cloudflare.CloudflareApiLive()),
268 Layer.orDie,
269 );
270
271export interface BootstrapOptions {
272 /** @default "alchemy-state-store" */
273 workerName?: string;
274 /** @default false */
275 force?: boolean;
276 /** @default "default" */
277 profile?: string;
278}
279
280/**
281 * Report `state.bootstrap.*` progress events around a state-store deploy or
282 * upgrade. Bootstrap runs lazily inside the plan's "loading state" phase,
283 * can take many seconds (it deploys a worker), and would otherwise be
284 * invisible to non-interactive renderers and traces — the Interaction task only
285 * paints the local terminal.
286 */
287const withStateBootstrapEvents =
288 (store: string) =>
289 <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
290 Effect.gen(function* () {
291 const report = yield* Progress;
292 yield* report({ _tag: "state.bootstrap.started", store });
293 const result = yield* effect;
294 yield* report({ _tag: "state.bootstrap.completed", store });
295 return result;
296 });
297
298export const bootstrap = (options: BootstrapOptions = {}) =>
299 withStateBootstrapEvents(options.workerName ?? STATE_STORE_SCRIPT_NAME)(
300 Effect.gen(function* () {
301 const interaction = yield* Interaction.Interaction;
302 const isCI = yield* CI;
303 const profileName = options.profile ?? (yield* currentProfileName);
304 const scriptName = options.workerName ?? STATE_STORE_SCRIPT_NAME;
305 const force = options.force ?? false;
306 const localStage = `${profileName}_${scriptName}`;
307 yield* Effect.annotateCurrentSpan({
308 "alchemy.state_store.script_name": scriptName,
309 "alchemy.state_store.profile": profileName,
310 "alchemy.state_store.force": force,
311 "alchemy.state_store.ci": isCI,
312 });
313 yield* annotateAccountHash();
314
315 if (yield* hasLocalStack(localStage)) {
316 // if there's a local stack still, we can assume we did not finish hoisting it, so finish that
317 // resume deployment
318 return yield* interaction.task(
319 {
320 label: `Resuming Cloudflare State Store '${scriptName}' deployment`,
321 },
322 deployWithLocalState({
323 scriptName,
324 profileName,
325 isCI,
326 force,
327 }),
328 );
329 }
330 const { accountId } =
331 yield* yield* CloudflareEnvironment.CloudflareEnvironment;
332 if (yield* isStateStoreServing(accountId)) {
333 // this is a regular update, let's check if it needs an update and refresh credentials
334 if (!force) {
335 yield* Interaction.accessors.output.info(
336 `Worker '${scriptName}' already exists; adopting and refreshing credentials. ` +
337 `Use --force to redeploy.`,
338 );
339 }
340 const credentials = yield* loginWithCloudflare(
341 profileName,
342 // force refresh during
343 true,
344 );
345 const { url, authToken } = credentials;
346 if (!isCI) {
347 // we don't write credentials in CI because the file system is ephemeral
348 const store = yield* CredentialsStore;
349 yield* store.write(
350 profileName,
351 CREDENTIALS_FILE,
352 StoredStateStoreCredentials,
353 credentials,
354 );
355 }
356 const { matches, expected, observed } =
357 yield* checkStateStoreVersion(url);
358 const httpState = yield* makeCloudflareStateStore({ url, authToken });
359 if (!matches || force) {
360 return yield* interaction.task(
361 {
362 label: `${matches ? "Redeploying" : "Updating"} Cloudflare State Store '${scriptName}'`,
363 detail: matches
364 ? "forced"
365 : `v${observed ?? "unknown"} → v${expected}`,
366 },
367 deployStateStore({
368 stage: scriptName,
369 state: httpState,
370 force,
371 }).pipe(Effect.flatMap(makeCloudflareStateStore)),
372 );
373 } else {
374 return httpState;
375 }
376 } else {
377 return yield* interaction.task(
378 { label: `Deploying Cloudflare State Store '${scriptName}'` },
379 deployWithLocalState({
380 scriptName,
381 profileName,
382 isCI,
383 force,
384 }),
385 );
386 }
387 }).pipe(
388 Effect.withSpan("state_store.bootstrap", {
389 attributes: {
390 "alchemy.state_store.op": "bootstrap",
391 "alchemy.state_store.script_name":
392 options.workerName ?? STATE_STORE_SCRIPT_NAME,
393 },
394 }),
395 ),
396 );
397
398export interface TeardownOptions {
399 /** @default "alchemy-state-store" */
400 workerName?: string;
401 /** @default "default" */
402 profile?: string;
403 /**
404 * Delete the account Secrets Store too, but only once the state-store
405 * secrets have been removed and no other secrets remain in it. A store that
406 * still holds foreign secrets is left in place.
407 * @default true
408 */
409 deleteEmptySecretsStore?: boolean;
410}
411
412/**
413 * The inverse of {@link bootstrap}: tear down the Cloudflare-deployed state
414 * store. Deletes the state-store Worker and the secrets it created in the
415 * account Secrets Store (the bearer token + the encryption key), then deletes
416 * the Secrets Store itself if it is left empty, and drops the locally cached
417 * state-store credentials for the profile.
418 *
419 * Idempotent — missing resources are treated as already-gone, so it is safe to
420 * re-run. Intended for reclaiming a throwaway account after testing; on a
421 * shared account it only removes resources alchemy created.
422 */
423export const teardownStateStore = (options: TeardownOptions = {}) =>
424 Effect.gen(function* () {
425 const interaction = yield* Interaction.Interaction;
426 const profileName = options.profile ?? (yield* currentProfileName);
427 const scriptName = options.workerName ?? STATE_STORE_SCRIPT_NAME;
428 const deleteEmptyStore = options.deleteEmptySecretsStore ?? true;
429 const { accountId } =
430 yield* yield* CloudflareEnvironment.CloudflareEnvironment;
431
432 yield* annotateAccountHash();
433 yield* Effect.annotateCurrentSpan({
434 "alchemy.state_store.script_name": scriptName,
435 "alchemy.state_store.profile": profileName,
436 });
437
438 // 1. Delete the state-store Worker.
439 yield* Interaction.accessors.output.info(
440 `Deleting state store worker '${scriptName}'...`,
441 );
442 yield* workers.deleteScript({ accountId, scriptName, force: true }).pipe(
443 Effect.asVoid,
444 Effect.catchTag("WorkerNotFound", () =>
445 interaction.output.info(
446 ` Worker '${scriptName}' not found (already gone).`,
447 ),
448 ),
449 );
450
451 // 2. Delete the secrets the state store created, plus any now-empty store.
452 const ourSecretNames = new Set<string>([
453 AuthTokenSecretName,
454 EncryptionKeySecretName,
455 ]);
456 const stores = yield* SecretsStore.listStores.items({ accountId }).pipe(
457 Stream.runCollect,
458 Effect.map((chunk) => Array.from(chunk)),
459 Effect.catchTag("InvalidAccountId", () => Effect.succeed([])),
460 );
461 for (const store of stores) {
462 const secrets = yield* SecretsStore.listStoreSecrets
463 .items({ accountId, storeId: store.id })
464 .pipe(
465 Stream.runCollect,
466 Effect.map((chunk) => Array.from(chunk)),
467 Effect.catchTag(["StoreNotFound", "InvalidAccountId"], () =>
468 Effect.succeed([]),
469 ),
470 );
471 const ours = secrets.filter((s) => ourSecretNames.has(s.name));
472 for (const secret of ours) {
473 yield* Interaction.accessors.output.info(
474 `Deleting secret '${secret.name}'...`,
475 );
476 yield* SecretsStore.deleteStoreSecret({
477 accountId,
478 storeId: store.id,
479 secretId: secret.id,
480 }).pipe(
481 Effect.asVoid,
482 Effect.catchTag(
483 ["SecretNotFound", "StoreNotFound", "NotFound", "InvalidAccountId"],
484 () => Effect.void,
485 ),
486 );
487 }
488 const remaining = secrets.length - ours.length;
489 if (deleteEmptyStore && remaining === 0) {
490 yield* Interaction.accessors.output.info(
491 `Deleting empty secrets store '${store.id}'...`,
492 );
493 yield* SecretsStore.deleteStore({
494 accountId,
495 storeId: store.id,
496 force: true,
497 }).pipe(
498 Effect.asVoid,
499 Effect.catchTag(
500 ["StoreNotFound", "NotFound", "InvalidAccountId"],
501 () => Effect.void,
502 ),
503 );
504 } else if (remaining > 0) {
505 yield* Interaction.accessors.output.info(
506 `Secrets store '${store.id}' still has ${remaining} other ` +
507 `secret(s); leaving it in place.`,
508 );
509 }
510 }
511
512 // 3. Drop the locally cached state-store credentials for this profile.
513 const credStore = yield* CredentialsStore;
514 yield* credStore.delete(profileName, CREDENTIALS_FILE).pipe(Effect.ignore);
515
516 yield* Interaction.accessors.output.success(
517 `Cloudflare State Store '${scriptName}' torn down.`,
518 );
519 }).pipe(
520 Effect.withSpan("state_store.teardown", {
521 attributes: {
522 "alchemy.state_store.op": "teardown",
523 "alchemy.state_store.script_name":
524 options.workerName ?? STATE_STORE_SCRIPT_NAME,
525 },
526 }),
527 );
528
529const deployStateStore = ({
530 stage,
531 state,
532 force,
533}: {
534 stage: string;
535 state: StateService;
536 force?: boolean;
537}) =>
538 Effect.gen(function* () {
539 yield* annotateAccountHash();
540 // deploy it with local state (which we will then hoist into the Cloudflare state store)
541 const stateLayer = Layer.succeed(State, Effect.succeed(state));
542 const { url, authToken } = yield* deploy({
543 // use the script name as the stage name (so the user can have multiple state stores)
544 stage,
545 force,
546 stack: Alchemy.Stack(
547 "CloudflareStateStore",
548 {
549 providers: Layer.mergeAll(Cloudflare.providers(), RandomProvider()),
550 state: stateLayer,
551 },
552 Effect.gen(function* () {
553 const token = yield* TokenValue;
554 const api = yield* Api;
555 yield* AuthToken; // make sure it's in the Secrets Store
556
557 // Surface the bearer token so tests and clients can authenticate
558 // after deploy. The underlying value lives in the Cloudflare
559 // Secrets Store; this output carries the same generated string.
560 return {
561 url: api.url.as<string>(),
562 authToken: token.text.pipe(Output.map(Redacted.value)),
563 };
564 }),
565 ),
566 }).pipe(
567 // The Cloudflare State Store is account-level infrastructure that
568 // outlives any single deploy: its underlying Secrets Store and
569 // auth-token secret may already exist from a previous (possibly
570 // partially-failed) bootstrap. Opt in to adoption so the
571 // resources reconcile in place instead of failing on conflict.
572 adopt(true),
573 // TODO(sam): we should not need to do this, but types do complain. fix deploy
574 Effect.provide(stateLayer),
575 );
576
577 yield* writeCredentials(url, authToken);
578
579 // Cloudflare's worker upload is eventually consistent: the deploy
580 // call returns as soon as the script upload is accepted, but the
581 // edge can keep serving the previous version for several seconds
582 // afterwards. Block here until `/version` reports the version this
583 // CLI was built against — otherwise downstream steps (syncing
584 // local state into the deployed store, version probes during
585 // adoption) end up talking to the old worker and may either
586 // observe stale data or trip the staleness check and recurse into
587 // another redeploy.
588 yield* waitForStateStoreVersion(url);
589 return { url, authToken };
590 }).pipe(
591 Effect.withSpan("state_store.deploy", {
592 attributes: {
593 "alchemy.state_store.op": "deploy",
594 },
595 }),
596 recordStateStoreOp("deploy"),
597 );
598
599const deployWithLocalState = ({
600 scriptName,
601 isCI,
602 force,
603 profileName,
604}: {
605 scriptName: string;
606 isCI: boolean;
607 force: boolean;
608 profileName: string;
609}) =>
610 Effect.gen(function* () {
611 const localState = yield* makeLocalState();
612 const localStage = `${profileName}_${scriptName}`;
613 const remoteStage = scriptName;
614 const { authToken } = yield* deployStateStore({
615 stage: localStage,
616 state: localState,
617 force,
618 });
619
620 const { url } = yield* loginWithCloudflare(profileName, force);
621 const httpState = yield* makeCloudflareStateStore({ url, authToken });
622
623 yield* hoistBootstrapStack({
624 source: {
625 state: localState,
626 stage: localStage,
627 },
628 destination: {
629 state: httpState,
630 stage: remoteStage,
631 },
632 });
633
634 yield* localState.deleteStack({
635 stack: "CloudflareStateStore",
636 stage: localStage,
637 });
638
639 return httpState;
640 }).pipe(
641 Effect.withSpan("state_store.finish_bootstrap", {
642 attributes: {
643 "alchemy.state_store.op": "finish_bootstrap",
644 "alchemy.state_store.ci": isCI,
645 },
646 }),
647 );
648
649/**
650 * Writes against a *just-deployed* state-store worker can fail
651 * transiently while Cloudflare propagates the script, its route, and
652 * its Secrets Store bindings to the edge:
653 *
654 * - 404 — the workers.dev route isn't serving the new script yet
655 * - 401 — the worker is up but its auth-token secret binding hasn't
656 * propagated, so token validation reads a stale/absent value
657 * - 5xx — the Store DO dies while its encryption-key secret binding
658 * is still propagating
659 * - transport errors (no response) — cold workers.dev host blips
660 *
661 * @internal exported for unit testing.
662 */
663export const isTransientBootstrapWriteError = (
664 error: Pick<StateStoreError, "http">,
665): boolean => {
666 if (error.http === undefined) return false;
667 const { status } = error.http;
668 return (
669 status === undefined || status === 401 || status === 404 || status >= 500
670 );
671};
672
673// check if there's a local stack that wasn't properly hoisted
674const hasLocalStack = (stage: string) =>
675 Effect.gen(function* () {
676 const localState = yield* makeLocalState();
677 return yield* Effect.map(
678 localState.listStages("CloudflareStateStore"),
679 // key off the profile name to avoid conflicts with other profiles
680 (stages) => stages.includes(stage),
681 );
682 });
683
684/**
685 * Non-destructively copy every resource in the
686 * `CloudflareStateStore/<scriptName>` stack from `source` into
687 * `destination`, leaving every other stack in `destination` untouched.
688 *
689 * This intentionally does not delete anything from `destination`: at
690 * bootstrap time the destination is the user's live remote state
691 * store, and removing entries that happen to be missing locally would
692 * be catastrophic.
693 */
694const hoistBootstrapStack = Effect.fn(function* ({
695 source,
696 destination,
697}: {
698 source: {
699 state: StateService;
700 stage: string;
701 };
702 destination: {
703 state: StateService;
704 stage: string;
705 };
706}) {
707 const stack = "CloudflareStateStore";
708 const fqns = yield* source.state.list({ stack, stage: source.stage });
709 yield* Effect.annotateCurrentSpan({
710 "alchemy.state_store.stack": stack,
711 "alchemy.state_store.stage": source.stage,
712 "alchemy.state_store.resources.count": fqns.length,
713 });
714 yield* Effect.forEach(
715 fqns,
716 Effect.fn(function* (fqn) {
717 const value = yield* source.state.get({
718 stack,
719 stage: source.stage,
720 fqn,
721 });
722 if (value) {
723 yield* destination.state
724 .set({
725 stack,
726 stage: destination.stage,
727 fqn,
728 value,
729 })
730 .pipe(
731 Effect.retry({
732 while: isTransientBootstrapWriteError,
733 // Bounded at ~30s: the freshly deployed worker (and its
734 // Secrets Store bindings) can take a while to serve
735 // consistently; anything persisting past that is a real
736 // failure to surface, not to spin on.
737 schedule: Schedule.max([
738 Schedule.fixed(500),
739 Schedule.recurs(60),
740 ]),
741 }),
742 );
743 }
744 }),
745 { concurrency: "unbounded" },
746 );
747}, Effect.withSpan("state_store.hoist_bootstrap_stack"));
748
749/**
750 * Log in to a Cloudflare-deployed HTTP state-store.
751 *
752 * 1. Find the single account-wide Secrets Store.
753 * 2. Upload a short-lived edge-preview worker that binds the
754 * auth-token secret and returns its value.
755 * 3. Derive the state-store worker URL from
756 * {@link STATE_STORE_SCRIPT_NAME} and the account's workers.dev
757 * subdomain.
758 * 4. Persist `{ url, token }` under the `http-state-store`
759 * credentials file.
760 *
761 * Requirements are covered by the Cloudflare provider stack —
762 * `CloudflareEnvironment`, `Credentials`, `HttpClient`, and
763 * `FileSystem`.
764 */
765export const loginWithCloudflare = (profileName: string, force: boolean) =>
766 Effect.gen(function* () {
767 const credStore = yield* CredentialsStore;
768 const isCI = yield* CI;
769 const { accountId } =
770 yield* yield* CloudflareEnvironment.CloudflareEnvironment;
771
772 if (!force) {
773 // try and read from the cached credentials first if not forcing (force will always refresh)
774 const credentials = yield* credStore.read(
775 profileName,
776 CREDENTIALS_FILE,
777 StoredStateStoreCredentials,
778 );
779 // Ignore a cache minted for a different account (or a legacy file with
780 // no `accountId`) — reusing it would hand back the wrong account's
781 // state-store URL. Fall through to re-derive against `accountId`.
782 if (
783 credentials &&
784 !isStateStoreCredentialsStale(credentials, accountId)
785 ) {
786 return credentials;
787 }
788 }
789
790 const interaction = yield* Interaction.Interaction;
791 const credentials = yield* interaction.task(
792 { label: "Refreshing Cloudflare State Store credentials" },
793 Effect.gen(function* () {
794 // 1. Locate the single Secrets Store on the account.
795 const store = yield* SecretsStore.listStores
796 .items({ accountId })
797 .pipe(Stream.runHead, Effect.map(Option.getOrUndefined));
798 if (!store) {
799 return yield* Effect.fail(
800 new AuthError({
801 message:
802 "No Secrets Store found on this account. Deploy the state store first.",
803 }),
804 );
805 }
806
807 // 2. Fetch the auth-token from Secrets Store with a temporary edge-preview worker.
808 const authToken = yield* readSecretViaEdge(
809 STATE_STORE_SCRIPT_NAME,
810 store.id,
811 AuthTokenSecretName,
812 ).pipe(
813 Effect.retry({
814 while: (error) =>
815 isWorkersPreviewConfigurationError(error) ||
816 isTransientEdgeSessionError(error),
817 // Cap the exponential delay at 2s so 15 retries stay within
818 // ~30s instead of doubling unboundedly.
819 schedule: Schedule.max([
820 Schedule.min([
821 Schedule.exponential(200),
822 Schedule.spaced("2 seconds"),
823 ]),
824 Schedule.recurs(15),
825 ]),
826 }),
827 );
828
829 // 3. Derive the deployed worker URL.
830 const { subdomain } = yield* workers.getSubdomain({ accountId });
831 const url = `https://${STATE_STORE_SCRIPT_NAME}.${subdomain}.workers.dev`;
832 const credentials = {
833 url,
834 authToken: authToken.trim(),
835 accountId,
836 };
837
838 if (!isCI) {
839 // 4. Persist credentials for subsequent invocations.
840 yield* credStore
841 .write(
842 profileName,
843 CREDENTIALS_FILE,
844 StoredStateStoreCredentials,
845 credentials,
846 )
847 .pipe(
848 Effect.mapError(
849 (e) =>
850 new AuthError({
851 message: "Failed to write credentials",
852 cause: e,
853 }),
854 ),
855 );
856 }
857
858 return credentials;
859 }),
860 );
861 if (!isCI) yield* interaction.output.info(` url: ${credentials.url}`);
862 return credentials;
863 }).pipe(
864 Effect.catchTag("EdgeSessionError", (e) =>
865 Effect.fail(
866 new AuthError({
867 message: `Edge-preview secret read failed: ${e.message}`,
868 cause: e.cause,
869 }),
870 ),
871 ),
872 Effect.withSpan("state_store.login", {
873 attributes: {
874 "alchemy.state_store.op": "login",
875 "alchemy.state_store.script_name": STATE_STORE_SCRIPT_NAME,
876 },
877 }),
878 );
879
880const isStateStoreAvailable = (scriptName: string = "alchemy-state-store") =>
881 Effect.gen(function* () {
882 // otherwise, the remote one might exist
883 const { accountId } =
884 yield* yield* CloudflareEnvironment.CloudflareEnvironment;
885 return yield* workers.getScriptSetting({ accountId, scriptName }).pipe(
886 Effect.map((setting) => setting !== undefined),
887 Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
888 Effect.catchTag("InvalidRoute", () => Effect.succeed(false)),
889 // A worker that exists but has no versions (a previous deploy was
890 // interrupted before any content upload) can't serve — treat it
891 // as absent so bootstrap redeploys it.
892 Effect.catchTag("WorkerHasNoVersions", () => Effect.succeed(false)),
893 );
894 });
895
896/**
897 * Does this account have a *functioning* state-store worker,
898 * verified by checking the /version endpoint
899 *
900 */
901const isStateStoreServing = (accountId: string) =>
902 Effect.gen(function* () {
903 const url = yield* workers.getSubdomain({ accountId }).pipe(
904 Effect.map(({ subdomain }) =>
905 subdomain
906 ? `https://${STATE_STORE_SCRIPT_NAME}.${subdomain}.workers.dev`
907 : undefined,
908 ),
909 Effect.catch(() => Effect.succeed(undefined)),
910 );
911 if (url === undefined) return false;
912 const { observed } = yield* checkStateStoreVersion(url);
913 return observed !== undefined;
914 });
915
916const makeCloudflareStateStore = Effect.fn(function* ({
917 url,
918 authToken,
919}: {
920 url: string;
921 authToken: string;
922}) {
923 const access = yield* Access.Access;
924 const accessHeaders = yield* access.getAccessHeaders(new URL(url).host);
925 return yield* makeHttpStateStore({
926 url,
927 authToken,
928 transformClient: HttpClientRequest.setHeaders(accessHeaders),
929 id: "cloudflare-http",
930 });
931});
932
933class StateStoreVersionNotReady extends Error {
934 readonly _tag = "StateStoreVersionNotReady";
935 constructor(
936 readonly expected: number,
937 readonly observed: number | undefined,
938 ) {
939 super(
940 `Cloudflare State Store version not ready (expected v${expected}, observed v${observed ?? "unknown"}).`,
941 );
942 }
943}
944
945const waitForStateStoreVersion = (url: string) =>
946 Effect.gen(function* () {
947 const { matches, expected, observed } = yield* checkStateStoreVersion(url);
948 if (!matches) {
949 return yield* Effect.fail(
950 new StateStoreVersionNotReady(expected, observed),
951 );
952 }
953 }).pipe(
954 Effect.retry({
955 while: (error) => error instanceof StateStoreVersionNotReady,
956 // Edge propagation is usually sub-second but production traces
957 // show redeploys occasionally serving the old version for well
958 // over 10s. Poll for ~30s before failing loudly.
959 schedule: Schedule.max([
960 Schedule.spaced("500 millis"),
961 Schedule.recurs(60),
962 ]),
963 }),
964 Effect.withSpan("state_store.wait_for_version", {
965 attributes: {
966 "alchemy.state_store.op": "wait_for_version",
967 "alchemy.state_store.url": url,
968 "alchemy.state_store.expected_version": STATE_STORE_VERSION,
969 },
970 }),
971 );
972
973const checkStateStoreVersion = (url: string) =>
974 Effect.gen(function* () {
975 const client = yield* HttpApiClient.make(StateApi, { baseUrl: url });
976 const isAvailable = yield* Effect.cached(
977 isStateStoreAvailable(STATE_STORE_SCRIPT_NAME),
978 );
979 // The /version route may 404 transiently after a fresh deploy
980 // while Cloudflare propagates the new script to the edge, and may
981 // also surface transport-level blips on cold workers.dev hosts.
982 // Retry the probe itself for ~10s before giving up — only after
983 // exhausting that budget do we collapse to `undefined` and let
984 // the caller treat it as a version mismatch.
985 const result = yield* client.version.getVersion().pipe(
986 Effect.catchTag("HttpClientError", (e) =>
987 // if we get a 404 here, it means we assumed the worker shoudl exist, but it does not
988 // we should do a check to see if it does
989 e.response?.status === 404
990 ? isAvailable.pipe(
991 Effect.flatMap((isAvailable) =>
992 // if the worker is available, then we should assume it was recently created and retry by propagating the error
993 // otherwise, return undefined (we don't know the version, there is no worker)
994 isAvailable ? Effect.fail(e) : Effect.succeed(undefined),
995 ),
996 )
997 : Effect.fail(e),
998 ),
999 Effect.retry({
1000 schedule: Schedule.max([
1001 Schedule.spaced("250 millis"),
1002 Schedule.recurs(40),
1003 ]),
1004 }),
1005 Effect.catch(() => Effect.succeed(undefined)),
1006 );
1007 const matches = result?.version === STATE_STORE_VERSION;
1008 yield* Effect.annotateCurrentSpan({
1009 "alchemy.state_store.expected_version": STATE_STORE_VERSION,
1010 "alchemy.state_store.observed_version": result?.version ?? -1,
1011 "alchemy.state_store.version_match": matches,
1012 });
1013 return {
1014 matches,
1015 expected: STATE_STORE_VERSION,
1016 observed: result?.version,
1017 };
1018 }).pipe(
1019 Effect.withSpan("state_store.check_version", {
1020 attributes: { "alchemy.state_store.op": "check_version" },
1021 }),
1022 );
1023
1024/**
1025 * Tiny ES-module worker that reads `env.SECRET.get()` and echoes it
1026 * back. Uploaded as an ephemeral edge-preview, called once, then
1027 * discarded — see {@link readSecretViaEdge}.
1028 */
1029const SECRET_PROBE_SOURCE = `export default {
1030 async fetch(_request, env) {
1031 try {
1032 const value = await env.SECRET.get();
1033 return new Response(value ?? "", { status: 200, headers: { "content-type": "text/plain" } });
1034 } catch (e) {
1035 return new Response("Error: " + (e && e.message ? e.message : String(e)), { status: 500 });
1036 }
1037 },
1038};`;
1039
1040/**
1041 * Upload an ephemeral edge-preview build of the given (already
1042 * deployed) script that binds the requested Secrets Store secret,
1043 * call it once with the preview token, and return the decoded value.
1044 * The Cloudflare REST API deliberately hides secret values; only
1045 * worker bindings can resolve them, so this is the out-of-band path.
1046 *
1047 * `scriptName` MUST be a script that is already deployed on the
1048 * account with workers.dev enabled — the `cf-workers-preview-token`
1049 * header swaps our probe code in for an existing route, it does not
1050 * create one. Using an undeployed name (or a deployed script that
1051 * doesn't have workers.dev enabled) makes the workers.dev edge serve
1052 * a generic Cloudflare 400 HTML error page instead of routing to the
1053 * preview. The state-store script itself satisfies both conditions.
1054 */
1055const readSecretViaEdge = (
1056 scriptName: string,
1057 storeId: string,
1058 secretName: string,
1059) =>
1060 Effect.gen(function* () {
1061 const http = yield* HttpClient.HttpClient;
1062 const file = new File([SECRET_PROBE_SOURCE], "worker.js", {
1063 type: "application/javascript+module",
1064 });
1065 const session = yield* createEdgeSession({
1066 scriptName,
1067 files: [file],
1068 bindings: [
1069 { type: "secrets_store_secret", name: "SECRET", secretName, storeId },
1070 ],
1071 });
1072 const response = yield* http.get(session.url, {
1073 headers: session.headers,
1074 });
1075 if (response.status !== 200) {
1076 const body = yield* response.text.pipe(
1077 Effect.catch(() => Effect.succeed("")),
1078 );
1079 // TEMP(sam): dump the full body so we can capture the exact
1080 // Cloudflare error page when the probe fails in the wild. Drop
1081 // this once we've confirmed the routing fix covers all the
1082 // observed failure modes.
1083 yield* Effect.logWarning(
1084 `Secret probe failed (${response.status}) at ${session.url}\n${body}`,
1085 );
1086 return yield* Effect.fail(
1087 new EdgeSessionError({
1088 message: `Secret probe returned ${response.status}: ${body.slice(0, 200)}`,
1089 }),
1090 );
1091 }
1092 return yield* response.text;
1093 }).pipe(
1094 Effect.mapError((cause) =>
1095 cause instanceof EdgeSessionError
1096 ? cause
1097 : new EdgeSessionError({ message: "Failed to read secret", cause }),
1098 ),
1099 Effect.withSpan("state_store.read_secret_via_edge", {
1100 attributes: {
1101 "alchemy.state_store.op": "read_secret_via_edge",
1102 "alchemy.state_store.script_name": scriptName,
1103 "alchemy.state_store.secret_name": secretName,
1104 },
1105 }),
1106 );
1107
1108const writeCredentials = (url: string, authToken: string) =>
1109 Effect.gen(function* () {
1110 const profileName = yield* currentProfileName;
1111 const credStore = yield* CredentialsStore;
1112 const { accountId } =
1113 yield* yield* CloudflareEnvironment.CloudflareEnvironment;
1114 yield* credStore.write(
1115 profileName,
1116 CREDENTIALS_FILE,
1117 StoredStateStoreCredentials,
1118 {
1119 url,
1120 authToken,
1121 accountId,
1122 },
1123 );
1124 });
1125
1126const isWorkersPreviewConfigurationError = (error: unknown) =>
1127 error instanceof EdgeSessionError &&
1128 (error.message.includes("Invalid Workers Preview configuration") ||
1129 error.message.includes("Error 1031"));
1130
1131/**
1132 * Edge-preview reads are flaky in ways that clear up on their own:
1133 * the workers.dev edge can serve a generic Cloudflare 400/502 HTML
1134 * page while preview routing propagates ("Secret probe returned
1135 * ..."), the session-create call can hit transient API blips, and the
1136 * probe fetch can fail at the transport level ("fetch failed").
1137 * Retry everything except causes that are clearly permanent
1138 * (bad credentials, invalid routes).
1139 *
1140 * @internal exported for unit testing.
1141 */
1142export const isTransientEdgeSessionError = (error: unknown): boolean => {
1143 if (!(error instanceof EdgeSessionError)) return false;
1144 // Non-200 probe responses are edge-propagation flakes, not client bugs.
1145 if (error.message.startsWith("Secret probe returned")) return true;
1146 const tag = (error.cause as { _tag?: unknown } | undefined)?._tag;
1147 if (
1148 typeof tag === "string" &&
1149 (tag.startsWith("Unauthorized") ||
1150 tag === "Forbidden" ||
1151 tag === "InvalidRoute" ||
1152 tag === "AuthError")
1153 ) {
1154 return false;
1155 }
1156 return true;
1157};
1158
1159/**
1160 * SHA-256 hex digest of the Cloudflare account ID. Used as a stable
1161 * pseudonymous identifier on telemetry spans so the dashboard can
1162 * count distinct state-store deployments without leaking the raw
1163 * accountId. Mirrors the `alchemy.git.origin_hash` pattern in
1164 * `Telemetry/Attributes.ts`.
1165 */
1166const hashAccountId = (accountId: string) =>
1167 Effect.sync(() =>
1168 crypto.createHash("sha256").update(accountId).digest("hex"),
1169 );
1170
1171/**
1172 * Best-effort Cloudflare-account-hash annotation on the current span.
1173 * Resolves the accountId from {@link CloudflareEnvironment} and
1174 * attaches `alchemy.cloudflare.account_hash` to whichever span is
1175 * active. Silently no-ops if the environment isn't resolvable so
1176 * State-store layer construction still succeeds in degraded paths.
1177 *
1178 * `noTrack` controls whether the hash is attached:
1179 * - `true` — never annotate (caller-level opt-out).
1180 * - `false` — always annotate, regardless of env.
1181 * - `undefined` — fall back to the `NO_TRACK` env var; default off.
1182 */
1183const annotateAccountHash = (noTrack?: boolean) =>
1184 Effect.gen(function* () {
1185 if (noTrack === true) return;
1186 if (noTrack === undefined) {
1187 const fromEnv = yield* Config.Boolean("NO_TRACK").pipe(
1188 Config.withDefault(false),
1189 );
1190 if (fromEnv) return;
1191 }
1192 const env = yield* Effect.serviceOption(
1193 CloudflareEnvironment.CloudflareEnvironment,
1194 );
1195 if (env._tag !== "Some") return;
1196 const hash = yield* hashAccountId((yield* env.value).accountId);
1197 yield* Effect.annotateCurrentSpan("alchemy.cloudflare.account_hash", hash);
1198 }).pipe(Effect.catch(() => Effect.void));