@striae-org/striae 5.0.0 → 5.1.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 +5 -2
  2. package/app/components/actions/case-export/download-handlers.ts +6 -7
  3. package/app/components/actions/case-manage.ts +10 -11
  4. package/app/components/actions/generate-pdf.ts +43 -1
  5. package/app/components/actions/image-manage.ts +13 -45
  6. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  7. package/app/routes/striae/striae.tsx +15 -4
  8. package/app/utils/data/operations/case-operations.ts +13 -1
  9. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  10. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  11. package/package.json +2 -2
  12. package/scripts/deploy-config.sh +149 -6
  13. package/scripts/deploy-pages-secrets.sh +0 -6
  14. package/scripts/deploy-worker-secrets.sh +66 -5
  15. package/scripts/encrypt-r2-backfill.mjs +376 -0
  16. package/worker-configuration.d.ts +13 -7
  17. package/workers/audit-worker/package.json +1 -4
  18. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  19. package/workers/audit-worker/wrangler.jsonc.example +5 -0
  20. package/workers/data-worker/package.json +1 -4
  21. package/workers/data-worker/src/data-worker.example.ts +280 -2
  22. package/workers/data-worker/src/encryption-utils.ts +145 -1
  23. package/workers/data-worker/wrangler.jsonc.example +4 -0
  24. package/workers/image-worker/package.json +1 -4
  25. package/workers/image-worker/src/encryption-utils.ts +217 -0
  26. package/workers/image-worker/src/image-worker.example.ts +196 -127
  27. package/workers/image-worker/wrangler.jsonc.example +7 -0
  28. package/workers/keys-worker/package.json +1 -4
  29. package/workers/pdf-worker/package.json +1 -4
  30. package/workers/user-worker/package.json +1 -4
@@ -1,13 +1,16 @@
1
1
  interface Env {
2
2
  R2_KEY_SECRET: string;
3
3
  STRIAE_AUDIT: R2Bucket;
4
+ DATA_AT_REST_ENCRYPTION_ENABLED?: string;
5
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
6
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
7
+ DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
4
8
  }
5
9
 
6
10
  interface AuditEntry {
7
11
  timestamp: string;
8
12
  userId: string;
9
13
  action: string;
10
- // Optional metadata fields that can be included
11
14
  [key: string]: unknown;
12
15
  }
13
16
 
@@ -26,7 +29,15 @@ interface AuditRetrievalResponse {
26
29
  total: number;
27
30
  }
28
31
 
29
- type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse;
32
+ type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse | Record<string, unknown>;
33
+
34
+ interface DataAtRestEnvelope {
35
+ algorithm: string;
36
+ encryptionVersion: string;
37
+ keyId: string;
38
+ dataIv: string;
39
+ wrappedKey: string;
40
+ }
30
41
 
31
42
  const corsHeaders: Record<string, string> = {
32
43
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
@@ -36,58 +47,513 @@ const corsHeaders: Record<string, string> = {
36
47
  };
37
48
 
38
49
  const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
39
- JSON.stringify(data),
50
+ JSON.stringify(data),
40
51
  { status, headers: corsHeaders }
41
52
  );
42
53
 
43
- const hasValidHeader = (request: Request, env: Env): boolean =>
44
- request.headers.get("X-Custom-Auth-Key") === env.R2_KEY_SECRET;
54
+ const hasValidHeader = (request: Request, env: Env): boolean =>
55
+ request.headers.get('X-Custom-Auth-Key') === env.R2_KEY_SECRET;
56
+
57
+ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
58
+ const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
59
+ const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
60
+
61
+ function isDataAtRestEncryptionEnabled(env: Env): boolean {
62
+ const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
63
+ if (!value) {
64
+ return false;
65
+ }
66
+
67
+ const normalizedValue = value.trim().toLowerCase();
68
+ return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'yes' || normalizedValue === 'on';
69
+ }
45
70
 
