@striae-org/striae 5.1.1 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.env.example +20 -1
  2. package/app/utils/data/permissions.ts +4 -2
  3. package/package.json +4 -4
  4. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  5. package/scripts/deploy-config/modules/keys.sh +404 -0
  6. package/scripts/deploy-config/modules/prompt.sh +372 -0
  7. package/scripts/deploy-config/modules/scaffolding.sh +336 -0
  8. package/scripts/deploy-config/modules/validation.sh +365 -0
  9. package/scripts/deploy-config.sh +47 -1572
  10. package/scripts/deploy-worker-secrets.sh +100 -5
  11. package/worker-configuration.d.ts +6 -3
  12. package/workers/audit-worker/package.json +1 -1
  13. package/workers/audit-worker/src/audit-worker.example.ts +188 -6
  14. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  15. package/workers/data-worker/package.json +1 -1
  16. package/workers/data-worker/src/data-worker.example.ts +344 -32
  17. package/workers/data-worker/wrangler.jsonc.example +1 -1
  18. package/workers/image-worker/package.json +1 -1
  19. package/workers/image-worker/src/image-worker.example.ts +190 -5
  20. package/workers/image-worker/wrangler.jsonc.example +1 -1
  21. package/workers/keys-worker/package.json +1 -1
  22. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  23. package/workers/pdf-worker/package.json +1 -1
  24. package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
  25. package/workers/pdf-worker/wrangler.jsonc.example +1 -5
  26. package/workers/user-worker/package.json +17 -17
  27. package/workers/user-worker/src/encryption-utils.ts +244 -0
  28. package/workers/user-worker/src/user-worker.example.ts +333 -31
  29. package/workers/user-worker/wrangler.jsonc.example +1 -1
  30. package/wrangler.toml.example +1 -1
