@striae-org/striae 5.2.0 → 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 +36 -33
  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 +10 -17
  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 -336
  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
@@ -0,0 +1,7 @@
1
+ import type { Env } from './types';
2
+
3
+ export const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
4
+ export const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
5
+
6
+ export const hasValidHeader = (request: Request, env: Env): boolean =>
7
+ request.headers.get('X-Custom-Auth-Key') === env.R2_KEY_SECRET;
@@ -0,0 +1,410 @@
1
+ import {
2
+ DATA_AT_REST_ENCRYPTION_ALGORITHM,
3
+ DATA_AT_REST_ENCRYPTION_VERSION
4
+ } from '../config';
5
+ import type {
6
+ DataAtRestEnvelope,
7
+ DecryptionTelemetryOutcome,
8
+ Env,
9
+ KeyRegistryPayload,
10
+ PrivateKeyRegistry
11
+ } from '../types';
12
+
13
+ function normalizePrivateKeyPem(rawValue: string): string {
14
+ return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
15
+ }
16
+
17
+ function getNonEmptyString(value: unknown): string | null {
18
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
19
+ }
20
+
21
+ function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
22
+ const keys: Record<string, string> = {};
23
+ const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
24
+ const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
25
+
26
+ if (registryJson) {
27
+ let parsedRegistry: unknown;
28
+
29
+ try {
30
+ parsedRegistry = JSON.parse(registryJson) as unknown;
31
+ } catch {
32
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
33
+ }
34
+
35
+ if (!parsedRegistry || typeof parsedRegistry !== 'object') {
36
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
37
+ }
38
+
39
+ const payload = parsedRegistry as KeyRegistryPayload;
40
+ const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
41
+ const rawKeys = payload.keys && typeof payload.keys === 'object'
42
+ ? payload.keys as Record<string, unknown>
43
+ : parsedRegistry as Record<string, unknown>;
44
+
45
+ for (const [keyId, pemValue] of Object.entries(rawKeys)) {
46
+ if (keyId === 'activeKeyId' || keyId === 'keys') {
47
+ continue;
48
+ }
49
+
50
+ const normalizedKeyId = getNonEmptyString(keyId);
51
+ const normalizedPem = getNonEmptyString(pemValue);
52
+ if (!normalizedKeyId || !normalizedPem) {
53
+ continue;
54
+ }
55
+
56
+ keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
57
+ }
58
+
59
+ const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
60
+
61
+ if (Object.keys(keys).length === 0) {
62
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
63
+ }
64
+
65
+ if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
66
+ throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
67
+ }
68
+
69
+ return {
70
+ activeKeyId: resolvedActiveKeyId ?? null,
71
+ keys
72
+ };
73
+ }
74
+
75
+ const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
76
+ const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
77
+ if (!legacyKeyId || !legacyPrivateKey) {
78
+ throw new Error('Data-at-rest decryption key registry is not configured');
79
+ }
80
+
81
+ keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
82
+
83
+ return {
84
+ activeKeyId: configuredActiveKeyId ?? legacyKeyId,
85
+ keys
86
+ };
87
+ }
88
+
89
+ function buildPrivateKeyCandidates(
90
+ recordKeyId: string,
91
+ registry: PrivateKeyRegistry
92
+ ): Array<{ keyId: string; privateKeyPem: string }> {
93
+ const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
94
+ const seen = new Set<string>();
95
+
96
+ const appendCandidate = (candidateKeyId: string | null): void => {
97
+ if (!candidateKeyId || seen.has(candidateKeyId)) {
98
+ return;
99
+ }
100
+
101
+ const privateKeyPem = registry.keys[candidateKeyId];
102
+ if (!privateKeyPem) {
103
+ return;
104
+ }
105
+
106
+ seen.add(candidateKeyId);
107
+ candidates.push({ keyId: candidateKeyId, privateKeyPem });
108
+ };
109
+
110
+ appendCandidate(getNonEmptyString(recordKeyId));
111
+ appendCandidate(registry.activeKeyId);
112
+
113
+ for (const keyId of Object.keys(registry.keys)) {
114
+ appendCandidate(keyId);
115
+ }
116
+
117
+ return candidates;
118
+ }
119
+
120
+ function logAuditDecryptionTelemetry(input: {
121
+ recordKeyId: string;
122
+ selectedKeyId: string | null;
123
+ attemptCount: number;
124
+ outcome: DecryptionTelemetryOutcome;
125
+ reason?: string;
126
+ }): void {
127
+ const details = {
128
+ scope: 'audit-at-rest',
129
+ recordKeyId: input.recordKeyId,
130
+ selectedKeyId: input.selectedKeyId,
131
+ attemptCount: input.attemptCount,
132
+ fallbackUsed: input.outcome === 'fallback-hit',
133
+ outcome: input.outcome,
134
+ reason: input.reason ?? null
135
+ };
136
+
137
+ if (input.outcome === 'all-failed') {
138
+ console.warn('Key registry decryption failed', details);
139
+ return;
140
+ }
141
+
142
+ console.info('Key registry decryption resolved', details);
143
+ }
144
+
145
+ function base64UrlDecode(value: string): Uint8Array {
146
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
147
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
148
+ const decoded = atob(normalized + padding);
149
+ const bytes = new Uint8Array(decoded.length);
150
+
151
+ for (let i = 0; i < decoded.length; i += 1) {
152
+ bytes[i] = decoded.charCodeAt(i);
153
+ }
154
+
155
+ return bytes;
156
+ }
157
+
158
+ function base64UrlEncode(value: Uint8Array): string {
159
+ let binary = '';
160
+ const chunkSize = 8192;
161
+
162
+ for (let i = 0; i < value.length; i += chunkSize) {
163
+ const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
164
+ for (let j = 0; j < chunk.length; j += 1) {
165
+ binary += String.fromCharCode(chunk[j]);
166
+ }
167
+ }
168
+
169
+ return btoa(binary)
170
+ .replace(/\+/g, '-')
171
+ .replace(/\//g, '_')
172
+ .replace(/=+$/g, '');
173
+ }
174
+
175
+ function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
176
+ const normalizedKey = privateKey
177
+ .trim()
178
+ .replace(/^['"]|['"]$/g, '')
179
+ .replace(/\\n/g, '\n');
180
+
181
+ const pemBody = normalizedKey
182
+ .replace('-----BEGIN PRIVATE KEY-----', '')
183
+ .replace('-----END PRIVATE KEY-----', '')
184
+ .replace(/\s+/g, '');
185
+
186
+ if (!pemBody) {
187
+ throw new Error('Encryption private key is invalid');
188
+ }
189
+
190
+ const binary = atob(pemBody);
191
+ const bytes = new Uint8Array(binary.length);
192
+
193
+ for (let index = 0; index < binary.length; index += 1) {
194
+ bytes[index] = binary.charCodeAt(index);
195
+ }
196
+
197
+ return bytes.buffer;
198
+ }
199
+
200
+ function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
201
+ const normalizedKey = publicKey
202
+ .trim()
203
+ .replace(/^['"]|['"]$/g, '')
204
+ .replace(/\\n/g, '\n');
205
+
206
+ const pemBody = normalizedKey
207
+ .replace('-----BEGIN PUBLIC KEY-----', '')
208
+ .replace('-----END PUBLIC KEY-----', '')
209
+ .replace(/\s+/g, '');
210
+
211
+ if (!pemBody) {
212
+ throw new Error('Encryption public key is invalid');
213
+ }
214
+
215
+ const binary = atob(pemBody);
216
+ const bytes = new Uint8Array(binary.length);
217
+
218
+ for (let index = 0; index < binary.length; index += 1) {
219
+ bytes[index] = binary.charCodeAt(index);
220
+ }
221
+
222
+ return bytes.buffer;
223
+ }
224
+
225
+ async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
226
+ return crypto.subtle.importKey(
227
+ 'pkcs8',
228
+ parsePkcs8PrivateKey(privateKeyPem),
229
+ {
230
+ name: 'RSA-OAEP',
231
+ hash: 'SHA-256'
232
+ },
233
+ false,
234
+ ['decrypt']
235
+ );
236
+ }
237
+
238
+ async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
239
+ return crypto.subtle.importKey(
240
+ 'spki',
241
+ parseSpkiPublicKey(publicKeyPem),
242
+ {
243
+ name: 'RSA-OAEP',
244
+ hash: 'SHA-256'
245
+ },
246
+ false,
247
+ ['encrypt']
248
+ );
249
+ }
250
+
251
+ async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
252
+ return crypto.subtle.generateKey(
253
+ {
254
+ name: 'AES-GCM',
255
+ length: 256
256
+ },
257
+ true,
258
+ usages
259
+ ) as Promise<CryptoKey>;
260
+ }
261
+
262
+ async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
263
+ const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
264
+ const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
265
+ const wrappedKey = await crypto.subtle.encrypt(
266
+ { name: 'RSA-OAEP' },
267
+ rsaPublicKey,
268
+ rawAesKey as BufferSource
269
+ );
270
+
271
+ return base64UrlEncode(new Uint8Array(wrappedKey));
272
+ }
273
+
274
+ async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
275
+ const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
276
+ const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
277
+
278
+ const rawAesKey = await crypto.subtle.decrypt(
279
+ { name: 'RSA-OAEP' },
280
+ rsaPrivateKey,
281
+ wrappedKeyBytes as BufferSource
282
+ );
283
+
284
+ return crypto.subtle.importKey(
285
+ 'raw',
286
+ rawAesKey,
287
+ { name: 'AES-GCM' },
288
+ false,
289
+ ['decrypt']
290
+ );
291
+ }
292
+
293
+ async function decryptJsonFromStorage(
294
+ ciphertext: ArrayBuffer,
295
+ envelope: DataAtRestEnvelope,
296
+ privateKeyPem: string
297
+ ): Promise<string> {
298
+ const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
299
+ const iv = base64UrlDecode(envelope.dataIv);
300
+
301
+ const plaintext = await crypto.subtle.decrypt(
302
+ { name: 'AES-GCM', iv: iv as BufferSource },
303
+ aesKey,
304
+ ciphertext as BufferSource
305
+ );
306
+
307
+ return new TextDecoder().decode(plaintext);
308
+ }
309
+
310
+ export async function decryptAuditJsonWithRegistry(
311
+ ciphertext: ArrayBuffer,
312
+ envelope: DataAtRestEnvelope,
313
+ env: Env
314
+ ): Promise<string> {
315
+ const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
316
+ const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
317
+ const primaryKeyId = candidates[0]?.keyId ?? null;
318
+ let lastError: unknown;
319
+
320
+ for (let index = 0; index < candidates.length; index += 1) {
321
+ const candidate = candidates[index];
322
+ try {
323
+ const plaintext = await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
324
+ logAuditDecryptionTelemetry({
325
+ recordKeyId: envelope.keyId,
326
+ selectedKeyId: candidate.keyId,
327
+ attemptCount: index + 1,
328
+ outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
329
+ });
330
+ return plaintext;
331
+ } catch (error) {
332
+ lastError = error;
333
+ }
334
+ }
335
+
336
+ logAuditDecryptionTelemetry({
337
+ recordKeyId: envelope.keyId,
338
+ selectedKeyId: null,
339
+ attemptCount: candidates.length,
340
+ outcome: 'all-failed',
341
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
342
+ });
343
+
344
+ throw new Error(
345
+ `Failed to decrypt audit record after ${candidates.length} key attempt(s): ${
346
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
347
+ }`
348
+ );
349
+ }
350
+
351
+ export async function encryptJsonForStorage(
352
+ plaintextJson: string,
353
+ publicKeyPem: string,
354
+ keyId: string
355
+ ): Promise<{ ciphertext: Uint8Array; envelope: DataAtRestEnvelope }> {
356
+ const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
357
+ const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
358
+ const iv = crypto.getRandomValues(new Uint8Array(12));
359
+
360
+ const plaintextBytes = new TextEncoder().encode(plaintextJson);
361
+ const encryptedBuffer = await crypto.subtle.encrypt(
362
+ { name: 'AES-GCM', iv: iv as BufferSource },
363
+ aesKey,
364
+ plaintextBytes as BufferSource
365
+ );
366
+
367
+ return {
368
+ ciphertext: new Uint8Array(encryptedBuffer),
369
+ envelope: {
370
+ algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
371
+ encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
372
+ keyId,
373
+ dataIv: base64UrlEncode(iv),
374
+ wrappedKey
375
+ }
376
+ };
377
+ }
378
+
379
+ export function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
380
+ const metadata = file.customMetadata;
381
+ if (!metadata) {
382
+ return null;
383
+ }
384
+
385
+ const {
386
+ algorithm,
387
+ encryptionVersion,
388
+ keyId,
389
+ dataIv,
390
+ wrappedKey
391
+ } = metadata;
392
+
393
+ if (
394
+ typeof algorithm !== 'string' ||
395
+ typeof encryptionVersion !== 'string' ||
396
+ typeof keyId !== 'string' ||
397
+ typeof dataIv !== 'string' ||
398
+ typeof wrappedKey !== 'string'
399
+ ) {
400
+ return null;
401
+ }
402
+
403
+ return {
404
+ algorithm,
405
+ encryptionVersion,
406
+ keyId,
407
+ dataIv,
408
+ wrappedKey
409
+ };
410
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ appendAuditEntry,
3
+ generateAuditFileName,
4
+ isValidAuditEntry,
5
+ readAuditEntriesFromObject
6
+ } from '../storage/audit-storage';
7
+ import type { AuditEntry, CreateResponse, Env } from '../types';
8
+
9
+ export async function handleAuditRequest(
10
+ request: Request,
11
+ env: Env,
12
+ url: URL,
13
+ respond: CreateResponse
14
+ ): Promise<Response> {
15
+ const bucket = env.STRIAE_AUDIT;
16
+ const userId = url.searchParams.get('userId');
17
+ const startDate = url.searchParams.get('startDate');
18
+ const endDate = url.searchParams.get('endDate');
19
+
20
+ if (request.method === 'POST') {
21
+ if (!userId) {
22
+ return respond({ error: 'userId parameter is required' }, 400);
23
+ }
24
+
25
+ const auditEntry: unknown = await request.json();
26
+
27
+ if (!isValidAuditEntry(auditEntry)) {
28
+ return respond({ error: 'Invalid audit entry structure. Required fields: timestamp, userId, action' }, 400);
29
+ }
30
+
31
+ if (auditEntry.userId !== userId) {
32
+ return respond({ error: 'userId parameter must match auditEntry.userId' }, 400);
33
+ }
34
+
35
+ const filename = generateAuditFileName(userId);
36
+
37
+ try {
38
+ const entryCount = await appendAuditEntry(bucket, filename, auditEntry, env);
39
+ return respond({
40
+ success: true,
41
+ entryCount,
42
+ filename
43
+ });
44
+ } catch (error) {
45
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
46
+ return respond({ error: `Failed to store audit entry: ${errorMessage}` }, 500);
47
+ }
48
+ }
49
+
50
+ if (request.method === 'GET') {
51
+ if (!userId) {
52
+ return respond({ error: 'userId parameter is required' }, 400);
53
+ }
54
+
55
+ try {
56
+ let allEntries: AuditEntry[] = [];
57
+
58
+ if (startDate && endDate) {
59
+ const start = new Date(startDate);
60
+ const end = new Date(endDate);
61
+ const currentDate = new Date(start);
62
+
63
+ while (currentDate <= end) {
64
+ const filename = generateAuditFileName(userId, currentDate);
65
+ const file = await bucket.get(filename);
66
+
67
+ if (file) {
68
+ const entries = await readAuditEntriesFromObject(file, env);
69
+ allEntries.push(...entries);
70
+ }
71
+
72
+ currentDate.setDate(currentDate.getDate() + 1);
73
+ }
74
+ } else {
75
+ const filename = generateAuditFileName(userId);
76
+ const file = await bucket.get(filename);
77
+
78
+ if (file) {
79
+ allEntries = await readAuditEntriesFromObject(file, env);
80
+ }
81
+ }
82
+
83
+ allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
84
+
85
+ return respond({
86
+ entries: allEntries,
87
+ total: allEntries.length
88
+ });
89
+ } catch (error) {
90
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
91
+ return respond({ error: `Failed to retrieve audit entries: ${errorMessage}` }, 500);
92
+ }
93
+ }
94
+
95
+ if (request.method === 'DELETE') {
96
+ if (!userId) {
97
+ return respond({ error: 'userId parameter is required' }, 400);
98
+ }
99
+
100
+ try {
101
+ const prefix = `audit-trails/${userId}/`;
102
+ let deletedCount = 0;
103
+ let cursor: string | undefined;
104
+
105
+ do {
106
+ const listed = await bucket.list({ prefix, cursor, limit: 1000 });
107
+
108
+ const keys = listed.objects.map((obj) => obj.key);
109
+ if (keys.length > 0) {
110
+ await bucket.delete(keys);
111
+ deletedCount += keys.length;
112
+ }
113
+
114
+ cursor = listed.truncated ? listed.cursor : undefined;
115
+ } while (cursor !== undefined);
116
+
117
+ return respond({ success: true, deletedCount });
118
+ } catch (error) {
119
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
120
+ return respond({ error: `Failed to delete audit entries: ${errorMessage}` }, 500);
121
+ }
122
+ }
123
+
124
+ return respond({ error: 'Method not allowed for audit endpoints. Only GET, POST, and DELETE are supported.' }, 405);
125
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ DATA_AT_REST_ENCRYPTION_ALGORITHM,
3
+ DATA_AT_REST_ENCRYPTION_VERSION
4
+ } from '../config';
5
+ import {
6
+ decryptAuditJsonWithRegistry,
7
+ encryptJsonForStorage,
8
+ extractDataAtRestEnvelope
9
+ } from '../crypto/data-at-rest';
10
+ import type { AuditEntry, Env } from '../types';
11
+
12
+ export function generateAuditFileName(userId: string, date: Date = new Date()): string {
13
+ const isoDate = date.toISOString().split('T')[0];
14
+ return `audit-trails/${userId}/${isoDate}.json`;
15
+ }
16
+
17
+ export function isValidAuditEntry(entry: unknown): entry is AuditEntry {
18
+ const candidate = entry as Partial<AuditEntry> | null;
19
+
20
+ return (
21
+ typeof candidate === 'object' &&
22
+ candidate !== null &&
23
+ typeof candidate.timestamp === 'string' &&
24
+ typeof candidate.userId === 'string' &&
25
+ typeof candidate.action === 'string'
26
+ );
27
+ }
28
+
29
+ export async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise<AuditEntry[]> {
30
+ const atRestEnvelope = extractDataAtRestEnvelope(file);
31
+ if (!atRestEnvelope) {
32
+ throw new Error('Audit record is not encrypted');
33
+ }
34
+
35
+ if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
36
+ throw new Error('Unsupported data-at-rest encryption algorithm');
37
+ }
38
+
39
+ if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
40
+ throw new Error('Unsupported data-at-rest encryption version');
41
+ }
42
+
43
+ const encryptedData = await file.arrayBuffer();
44
+ const plaintext = await decryptAuditJsonWithRegistry(encryptedData, atRestEnvelope, env);
45
+
46
+ return JSON.parse(plaintext) as AuditEntry[];
47
+ }
48
+
49
+ export async function writeAuditEntriesToObject(
50
+ bucket: R2Bucket,
51
+ filename: string,
52
+ entries: AuditEntry[],
53
+ env: Env
54
+ ): Promise<void> {
55
+ const serializedData = JSON.stringify(entries);
56
+
57
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
58
+ throw new Error('Audit encryption is not fully configured');
59
+ }
60
+
61
+ const encryptedPayload = await encryptJsonForStorage(
62
+ serializedData,
63
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
64
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
65
+ );
66
+
67
+ await bucket.put(filename, encryptedPayload.ciphertext, {
68
+ customMetadata: {
69
+ algorithm: encryptedPayload.envelope.algorithm,
70
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
71
+ keyId: encryptedPayload.envelope.keyId,
72
+ dataIv: encryptedPayload.envelope.dataIv,
73
+ wrappedKey: encryptedPayload.envelope.wrappedKey
74
+ }
75
+ });
76
+ }
77
+
78
+ export async function appendAuditEntry(
79
+ bucket: R2Bucket,
80
+ filename: string,
81
+ newEntry: AuditEntry,
82
+ env: Env
83
+ ): Promise<number> {
84
+ try {
85
+ const existingFile = await bucket.get(filename);
86
+ let entries: AuditEntry[] = [];
87
+
88
+ if (existingFile) {
89
+ entries = await readAuditEntriesFromObject(existingFile, env);
90
+ }
91
+
92
+ entries.push(newEntry);
93
+ await writeAuditEntriesToObject(bucket, filename, entries, env);
94
+ return entries.length;
95
+ } catch (error) {
96
+ console.error('Error appending audit entry:', error);
97
+ throw error;
98
+ }
99
+ }
@@ -0,0 +1,56 @@
1
+ export 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
+ export interface KeyRegistryPayload {
13
+ activeKeyId?: unknown;
14
+ keys?: unknown;
15
+ }
16
+
17
+ export interface PrivateKeyRegistry {
18
+ activeKeyId: string | null;
19
+ keys: Record<string, string>;
20
+ }
21
+
22
+ export type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
23
+
24
+ export interface AuditEntry {
25
+ timestamp: string;
26
+ userId: string;
27
+ action: string;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface SuccessResponse {
32
+ success: boolean;
33
+ entryCount?: number;
34
+ filename?: string;
35
+ }
36
+
37
+ export interface ErrorResponse {
38
+ error: string;
39
+ }
40
+
41
+ export interface AuditRetrievalResponse {
42
+ entries: AuditEntry[];
43
+ total: number;
44
+ }
45
+
46
+ export type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse | Record<string, unknown>;
47
+
48
+ export type CreateResponse = (data: APIResponse, status?: number) => Response;
49
+
50
+ export interface DataAtRestEnvelope {
51
+ algorithm: string;
52
+ encryptionVersion: string;
53
+ keyId: string;
54
+ dataIv: string;
55
+ wrappedKey: string;
56
+ }
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable */
2
2
  // Generated by Wrangler by running `wrangler types` (hash: e47787017d9af07a8f6e4fa965fca8c2)
3
- // Runtime types generated with workerd@1.20250823.0 2026-03-20 nodejs_compat
3
+ // Runtime types generated with workerd@1.20250823.0 2026-03-26 nodejs_compat
4
4
  declare namespace Cloudflare {
5
5
  interface Env {
6
6
  STRIAE_AUDIT: R2Bucket;