46
- // Helper function to generate audit file names with user and date
47
- const generateAuditFileName = (userId: string): string => {
48
- const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
71
+ function generateAuditFileName(userId: string): string {
72
+ const date = new Date().toISOString().split('T')[0];
49
73
  return `audit-trails/${userId}/${date}.json`;
50
- };
74
+ }
75
+
76
+ function isValidAuditEntry(entry: unknown): entry is AuditEntry {
77
+ const candidate = entry as Partial<AuditEntry> | null;
78
+
79
+ return (
80
+ typeof candidate === 'object' &&
81
+ candidate !== null &&
82
+ typeof candidate.timestamp === 'string' &&
83
+ typeof candidate.userId === 'string' &&
84
+ typeof candidate.action === 'string'
85
+ );
86
+ }
87
+
88
+ function base64UrlDecode(value: string): Uint8Array {
89
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
90
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
91
+ const decoded = atob(normalized + padding);
92
+ const bytes = new Uint8Array(decoded.length);
93
+
94
+ for (let i = 0; i < decoded.length; i += 1) {
95
+ bytes[i] = decoded.charCodeAt(i);
96
+ }
97
+
98
+ return bytes;
99
+ }
100
+
101
+ function base64UrlEncode(value: Uint8Array): string {
102
+ let binary = '';
103
+ const chunkSize = 8192;
104
+
105
+ for (let i = 0; i < value.length; i += chunkSize) {
106
+ const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
107
+ for (let j = 0; j < chunk.length; j += 1) {
108
+ binary += String.fromCharCode(chunk[j]);
109
+ }
110
+ }
111
+
112
+ return btoa(binary)
113
+ .replace(/\+/g, '-')
114
+ .replace(/\//g, '_')
115
+ .replace(/=+$/g, '');
116
+ }
117
+
118
+ function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
119
+ const normalizedKey = privateKey
120
+ .trim()
121
+ .replace(/^['"]|['"]$/g, '')
122
+ .replace(/\\n/g, '\n');
123
+
124
+ const pemBody = normalizedKey
125
+ .replace('-----BEGIN PRIVATE KEY-----', '')
126
+ .replace('-----END PRIVATE KEY-----', '')
127
+ .replace(/\s+/g, '');
128
+
129
+ if (!pemBody) {
130
+ throw new Error('Encryption private key is invalid');
131
+ }
132
+
133
+ const binary = atob(pemBody);
134
+ const bytes = new Uint8Array(binary.length);
135
+
136
+ for (let index = 0; index < binary.length; index += 1) {
137
+ bytes[index] = binary.charCodeAt(index);
138
+ }
139
+
140
+ return bytes.buffer;
141
+ }
142
+
143
+ function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
144
+ const normalizedKey = publicKey
145
+ .trim()
146
+ .replace(/^['"]|['"]$/g, '')
147
+ .replace(/\\n/g, '\n');
148
+
149
+ const pemBody = normalizedKey
150
+ .replace('-----BEGIN PUBLIC KEY-----', '')
151
+ .replace('-----END PUBLIC KEY-----', '')
152
+ .replace(/\s+/g, '');
153
+
154
+ if (!pemBody) {
155
+ throw new Error('Encryption public key is invalid');
156
+ }
157
+
158
+ const binary = atob(pemBody);
159
+ const bytes = new Uint8Array(binary.length);
160
+
161
+ for (let index = 0; index < binary.length; index += 1) {
162
+ bytes[index] = binary.charCodeAt(index);
163
+ }
164
+
165
+ return bytes.buffer;
166
+ }
167
+
168
+ async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
169
+ return crypto.subtle.importKey(
170
+ 'pkcs8',
171
+ parsePkcs8PrivateKey(privateKeyPem),
172
+ {
173
+ name: 'RSA-OAEP',
174
+ hash: 'SHA-256'
175
+ },
176
+ false,
177
+ ['decrypt']
178
+ );
179
+ }
180
+
181
+ async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
182
+ return crypto.subtle.importKey(
183
+ 'spki',
184
+ parseSpkiPublicKey(publicKeyPem),
185
+ {
186
+ name: 'RSA-OAEP',
187
+ hash: 'SHA-256'
188
+ },
189
+ false,
190
+ ['encrypt']
191
+ );
192
+ }
193
+
194
+ async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
195
+ return crypto.subtle.generateKey(
196
+ {
197
+ name: 'AES-GCM',
198
+ length: 256
199
+ },
200
+ true,
201
+ usages
202
+ ) as Promise<CryptoKey>;
203
+ }
204
+
205
+ async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
206
+ const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
207
+ const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
208
+ const wrappedKey = await crypto.subtle.encrypt(
209
+ { name: 'RSA-OAEP' },
210
+ rsaPublicKey,
211
+ rawAesKey as BufferSource
212
+ );
213
+
214
+ return base64UrlEncode(new Uint8Array(wrappedKey));
215
+ }
216
+
217
+ async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
218
+ const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
219
+ const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
220
+
221
+ const rawAesKey = await crypto.subtle.decrypt(
222
+ { name: 'RSA-OAEP' },
223
+ rsaPrivateKey,
224
+ wrappedKeyBytes as BufferSource
225
+ );
226
+
227
+ return crypto.subtle.importKey(
228
+ 'raw',
229
+ rawAesKey,
230
+ { name: 'AES-GCM' },
231
+ false,
232
+ ['decrypt']
233
+ );
234
+ }
235
+
236
+ async function decryptJsonFromStorage(
237
+ ciphertext: ArrayBuffer,
238
+ envelope: DataAtRestEnvelope,
239
+ privateKeyPem: string
240
+ ): Promise<string> {
241
+ const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
242
+ const iv = base64UrlDecode(envelope.dataIv);
243
+
244
+ const plaintext = await crypto.subtle.decrypt(
245
+ { name: 'AES-GCM', iv: iv as BufferSource },
246
+ aesKey,
247
+ ciphertext as BufferSource
248
+ );
249
+
250
+ return new TextDecoder().decode(plaintext);
251
+ }
252
+
253
+ async function encryptJsonForStorage(
254
+ plaintextJson: string,
255
+ publicKeyPem: string,
256
+ keyId: string
257
+ ): Promise<{ ciphertext: Uint8Array; envelope: DataAtRestEnvelope }> {
258
+ const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
259
+ const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
260
+ const iv = crypto.getRandomValues(new Uint8Array(12));
261
+
262
+ const plaintextBytes = new TextEncoder().encode(plaintextJson);
263
+ const encryptedBuffer = await crypto.subtle.encrypt(
264
+ { name: 'AES-GCM', iv: iv as BufferSource },
265
+ aesKey,
266
+ plaintextBytes as BufferSource
267
+ );
268
+
269
+ return {
270
+ ciphertext: new Uint8Array(encryptedBuffer),
271
+ envelope: {
272
+ algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
273
+ encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
274
+ keyId,
275
+ dataIv: base64UrlEncode(iv),
276
+ wrappedKey
277
+ }
278
+ };
279
+ }
280
+
281
+ function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
282
+ const metadata = file.customMetadata;
283
+ if (!metadata) {
284
+ return null;
285
+ }
286
+
287
+ const {
288
+ algorithm,
289
+ encryptionVersion,
290
+ keyId,
291
+ dataIv,
292
+ wrappedKey
293
+ } = metadata;
51
294
 
