1import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import * as ProviderLayer from "../../Local/ProviderLayer.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import { localAccountId } from "../LocalAccount.ts";
10import { generateLocalId } from "../LocalRuntime.ts";
11import type { Providers } from "../Providers.ts";
12
13export type Store = Resource<
14 "Cloudflare.SecretsStore",
15 {},
16 {
17 storeId: string;
18 storeName: string;
19 accountId: string;
20 },
21 never,
22 Providers
23>;
24
25/**
26 * A Cloudflare Secrets Store, a per-account container for secrets that
27 * can be bound into Workers with full redaction and audit support.
28 *
29 * Cloudflare enforces a limit of **one Secrets Store per account**.
30 * Deleting a store changes its ID and permanently destroys all secrets
31 * inside it. Because of this, the provider always **adopts** an existing
32 * store rather than creating a new one, and **never deletes** the store
33 * on teardown. The `read` lifecycle reports the existing account store
34 * (if any) as plain attrs, so the engine silently adopts it on cold
35 * start and `create` is only ever invoked when no store exists yet.
36 * Once it exists it is treated as account-level infrastructure that
37 * outlives any single stack.
38 * ### Creating a Store
39 * **Example:** Basic Secrets Store (adopts existing or creates one)
40 * ```typescript
41 * const store = yield* Cloudflare.SecretsStore.Store("MyStore");
42 * ```
43 *
44 * **Example:** Adopt a specific named store
45 * ```typescript
46 * const store = yield* Cloudflare.SecretsStore.Store("MyStore", {
47 * name: "production-secrets",
48 * });
49 * ```
50 *
51 * @resource
52 * @product Secrets Store
53 * @category Storage & Databases
54 */
55export const Store = Resource<Store>("Cloudflare.SecretsStore");
56
57export const StoreProviderLive = () =>
58 Provider.succeed(Store, {
59 stables: ["storeId", "storeName", "accountId"],
60 // The engine calls `read` whenever there's no prior state. Cloudflare
61 // allows exactly one Secrets Store per account, so any account that's
62 // ever provisioned one must reuse it. Returning the existing store as
63 // plain attrs makes the engine silently adopt it; `create` is only
64 // ever invoked when no store exists yet.
65 read: Effect.fn(function* ({ output }) {
66 const { accountId } = yield* yield* CloudflareEnvironment;
67 const acct = output?.accountId ?? accountId;
68 const match = output?.storeId
69 ? yield* findStoreById(acct, output.storeId)
70 : yield* firstStore(acct);
71 if (!match) return undefined;
72 return {
73 storeId: match.id,
74 storeName: match.name,
75 accountId: acct,
76 };
77 }),
78 reconcile: Effect.fn(function* ({ output }) {
79 const { accountId } = yield* yield* CloudflareEnvironment;
80 const acct = output?.accountId ?? accountId;
81
82 // Observe — Cloudflare permits exactly one Secrets Store per
83 // account. Reuse the cached store if it still exists, otherwise
84 // reuse the first one listed.
85 const cached = output?.storeId
86 ? yield* findStoreById(acct, output.storeId)
87 : undefined;
88 const observed = cached ?? (yield* firstStore(acct));
89
90 if (observed) {
91 return {
92 storeId: observed.id,
93 storeName: observed.name,
94 accountId: acct,
95 };
96 }
97
98 // Ensure — no store yet. Create the default. Cloudflare reports
99 // a concurrent create as `MaximumStoresExceeded`; tolerate by
100 // re-listing and adopting the now-existing store.
101 const response = yield* secretsStore
102 .createStore({
103 accountId: acct,
104 // `default_secrets_store` is the name Cloudflare uses for an
105 // account's default Secrets Store.
106 name: "default_secrets_store",
107 })
108 .pipe(
109 Effect.catchTag("MaximumStoresExceeded", () =>
110 Effect.succeed(undefined),
111 ),
112 );
113
114 if (response) {
115 return {
116 storeId: response.id,
117 storeName: response.name,
118 accountId: acct,
119 };
120 }
121
122 const first = yield* firstStore(acct);
123 if (first) {
124 return {
125 storeId: first.id,
126 storeName: first.name,
127 accountId: acct,
128 };
129 }
130
131 return yield* Effect.die(
132 new Error(
133 `Cloudflare reported MaximumStoresExceeded for account ${acct} but no store could be listed.`,
134 ),
135 );
136 }),
137 // Account-scoped collection. Cloudflare exposes a paginated
138 // `secrets_store/stores` list op; enumerate every page and hydrate each
139 // store into the exact `read` Attributes shape. Cloudflare currently
140 // permits only one (default) store per account, so this is usually a
141 // single-element array, but the enumeration is exhaustive regardless.
142 list: Effect.fn(function* () {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144 return yield* secretsStore.listStores.pages({ accountId }).pipe(
145 Stream.runCollect,
146 Effect.map((chunk) =>
147 Array.from(chunk).flatMap((page) =>
148 (page.result ?? []).map((store) => ({
149 storeId: store.id,
150 storeName: store.name,
151 accountId: store.accountId ?? accountId,
152 })),
153 ),
154 ),
155 );
156 }),
157 delete: Effect.fn(function* () {
158 // Intentional no-op. Cloudflare only allows one Secrets Store per
159 // account and deleting it permanently destroys all secrets inside.
160 // The store is treated as shared, account-level infrastructure that
161 // should never be torn down by a single stack.
162 }),
163 });
164
165/**
166 * Local (dev) provider — the store is purely virtual: a `dev:` id keyed
167 * into the local workerd Secrets Store simulator. The local `Secret`
168 * provider seeds values into it and `toRuntimeBinding` lowers a
169 * `secrets_store_secret` binding whose store id is `dev:`-prefixed onto the
170 * local secrets-store service; data persists under
171 * `.alchemy/local/secrets-store`.
172 */
173export const StoreProviderLocal = () =>
174 Provider.succeed(Store, {
175 stables: ["accountId"],
176 diff: Effect.fn(function* ({ output }) {
177 const accountId = yield* localAccountId;
178 if (!output?.storeId) return { action: "update" } as const;
179 if (output.accountId !== accountId) {
180 return { action: "replace" } as const;
181 }
182 // Fall through to the engine's default prop diff.
183 }),
184 read: Effect.fn(function* ({ output }) {
185 // Purely virtual — the persisted state row is the source of truth.
186 return output ?? undefined;
187 }),
188 reconcile: Effect.fn(function* ({ output }) {
189 const accountId = yield* localAccountId;
190 return {
191 storeId: output?.storeId ?? generateLocalId(),
192 // Mirror the name Cloudflare uses for an account's default store.
193 storeName: output?.storeName ?? "default_secrets_store",
194 accountId: output?.accountId ?? accountId,
195 };
196 }),
197 delete: Effect.fn(function* () {
198 // The simulator's on-disk data is keyed by the dev id; dropping the
199 // state row is enough — orphaned data is reclaimed with `.alchemy`.
200 }),
201 });
202
203export const SecretsStoreProvider = () =>
204 ProviderLayer.dual(Store, {
205 local: () => StoreProviderLocal(),
206 live: () => StoreProviderLive(),
207 });
208
209/** First store on the account (Cloudflare permits at most one). */
210const firstStore = (accountId: string) =>
211 secretsStore.listStores
212 .items({ accountId })
213 .pipe(Stream.runHead, Effect.map(Option.getOrUndefined));
214
215/** Look a store up by id, terminating pagination on the first match. */
216const findStoreById = (accountId: string, storeId: string) =>
217 secretsStore.listStores.items({ accountId }).pipe(
218 Stream.filter((s) => s.id === storeId),
219 Stream.runHead,
220 Effect.map(Option.getOrUndefined),
221 );