@striae-org/striae 5.2.1 → 5.3.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 (105) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +2 -174
  4. package/app/components/actions/case-export/download-handlers.ts +83 -750
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +13 -14
  9. package/app/components/actions/case-import/zip-processing.ts +92 -12
  10. package/app/components/actions/generate-pdf.ts +3 -2
  11. package/app/components/audit/user-audit-viewer.tsx +0 -19
  12. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  13. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  14. package/app/components/navbar/navbar.tsx +1 -1
  15. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
  19. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  20. package/app/components/toast/toast.module.css +36 -0
  21. package/app/components/toast/toast.tsx +6 -2
  22. package/app/components/user/manage-profile.tsx +4 -3
  23. package/app/config-example/config.json +1 -2
  24. package/app/root.tsx +0 -7
  25. package/app/routes/_index.tsx +1 -1
  26. package/app/routes/auth/login.example.tsx +22 -103
  27. package/app/routes/auth/route.ts +1 -1
  28. package/app/routes/striae/striae.tsx +53 -59
  29. package/app/services/firebase/index.ts +0 -3
  30. package/app/types/export.ts +1 -2
  31. package/app/utils/auth/index.ts +0 -1
  32. package/app/utils/data/permissions.ts +3 -2
  33. package/package.json +9 -16
  34. package/public/_headers +0 -4
  35. package/public/_routes.json +0 -1
  36. package/worker-configuration.d.ts +20 -17
  37. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  38. package/workers/audit-worker/src/config.ts +7 -0
  39. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  40. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  41. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  42. package/workers/audit-worker/src/types.ts +56 -0
  43. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  45. package/workers/data-worker/src/config.ts +11 -0
  46. package/workers/data-worker/src/data-worker.example.ts +21 -942
  47. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  48. package/workers/data-worker/src/handlers/signing.ts +174 -0
  49. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  50. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  51. package/workers/data-worker/src/types.ts +46 -0
  52. package/workers/data-worker/worker-configuration.d.ts +1 -1
  53. package/workers/data-worker/wrangler.jsonc.example +1 -1
  54. package/workers/image-worker/worker-configuration.d.ts +1 -1
  55. package/workers/image-worker/wrangler.jsonc.example +1 -1
  56. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/src/auth.ts +30 -0
  59. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  60. package/workers/user-worker/src/config.ts +4 -0
  61. package/workers/user-worker/src/encryption-utils.ts +25 -0
  62. package/workers/user-worker/src/firebase/admin.ts +152 -0
  63. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  64. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  65. package/workers/user-worker/src/storage/user-records.ts +34 -0
  66. package/workers/user-worker/src/types.ts +106 -0
  67. package/workers/user-worker/src/user-worker.example.ts +18 -964
  68. package/workers/user-worker/worker-configuration.d.ts +4 -2
  69. package/workers/user-worker/wrangler.jsonc.example +12 -1
  70. package/wrangler.toml.example +1 -1
  71. package/app/components/actions/case-export/data-processing.ts +0 -223
  72. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  73. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  74. package/app/types/exceljs-bare.d.ts +0 -9
  75. package/app/utils/auth/auth.ts +0 -11
  76. package/public/.well-known/security.txt +0 -6
  77. package/public/favicon.ico +0 -0
  78. package/public/icon-256.png +0 -0
  79. package/public/icon-512.png +0 -0
  80. package/public/manifest.json +0 -39
  81. package/public/shortcut.png +0 -0
  82. package/public/social-image.png +0 -0
  83. package/public/vendor/exceljs.LICENSE +0 -22
  84. package/public/vendor/exceljs.bare.min.js +0 -45
  85. package/scripts/deploy-all.sh +0 -166
  86. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  87. package/scripts/deploy-config/modules/keys.sh +0 -404
  88. package/scripts/deploy-config/modules/prompt.sh +0 -372
  89. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  90. package/scripts/deploy-config/modules/validation.sh +0 -365
  91. package/scripts/deploy-config.sh +0 -236
  92. package/scripts/deploy-pages-secrets.sh +0 -231
  93. package/scripts/deploy-pages.sh +0 -34
  94. package/scripts/deploy-primershear-emails.sh +0 -167
  95. package/scripts/deploy-worker-secrets.sh +0 -374
  96. package/scripts/dev.cjs +0 -23
  97. package/scripts/install-workers.sh +0 -88
  98. package/scripts/run-eslint.cjs +0 -43
  99. package/scripts/update-compatibility-dates.cjs +0 -124
  100. package/scripts/update-markdown-versions.cjs +0 -43
  101. package/workers/keys-worker/package.json +0 -18
  102. package/workers/keys-worker/src/keys.example.ts +0 -67
  103. package/workers/keys-worker/src/keys.ts +0 -67
  104. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  105. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -1,735 +1,19 @@