52
- // Helper function to append audit entry to existing file
53
- const appendAuditEntry = async (bucket: R2Bucket, filename: string, newEntry: AuditEntry): Promise<number> => {
295
+ if (
296
+ typeof algorithm !== 'string' ||
297
+ typeof encryptionVersion !== 'string' ||
298
+ typeof keyId !== 'string' ||
299
+ typeof dataIv !== 'string' ||
300
+ typeof wrappedKey !== 'string'
301
+ ) {
302
+ return null;
303
+ }
304
+
305
+ return {
306
+ algorithm,
307
+ encryptionVersion,
308
+ keyId,
309
+ dataIv,
310
+ wrappedKey
311
+ };
312
+ }
313
+
314
+ function hasDataAtRestMetadata(metadata: Record<string, string> | undefined): boolean {
315
+ if (!metadata) {
316
+ return false;
317
+ }
318
+
319
+ return (
320
+ typeof metadata.algorithm === 'string' &&
321
+ typeof metadata.encryptionVersion === 'string' &&
322
+ typeof metadata.keyId === 'string' &&
323
+ typeof metadata.dataIv === 'string' &&
324
+ typeof metadata.wrappedKey === 'string'
325
+ );
326
+ }
327
+
328
+ function clampBackfillBatchSize(size: number | undefined): number {
329
+ if (typeof size !== 'number' || !Number.isFinite(size)) {
330
+ return 100;
331
+ }
332
+
333
+ const normalized = Math.floor(size);
334
+ if (normalized < 1) {
335
+ return 1;
336
+ }
337
+
338
+ if (normalized > 1000) {
339
+ return 1000;
340
+ }
341
+
342
+ return normalized;
343
+ }
344
+
345
+ async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise<AuditEntry[]> {
346
+ const atRestEnvelope = extractDataAtRestEnvelope(file);
347
+ if (!atRestEnvelope) {
348
+ const fileText = await file.text();
349
+ return JSON.parse(fileText) as AuditEntry[];
350
+ }
351
+
352
+ if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
353
+ throw new Error('Unsupported data-at-rest encryption algorithm');
354
+ }
355
+
356
+ if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
357
+ throw new Error('Unsupported data-at-rest encryption version');
358
+ }
359
+
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
+ const encryptedData = await file.arrayBuffer();
365
+ const plaintext = await decryptJsonFromStorage(
366
+ encryptedData,
367
+ atRestEnvelope,
368
+ env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
369
+ );
370
+
371
+ return JSON.parse(plaintext) as AuditEntry[];
372
+ }
373
+
374
+ async function writeAuditEntriesToObject(
375
+ bucket: R2Bucket,
376
+ filename: string,
377
+ entries: AuditEntry[],
378
+ env: Env
379
+ ): Promise<void> {
380
+ const serializedData = JSON.stringify(entries);
381
+
382
+ if (!isDataAtRestEncryptionEnabled(env)) {
383
+ await bucket.put(filename, serializedData);
384
+ return;
385
+ }
386
+
387
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
388
+ throw new Error('Data-at-rest encryption is enabled but not fully configured');
389
+ }
390
+
391
+ const encryptedPayload = await encryptJsonForStorage(
392
+ serializedData,
393
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
394
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
395
+ );
396
+
397
+ await bucket.put(filename, encryptedPayload.ciphertext, {
398
+ customMetadata: {
399
+ algorithm: encryptedPayload.envelope.algorithm,
400
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
401
+ keyId: encryptedPayload.envelope.keyId,
402
+ dataIv: encryptedPayload.envelope.dataIv,
403
+ wrappedKey: encryptedPayload.envelope.wrappedKey
404
+ }
405
+ });
406
+ }
407
+
408
+ async function appendAuditEntry(
409
+ bucket: R2Bucket,
410
+ filename: string,
411
+ newEntry: AuditEntry,
412
+ env: Env
413
+ ): Promise<number> {
54
414
  try {
55
415
  const existingFile = await bucket.get(filename);
56
416
  let entries: AuditEntry[] = [];
57
-
417
+
58
418
  if (existingFile) {
59
- const existingData = await existingFile.text();
60
- entries = JSON.parse(existingData);
419
+ entries = await readAuditEntriesFromObject(existingFile, env);
61
420
  }
62
-
421
+
63
422
  entries.push(newEntry);
64
- await bucket.put(filename, JSON.stringify(entries));
423
+ await writeAuditEntriesToObject(bucket, filename, entries, env);
65
424
  return entries.length;
66
425
  } catch (error) {
67
426
  console.error('Error appending audit entry:', error);
68
427
  throw error;
69
428
  }
70
- };
429
+ }
71
430
 
