| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | |
| 4 | import { pipe } from "effect/Function"; |
| 5 | import type { |
| 6 | ReplacedResourceState, |
| 7 | ResourceState, |
| 8 | } from "../../State/ResourceState.ts"; |
| 9 | import { encodeState } from "../../State/StateEncoding.ts"; |
| 10 | import * as Secret from "../SecretsStore/index.ts"; |
| 11 | import { DurableObject } from "../Workers/DurableObject.ts"; |
| 12 | import { DurableObjectState } from "../Workers/DurableObjectState.ts"; |
| 13 | import { EncryptionKey } from "./Token.ts"; |
| 14 | |
| 15 | export 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. */ |
| 267 | const SEP = "\x00"; |
| 268 | |
| 269 | /** Key prefix for resource entries in a stack DO. */ |
| 270 | const RESOURCE_PREFIX = `r${SEP}`; |
| 271 | |
| 272 | /** Key prefix for stack-output entries in a stack DO. */ |
| 273 | const STACK_OUTPUT_PREFIX = `o${SEP}`; |
| 274 | |
| 275 | /** Key prefix for stack-index entries in the root DO. */ |
| 276 | const STACK_INDEX_PREFIX = "s:"; |
| 277 | |
| 278 | /** AES-CTR counter block length. */ |
| 279 | const NONCE_BYTES = 16; |
| 280 | |
| 281 | /** Build the resource key inside a *stack DO*. */ |
| 282 | const resourceKey = (stage: string, fqn: string) => |
| 283 | `${RESOURCE_PREFIX}${stage}${SEP}${fqn}`; |
| 284 | |
| 285 | /** Prefix matching every resource key inside a specific stage. */ |
| 286 | const stagePrefix = (stage: string) => `${RESOURCE_PREFIX}${stage}${SEP}`; |
| 287 | |
| 288 | /** Build the stack-output key inside a *stack DO*. */ |
| 289 | const 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 | */ |
| 295 | const 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 | */ |
| 310 | const allocBytes = (size: number): Uint8Array<ArrayBuffer> => |
| 311 | new Uint8Array(new ArrayBuffer(size)); |