1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3
4import { pipe } from "effect/Function";
5import type {
6 ReplacedResourceState,
7 ResourceState,
8} from "../../State/ResourceState.ts";
9import { encodeState } from "../../State/StateEncoding.ts";
10import * as Secret from "../SecretsStore/index.ts";
11import { DurableObject } from "../Workers/DurableObject.ts";
12import { DurableObjectState } from "../Workers/DurableObjectState.ts";
13import { EncryptionKey } from "./Token.ts";
14
15export default class Store extends DurableObject<Store>()(
16 "Store",
17 Effect.gen(function* () {
18 // Outer (class-level) phase — resolve the binding factory once.
19 // The actual secret read happens inside each DO instance below,
20 // since `SecretClient.get()` needs the per-instance worker env.
21 const encryptionSecret = yield* Secret.ReadSecret(EncryptionKey);
22 const state = yield* DurableObjectState;
23 const storage = state.storage;
24
25 return Effect.gen(function* () {
26 const keyHex = yield* encryptionSecret
27 .get()
28 .pipe(Effect.map(Redacted.value), Effect.orDie);
29 const cryptoKey = yield* Effect.tryPromise(() =>
30 crypto.subtle.importKey(
31 "raw",
32 Buffer.from(keyHex, "hex"),
33 { name: "AES-CTR" },
34 false,
35 ["encrypt", "decrypt"],
36 ),
37 ).pipe(Effect.orDie);
38
39 const encryptValue = (value: unknown) =>
40 Effect.tryPromise(async () => {
41 const plaintext = new TextEncoder().encode(
42 JSON.stringify(encodeState(value)),
43 );
44 const counter = crypto.getRandomValues(allocBytes(NONCE_BYTES));
45 const ct = new Uint8Array(
46 await crypto.subtle.encrypt(
47 { name: "AES-CTR", counter, length: 64 },
48 cryptoKey,
49 plaintext,
50 ),
51 );
52 // Frame as a single base64 string: nonce || ciphertext.
53 return Buffer.concat([counter, ct]).toString("base64");
54 }).pipe(Effect.orDie);
55
56 const decryptEntry = (entry: string) =>
57 Effect.tryPromise(async () => {
58 const framed = Buffer.from(entry, "base64");
59 const counter = framed.subarray(0, NONCE_BYTES);
60 const ciphertext = framed.subarray(NONCE_BYTES);
61 let pt;
62 try {
63 pt = await crypto.subtle.decrypt(
64 { name: "AES-CTR", counter, length: 64 },
65 cryptoKey,
66 ciphertext,
67 );
68 } catch (error) {
69 // We return undefined here because in 2.0.0-beta.45, we rotated encryption keys unnecessarily.
70 // So, we catch a decryption error here and return undefined instead.
71 // The engine should reconcile, hopefully, but users may lose some data
72 console.error(
73 "Error decrypting entry. Returning undefined instead.",
74 error,
75 );
76 return undefined;
77 }
78 return JSON.parse(new TextDecoder().decode(pt)) as ResourceState;
79 }).pipe(Effect.orDie);
80
81 return {
82 // -- Root DO methods -----------------------------------------
83
84 /**
85 * (Root DO only) List every stack name ever registered.
86 */
87 listStacks: Effect.fn(function* () {
88 const entries = yield* storage.list<number>({
89 prefix: STACK_INDEX_PREFIX,
90 });
91 const stacks: string[] = [];
92 for (const key of entries.keys()) {
93 stacks.push(key.slice(STACK_INDEX_PREFIX.length));
94 }
95 return stacks;
96 }),
97
98 /**
99 * (Root DO only) Register a stack name. Idempotent — safe to
100 * call on every `set` to the corresponding stack DO.
101 */
102 registerStack: ({ stack }: { stack: string }) =>
103 storage.put(`${STACK_INDEX_PREFIX}${stack}`, 1),
104
105 /**
106 * (Root DO only) Remove a stack name from the global index.
107 */
108 unregisterStack: ({ stack }: { stack: string }) =>
109 storage.delete(`${STACK_INDEX_PREFIX}${stack}`),
110
111 // -- Stack DO methods ----------------------------------------
112
113 /** (Stack DO only) List stages with at least one resource. */
114 listStages: () =>
115 storage
116 .list<string>({
117 prefix: RESOURCE_PREFIX,
118 })
119 .pipe(
120 Effect.map((entries) => {
121 const stages = new Set<string>();
122 for (const key of entries.keys()) {
123 const parsed = parseResourceKey(key);
124 if (parsed) stages.add(parsed.stage);
125 }
126 return [...stages];
127 }),
128 ),
129
130 /** (Stack DO only) List every resource FQN in a stage. */
131 listResources: ({ stage }: { stage: string }) =>
132 storage
133 .list<string>({
134 prefix: stagePrefix(stage),
135 })
136 .pipe(
137 Effect.map((entries) => {
138 const fqns: string[] = [];
139 for (const key of entries.keys()) {
140 const parsed = parseResourceKey(key);
141 if (parsed) fqns.push(parsed.fqn);
142 }
143 return fqns;
144 }),
145 ),
146
147 /**
148 * (Stack DO only) Get a resource by (stage, fqn). Returns
149 * null if missing.
150 */
151 get: ({ stage, fqn }: { stage: string; fqn: string }) =>
152 storage
153 .get<string>(resourceKey(stage, fqn))
154 .pipe(
155 Effect.flatMap((entry) =>
156 entry == null ? Effect.succeed(undefined) : decryptEntry(entry),
157 ),
158 ),
159
160 /**
161 * (Stack DO only) Persist a resource. Returns the stored
162 * value unchanged.
163 */
164 set: ({
165 stage,
166 fqn,
167 value,
168 }: {
169 stage: string;
170 fqn: string;
171 value: ResourceState;
172 }) =>
173 encryptValue(value).pipe(
174 Effect.flatMap((encrypted) =>
175 storage
176 .put<string>(resourceKey(stage, fqn), encrypted)
177 .pipe(Effect.asVoid),
178 ),
179 Effect.map(() => value),
180 ),
181
182 /**
183 * (Stack DO only) Delete a resource. Idempotent.
184 *
185 * Exposed as `remove` (not `delete`) because Cloudflare's
186 * Durable Object RPC stub reserves `delete` and refuses to
187 * proxy the call, surfacing as "RPC receiver does not
188 * implement the method 'delete'".
189 */
190 remove: ({ stage, fqn }: { stage: string; fqn: string }) =>
191 storage.delete(resourceKey(stage, fqn)),
192
193 /**
194 * (Stack DO only) Delete every resource in this stack, or every
195 * resource in a single stage when specified.
196 */
197 deleteStack: ({ stage }: { stage?: string } = {}) =>
198 stage === undefined
199 ? storage.deleteAll()
200 : storage.list<string>({ prefix: stagePrefix(stage) }).pipe(
201 Effect.flatMap((entries) => {
202 const keys = [...entries.keys(), stackOutputKey(stage)];
203 return storage.delete(keys).pipe(Effect.asVoid);
204 }),
205 ),
206
207 /**
208 * (Stack DO only) Read the persisted stack output for `stage`.
209 * Returns `undefined` when the stage has not been deployed.
210 */
211 getOutput: ({ stage }: { stage: string }) =>
212 storage
213 .get<string>(stackOutputKey(stage))
214 .pipe(
215 Effect.flatMap((entry) =>
216 entry == null ? Effect.succeed(undefined) : decryptEntry(entry),
217 ),
218 ),
219
220 /**
221 * (Stack DO only) Persist the resolved stack output for
222 * `stage`. Returns the stored value unchanged.
223 */
224 setOutput: ({ stage, value }: { stage: string; value: any }) =>
225 encryptValue(value).pipe(
226 Effect.flatMap((encrypted) =>
227 storage
228 .put<string>(stackOutputKey(stage), encrypted)
229 .pipe(Effect.asVoid),
230 ),
231 Effect.map(() => value),
232 ),
233
234 /**
235 * (Stack DO only) Return every resource in a stage whose
236 * `status === "replaced"`. Each entry is decrypted so the
237 * `status` field can be inspected.
238 */
239 getReplacedResources: ({ stage }: { stage: string }) =>
240 pipe(
241 storage.list<string>({ prefix: stagePrefix(stage) }),
242 Effect.map((entries) =>
243 [...entries.values()].filter((e): e is string => !!e),
244 ),
245 Effect.flatMap(
246 Effect.forEach(decryptEntry, { concurrency: "unbounded" }),
247 ),
248 Effect.map((decoded) =>
249 decoded.filter(
250 (d): d is ReplacedResourceState => d?.status === "replaced",
251 ),
252 ),
253 ),
254 };
255 });
256 }).pipe(Effect.provide(Secret.ReadSecretBinding)),
257) {
258 /**
259 * Well-known DO name whose sole job is to track the set of stacks
260 * that have ever had resources written. `listStacks` queries it;
261 * every `set` asks it to register the stack (idempotent).
262 */
263 static readonly ROOT_DO_NAME = "__root__" as const;
264}
265
266/** NUL byte separator for composite keys. */
267const SEP = "\x00";
268
269/** Key prefix for resource entries in a stack DO. */
270const RESOURCE_PREFIX = `r${SEP}`;
271
272/** Key prefix for stack-output entries in a stack DO. */
273const STACK_OUTPUT_PREFIX = `o${SEP}`;
274
275/** Key prefix for stack-index entries in the root DO. */
276const STACK_INDEX_PREFIX = "s:";
277
278/** AES-CTR counter block length. */
279const NONCE_BYTES = 16;
280
281/** Build the resource key inside a *stack DO*. */
282const resourceKey = (stage: string, fqn: string) =>
283 `${RESOURCE_PREFIX}${stage}${SEP}${fqn}`;
284
285/** Prefix matching every resource key inside a specific stage. */
286const stagePrefix = (stage: string) => `${RESOURCE_PREFIX}${stage}${SEP}`;
287
288/** Build the stack-output key inside a *stack DO*. */
289const stackOutputKey = (stage: string) => `${STACK_OUTPUT_PREFIX}${stage}`;
290
291/**
292 * Parse a resource key back into its (stage, fqn) tuple. Returns
293 * undefined for keys that do not match the expected shape.
294 */
295const parseResourceKey = (
296 key: string,
297): { stage: string; fqn: string } | undefined => {
298 if (!key.startsWith(RESOURCE_PREFIX)) return undefined;
299 const rest = key.slice(RESOURCE_PREFIX.length);
300 const sep = rest.indexOf(SEP);
301 if (sep < 0) return undefined;
302 return { stage: rest.slice(0, sep), fqn: rest.slice(sep + 1) };
303};
304
305/**
306 * Allocate a `Uint8Array` over a fresh `ArrayBuffer` (not shared) so
307 * the resulting buffer satisfies Web Crypto's `BufferSource` type
308 * constraint under strict DOM typings.
309 */
310const allocBytes = (size: number): Uint8Array<ArrayBuffer> =>
311 new Uint8Array(new ArrayBuffer(size));