72
- // Type guard to validate audit entry structure
73
- const isValidAuditEntry = (entry: unknown): entry is AuditEntry => {
74
- const candidate = entry as Partial<AuditEntry> | null;
431
+ async function handleDataAtRestBackfill(request: Request, env: Env): Promise<Response> {
432
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
433
+ return createResponse(
434
+ { error: 'Data-at-rest encryption is not configured for backfill writes' },
435
+ 400
436
+ );
437
+ }
75
438
 
76
- return (
77
- typeof candidate === 'object' &&
78
- candidate !== null &&
79
- typeof candidate.timestamp === 'string' &&
80
- typeof candidate.userId === 'string' &&
81
- typeof candidate.action === 'string'
82
- );
83
- };
439
+ const requestBody = await request.json().catch(() => ({})) as {
440
+ dryRun?: boolean;
441
+ prefix?: string;
442
+ cursor?: string;
443
+ batchSize?: number;
444
+ };
445
+
446
+ const dryRun = requestBody.dryRun === true;
447
+ const prefix = typeof requestBody.prefix === 'string' ? requestBody.prefix : '';
448
+ const cursor = typeof requestBody.cursor === 'string' && requestBody.cursor.length > 0
449
+ ? requestBody.cursor
450
+ : undefined;
451
+ const batchSize = clampBackfillBatchSize(requestBody.batchSize);
452
+
453
+ const bucket = env.STRIAE_AUDIT;
454
+ const listed = await bucket.list({
455
+ prefix: prefix.length > 0 ? prefix : undefined,
456
+ cursor,
457
+ limit: batchSize
458
+ });
459
+
460
+ let scanned = 0;
461
+ let eligible = 0;
462
+ let encrypted = 0;
463
+ let skippedEncrypted = 0;
464
+ let skippedNonJson = 0;
465
+ let failed = 0;
466
+ const failures: Array<{ key: string; error: string }> = [];
467
+
468
+ for (const object of listed.objects) {
469
+ scanned += 1;
470
+ const key = object.key;
471
+
472
+ if (!key.endsWith('.json')) {
473
+ skippedNonJson += 1;
474
+ continue;
475
+ }
476
+
477
+ const objectHead = await bucket.head(key);
478
+ if (!objectHead) {
479
+ failed += 1;
480
+ if (failures.length < 20) {
481
+ failures.push({ key, error: 'Object not found during metadata check' });
482
+ }
483
+ continue;
484
+ }
485
+
486
+ if (hasDataAtRestMetadata(objectHead.customMetadata)) {
487
+ skippedEncrypted += 1;
488
+ continue;
489
+ }
490
+
491
+ eligible += 1;
492
+
493
+ if (dryRun) {
494
+ continue;
495
+ }
496
+
497
+ try {
498
+ const existingObject = await bucket.get(key);
499
+ if (!existingObject) {
500
+ failed += 1;
501
+ if (failures.length < 20) {
502
+ failures.push({ key, error: 'Object disappeared before processing' });
503
+ }
504
+ continue;
505
+ }
506
+
507
+ const plaintext = await existingObject.text();
508
+ const encryptedPayload = await encryptJsonForStorage(
509
+ plaintext,
510
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
511
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
512
+ );
513
+
514
+ await bucket.put(key, encryptedPayload.ciphertext, {
515
+ customMetadata: {
516
+ algorithm: encryptedPayload.envelope.algorithm,
517
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
518
+ keyId: encryptedPayload.envelope.keyId,
519
+ dataIv: encryptedPayload.envelope.dataIv,
520
+ wrappedKey: encryptedPayload.envelope.wrappedKey
521
+ }
522
+ });
523
+
524
+ encrypted += 1;
525
+ } catch (error) {
526
+ failed += 1;
527
+ if (failures.length < 20) {
528
+ const errorMessage = error instanceof Error ? error.message : 'Unknown backfill failure';
529
+ failures.push({ key, error: errorMessage });
530
+ }
531
+ }
532
+ }
533
+
534
+ return createResponse({
535
+ success: failed === 0,
536
+ dryRun,
537
+ prefix: prefix.length > 0 ? prefix : null,
538
+ batchSize,
539
+ scanned,
540
+ eligible,
541
+ encrypted,
542
+ skippedEncrypted,
543
+ skippedNonJson,
544
+ failed,
545
+ failures,
546
+ hasMore: listed.truncated,
547
+ nextCursor: listed.truncated ? listed.cursor : null
548
+ });
549
+ }
84
550
 