@@ -95,6 +95,46 @@ load_required_admin_service_credentials() {
95
95
 
96
96
  load_required_admin_service_credentials
97
97
 
98
+ build_user_worker_secret_list() {
99
+ local secrets=(
100
+ "USER_DB_AUTH"
101
+ "R2_KEY_SECRET"
102
+ "IMAGES_API_TOKEN"
103
+ "DATA_WORKER_DOMAIN"
104
+ "IMAGES_WORKER_DOMAIN"
105
+ "PROJECT_ID"
106
+ "FIREBASE_SERVICE_ACCOUNT_EMAIL"
107
+ "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY"
108
+ )
109
+
110
+ if [ -n "${USER_KV_ENCRYPTION_PRIVATE_KEY:-}" ]; then
111
+ secrets+=("USER_KV_ENCRYPTION_PRIVATE_KEY")
112
+ fi
113
+
114
+ if [ -n "${USER_KV_ENCRYPTION_KEYS_JSON:-}" ]; then
115
+ secrets+=("USER_KV_ENCRYPTION_KEYS_JSON")
116
+ fi
117
+
118
+ if [ -n "${USER_KV_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
119
+ secrets+=("USER_KV_ENCRYPTION_ACTIVE_KEY_ID")
120
+ fi
121
+
122
+ local write_endpoints_enabled_normalized
123
+ write_endpoints_enabled_normalized=$(printf '%s' "${USER_KV_WRITE_ENDPOINTS_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')
124
+
125
+ if [ "$write_endpoints_enabled_normalized" = "1" ] || [ "$write_endpoints_enabled_normalized" = "true" ] || [ "$write_endpoints_enabled_normalized" = "yes" ] || [ "$write_endpoints_enabled_normalized" = "on" ]; then
126
+ if [ -n "${USER_KV_ENCRYPTION_KEY_ID:-}" ]; then
127
+ secrets+=("USER_KV_ENCRYPTION_KEY_ID")
128
+ fi
129
+
130
+ if [ -n "${USER_KV_ENCRYPTION_PUBLIC_KEY:-}" ]; then
131
+ secrets+=("USER_KV_ENCRYPTION_PUBLIC_KEY")
132
+ fi
133
+ fi
134
+
135
+ printf '%s\n' "${secrets[@]}"
136
+ }
137
+
98
138
  build_audit_worker_secret_list() {
99
139
  local secrets=(
100
140
  "R2_KEY_SECRET"
@@ -116,6 +156,14 @@ build_audit_worker_secret_list() {
116
156
  secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
117
157
  fi
118
158
 
159
+ if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
160
+ secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
161
+ fi
162
+
163
+ if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
164
+ secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
165
+ fi
166
+
119
167
  printf '%s\n' "${secrets[@]}"
120
168
  }
121
169
 
@@ -192,6 +240,45 @@ build_data_worker_secret_list() {
192
240
  secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
193
241
  fi
194
242
 
243
+ if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
244
+ secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
245
+ fi
246
+
247
+ if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
248
+ secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
249
+ fi
250
+
251
+ if [ -n "${EXPORT_ENCRYPTION_KEYS_JSON:-}" ]; then
252
+ secrets+=("EXPORT_ENCRYPTION_KEYS_JSON")
253
+ fi
254
+
255
+ if [ -n "${EXPORT_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
256
+ secrets+=("EXPORT_ENCRYPTION_ACTIVE_KEY_ID")
257
+ fi
258
+
259
+ printf '%s\n' "${secrets[@]}"
260
+ }
261
+
262
+ build_images_worker_secret_list() {
263
+ local secrets=(
264
+ "IMAGES_API_TOKEN"
265
+ "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY"
266
+ "DATA_AT_REST_ENCRYPTION_KEY_ID"
267
+ "IMAGE_SIGNED_URL_SECRET"
268
+ )
269
+
270
+ if [ -n "${DATA_AT_REST_ENCRYPTION_PRIVATE_KEY:-}" ]; then
271
+ secrets+=("DATA_AT_REST_ENCRYPTION_PRIVATE_KEY")
272
+ fi
273
+
274
+ if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
275
+ secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
276
+ fi
277
+
278
+ if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
279
+ secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
280
+ fi
281
+
195
282
  printf '%s\n' "${secrets[@]}"
196
283
  }
197
284
 
@@ -235,9 +322,13 @@ if ! set_worker_secrets "Keys Worker" "workers/keys-worker" \
235
322
  echo -e "${YELLOW}⚠️ Skipping Keys Worker (not configured)${NC}"
236
323
  fi
237
324
 
238
- # User Worker
239
- if ! set_worker_secrets "User Worker" "workers/user-worker" \
240
- "USER_DB_AUTH" "R2_KEY_SECRET" "IMAGES_API_TOKEN" "DATA_WORKER_DOMAIN" "IMAGES_WORKER_DOMAIN" "PROJECT_ID" "FIREBASE_SERVICE_ACCOUNT_EMAIL" "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY"; then
325
+ # User Worker
326
+ user_worker_secrets=()
327
+ while IFS= read -r secret; do
328
+ user_worker_secrets+=("$secret")
329
+ done < <(build_user_worker_secret_list)
330
+
331
+ if ! set_worker_secrets "User Worker" "workers/user-worker" "${user_worker_secrets[@]}"; then
241
332
  echo -e "${YELLOW}⚠️ Skipping User Worker (not configured)${NC}"
242
333
  fi
243
334
 
@@ -252,8 +343,12 @@ if ! set_worker_secrets "Data Worker" "workers/data-worker" "${data_worker_secre
252
343
  fi
253
344
 
254
345
  # Images Worker
255
- if ! set_worker_secrets "Images Worker" "workers/image-worker" \
256
- "IMAGES_API_TOKEN" "DATA_AT_REST_ENCRYPTION_PRIVATE_KEY" "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY" "DATA_AT_REST_ENCRYPTION_KEY_ID" "IMAGE_SIGNED_URL_SECRET"; then
346
+ images_worker_secrets=()
347
+ while IFS= read -r secret; do
348
+ images_worker_secrets+=("$secret")
349
+ done < <(build_images_worker_secret_list)
350
+
351
+ if ! set_worker_secrets "Images Worker" "workers/image-worker" "${images_worker_secrets[@]}"; then
257
352
  echo -e "${YELLOW}⚠️ Skipping Images Worker (not configured)${NC}"
258
353
  fi
259
354
 
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable */
2
- // Generated by Wrangler by running `wrangler types` (hash: abf7efe3b0d9f2d66e012084f057d73b)
3
- // Runtime types generated with workerd@1.20250823.0 2026-03-24 nodejs_compat
2
+ // Generated by Wrangler by running `wrangler types` (hash: 3fce96aefe167606678b821d5dbcc109)
3
+ // Runtime types generated with workerd@1.20250823.0 2026-03-25 nodejs_compat
4
4
  declare namespace Cloudflare {
5
5
  interface Env {
6
6
  ACCOUNT_ID: string;
@@ -25,6 +25,9 @@ declare namespace Cloudflare {
25
25
  USER_WORKER_NAME: string;
26
26
  USER_WORKER_DOMAIN: string;
27
27
  KV_STORE_ID: string;
28
+ USER_KV_ENCRYPTION_PRIVATE_KEY: string;
29
+ USER_KV_ENCRYPTION_KEY_ID: string;
30
+ USER_KV_ENCRYPTION_PUBLIC_KEY: string;
28
31
  DATA_WORKER_NAME: string;
29
32
  DATA_BUCKET_NAME: string;
30
33
  FILES_BUCKET_NAME: string;
@@ -58,7 +61,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
58
61
  [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
59
62
  };
60
63
  declare namespace NodeJS {
61
- interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ACCOUNT_ID" | "USER_DB_AUTH" | "R2_KEY_SECRET" | "IMAGES_API_TOKEN" | "API_KEY" | "AUTH_DOMAIN" | "PROJECT_ID" | "STORAGE_BUCKET" | "MESSAGING_SENDER_ID" | "APP_ID" | "MEASUREMENT_ID" | "FIREBASE_SERVICE_ACCOUNT_EMAIL" | "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY" | "PAGES_PROJECT_NAME" | "PAGES_CUSTOM_DOMAIN" | "KEYS_WORKER_NAME" | "KEYS_WORKER_DOMAIN" | "KEYS_AUTH" | "ACCOUNT_HASH" | "USER_WORKER_NAME" | "USER_WORKER_DOMAIN" | "KV_STORE_ID" | "DATA_WORKER_NAME" | "DATA_BUCKET_NAME" | "FILES_BUCKET_NAME" | "DATA_WORKER_DOMAIN" | "MANIFEST_SIGNING_PRIVATE_KEY" | "MANIFEST_SIGNING_KEY_ID" | "MANIFEST_SIGNING_PUBLIC_KEY" | "EXPORT_ENCRYPTION_PRIVATE_KEY" | "EXPORT_ENCRYPTION_KEY_ID" | "EXPORT_ENCRYPTION_PUBLIC_KEY" | "DATA_AT_REST_ENCRYPTION_PRIVATE_KEY" | "DATA_AT_REST_ENCRYPTION_KEY_ID" | "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY" | "AUDIT_WORKER_NAME" | "AUDIT_BUCKET_NAME" | "AUDIT_WORKER_DOMAIN" | "IMAGES_WORKER_NAME" | "IMAGES_WORKER_DOMAIN" | "IMAGE_SIGNED_URL_SECRET" | "IMAGE_SIGNED_URL_TTL_SECONDS" | "PDF_WORKER_NAME" | "PDF_WORKER_DOMAIN" | "PDF_WORKER_AUTH" | "BROWSER_API_TOKEN" | "PRIMERSHEAR_EMAILS" | "DATA_AT_REST_ENCRYPTION_ENABLED">> {}
64
+ interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ACCOUNT_ID" | "USER_DB_AUTH" | "R2_KEY_SECRET" | "IMAGES_API_TOKEN" | "API_KEY" | "AUTH_DOMAIN" | "PROJECT_ID" | "STORAGE_BUCKET" | "MESSAGING_SENDER_ID" | "APP_ID" | "MEASUREMENT_ID" | "FIREBASE_SERVICE_ACCOUNT_EMAIL" | "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY" | "PAGES_PROJECT_NAME" | "PAGES_CUSTOM_DOMAIN" | "KEYS_WORKER_NAME" | "KEYS_WORKER_DOMAIN" | "KEYS_AUTH" | "ACCOUNT_HASH" | "USER_WORKER_NAME" | "USER_WORKER_DOMAIN" | "KV_STORE_ID" | "USER_KV_ENCRYPTION_PRIVATE_KEY" | "USER_KV_ENCRYPTION_KEY_ID" | "USER_KV_ENCRYPTION_PUBLIC_KEY" | "DATA_WORKER_NAME" | "DATA_BUCKET_NAME" | "FILES_BUCKET_NAME" | "DATA_WORKER_DOMAIN" | "MANIFEST_SIGNING_PRIVATE_KEY" | "MANIFEST_SIGNING_KEY_ID" | "MANIFEST_SIGNING_PUBLIC_KEY" | "EXPORT_ENCRYPTION_PRIVATE_KEY" | "EXPORT_ENCRYPTION_KEY_ID" | "EXPORT_ENCRYPTION_PUBLIC_KEY" | "DATA_AT_REST_ENCRYPTION_PRIVATE_KEY" | "DATA_AT_REST_ENCRYPTION_KEY_ID" | "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY" | "AUDIT_WORKER_NAME" | "AUDIT_BUCKET_NAME" | "AUDIT_WORKER_DOMAIN" | "IMAGES_WORKER_NAME" | "IMAGES_WORKER_DOMAIN" | "IMAGE_SIGNED_URL_SECRET" | "IMAGE_SIGNED_URL_TTL_SECONDS" | "PDF_WORKER_NAME" | "PDF_WORKER_DOMAIN" | "PDF_WORKER_AUTH" | "BROWSER_API_TOKEN" | "PRIMERSHEAR_EMAILS" | "DATA_AT_REST_ENCRYPTION_ENABLED">> {}
62
65
  }
63
66
 
64
67
  // Begin runtime types
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.76.0"
12
+ "wrangler": "^4.77.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -5,8 +5,22 @@ interface Env {
5
5
  DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
6
6
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
7
7
  DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
8
+ DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
9
+ DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
8
10
  }
9
11
 
12
+ interface KeyRegistryPayload {
13
+ activeKeyId?: unknown;
14
+ keys?: unknown;
15
+ }
16
+
17
+ interface PrivateKeyRegistry {
18
+ activeKeyId: string | null;
19
+ keys: Record<string, string>;
20
+ }
21
+
22
+ type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
23
+
10
24
  interface AuditEntry {
11
25
  timestamp: string;
12
26
  userId: string;
@@ -58,6 +72,178 @@ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
58
72
  const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
59
73
  const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
60
74
 
75
+ function normalizePrivateKeyPem(rawValue: string): string {
76
+ return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
77
+ }
78
+
79
+ function getNonEmptyString(value: unknown): string | null {
80
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
81
+ }
82
+
83
+ function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
84
+ const keys: Record<string, string> = {};
85
+ const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
86
+ const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
87
+
88
+ if (registryJson) {
89
+ let parsedRegistry: unknown;
90
+ try {
91
+ parsedRegistry = JSON.parse(registryJson) as unknown;
92
+ } catch {
93
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
94
+ }
95
+
96
+ if (!parsedRegistry || typeof parsedRegistry !== 'object') {
97
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
98
+ }
99
+
100
+ const payload = parsedRegistry as KeyRegistryPayload;
101
+ const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
102
+ const rawKeys = payload.keys && typeof payload.keys === 'object'
103
+ ? payload.keys as Record<string, unknown>
104
+ : parsedRegistry as Record<string, unknown>;
105
+
106
+ for (const [keyId, pemValue] of Object.entries(rawKeys)) {
107
+ if (keyId === 'activeKeyId' || keyId === 'keys') {
108
+ continue;
109
+ }
110
+
111
+ const normalizedKeyId = getNonEmptyString(keyId);
112
+ const normalizedPem = getNonEmptyString(pemValue);
113
+ if (!normalizedKeyId || !normalizedPem) {
114
+ continue;
115
+ }
116
+
117
+ keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
118
+ }
119
+
120
+ const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
121
+
122
+ if (Object.keys(keys).length === 0) {
123
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
124
+ }
125
+
126
+ if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
127
+ throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
128
+ }
129
+
130
+ return {
131
+ activeKeyId: resolvedActiveKeyId ?? null,
132
+ keys
133
+ };
134
+ }
135
+
136
+ const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
137
+ const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
138
+ if (!legacyKeyId || !legacyPrivateKey) {
139
+ throw new Error('Data-at-rest decryption key registry is not configured');
140
+ }
141
+
142
+ keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
143
+
144
+ return {
145
+ activeKeyId: configuredActiveKeyId ?? legacyKeyId,
146
+ keys
147
+ };
148
+ }
149
+
150
+ function buildPrivateKeyCandidates(
151
+ recordKeyId: string,
152
+ registry: PrivateKeyRegistry
153
+ ): Array<{ keyId: string; privateKeyPem: string }> {
154
+ const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
155
+ const seen = new Set<string>();
156
+
157
+ const appendCandidate = (candidateKeyId: string | null): void => {
158
+ if (!candidateKeyId || seen.has(candidateKeyId)) {
159
+ return;
160
+ }
161
+
162
+ const privateKeyPem = registry.keys[candidateKeyId];
163
+ if (!privateKeyPem) {
164
+ return;
165
+ }
166
+
167
+ seen.add(candidateKeyId);
168
+ candidates.push({ keyId: candidateKeyId, privateKeyPem });
169
+ };
170
+
171
+ appendCandidate(getNonEmptyString(recordKeyId));
172
+ appendCandidate(registry.activeKeyId);
173
+
174
+ for (const keyId of Object.keys(registry.keys)) {
175
+ appendCandidate(keyId);
176
+ }
177
+
178
+ return candidates;
179
+ }
180
+
181
+ function logAuditDecryptionTelemetry(input: {
182
+ recordKeyId: string;
183
+ selectedKeyId: string | null;
184
+ attemptCount: number;
185
+ outcome: DecryptionTelemetryOutcome;
186
+ reason?: string;
187
+ }): void {
188
+ const details = {
189
+ scope: 'audit-at-rest',
190
+ recordKeyId: input.recordKeyId,
191
+ selectedKeyId: input.selectedKeyId,
192
+ attemptCount: input.attemptCount,
193
+ fallbackUsed: input.outcome === 'fallback-hit',
194
+ outcome: input.outcome,
195
+ reason: input.reason ?? null
196
+ };
197
+
198
+ if (input.outcome === 'all-failed') {
199
+ console.warn('Key registry decryption failed', details);
200
+ return;
201
+ }
202
+
203
+ console.info('Key registry decryption resolved', details);
204
+ }
205
+
206
+ async function decryptAuditJsonWithRegistry(
207
+ ciphertext: ArrayBuffer,
208
+ envelope: DataAtRestEnvelope,
209
+ env: Env
210
+ ): Promise<string> {
211
+ const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
212
+ const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
213
+ const primaryKeyId = candidates[0]?.keyId ?? null;
214
+ let lastError: unknown;
215
+
216
+ for (let index = 0; index < candidates.length; index += 1) {
217
+ const candidate = candidates[index];
218
+ try {
219
+ const plaintext = await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
220
+ logAuditDecryptionTelemetry({
221
+ recordKeyId: envelope.keyId,
222
+ selectedKeyId: candidate.keyId,
223
+ attemptCount: index + 1,
224
+ outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
225
+ });
226
+ return plaintext;
227
+ } catch (error) {
228
+ lastError = error;
229
+ }
230
+ }
231
+
232
+ logAuditDecryptionTelemetry({
233
+ recordKeyId: envelope.keyId,
234
+ selectedKeyId: null,
235
+ attemptCount: candidates.length,
236
+ outcome: 'all-failed',
237
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
238
+ });
239
+
240
+ throw new Error(
241
+ `Failed to decrypt audit record after ${candidates.length} key attempt(s): ${
242
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
243
+ }`
244
+ );
245
+ }
246
+
61
247
  function isDataAtRestEncryptionEnabled(env: Env): boolean {
62
248
  const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
63
249
  if (!value) {
@@ -357,15 +543,11 @@ async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise
357
543
  throw new Error('Unsupported data-at-rest encryption version');
358
544
  }
359
545
 
360
- if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
361
- throw new Error('Data-at-rest decryption is not configured on this server');
362
- }
363
-
364
546
  const encryptedData = await file.arrayBuffer();
365
- const plaintext = await decryptJsonFromStorage(
547
+ const plaintext = await decryptAuditJsonWithRegistry(
366
548
  encryptedData,
367
549
  atRestEnvelope,
368
- env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
550
+ env
369
551
  );
370
552
 
371
553
  return JSON.parse(plaintext) as AuditEntry[];
@@ -7,7 +7,7 @@
7
7
  "name": "AUDIT_WORKER_NAME",
8
8
  "account_id": "ACCOUNT_ID",
9
9
  "main": "src/audit-worker.ts",
10
- "compatibility_date": "2026-03-24",
10
+ "compatibility_date": "2026-03-25",
11
11
  "compatibility_flags": [
12
12
  "nodejs_compat"
13
13
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.76.0"
12
+ "wrangler": "^4.77.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",