1import type { RuntimeServices } from "@alchemy.run/cloudflare-runtime/core";
2import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store";
3import * as Console from "effect/Console";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10import * as HttpClient from "effect/http/HttpClient";
11import { Unowned } from "../../AdoptPolicy.ts";
12import { isResolved } from "../../Diff.ts";
13import * as ProviderLayer from "../../Local/ProviderLayer.ts";
14import * as RpcProvider from "../../Local/RpcProvider.ts";
15import * as Provider from "../../Provider.ts";
16import { isResourceOfType, Resource } from "../../Resource.ts";
17import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
18import {
19 generateLocalId,
20 LOCAL_PROVIDERS_URL,
21 localRuntimeServices,
22} from "../LocalRuntime.ts";
23import type { Providers } from "../Providers.ts";
24import {
25 deleteLocalSecret,
26 seedLocalSecret,
27} from "./LocalSecretsStoreGateway.ts";
28
29export type StoreSecretProps = {
30 /**
31 * The Secrets Store that owns this secret.
32 */
33 store: {
34 storeId: string;
35 accountId: string;
36 };
37 /**
38 * The name of the secret within the store.
39 * If omitted, the resource's logical ID is used.
40 */
41 name?: string;
42 /**
43 * The secret value. Treated as redacted and never logged.
44 */
45 value: Redacted.Redacted<string>;
46 /**
47 * Services allowed to reference this secret.
48 * @default ["workers"]
49 */
50 scopes?: string[];
51 /**
52 * Optional free-form description.
53 */
54 comment?: string;
55};
56
57export type Secret = Resource<
58 "Cloudflare.SecretsStore.Secret",
59 StoreSecretProps,
60 {
61 secretId: string;
62 secretName: string;
63 storeId: string;
64 accountId: string;
65 status: SecretStatus;
66 scopes: string[];
67 comment: string | undefined;
68 },
69 never,
70 Providers
71>;
72
73export const isSecret = (value: unknown): value is Secret =>
74 isResourceOfType(value, "Cloudflare.SecretsStore.Secret");
75
76export type SecretStatus = "pending" | "active" | "deleted";
77
78// Distilled widened generated string enums to open unions (`string & {}`); the
79// API only ever returns the known variants, so narrow at the boundary.
80const asSecretStatus = (status: string): SecretStatus => status as SecretStatus;
81
82/**
83 * A single secret stored inside a Cloudflare Secrets Store.
84 *
85 * The secret value is treated as redacted and is only ever sent to
86 * Cloudflare at create time. Updating `scopes` or `comment` issues a
87 * PATCH; changing `value` or `name` replaces the secret.
88 * ### Creating a Secret
89 * **Example:** Basic Secret
90 * ```typescript
91 * const store = yield* Cloudflare.SecretsStore.Store("MyStore");
92 * const apiKey = yield* Cloudflare.SecretsStore.Secret("ApiKey", {
93 * store,
94 * value: Redacted.make(process.env.API_KEY!),
95 * });
96 * ```
97 *
98 * ### Binding to a Worker
99 * **Example:** Reading a secret at runtime
100 * ```typescript
101 * const apiKey = yield* Cloudflare.SecretsStore.ReadSecret(ApiKey);
102 * // `apiKey` is itself an Effect that resolves to the secret value:
103 * const value = yield* apiKey;
104 * // Or call `.get()` explicitly:
105 * const value = yield* apiKey.get();
106 * ```
107 *
108 * @resource
109 * @product Secrets Store
110 * @category Storage & Databases
111 */
112export const Secret = Resource<Secret>("Cloudflare.SecretsStore.Secret");
113
114export const SecretProviderLive = () =>
115 Provider.succeed(Secret, {
116 stables: ["secretId", "secretName", "storeId", "accountId"],
117 diff: Effect.fn(function* ({ id, olds = {} as any, news, output }) {
118 if (!isResolved(news)) return undefined;
119 const oldStoreId = output?.storeId ?? olds.store?.storeId;
120 const newStoreId = news.store.storeId;
121 const oldName = output?.secretName ?? resolveName(id, olds.name);
122 const newName = resolveName(id, news.name);
123 if (oldStoreId !== newStoreId || oldName !== newName) {
124 return { action: "replace" } as const;
125 }
126 const oldValue = olds.value ? Redacted.value(olds.value) : undefined;
127 const newValue = Redacted.value(news.value);
128 if (oldValue !== newValue) {
129 return { action: "update" } as const;
130 }
131 }),
132 reconcile: Effect.fn(function* ({ id, news, output }) {
133 const name = resolveName(id, news.name);
134 const scopes = resolveScopes(news.scopes);
135 const accountId = news.store.accountId;
136 const storeId = news.store.storeId;
137
138 // Observe — re-fetch the cached secret; fall back to a name
139 // scan over the store so we recover from out-of-band deletes
140 // or partial state-persistence failures.
141 let observed:
142 | {
143 id: string;
144 name: string;
145 storeId: string;
146 status: string;
147 comment?: string | null;
148 }
149 | undefined;
150 if (output?.secretId) {
151 observed = yield* secretsStore
152 .getStoreSecret({
153 accountId: output.accountId,
154 storeId: output.storeId,
155 secretId: output.secretId,
156 })
157 .pipe(
158 Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)),
159 Effect.catchTag("StoreNotFound", () => Effect.succeed(undefined)),
160 );
161 }
162 if (!observed) {
163 observed = yield* secretsStore.listStoreSecrets
164 .items({ accountId, storeId })
165 .pipe(
166 Stream.filter((s) => s.name === name),
167 Stream.runHead,
168 Effect.catchTag("StoreNotFound", () => Effect.succeedNone),
169 Effect.map(Option.getOrUndefined),
170 );
171 }
172
173 // Ensure — create if missing. Cloudflare reports a concurrent
174 // create as `SecretNameAlreadyExists`; tolerate by re-listing
175 // the store and adopting the secret with the same name. The
176 // value can't be read back from the API; we trust an
177 // identically-named secret reflects the same intent.
178 if (!observed) {
179 const created = yield* secretsStore
180 .createStoreSecret({
181 accountId,
182 storeId,
183 body: [
184 {
185 name,
186 scopes,
187 value: Redacted.value(news.value),
188 comment: news.comment,
189 },
190 ],
191 })
192 .pipe(
193 Effect.catchTag("SecretNameAlreadyExists", () =>
194 Effect.succeed(undefined),
195 ),
196 );
197 if (created) {
198 const secret = created.result[0]!;
199 // Freshly created secrets report "pending" until Cloudflare
200 // activates them; a worker deploy that references a pending
201 // secret is rejected with "Secrets Store binding ... which
202 // were not found". Wait (bounded) for activation so
203 // downstream deploys in the same run see an active secret.
204 const status = yield* waitForSecretActive(
205 { accountId, storeId, secretId: secret.id },
206 asSecretStatus(secret.status),
207 );
208 return {
209 secretId: secret.id,
210 secretName: secret.name,
211 storeId: secret.storeId,
212 accountId,
213 status,
214 scopes,
215 comment: secret.comment ?? undefined,
216 };
217 }
218 const existing = yield* secretsStore.listStoreSecrets
219 .items({ accountId, storeId })
220 .pipe(
221 Stream.filter((s) => s.name === name),
222 Stream.runHead,
223 Effect.map(Option.getOrUndefined),
224 );
225 if (!existing) {
226 return yield* Effect.die(
227 new Error(
228 `Secret '${name}' reported as already existing in store ${storeId} but could not be found on lookup.`,
229 ),
230 );
231 }
232 observed = existing;
233 }
234
235 const patched = yield* secretsStore.patchStoreSecret({
236 accountId,
237 storeId,
238 secretId: observed.id,
239 scopes,
240 comment: news.comment,
241 value: Redacted.value(news.value),
242 });
243 const status = yield* waitForSecretActive(
244 { accountId, storeId, secretId: observed.id },
245 asSecretStatus(patched.status),
246 );
247 return {
248 secretId: observed.id,
249 secretName: observed.name,
250 storeId: observed.storeId,
251 accountId,
252 status,
253 scopes,
254 comment: patched.comment ?? undefined,
255 };
256 }),
257 delete: Effect.fn(function* ({ output }) {
258 yield* secretsStore
259 .deleteStoreSecret({
260 accountId: output.accountId,
261 storeId: output.storeId,
262 secretId: output.secretId,
263 })
264 .pipe(
265 Effect.tap(() => Effect.log(`deleted ${output.secretId}`)),
266 Effect.tapError(Console.log),
267 Effect.catchTag("SecretNotFound", () => Effect.void),
268 Effect.catchTag("StoreNotFound", () => Effect.void),
269 Effect.catchTag("NotFound", () => Effect.void),
270 );
271 }),
272 read: Effect.fn(function* ({ id, olds, output }) {
273 if (output?.secretId) {
274 return yield* secretsStore
275 .getStoreSecret({
276 accountId: output.accountId,
277 storeId: output.storeId,
278 secretId: output.secretId,
279 })
280 .pipe(
281 Effect.map((secret) => ({
282 secretId: secret.id,
283 secretName: secret.name,
284 storeId: secret.storeId,
285 accountId: output.accountId,
286 status: asSecretStatus(secret.status),
287 scopes: output.scopes,
288 comment: secret.comment ?? undefined,
289 })),
290 Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)),
291 Effect.catchTag("StoreNotFound", () => Effect.succeed(undefined)),
292 );
293 }
294 // An interrupted first deploy can persist `creating` props whose
295 // parent Outputs were stripped to holes (see stripUnresolved), so
296 // `olds.store` can survive as `{}`. Treat an unresolved parent
297 // reference like a missing one — the plan falls through to the
298 // create path instead of handing `undefined` to the API (#995).
299 if (!olds?.store?.storeId || !olds.store.accountId) return undefined;
300 const name = resolveName(id, olds.name);
301 const match = yield* secretsStore.listStoreSecrets
302 .items({
303 accountId: olds.store.accountId,
304 storeId: olds.store.storeId,
305 })
306 .pipe(
307 Stream.filter((s) => s.name === name),
308 Stream.runHead,
309 Effect.catchTag("StoreNotFound", () => Effect.succeedNone),
310 Effect.map(Option.getOrUndefined),
311 );
312 if (!match) return undefined;
313 // Secrets carry no ownership signal (Cloudflare doesn't expose
314 // tags on store secrets), so a name match is not proof we own
315 // it. Brand it `Unowned` so the engine surfaces
316 // `OwnedBySomeoneElse` unless the caller opted in via `--adopt`.
317 return Unowned({
318 secretId: match.id,
319 secretName: match.name,
320 storeId: match.storeId,
321 accountId: olds.store.accountId,
322 status: asSecretStatus(match.status),
323 scopes: resolveScopes(olds.scopes),
324 comment: match.comment ?? undefined,
325 });
326 }),
327 // Parent fan-out: secrets are sub-resources keyed by {accountId,
328 // storeId} and there is no account-wide secret enumeration API.
329 // Enumerate every Secrets Store in the account, then list the
330 // secrets inside each store with bounded concurrency, paginating
331 // both levels exhaustively. The secret value is write-only and is
332 // never returned by the API — matching `read`, it is omitted.
333 list: Effect.fn(function* () {
334 const { accountId } = yield* yield* CloudflareEnvironment;
335 const stores = yield* secretsStore.listStores.pages({ accountId }).pipe(
336 Stream.runCollect,
337 Effect.map((chunk) =>
338 Array.from(chunk).flatMap((page) => page.result ?? []),
339 ),
340 );
341 const rows = yield* Effect.forEach(
342 stores,
343 (store) =>
344 secretsStore.listStoreSecrets
345 .pages({ accountId, storeId: store.id })
346 .pipe(
347 Stream.runCollect,
348 Effect.map((chunk) =>
349 Array.from(chunk).flatMap((page) =>
350 (page.result ?? []).map((secret) => ({
351 secretId: secret.id,
352 secretName: secret.name,
353 storeId: secret.storeId,
354 accountId,
355 status: asSecretStatus(secret.status),
356 scopes: resolveScopes(secret.scopes ?? undefined),
357 comment: secret.comment ?? undefined,
358 })),
359 ),
360 ),
361 // A store deleted out-of-band between enumeration and
362 // listing its secrets surfaces as StoreNotFound — skip it.
363 Effect.catchTag("StoreNotFound", () =>
364 Effect.succeed([] as ReadonlyArray<Secret["Attributes"]>),
365 ),
366 ),
367 { concurrency: 10 },
368 );
369 return rows.flat();
370 }),
371 });
372
373/**
374 * Local (dev) provider — the secret's identity is virtual (a `dev:` id) but
375 * its VALUE is real: reconcile seeds it into the local workerd Secrets
376 * Store simulator (through the `SecretsStore.admin` gateway, see
377 * `LocalSecretsStoreGateway.ts`) so a dev worker's `env.SECRET.get()`
378 * returns it, and delete removes the key again. Data lands in the same
379 * `.alchemy/local/secrets-store` directory the worker's lowered
380 * `secrets_store_secret` binding reads.
381 *
382 * RPC-backed: under `alchemy dev` (an `RpcProviderProxy` in context) the
383 * whole lifecycle runs in the Cloudflare sidecar process — where
384 * `localRuntimeServices()` is real and shared with the Worker/Queue/D1
385 * local providers — instead of in the user's process where that layer is
386 * gated empty (the class of bug behind #1007). In-process runs (no proxy:
387 * `sidecar: false` tests, a plain deploy deleting a local-mode row) build
388 * the provider directly with the un-gated runtime from the `dual`
389 * registration.
390 */
391export const SecretProviderLocal = () =>
392 RpcProvider.effect(
393 Secret,
394 LOCAL_PROVIDERS_URL,
395 Effect.gen(function* () {
396 // The local runtime services (workerd `Runtime`, binding plugins) and
397 // the HTTP client are resolved once at layer build and closed over —
398 // lifecycle effects run with the engine's call-time context, which
399 // doesn't include them.
400 const runtimeContext = yield* Effect.context<
401 RuntimeServices | HttpClient.HttpClient
402 >();
403
404 return {
405 stables: ["accountId"],
406 diff: Effect.fn(function* ({ id, olds, news, output }) {
407 if (!output?.secretId) return { action: "update" } as const;
408 if (!isResolved(news)) return undefined;
409 // Mirror the live provider's rules: a new store or name replaces
410 // the secret; a new value updates it in place (re-seed).
411 const oldStoreId = output.storeId;
412 const oldName = output.secretName;
413 const newName = resolveName(id, news.name);
414 if (oldStoreId !== news.store.storeId || oldName !== newName) {
415 return { action: "replace" } as const;
416 }
417 const oldValue = olds?.value ? Redacted.value(olds.value) : undefined;
418 if (oldValue !== Redacted.value(news.value)) {
419 return { action: "update" } as const;
420 }
421 // Fall through to the engine's default prop diff (scopes/comment
422 // changes update in place).
423 }),
424 read: Effect.fn(function* ({ output }) {
425 // Virtual identity — the persisted state row is the source of
426 // truth (the seeded value converges on every reconcile).
427 return output ?? undefined;
428 }),
429 reconcile: Effect.fn(function* ({ id, news, output }) {
430 const name = resolveName(id, news.name);
431 const storeId = news.store.storeId;
432
433 // Seed — write the value into the local simulator so the dev
434 // worker's `env.<binding>.get()` returns it. An overwrite is
435 // idempotent, so re-running after a crash converges.
436 yield* seedLocalSecret(
437 storeId,
438 name,
439 Redacted.value(news.value),
440 ).pipe(Effect.provideContext(runtimeContext));
441
442 return {
443 secretId: output?.secretId ?? generateLocalId(),
444 secretName: name,
445 storeId,
446 accountId: news.store.accountId,
447 status: "active" as const,
448 scopes: resolveScopes(news.scopes),
449 comment: news.comment,
450 };
451 }),
452 delete: Effect.fn(function* ({ output }) {
453 // Remove the seeded value (idempotent — deleting a missing key is
454 // a no-op). A create-first replacement's old generation deletes a
455 // different key than its successor seeded, so this never races.
456 yield* deleteLocalSecret(output.storeId, output.secretName).pipe(
457 Effect.provideContext(runtimeContext),
458 );
459 }),
460 };
461 }),
462 );
463
464export const StoreSecretProvider = () =>
465 ProviderLayer.dual(Secret, {
466 // The local provider's reconcile/delete boot an ephemeral workerd
467 // gateway to seed the simulator, so it needs the shared local runtime
468 // layer. Under `alchemy dev` the provider is an RPC stub (this gated
469 // layer is empty and unused) and the provider group (`../Local.ts`)
470 // supplies the real runtime; without the proxy the provider builds
471 // in-process and this layer is real.
472 local: () =>
473 SecretProviderLocal().pipe(Layer.provide(localRuntimeServices())),
474 live: () => SecretProviderLive(),
475 });
476
477/**
478 * Poll a secret until Cloudflare reports it "active" (bounded at
479 * ~10s). Returns the last observed status rather than failing on
480 * timeout — consumers that bind the secret retry the
481 * `SecretsStoreBindingNotFound` deploy rejection themselves, so a
482 * slow activation degrades to a retried deploy instead of a hard
483 * error here.
484 */
485const waitForSecretActive = (
486 key: { accountId: string; storeId: string; secretId: string },
487 initialStatus: SecretStatus,
488) =>
489 initialStatus === "active"
490 ? Effect.succeed<SecretStatus>("active")
491 : secretsStore.getStoreSecret(key).pipe(
492 Effect.map((s) => asSecretStatus(s.status)),
493 Effect.repeat({
494 schedule: Schedule.spaced("500 millis"),
495 until: (status) => status === "active",
496 times: 20,
497 }),
498 // The secret was observed moments ago; a NotFound here is a
499 // read-replica lag blip, not a deletion. Report the last known
500 // status and let the deploy-side retry take over.
501 Effect.catchTag("SecretNotFound", () => Effect.succeed(initialStatus)),
502 Effect.catchTag("StoreNotFound", () => Effect.succeed(initialStatus)),
503 );
504
505const resolveScopes = (scopes: string[] | undefined): string[] =>
506 scopes && scopes.length > 0 ? scopes : ["workers"];
507
508const resolveName = (id: string, name: string | undefined): string =>
509 name ?? id;