85
551
  export default {
86
- async fetch(request: Request, env: Env): Promise<Response> {
552
+ async fetch(request: Request, env: Env): Promise<Response> {
87
553
  if (request.method === 'OPTIONS') {
88
554
  return new Response(null, { headers: corsHeaders });
89
555
  }
90
-
556
+
91
557
  if (!hasValidHeader(request, env)) {
92
558
  return createResponse({ error: 'Forbidden' }, 403);
93
559
  }
@@ -97,7 +563,10 @@ export default {
97
563
  const pathname = url.pathname;
98
564
  const bucket = env.STRIAE_AUDIT;
99
565
 
100
- // This worker only handles audit trail endpoints
566
+ if (request.method === 'POST' && pathname === DATA_AT_REST_BACKFILL_PATH) {
567
+ return await handleDataAtRestBackfill(request, env);
568
+ }
569
+
101
570
  if (!pathname.startsWith('/audit/')) {
102
571
  return createResponse({ error: 'This worker only handles audit endpoints. Use /audit/ path.' }, 404);
103
572
  }
@@ -105,77 +574,69 @@ export default {
105
574
  const userId = url.searchParams.get('userId');
106
575
  const startDate = url.searchParams.get('startDate');
107
576
  const endDate = url.searchParams.get('endDate');
108
-
577
+
109
578
  if (request.method === 'POST') {
110
- // Add audit entry
111
579
  if (!userId) {
112
580
  return createResponse({ error: 'userId parameter is required' }, 400);
113
581
  }
114
-
582
+
115
583
  const auditEntry: unknown = await request.json();
116
-
117
- // Validate audit entry structure using type guard
584
+
118
585
  if (!isValidAuditEntry(auditEntry)) {
119
586
  return createResponse({ error: 'Invalid audit entry structure. Required fields: timestamp, userId, action' }, 400);
120
587
  }
121
-
588
+
122
589
  const filename = generateAuditFileName(userId);
123
-
590
+
124
591
  try {
125
- const entryCount = await appendAuditEntry(bucket, filename, auditEntry);
126
- return createResponse({
127
- success: true,
592
+ const entryCount = await appendAuditEntry(bucket, filename, auditEntry, env);
593
+ return createResponse({
594
+ success: true,
128
595
  entryCount,
129
- filename
596
+ filename
130
597
  });
131
598
  } catch (error) {
132
599
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
133
600
  return createResponse({ error: `Failed to store audit entry: ${errorMessage}` }, 500);
134
601
  }
135
602
  }
136
-
603
+
137
604
  if (request.method === 'GET') {
138
- // Retrieve audit entries
139
605
  if (!userId) {
140
606
  return createResponse({ error: 'userId parameter is required' }, 400);
141
607
  }
142
-
608
+
143
609
  try {
144
610
  let allEntries: AuditEntry[] = [];
145
-
611
+
146
612
  if (startDate && endDate) {
147
- // Get entries for date range
148
613
  const start = new Date(startDate);
149
614
  const end = new Date(endDate);
150
615
  const currentDate = new Date(start);
151
-
616
+
152
617
  while (currentDate <= end) {
153
618
  const dateStr = currentDate.toISOString().split('T')[0];
154
619
  const filename = `audit-trails/${userId}/${dateStr}.json`;
155
620
  const file = await bucket.get(filename);
156
-
621
+
157
622
  if (file) {
158
- const fileText = await file.text();
159
- const entries: AuditEntry[] = JSON.parse(fileText);
623
+ const entries = await readAuditEntriesFromObject(file, env);
160
624
  allEntries.push(...entries);
161
625
  }
162
-
626
+
163
627
  currentDate.setDate(currentDate.getDate() + 1);
164
628
  }
165
629
  } else {
166
- // Get today's entries
167
630
  const filename = generateAuditFileName(userId);
168
631
  const file = await bucket.get(filename);
169
-
632
+
170
633
  if (file) {
171
- const fileText = await file.text();
172
- allEntries = JSON.parse(fileText);
634
+ allEntries = await readAuditEntriesFromObject(file, env);
173
635
  }
174
636
  }
175
-
176
- // Sort by timestamp (newest first)
637
+
177
638
  allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
178
-
639
+
179
640
  return createResponse({
180
641
  entries: allEntries,
181
642
  total: allEntries.length
@@ -185,7 +646,7 @@ export default {
185
646
  return createResponse({ error: `Failed to retrieve audit entries: ${errorMessage}` }, 500);
186
647
  }
187
648
  }
188
-
649
+
189
650
  return createResponse({ error: 'Method not allowed for audit endpoints. Only GET and POST are supported.' }, 405);
190
651
 
191
652
  } catch (error) {
@@ -194,4 +655,4 @@ export default {
194
655
  return createResponse({ error: errorMessage }, 500);
195
656
  }
196
657
  }
197
- };
658
+ };
@@ -1,4 +1,9 @@
1
1
  {
2
+ // Required secrets: R2_KEY_SECRET
3
+ // Optional data-at-rest secrets/vars:
4
+ // - DATA_AT_REST_ENCRYPTION_ENABLED=true
5
+ // - DATA_AT_REST_ENCRYPTION_PRIVATE_KEY (required for decrypting encrypted records)
6
+ // - DATA_AT_REST_ENCRYPTION_PUBLIC_KEY and DATA_AT_REST_ENCRYPTION_KEY_ID (required when encrypt-on-write is enabled)
2
7
  "name": "AUDIT_WORKER_NAME",
3
8
  "account_id": "ACCOUNT_ID",
4
9
  "main": "src/audit-worker.ts",