1
- interface Env {
2
- R2_KEY_SECRET: string;
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;
8
- DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
9
- DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
10
- }
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
-
24
- interface AuditEntry {
25
- timestamp: string;
26
- userId: string;
27
- action: string;
28
- [key: string]: unknown;
29
- }
30
-
31
- interface SuccessResponse {
32
- success: boolean;
33
- entryCount?: number;
34
- filename?: string;
35
- }
36
-
37
- interface ErrorResponse {
38
- error: string;
39
- }
40
-
41
- interface AuditRetrievalResponse {
42
- entries: AuditEntry[];
43
- total: number;
44
- }
45
-
46
- type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse | Record<string, unknown>;
47
-
48
- interface DataAtRestEnvelope {
49
- algorithm: string;
50
- encryptionVersion: string;
51
- keyId: string;
52
- dataIv: string;
53
- wrappedKey: string;
54
- }
1
+ import { hasValidHeader } from './config';
2
+ import { handleAuditRequest } from './handlers/audit-routes';
3
+ import type { CreateResponse, Env } from './types';
55
4
 
56
5
  const corsHeaders: Record<string, string> = {
57
6
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
58
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
7
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
59
8
  'Access-Control-Allow-Headers': 'Content-Type, X-Custom-Auth-Key',
60
9
  'Content-Type': 'application/json'
61
10
  };
62
11
 
63
- const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
12
+ const createWorkerResponse: CreateResponse = (data, status: number = 200): Response => new Response(
64
13
  JSON.stringify(data),
65
14
  { status, headers: corsHeaders }
66
15
  );
67
16
 
68
- const hasValidHeader = (request: Request, env: Env): boolean =>
69
- request.headers.get('X-Custom-Auth-Key') === env.R2_KEY_SECRET;
70
-
71
- const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
72
- const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
73
- const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
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
-
247
- function isDataAtRestEncryptionEnabled(env: Env): boolean {
248
- const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
249
- if (!value) {
250
- return false;
251
- }
252
-
253
- const normalizedValue = value.trim().toLowerCase();
254
- return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'yes' || normalizedValue === 'on';
255
- }
256
-
257
- function generateAuditFileName(userId: string): string {
258
- const date = new Date().toISOString().split('T')[0];
259
- return `audit-trails/${userId}/${date}.json`;
260
- }
261
-
262
- function isValidAuditEntry(entry: unknown): entry is AuditEntry {
263
- const candidate = entry as Partial<AuditEntry> | null;
264
-
265
- return (
266
- typeof candidate === 'object' &&
267
- candidate !== null &&
268
- typeof candidate.timestamp === 'string' &&
269
- typeof candidate.userId === 'string' &&
270
- typeof candidate.action === 'string'
271
- );
272
- }
273
-
274
- function base64UrlDecode(value: string): Uint8Array {
275
- const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
276
- const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
277
- const decoded = atob(normalized + padding);
278
- const bytes = new Uint8Array(decoded.length);
279
-
280
- for (let i = 0; i < decoded.length; i += 1) {
281
- bytes[i] = decoded.charCodeAt(i);
282
- }
283
-
284
- return bytes;
285
- }
286
-
287
- function base64UrlEncode(value: Uint8Array): string {
288
- let binary = '';
289
- const chunkSize = 8192;
290
-
291
- for (let i = 0; i < value.length; i += chunkSize) {
292
- const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
293
- for (let j = 0; j < chunk.length; j += 1) {
294
- binary += String.fromCharCode(chunk[j]);
295
- }
296
- }
297
-
298
- return btoa(binary)
299
- .replace(/\+/g, '-')
300
- .replace(/\//g, '_')
301
- .replace(/=+$/g, '');
302
- }
303
-
304
- function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
305
- const normalizedKey = privateKey
306
- .trim()
307
- .replace(/^['"]|['"]$/g, '')
308
- .replace(/\\n/g, '\n');
309
-
310
- const pemBody = normalizedKey
311
- .replace('-----BEGIN PRIVATE KEY-----', '')
312
- .replace('-----END PRIVATE KEY-----', '')
313
- .replace(/\s+/g, '');
314
-
315
- if (!pemBody) {
316
- throw new Error('Encryption private key is invalid');
317
- }
318
-
319
- const binary = atob(pemBody);
320
- const bytes = new Uint8Array(binary.length);
321
-
322
- for (let index = 0; index < binary.length; index += 1) {
323
- bytes[index] = binary.charCodeAt(index);
324
- }
325
-
326
- return bytes.buffer;
327
- }
328
-
329
- function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
330
- const normalizedKey = publicKey
331
- .trim()
332
- .replace(/^['"]|['"]$/g, '')
333
- .replace(/\\n/g, '\n');
334
-
335
- const pemBody = normalizedKey
336
- .replace('-----BEGIN PUBLIC KEY-----', '')
337
- .replace('-----END PUBLIC KEY-----', '')
338
- .replace(/\s+/g, '');
339
-
340
- if (!pemBody) {
341
- throw new Error('Encryption public key is invalid');
342
- }
343
-
344
- const binary = atob(pemBody);
345
- const bytes = new Uint8Array(binary.length);
346
-
347
- for (let index = 0; index < binary.length; index += 1) {
348
- bytes[index] = binary.charCodeAt(index);
349
- }
350
-
351
- return bytes.buffer;
352
- }
353
-
354
- async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
355
- return crypto.subtle.importKey(
356
- 'pkcs8',
357
- parsePkcs8PrivateKey(privateKeyPem),
358
- {
359
- name: 'RSA-OAEP',
360
- hash: 'SHA-256'
361
- },
362
- false,
363
- ['decrypt']
364
- );
365
- }
366
-
367
- async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
368
- return crypto.subtle.importKey(
369
- 'spki',
370
- parseSpkiPublicKey(publicKeyPem),
371
- {
372
- name: 'RSA-OAEP',
373
- hash: 'SHA-256'
374
- },
375
- false,
376
- ['encrypt']
377
- );
378
- }
379
-
380
- async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
381
- return crypto.subtle.generateKey(
382
- {
383
- name: 'AES-GCM',
384
- length: 256
385
- },
386
- true,
387
- usages
388
- ) as Promise<CryptoKey>;
389
- }
390
-
391
- async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
392
- const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
393
- const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
394
- const wrappedKey = await crypto.subtle.encrypt(
395
- { name: 'RSA-OAEP' },
396
- rsaPublicKey,
397
- rawAesKey as BufferSource
398
- );
399
-
400
- return base64UrlEncode(new Uint8Array(wrappedKey));
401
- }
402
-
403
- async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
404
- const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
405
- const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
406
-
407
- const rawAesKey = await crypto.subtle.decrypt(
408
- { name: 'RSA-OAEP' },
409
- rsaPrivateKey,
410
- wrappedKeyBytes as BufferSource
411
- );
412
-
413
- return crypto.subtle.importKey(
414
- 'raw',
415
- rawAesKey,
416
- { name: 'AES-GCM' },
417
- false,
418
- ['decrypt']
419
- );
420
- }
421
-
422
- async function decryptJsonFromStorage(
423
- ciphertext: ArrayBuffer,
424
- envelope: DataAtRestEnvelope,
425
- privateKeyPem: string
426
- ): Promise<string> {
427
- const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
428
- const iv = base64UrlDecode(envelope.dataIv);
429
-
430
- const plaintext = await crypto.subtle.decrypt(
431
- { name: 'AES-GCM', iv: iv as BufferSource },
432
- aesKey,
433
- ciphertext as BufferSource
434
- );
435
-
436
- return new TextDecoder().decode(plaintext);
437
- }
438
-
439
- async function encryptJsonForStorage(
440
- plaintextJson: string,
441
- publicKeyPem: string,
442
- keyId: string
443
- ): Promise<{ ciphertext: Uint8Array; envelope: DataAtRestEnvelope }> {
444
- const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
445
- const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
446
- const iv = crypto.getRandomValues(new Uint8Array(12));
447
-
448
- const plaintextBytes = new TextEncoder().encode(plaintextJson);
449
- const encryptedBuffer = await crypto.subtle.encrypt(
450
- { name: 'AES-GCM', iv: iv as BufferSource },
451
- aesKey,
452
- plaintextBytes as BufferSource
453
- );
454
-
455
- return {
456
- ciphertext: new Uint8Array(encryptedBuffer),
457
- envelope: {
458
- algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
459
- encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
460
- keyId,
461
- dataIv: base64UrlEncode(iv),
462
- wrappedKey
463
- }
464
- };
465
- }
466
-
467
- function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
468
- const metadata = file.customMetadata;
469
- if (!metadata) {
470
- return null;
471
- }
472
-
473
- const {
474
- algorithm,
475
- encryptionVersion,
476
- keyId,
477
- dataIv,
478
- wrappedKey
479
- } = metadata;
480
-
481
- if (
482
- typeof algorithm !== 'string' ||
483
- typeof encryptionVersion !== 'string' ||
484
- typeof keyId !== 'string' ||
485
- typeof dataIv !== 'string' ||
486
- typeof wrappedKey !== 'string'
487
- ) {
488
- return null;
489
- }
490
-
491
- return {
492
- algorithm,
493
- encryptionVersion,
494
- keyId,
495
- dataIv,
496
- wrappedKey
497
- };
498
- }
499
-
500
- function hasDataAtRestMetadata(metadata: Record<string, string> | undefined): boolean {
501
- if (!metadata) {
502
- return false;
503
- }
504
-
505
- return (
506
- typeof metadata.algorithm === 'string' &&
507
- typeof metadata.encryptionVersion === 'string' &&
508
- typeof metadata.keyId === 'string' &&
509
- typeof metadata.dataIv === 'string' &&
510
- typeof metadata.wrappedKey === 'string'
511
- );
512
- }
513
-
514
- function clampBackfillBatchSize(size: number | undefined): number {
515
- if (typeof size !== 'number' || !Number.isFinite(size)) {
516
- return 100;
517
- }
518
-
519
- const normalized = Math.floor(size);
520
- if (normalized < 1) {
521
- return 1;
522
- }
523
-
524
- if (normalized > 1000) {
525
- return 1000;
526
- }
527
-
528
- return normalized;
529
- }
530
-
531
- async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise<AuditEntry[]> {
532
- const atRestEnvelope = extractDataAtRestEnvelope(file);
533
- if (!atRestEnvelope) {
534
- const fileText = await file.text();
535
- return JSON.parse(fileText) as AuditEntry[];
536
- }
537
-
538
- if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
539
- throw new Error('Unsupported data-at-rest encryption algorithm');
540
- }
541
-
542
- if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
543
- throw new Error('Unsupported data-at-rest encryption version');
544
- }
545
-
546
- const encryptedData = await file.arrayBuffer();
547
- const plaintext = await decryptAuditJsonWithRegistry(
548
- encryptedData,
549
- atRestEnvelope,
550
- env
551
- );
552
-
553
- return JSON.parse(plaintext) as AuditEntry[];
554
- }
555
-
556
- async function writeAuditEntriesToObject(
557
- bucket: R2Bucket,
558
- filename: string,
559
- entries: AuditEntry[],
560
- env: Env
561
- ): Promise<void> {
562
- const serializedData = JSON.stringify(entries);
563
-
564
- if (!isDataAtRestEncryptionEnabled(env)) {
565
- await bucket.put(filename, serializedData);
566
- return;
567
- }
568
-
569
- if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
570
- throw new Error('Data-at-rest encryption is enabled but not fully configured');
571
- }
572
-
573
- const encryptedPayload = await encryptJsonForStorage(
574
- serializedData,
575
- env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
576
- env.DATA_AT_REST_ENCRYPTION_KEY_ID
577
- );
578
-
579
- await bucket.put(filename, encryptedPayload.ciphertext, {
580
- customMetadata: {
581
- algorithm: encryptedPayload.envelope.algorithm,
582
- encryptionVersion: encryptedPayload.envelope.encryptionVersion,
583
- keyId: encryptedPayload.envelope.keyId,
584
- dataIv: encryptedPayload.envelope.dataIv,
585
- wrappedKey: encryptedPayload.envelope.wrappedKey
586
- }
587
- });
588
- }
589
-
590
- async function appendAuditEntry(
591
- bucket: R2Bucket,
592
- filename: string,
593
- newEntry: AuditEntry,
594
- env: Env
595
- ): Promise<number> {
596
- try {
597
- const existingFile = await bucket.get(filename);
598
- let entries: AuditEntry[] = [];
599
-
600
- if (existingFile) {
601
- entries = await readAuditEntriesFromObject(existingFile, env);
602
- }
603
-
604
- entries.push(newEntry);
605
- await writeAuditEntriesToObject(bucket, filename, entries, env);
606
- return entries.length;
607
- } catch (error) {
608
- console.error('Error appending audit entry:', error);
609
- throw error;
610
- }
611
- }
612
-
613
- async function handleDataAtRestBackfill(request: Request, env: Env): Promise<Response> {
614
- if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
615
- return createResponse(
616
- { error: 'Data-at-rest encryption is not configured for backfill writes' },
617
- 400
618
- );
619
- }
620
-
621
- const requestBody = await request.json().catch(() => ({})) as {
622
- dryRun?: boolean;
623
- prefix?: string;
624
- cursor?: string;
625
- batchSize?: number;
626
- };
627
-
628
- const dryRun = requestBody.dryRun === true;
629
- const prefix = typeof requestBody.prefix === 'string' ? requestBody.prefix : '';
630
- const cursor = typeof requestBody.cursor === 'string' && requestBody.cursor.length > 0
631
- ? requestBody.cursor
632
- : undefined;
633
- const batchSize = clampBackfillBatchSize(requestBody.batchSize);
634
-
635
- const bucket = env.STRIAE_AUDIT;
636
- const listed = await bucket.list({
637
- prefix: prefix.length > 0 ? prefix : undefined,
638
- cursor,
639
- limit: batchSize
640
- });
641
-
642
- let scanned = 0;
643
- let eligible = 0;
644
- let encrypted = 0;
645
- let skippedEncrypted = 0;
646
- let skippedNonJson = 0;
647
- let failed = 0;
648
- const failures: Array<{ key: string; error: string }> = [];
649
-
650
- for (const object of listed.objects) {
651
- scanned += 1;
652
- const key = object.key;
653
-
654
- if (!key.endsWith('.json')) {
655
- skippedNonJson += 1;
656
- continue;
657
- }
658
-
659
- const objectHead = await bucket.head(key);
660
- if (!objectHead) {
661
- failed += 1;
662
- if (failures.length < 20) {
663
- failures.push({ key, error: 'Object not found during metadata check' });
664
- }
665
- continue;
666
- }
667
-
668
- if (hasDataAtRestMetadata(objectHead.customMetadata)) {
669
- skippedEncrypted += 1;
670
- continue;
671
- }
672
-
673
- eligible += 1;
674
-
675
- if (dryRun) {
676
- continue;
677
- }
678
-
679
- try {
680
- const existingObject = await bucket.get(key);
681
- if (!existingObject) {
682
- failed += 1;
683
- if (failures.length < 20) {
684
- failures.push({ key, error: 'Object disappeared before processing' });
685
- }
686
- continue;
687
- }
688
-
689
- const plaintext = await existingObject.text();
690
- const encryptedPayload = await encryptJsonForStorage(
691
- plaintext,
692
- env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
693
- env.DATA_AT_REST_ENCRYPTION_KEY_ID
694
- );
695
-
696
- await bucket.put(key, encryptedPayload.ciphertext, {
697
- customMetadata: {
698
- algorithm: encryptedPayload.envelope.algorithm,
699
- encryptionVersion: encryptedPayload.envelope.encryptionVersion,
700
- keyId: encryptedPayload.envelope.keyId,
701
- dataIv: encryptedPayload.envelope.dataIv,
702
- wrappedKey: encryptedPayload.envelope.wrappedKey
703
- }
704
- });
705
-
706
- encrypted += 1;
707
- } catch (error) {
708
- failed += 1;
709
- if (failures.length < 20) {
710
- const errorMessage = error instanceof Error ? error.message : 'Unknown backfill failure';
711
- failures.push({ key, error: errorMessage });
712
- }
713
- }
714
- }
715
-
716
- return createResponse({
717
- success: failed === 0,
718
- dryRun,
719
- prefix: prefix.length > 0 ? prefix : null,
720
- batchSize,
721
- scanned,
722
- eligible,
723
- encrypted,
724
- skippedEncrypted,
725
- skippedNonJson,
726
- failed,
727
- failures,
728
- hasMore: listed.truncated,
729
- nextCursor: listed.truncated ? listed.cursor : null
730
- });
731
- }
732
-
733
17
  export default {
734
18
  async fetch(request: Request, env: Env): Promise<Response> {
735
19
  if (request.method === 'OPTIONS') {
@@ -737,104 +21,23 @@ export default {
737
21
  }
738
22
 
739
23
  if (!hasValidHeader(request, env)) {
740
- return createResponse({ error: 'Forbidden' }, 403);
24
+ return createWorkerResponse({ error: 'Forbidden' }, 403);
741
25
  }
742
26
 
743
27
  try {
744
28
  const url = new URL(request.url);
745
29
  const pathname = url.pathname;
746
- const bucket = env.STRIAE_AUDIT;
747
-
748
- if (request.method === 'POST' && pathname === DATA_AT_REST_BACKFILL_PATH) {
749
- return await handleDataAtRestBackfill(request, env);
750
- }
751
30
 
752
31
  if (!pathname.startsWith('/audit/')) {
753
- return createResponse({ error: 'This worker only handles audit endpoints. Use /audit/ path.' }, 404);
754
- }
755
-
756
- const userId = url.searchParams.get('userId');
757
- const startDate = url.searchParams.get('startDate');
758
- const endDate = url.searchParams.get('endDate');
759
-
760
- if (request.method === 'POST') {
761
- if (!userId) {
762
- return createResponse({ error: 'userId parameter is required' }, 400);
763
- }
764
-
765
- const auditEntry: unknown = await request.json();
766
-
767
- if (!isValidAuditEntry(auditEntry)) {
768
- return createResponse({ error: 'Invalid audit entry structure. Required fields: timestamp, userId, action' }, 400);
769
- }
770
-
771
- const filename = generateAuditFileName(userId);
772
-
773
- try {
774
- const entryCount = await appendAuditEntry(bucket, filename, auditEntry, env);
775
- return createResponse({
776
- success: true,
777
- entryCount,
778
- filename
779
- });
780
- } catch (error) {
781
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
782
- return createResponse({ error: `Failed to store audit entry: ${errorMessage}` }, 500);
783
- }
784
- }
785
-
786
- if (request.method === 'GET') {
787
- if (!userId) {
788
- return createResponse({ error: 'userId parameter is required' }, 400);
789
- }
790
-
791
- try {
792
- let allEntries: AuditEntry[] = [];
793
-
794
- if (startDate && endDate) {
795
- const start = new Date(startDate);
796
- const end = new Date(endDate);
797
- const currentDate = new Date(start);
798
-
799
- while (currentDate <= end) {
800
- const dateStr = currentDate.toISOString().split('T')[0];
801
- const filename = `audit-trails/${userId}/${dateStr}.json`;
802
- const file = await bucket.get(filename);
803
-
804
- if (file) {
805
- const entries = await readAuditEntriesFromObject(file, env);
806
- allEntries.push(...entries);
807
- }
808
-
809
- currentDate.setDate(currentDate.getDate() + 1);
810
- }
811
- } else {
812
- const filename = generateAuditFileName(userId);
813
- const file = await bucket.get(filename);
814
-
815
- if (file) {
816
- allEntries = await readAuditEntriesFromObject(file, env);
817
- }
818
- }
819
-
820
- allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
821
-
822
- return createResponse({
823
- entries: allEntries,
824
- total: allEntries.length
825
- });
826
- } catch (error) {
827
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
828
- return createResponse({ error: `Failed to retrieve audit entries: ${errorMessage}` }, 500);
829
- }
32
+ return createWorkerResponse({ error: 'This worker only handles audit endpoints. Use /audit/ path.' }, 404);
830
33
  }
831
34
 
832
- return createResponse({ error: 'Method not allowed for audit endpoints. Only GET and POST are supported.' }, 405);
35
+ return await handleAuditRequest(request, env, url, createWorkerResponse);
833
36
 
834
37
  } catch (error) {
835
38
  console.error('Audit Worker error:', error);
836
39
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
837
- return createResponse({ error: errorMessage }, 500);
40
+ return createWorkerResponse({ error: errorMessage }, 500);
838
41
  }
839
42
  }
840
43
  };