@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,126 +1,14 @@
1
+ import { authenticate, requireUserKvReadConfig, requireUserKvWriteConfig } from './auth';
2
+ import { USER_CASES_SEGMENT } from './config';
1
3
  import {
2
- decryptJsonFromUserKv,
3
- encryptJsonForUserKv,
4
- tryParseEncryptedRecord,
5
- type UserKvEncryptedRecord,
6
- validateEncryptedRecord
7
- } from './encryption-utils';
8
-
9
- interface Env {
10
- USER_DB_AUTH: string;
11
- USER_DB: KVNamespace;
12
- R2_KEY_SECRET: string;
13
- IMAGES_API_TOKEN: string;
14
- DATA_WORKER_DOMAIN?: string;
15
- IMAGES_WORKER_DOMAIN?: string;
16
- PROJECT_ID: string;
17
- FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
18
- FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
19
- USER_KV_ENCRYPTION_PRIVATE_KEY: string;
20
- USER_KV_ENCRYPTION_PUBLIC_KEY: string;
21
- USER_KV_ENCRYPTION_KEY_ID: string;
22
- USER_KV_ENCRYPTION_KEYS_JSON?: string;
23
- USER_KV_ENCRYPTION_ACTIVE_KEY_ID?: string;
24
- }
25
-
26
- interface KeyRegistryPayload {
27
- activeKeyId?: unknown;
28
- keys?: unknown;
29
- }
30
-
31
- interface PrivateKeyRegistry {
32
- activeKeyId: string | null;
33
- keys: Record<string, string>;
34
- }
35
-
36
- type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
37
-
38
- interface UserData {
39
- uid: string;
40
- email: string;
41
- firstName: string;
42
- lastName: string;
43
- company: string;
44
- badgeId?: string;
45
- permitted: boolean;
46
- cases: CaseItem[];
47
- readOnlyCases?: ReadOnlyCaseItem[];
48
- createdAt?: string;
49
- updatedAt?: string;
50
- }
51
-
52
- function isLegacyUserData(value: unknown): value is UserData {
53
- if (!value || typeof value !== 'object') {
54
- return false;
55
- }
56
-
57
- const candidate = value as Partial<UserData>;
58
- return (
59
- typeof candidate.uid === 'string' &&
60
- typeof candidate.email === 'string' &&
61
- typeof candidate.firstName === 'string' &&
62
- typeof candidate.lastName === 'string' &&
63
- typeof candidate.company === 'string' &&
64
- typeof candidate.permitted === 'boolean' &&
65
- Array.isArray(candidate.cases)
66
- );
67
- }
68
-
69
- interface CaseItem {
70
- caseNumber: string;
71
- caseName?: string;
72
- [key: string]: unknown;
73
- }
74
-
75
- interface ReadOnlyCaseItem {
76
- caseNumber: string;
77
- caseName?: string;
78
- [key: string]: unknown;
79
- }
80
-
81
- interface UserRequestData {
82
- email?: string;
83
- firstName?: string;
84
- lastName?: string;
85
- company?: string;
86
- badgeId?: string;
87
- permitted?: boolean;
88
- readOnlyCases?: ReadOnlyCaseItem[];
89
- }
90
-
91
- interface AddCasesRequest {
92
- cases: CaseItem[];
93
- }
94
-
95
- interface DeleteCasesRequest {
96
- casesToDelete: string[];
97
- }
98
-
99
- interface CaseData {
100
- files?: Array<{ id: string; [key: string]: unknown }>;
101
- [key: string]: unknown;
102
- }
103
-
104
- interface AccountDeletionProgressEvent {
105
- event: 'start' | 'case-start' | 'case-complete' | 'complete' | 'error';
106
- totalCases: number;
107
- completedCases: number;
108
- currentCaseNumber?: string;
109
- success?: boolean;
110
- message?: string;
111
- }
112
-
113
- interface GoogleOAuthTokenResponse {
114
- access_token?: string;
115
- error?: string;
116
- error_description?: string;
117
- }
118
-
119
- interface FirebaseDeleteAccountErrorResponse {
120
- error?: {
121
- message?: string;
122
- };
123
- }
4
+ handleAddCases,
5
+ handleAddUser,
6
+ handleDeleteCases,
7
+ handleDeleteUser,
8
+ handleDeleteUserWithProgress,
9
+ handleGetUser
10
+ } from './handlers/user-routes';
11
+ import type { Env } from './types';
124
12
 
125
13
  const corsHeaders: Record<string, string> = {
126
14
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
@@ -129,842 +17,6 @@ const corsHeaders: Record<string, string> = {
129
17
  'Content-Type': 'application/json'
130
18
  };
131
19
 
132
- // Worker URLs - configure these for deployment
133
- const DEFAULT_DATA_WORKER_BASE_URL = 'DATA_WORKER_DOMAIN';
134
- const DEFAULT_IMAGE_WORKER_BASE_URL = 'IMAGES_WORKER_DOMAIN';
135
-
136
- const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
137
- const FIREBASE_IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1/projects';
138
- const GOOGLE_IDENTITY_TOOLKIT_SCOPE = 'https://www.googleapis.com/auth/identitytoolkit';
139
- const textEncoder = new TextEncoder();
140
-
141
- async function authenticate(request: Request, env: Env): Promise<void> {
142
- const authKey = request.headers.get('X-Custom-Auth-Key');
143
- if (authKey !== env.USER_DB_AUTH) throw new Error('Unauthorized');
144
- }
145
-
146
- function normalizeWorkerBaseUrl(workerDomain: string): string {
147
- const trimmedDomain = workerDomain.trim().replace(/\/+$/, '');
148
- if (trimmedDomain.startsWith('http://') || trimmedDomain.startsWith('https://')) {
149
- return trimmedDomain;
150
- }
151
-
152
- return `https://${trimmedDomain}`;
153
- }
154
-
155
- function resolveDataWorkerBaseUrl(env: Env): string {
156
- const configuredDomain = typeof env.DATA_WORKER_DOMAIN === 'string' ? env.DATA_WORKER_DOMAIN.trim() : '';
157
- if (configuredDomain.length > 0) {
158
- return normalizeWorkerBaseUrl(configuredDomain);
159
- }
160
-
161
- return normalizeWorkerBaseUrl(DEFAULT_DATA_WORKER_BASE_URL);
162
- }
163
-
164
- function resolveImageWorkerBaseUrl(env: Env): string {
165
- const configuredDomain = typeof env.IMAGES_WORKER_DOMAIN === 'string' ? env.IMAGES_WORKER_DOMAIN.trim() : '';
166
- if (configuredDomain.length > 0) {
167
- return normalizeWorkerBaseUrl(configuredDomain);
168
- }
169
-
170
- return normalizeWorkerBaseUrl(DEFAULT_IMAGE_WORKER_BASE_URL);
171
- }
172
-
173
- function requireUserKvReadConfig(env: Env): void {
174
- const hasLegacyPrivateKey = typeof env.USER_KV_ENCRYPTION_PRIVATE_KEY === 'string' && env.USER_KV_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
175
- const hasRegistryPrivateKeys = typeof env.USER_KV_ENCRYPTION_KEYS_JSON === 'string' && env.USER_KV_ENCRYPTION_KEYS_JSON.trim().length > 0;
176
-
177
- if (!hasLegacyPrivateKey && !hasRegistryPrivateKeys) {
178
- throw new Error('User KV encryption is not fully configured');
179
- }
180
- }
181
-
182
- function requireUserKvWriteConfig(env: Env): void {
183
- const hasLegacyPrivateKey = typeof env.USER_KV_ENCRYPTION_PRIVATE_KEY === 'string' && env.USER_KV_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
184
- const hasRegistryPrivateKeys = typeof env.USER_KV_ENCRYPTION_KEYS_JSON === 'string' && env.USER_KV_ENCRYPTION_KEYS_JSON.trim().length > 0;
185
-
186
- if (
187
- !env.USER_KV_ENCRYPTION_PUBLIC_KEY ||
188
- !env.USER_KV_ENCRYPTION_KEY_ID ||
189
- (!hasLegacyPrivateKey && !hasRegistryPrivateKeys)
190
- ) {
191
- throw new Error('User KV encryption is not fully configured');
192
- }
193
- }
194
-
195
- function normalizePrivateKeyPem(rawValue: string): string {
196
- return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
197
- }
198
-
199
- function getNonEmptyString(value: unknown): string | null {
200
- return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
201
- }
202
-
203
- function parseUserKvPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
204
- const keys: Record<string, string> = {};
205
- const configuredActiveKeyId = getNonEmptyString(env.USER_KV_ENCRYPTION_ACTIVE_KEY_ID);
206
-
207
- if (getNonEmptyString(env.USER_KV_ENCRYPTION_KEYS_JSON)) {
208
- let parsedRegistry: unknown;
209
- try {
210
- parsedRegistry = JSON.parse(env.USER_KV_ENCRYPTION_KEYS_JSON as string) as unknown;
211
- } catch {
212
- throw new Error('USER_KV_ENCRYPTION_KEYS_JSON is not valid JSON');
213
- }
214
-
215
- if (!parsedRegistry || typeof parsedRegistry !== 'object') {
216
- throw new Error('USER_KV_ENCRYPTION_KEYS_JSON must be an object');
217
- }
218
-
219
- const payload = parsedRegistry as KeyRegistryPayload;
220
- if (!payload.keys || typeof payload.keys !== 'object') {
221
- throw new Error('USER_KV_ENCRYPTION_KEYS_JSON must include a keys object');
222
- }
223
-
224
- for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
225
- const normalizedKeyId = getNonEmptyString(keyId);
226
- const normalizedPem = getNonEmptyString(pemValue);
227
- if (!normalizedKeyId || !normalizedPem) {
228
- continue;
229
- }
230
-
231
- keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
232
- }
233
-
234
- const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
235
- const activeKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
236
-
237
- if (Object.keys(keys).length === 0) {
238
- throw new Error('USER_KV_ENCRYPTION_KEYS_JSON does not contain any usable keys');
239
- }
240
-
241
- if (activeKeyId && !keys[activeKeyId]) {
242
- throw new Error('USER_KV active key ID is not present in USER_KV_ENCRYPTION_KEYS_JSON');
243
- }
244
-
245
- return {
246
- activeKeyId: activeKeyId ?? null,
247
- keys
248
- };
249
- }
250
-
251
- const legacyKeyId = getNonEmptyString(env.USER_KV_ENCRYPTION_KEY_ID);
252
- const legacyPrivateKey = getNonEmptyString(env.USER_KV_ENCRYPTION_PRIVATE_KEY);
253
- if (!legacyKeyId || !legacyPrivateKey) {
254
- throw new Error('User KV encryption private key registry is not configured');
255
- }
256
-
257
- keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
258
-
259
- return {
260
- activeKeyId: configuredActiveKeyId ?? legacyKeyId,
261
- keys
262
- };
263
- }
264
-
265
- function buildPrivateKeyCandidates(
266
- recordKeyId: string,
267
- registry: PrivateKeyRegistry
268
- ): Array<{ keyId: string; privateKeyPem: string }> {
269
- const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
270
- const seen = new Set<string>();
271
-
272
- const appendCandidate = (candidateKeyId: string | null): void => {
273
- if (!candidateKeyId || seen.has(candidateKeyId)) {
274
- return;
275
- }
276
-
277
- const privateKeyPem = registry.keys[candidateKeyId];
278
- if (!privateKeyPem) {
279
- return;
280
- }
281
-
282
- seen.add(candidateKeyId);
283
- candidates.push({ keyId: candidateKeyId, privateKeyPem });
284
- };
285
-
286
- appendCandidate(getNonEmptyString(recordKeyId));
287
- appendCandidate(registry.activeKeyId);
288
-
289
- for (const keyId of Object.keys(registry.keys)) {
290
- appendCandidate(keyId);
291
- }
292
-
293
- return candidates;
294
- }
295
-
296
- function logUserKvDecryptionTelemetry(input: {
297
- recordKeyId: string;
298
- selectedKeyId: string | null;
299
- attemptCount: number;
300
- outcome: DecryptionTelemetryOutcome;
301
- reason?: string;
302
- }): void {
303
- const details = {
304
- scope: 'user-kv',
305
- recordKeyId: input.recordKeyId,
306
- selectedKeyId: input.selectedKeyId,
307
- attemptCount: input.attemptCount,
308
- fallbackUsed: input.outcome === 'fallback-hit',
309
- outcome: input.outcome,
310
- reason: input.reason ?? null
311
- };
312
-
313
- if (input.outcome === 'all-failed') {
314
- console.warn('Key registry decryption failed', details);
315
- return;
316
- }
317
-
318
- console.info('Key registry decryption resolved', details);
319
- }
320
-
321
- async function decryptUserKvRecord(
322
- encryptedRecord: UserKvEncryptedRecord,
323
- registry: PrivateKeyRegistry
324
- ): Promise<string> {
325
- const candidates = buildPrivateKeyCandidates(encryptedRecord.keyId, registry);
326
- const primaryKeyId = candidates[0]?.keyId ?? null;
327
- let lastError: unknown;
328
-
329
- for (let index = 0; index < candidates.length; index += 1) {
330
- const candidate = candidates[index];
331
- try {
332
- const decryptedJson = await decryptJsonFromUserKv(encryptedRecord, candidate.privateKeyPem);
333
- logUserKvDecryptionTelemetry({
334
- recordKeyId: encryptedRecord.keyId,
335
- selectedKeyId: candidate.keyId,
336
- attemptCount: index + 1,
337
- outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
338
- });
339
- return decryptedJson;
340
- } catch (error) {
341
- lastError = error;
342
- }
343
- }
344
-
345
- logUserKvDecryptionTelemetry({
346
- recordKeyId: encryptedRecord.keyId,
347
- selectedKeyId: null,
348
- attemptCount: candidates.length,
349
- outcome: 'all-failed',
350
- reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
351
- });
352
-
353
- throw new Error(
354
- `Failed to decrypt user KV record after ${candidates.length} key attempt(s): ${
355
- lastError instanceof Error ? lastError.message : 'unknown decryption error'
356
- }`
357
- );
358
- }
359
-
360
- async function readUserRecord(env: Env, userUid: string): Promise<UserData | null> {
361
- const storedValue = await env.USER_DB.get(userUid);
362
- if (storedValue === null) {
363
- return null;
364
- }
365
-
366
- const encryptedRecord = tryParseEncryptedRecord(storedValue);
367
- if (encryptedRecord) {
368
- validateEncryptedRecord(encryptedRecord);
369
- const keyRegistry = parseUserKvPrivateKeyRegistry(env);
370
- const decryptedJson = await decryptUserKvRecord(encryptedRecord, keyRegistry);
371
- return JSON.parse(decryptedJson) as UserData;
372
- }
373
-
374
- // Legacy support: accept existing plaintext records and opportunistically
375
- // rewrite them as encrypted records during the first successful read.
376
- let parsedLegacyRecord: unknown;
377
- try {
378
- parsedLegacyRecord = JSON.parse(storedValue) as unknown;
379
- } catch {
380
- throw new Error('User KV record is not encrypted');
381
- }
382
-
383
- if (!isLegacyUserData(parsedLegacyRecord)) {
384
- throw new Error('User KV record is not encrypted');
385
- }
386
-
387
- const legacyUserData = parsedLegacyRecord;
388
-
389
- if (legacyUserData.uid !== userUid) {
390
- throw new Error('User KV record UID mismatch');
391
- }
392
-
393
- try {
394
- await writeUserRecord(env, userUid, legacyUserData);
395
- console.info('Migrated plaintext USER_DB record to encrypted format', {
396
- scope: 'user-kv',
397
- uid: userUid
398
- });
399
- } catch (error) {
400
- console.warn('Failed to migrate plaintext USER_DB record during read', {
401
- scope: 'user-kv',
402
- uid: userUid,
403
- reason: error instanceof Error ? error.message : 'unknown migration error'
404
- });
405
- }
406
-
407
- return legacyUserData;
408
- }
409
-
410
- async function writeUserRecord(env: Env, userUid: string, userData: UserData): Promise<void> {
411
- const encryptedPayload = await encryptJsonForUserKv(
412
- JSON.stringify(userData),
413
- env.USER_KV_ENCRYPTION_PUBLIC_KEY,
414
- env.USER_KV_ENCRYPTION_KEY_ID
415
- );
416
-
417
- await env.USER_DB.put(userUid, encryptedPayload);
418
- }
419
-
420
- function base64UrlEncode(value: string | Uint8Array): string {
421
- const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
422
- let binary = '';
423
-
424
- for (const byte of bytes) {
425
- binary += String.fromCharCode(byte);
426
- }
427
-
428
- return btoa(binary)
429
- .replace(/\+/g, '-')
430
- .replace(/\//g, '_')
431
- .replace(/=+$/g, '');
432
- }
433
-
434
- function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
435
- const normalizedKey = privateKey
436
- .trim()
437
- .replace(/^['"]|['"]$/g, '')
438
- .replace(/\\n/g, '\n');
439
-
440
- const pemBody = normalizedKey
441
- .replace('-----BEGIN PRIVATE KEY-----', '')
442
- .replace('-----END PRIVATE KEY-----', '')
443
- .replace(/\s+/g, '');
444
-
445
- if (!pemBody) {
446
- throw new Error('Firebase service account private key is invalid');
447
- }
448
-
449
- const binary = atob(pemBody);
450
- const bytes = new Uint8Array(binary.length);
451
-
452
- for (let index = 0; index < binary.length; index += 1) {
453
- bytes[index] = binary.charCodeAt(index);
454
- }
455
-
456
- return bytes.buffer;
457
- }
458
-
459
- async function buildServiceAccountAssertion(env: Env): Promise<string> {
460
- const issuedAt = Math.floor(Date.now() / 1000);
461
- const header = {
462
- alg: 'RS256',
463
- typ: 'JWT'
464
- };
465
- const payload = {
466
- iss: env.FIREBASE_SERVICE_ACCOUNT_EMAIL,
467
- scope: GOOGLE_IDENTITY_TOOLKIT_SCOPE,
468
- aud: GOOGLE_OAUTH_TOKEN_URL,
469
- iat: issuedAt,
470
- exp: issuedAt + 3600
471
- };
472
- const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
473
-
474
- let signingKey: CryptoKey;
475
-
476
- try {
477
- signingKey = await crypto.subtle.importKey(
478
- 'pkcs8',
479
- parsePkcs8PrivateKey(env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY),
480
- {
481
- name: 'RSASSA-PKCS1-v1_5',
482
- hash: 'SHA-256'
483
- },
484
- false,
485
- ['sign']
486
- );
487
- } catch {
488
- throw new Error('Invalid Firebase service account private key format. Use the service account JSON private_key value (PKCS8) and keep newline markers as \\n.');
489
- }
490
-
491
- const signature = await crypto.subtle.sign(
492
- { name: 'RSASSA-PKCS1-v1_5' },
493
- signingKey,
494
- textEncoder.encode(unsignedToken)
495
- );
496
-
497
- return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`;
498
- }
499
-
500
- async function getGoogleAccessToken(env: Env): Promise<string> {
501
- const assertion = await buildServiceAccountAssertion(env);
502
- const body = new URLSearchParams({
503
- grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
504
- assertion
505
- });
506
-
507
- const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
508
- method: 'POST',
509
- headers: {
510
- 'Content-Type': 'application/x-www-form-urlencoded'
511
- },
512
- body
513
- });
514
-
515
- const tokenData = await tokenResponse.json().catch(() => ({})) as GoogleOAuthTokenResponse;
516
- if (!tokenResponse.ok || !tokenData.access_token) {
517
- const errorReason = tokenData.error_description || tokenData.error || `HTTP ${tokenResponse.status}`;
518
- throw new Error(`Failed to authorize Firebase admin deletion: ${errorReason}`);
519
- }
520
-
521
- return tokenData.access_token;
522
- }
523
-
524
- async function deleteFirebaseAuthUser(env: Env, userUid: string): Promise<void> {
525
- if (!env.PROJECT_ID || !env.FIREBASE_SERVICE_ACCOUNT_EMAIL || !env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY) {
526
- throw new Error('Firebase Auth deletion is not configured in User Worker secrets');
527
- }
528
-
529
- const accessToken = await getGoogleAccessToken(env);
530
- const deleteResponse = await fetch(
531
- `${FIREBASE_IDENTITY_TOOLKIT_BASE_URL}/${encodeURIComponent(env.PROJECT_ID)}/accounts:delete`,
532
- {
533
- method: 'POST',
534
- headers: {
535
- 'Authorization': `Bearer ${accessToken}`,
536
- 'Content-Type': 'application/json'
537
- },
538
- body: JSON.stringify({ localId: userUid })
539
- }
540
- );
541
-
542
- if (deleteResponse.ok) {
543
- return;
544
- }
545
-
546
- const deleteErrorPayload = await deleteResponse.json().catch(() => ({})) as FirebaseDeleteAccountErrorResponse;
547
- const deleteErrorMessage = deleteErrorPayload.error?.message || '';
548
-
549
- if (deleteErrorMessage.includes('USER_NOT_FOUND')) {
550
- return;
551
- }
552
-
553
- throw new Error(deleteErrorMessage ? `Firebase Auth deletion failed: ${deleteErrorMessage}` : `Firebase Auth deletion failed with status ${deleteResponse.status}`);
554
- }
555
-
556
- async function handleGetUser(env: Env, userUid: string): Promise<Response> {
557
- try {
558
- const userData = await readUserRecord(env, userUid);
559
- if (userData === null) {
560
- return new Response('User not found', {
561
- status: 404,
562
- headers: corsHeaders
563
- });
564
- }
565
- return new Response(JSON.stringify(userData), {
566
- status: 200,
567
- headers: corsHeaders
568
- });
569
- } catch (error) {
570
- const errorMessage = error instanceof Error ? error.message : 'Unknown user data read error';
571
- console.error('Failed to get user data:', { uid: userUid, reason: errorMessage });
572
-
573
- return new Response('Failed to get user data', {
574
- status: 500,
575
- headers: corsHeaders
576
- });
577
- }
578
- }
579
-
580
- async function handleAddUser(request: Request, env: Env, userUid: string): Promise<Response> {
581
- try {
582
- const requestData: UserRequestData = await request.json();
583
- const { email, firstName, lastName, company, badgeId, permitted } = requestData;
584
- const normalizedBadgeId = typeof badgeId === 'string' ? badgeId.trim() : undefined;
585
-
586
- // Check for existing user
587
- const existingUser = await readUserRecord(env, userUid);
588
-
589
- let userData: UserData;
590
- if (existingUser !== null) {
591
- // Update existing user, preserving cases
592
- userData = {
593
- ...existingUser,
594
- // Preserve all existing fields
595
- email: email || existingUser.email,
596
- firstName: firstName || existingUser.firstName,
597
- lastName: lastName || existingUser.lastName,
598
- company: company || existingUser.company,
599
- badgeId: normalizedBadgeId !== undefined ? normalizedBadgeId : (existingUser.badgeId ?? ''),
600
- permitted: permitted !== undefined ? permitted : existingUser.permitted,
601
- updatedAt: new Date().toISOString()
602
- };
603
- if (requestData.readOnlyCases !== undefined) {
604
- userData.readOnlyCases = requestData.readOnlyCases;
605
- }
606
- } else {
607
- // Create new user
608
- userData = {
609
- uid: userUid,
610
- email: email || '',
611
- firstName: firstName || '',
612
- lastName: lastName || '',
613
- company: company || '',
614
- badgeId: normalizedBadgeId ?? '',
615
- permitted: permitted !== undefined ? permitted : true,
616
- cases: [],
617
- createdAt: new Date().toISOString()
618
- };
619
- if (requestData.readOnlyCases !== undefined) {
620
- userData.readOnlyCases = requestData.readOnlyCases;
621
- }
622
- }
623
-
624
- // Store value in KV
625
- await writeUserRecord(env, userUid, userData);
626
-
627
- return new Response(JSON.stringify(userData), {
628
- status: existingUser !== null ? 200 : 201,
629
- headers: corsHeaders
630
- });
631
- } catch {
632
- return new Response('Failed to save user data', {
633
- status: 500,
634
- headers: corsHeaders
635
- });
636
- }
637
- }
638
-
639
- // Function to delete a single case (similar to case-manage.ts deleteCase)
640
- async function deleteSingleCase(env: Env, userUid: string, caseNumber: string): Promise<void> {
641
- const dataApiKey = env.R2_KEY_SECRET;
642
- const imageApiKey = env.IMAGES_API_TOKEN;
643
-
644
- const dataWorkerBaseUrl = resolveDataWorkerBaseUrl(env);
645
- const imageWorkerBaseUrl = resolveImageWorkerBaseUrl(env);
646
- const encodedUserId = encodeURIComponent(userUid);
647
- const encodedCaseNumber = encodeURIComponent(caseNumber);
648
-
649
- const caseResponse = await fetch(`${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/data.json`, {
650
- headers: { 'X-Custom-Auth-Key': dataApiKey }
651
- });
652
-
653
- if (caseResponse.status === 404) {
654
- return;
655
- }
656
-
657
- if (!caseResponse.ok) {
658
- throw new Error(`Failed to load case data for deletion (${caseNumber}): ${caseResponse.status}`);
659
- }
660
-
661
- const caseData = await caseResponse.json() as CaseData;
662
- const deletionErrors: string[] = [];
663
-
664
- // Delete all files associated with this case
665
- if (caseData.files && caseData.files.length > 0) {
666
- for (const file of caseData.files) {
667
- const encodedFileId = encodeURIComponent(file.id);
668
-
669
- try {
670
- const imageDeleteResponse = await fetch(`${imageWorkerBaseUrl}/${encodedFileId}`, {
671
- method: 'DELETE',
672
- headers: {
673
- 'Authorization': `Bearer ${imageApiKey}`
674
- }
675
- });
676
-
677
- if (!imageDeleteResponse.ok && imageDeleteResponse.status !== 404) {
678
- deletionErrors.push(`image ${file.id} delete failed (${imageDeleteResponse.status})`);
679
- }
680
- } catch (error) {
681
- const message = error instanceof Error ? error.message : 'unknown image delete error';
682
- deletionErrors.push(`image ${file.id} delete threw (${message})`);
683
- }
684
-
685
- try {
686
- const notesDeleteResponse = await fetch(
687
- `${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/${encodedFileId}/data.json`,
688
- {
689
- method: 'DELETE',
690
- headers: { 'X-Custom-Auth-Key': dataApiKey }
691
- }
692
- );
693
-
694
- if (!notesDeleteResponse.ok && notesDeleteResponse.status !== 404) {
695
- deletionErrors.push(`annotation ${file.id} delete failed (${notesDeleteResponse.status})`);
696
- }
697
- } catch (error) {
698
- const message = error instanceof Error ? error.message : 'unknown annotation delete error';
699
- deletionErrors.push(`annotation ${file.id} delete threw (${message})`);
700
- }
701
- }
702
- }
703
-
704
- // Delete case data file
705
- try {
706
- const caseDeleteResponse = await fetch(
707
- `${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/data.json`,
708
- {
709
- method: 'DELETE',
710
- headers: { 'X-Custom-Auth-Key': dataApiKey }
711
- }
712
- );
713
-
714
- if (!caseDeleteResponse.ok && caseDeleteResponse.status !== 404) {
715
- deletionErrors.push(`case ${caseNumber} delete failed (${caseDeleteResponse.status})`);
716
- }
717
- } catch (error) {
718
- const message = error instanceof Error ? error.message : 'unknown case delete error';
719
- deletionErrors.push(`case ${caseNumber} delete threw (${message})`);
720
- }
721
-
722
- if (deletionErrors.length > 0) {
723
- throw new Error(`Case cleanup incomplete for ${caseNumber}: ${deletionErrors.join('; ')}`);
724
- }
725
- }
726
-
727
- async function deleteUserConfirmationSummary(env: Env, userUid: string): Promise<void> {
728
- const dataApiKey = env.R2_KEY_SECRET;
729
- const dataWorkerBaseUrl = resolveDataWorkerBaseUrl(env);
730
- const encodedUserId = encodeURIComponent(userUid);
731
- const confirmationSummaryPath = `${dataWorkerBaseUrl}/${encodedUserId}/meta/confirmation-status.json`;
732
-
733
- const response = await fetch(confirmationSummaryPath, {
734
- method: 'DELETE',
735
- headers: { 'X-Custom-Auth-Key': dataApiKey }
736
- });
737
-
738
- if (!response.ok && response.status !== 404) {
739
- throw new Error(`Failed to delete confirmation summary metadata: ${response.status}`);
740
- }
741
- }
742
-
743
- async function executeUserDeletion(
744
- env: Env,
745
- userUid: string,
746
- reportProgress?: (progress: AccountDeletionProgressEvent) => void
747
- ): Promise<{ success: boolean; message: string; totalCases: number; completedCases: number }> {
748
- const userData = await readUserRecord(env, userUid);
749
- if (userData === null) {
750
- throw new Error('User not found');
751
- }
752
-
753
- const ownedCases = (userData.cases || []).map((caseItem) => caseItem.caseNumber);
754
- const readOnlyCases = (userData.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
755
- const allCaseNumbers = Array.from(new Set([...ownedCases, ...readOnlyCases]));
756
- const totalCases = allCaseNumbers.length;
757
- let completedCases = 0;
758
- const caseCleanupErrors: string[] = [];
759
-
760
- reportProgress?.({
761
- event: 'start',
762
- totalCases,
763
- completedCases
764
- });
765
-
766
- for (const caseNumber of allCaseNumbers) {
767
- reportProgress?.({
768
- event: 'case-start',
769
- totalCases,
770
- completedCases,
771
- currentCaseNumber: caseNumber
772
- });
773
-
774
- let caseDeletionError: string | null = null;
775
- try {
776
- await deleteSingleCase(env, userUid, caseNumber);
777
- } catch (error) {
778
- caseDeletionError = error instanceof Error ? error.message : `Case cleanup failed for ${caseNumber}`;
779
- caseCleanupErrors.push(caseDeletionError);
780
- console.error(`Case cleanup error for ${caseNumber}:`, error);
781
- }
782
-
783
- completedCases += 1;
784
-
785
- reportProgress?.({
786
- event: 'case-complete',
787
- totalCases,
788
- completedCases,
789
- currentCaseNumber: caseNumber,
790
- success: caseDeletionError === null,
791
- message: caseDeletionError || undefined
792
- });
793
- }
794
-
795
- if (caseCleanupErrors.length > 0) {
796
- throw new Error(`Failed to fully delete all case data: ${caseCleanupErrors.join(' | ')}`);
797
- }
798
-
799
- await deleteUserConfirmationSummary(env, userUid);
800
- await deleteFirebaseAuthUser(env, userUid);
801
-
802
- // Delete the user account from the database
803
- await env.USER_DB.delete(userUid);
804
-
805
- return {
806
- success: true,
807
- message: 'Account successfully deleted',
808
- totalCases,
809
- completedCases
810
- };
811
- }
812
-
813
- async function handleDeleteUser(env: Env, userUid: string): Promise<Response> {
814
- try {
815
- const result = await executeUserDeletion(env, userUid);
816
-
817
- return new Response(JSON.stringify({
818
- success: result.success,
819
- message: result.message
820
- }), {
821
- status: 200,
822
- headers: corsHeaders
823
- });
824
- } catch (error) {
825
- console.error('Delete user error:', error);
826
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
827
-
828
- if (errorMessage === 'User not found') {
829
- return new Response('User not found', {
830
- status: 404,
831
- headers: corsHeaders
832
- });
833
- }
834
-
835
- return new Response(JSON.stringify({
836
- success: false,
837
- message: 'Failed to delete user account'
838
- }), {
839
- status: 500,
840
- headers: corsHeaders
841
- });
842
- }
843
- }
844
-
845
- function handleDeleteUserWithProgress(env: Env, userUid: string): Response {
846
- const sseHeaders: Record<string, string> = {
847
- ...corsHeaders,
848
- 'Content-Type': 'text/event-stream',
849
- 'Cache-Control': 'no-cache, no-transform',
850
- 'Connection': 'keep-alive'
851
- };
852
-
853
- const encoder = new TextEncoder();
854
-
855
- const stream = new ReadableStream<Uint8Array>({
856
- async start(controller) {
857
- const sendEvent = (payload: AccountDeletionProgressEvent) => {
858
- controller.enqueue(encoder.encode(`event: ${payload.event}\ndata: ${JSON.stringify(payload)}\n\n`));
859
- };
860
-
861
- try {
862
- const result = await executeUserDeletion(env, userUid, sendEvent);
863
- sendEvent({
864
- event: 'complete',
865
- totalCases: result.totalCases,
866
- completedCases: result.completedCases,
867
- success: result.success,
868
- message: result.message
869
- });
870
- } catch (error) {
871
- const errorMessage = error instanceof Error ? error.message : 'Failed to delete user account';
872
-
873
- sendEvent({
874
- event: 'error',
875
- totalCases: 0,
876
- completedCases: 0,
877
- success: false,
878
- message: errorMessage
879
- });
880
- } finally {
881
- controller.close();
882
- }
883
- }
884
- });
885
-
886
- return new Response(stream, {
887
- status: 200,
888
- headers: sseHeaders
889
- });
890
- }
891
-
892
- async function handleAddCases(request: Request, env: Env, userUid: string): Promise<Response> {
893
- try {
894
- const { cases = [] }: AddCasesRequest = await request.json();
895
-
896
- // Get current user data
897
- const userData = await readUserRecord(env, userUid);
898
- if (!userData) {
899
- return new Response('User not found', {
900
- status: 404,
901
- headers: corsHeaders
902
- });
903
- }
904
-
905
- // Update cases
906
- const existingCases = userData.cases || [];
907
-
908
- // Filter out duplicates
909
- const newCases = cases.filter(newCase =>
910
- !existingCases.some(existingCase =>
911
- existingCase.caseNumber === newCase.caseNumber
912
- )
913
- );
914
-
915
- // Update user data
916
- userData.cases = [...existingCases, ...newCases];
917
- userData.updatedAt = new Date().toISOString();
918
-
919
- // Save to KV
920
- await writeUserRecord(env, userUid, userData);
921
-
922
- return new Response(JSON.stringify(userData), {
923
- status: 200,
924
- headers: corsHeaders
925
- });
926
- } catch {
927
- return new Response('Failed to add cases', {
928
- status: 500,
929
- headers: corsHeaders
930
- });
931
- }
932
- }
933
-
934
- async function handleDeleteCases(request: Request, env: Env, userUid: string): Promise<Response> {
935
- try {
936
- const { casesToDelete }: DeleteCasesRequest = await request.json();
937
-
938
- // Get current user data
939
- const userData = await readUserRecord(env, userUid);
940
- if (!userData) {
941
- return new Response('User not found', {
942
- status: 404,
943
- headers: corsHeaders
944
- });
945
- }
946
-
947
- // Update user data
948
- userData.cases = userData.cases.filter(c =>
949
- !casesToDelete.includes(c.caseNumber)
950
- );
951
- userData.updatedAt = new Date().toISOString();
952
-
953
- // Save to KV
954
- await writeUserRecord(env, userUid, userData);
955
-
956
- return new Response(JSON.stringify(userData), {
957
- status: 200,
958
- headers: corsHeaders
959
- });
960
- } catch {
961
- return new Response('Failed to delete cases', {
962
- status: 500,
963
- headers: corsHeaders
964
- });
965
- }
966
- }
967
-
968
20
  export default {
969
21
  async fetch(request: Request, env: Env): Promise<Response> {
970
22
  if (request.method === 'OPTIONS') {
@@ -984,7 +36,7 @@ export default {
984
36
  const url = new URL(request.url);
985
37
  const parts = url.pathname.split('/');
986
38
  const userUid = parts[1];
987
- const isCasesEndpoint = parts[2] === 'cases';
39
+ const isCasesEndpoint = parts[2] === USER_CASES_SEGMENT;
988
40
 
989
41
  if (!userUid) {
990
42
  return new Response('Not Found', { status: 404 });
@@ -993,8 +45,8 @@ export default {
993
45
  // Handle regular cases endpoint
994
46
  if (isCasesEndpoint) {
995
47
  switch (request.method) {
996
- case 'PUT': return handleAddCases(request, env, userUid);
997
- case 'DELETE': return handleDeleteCases(request, env, userUid);
48
+ case 'PUT': return handleAddCases(request, env, userUid, corsHeaders);
49
+ case 'DELETE': return handleDeleteCases(request, env, userUid, corsHeaders);
998
50
  default: return new Response('Method not allowed', {
999
51
  status: 405,
1000
52
  headers: corsHeaders
@@ -1007,9 +59,11 @@ export default {
1007
59
  const streamProgress = url.searchParams.get('stream') === 'true' || acceptsEventStream;
1008
60
 
1009
61
  switch (request.method) {
1010
- case 'GET': return handleGetUser(env, userUid);
1011
- case 'PUT': return handleAddUser(request, env, userUid);
1012
- case 'DELETE': return streamProgress ? handleDeleteUserWithProgress(env, userUid) : handleDeleteUser(env, userUid);
62
+ case 'GET': return handleGetUser(env, userUid, corsHeaders);
63
+ case 'PUT': return handleAddUser(request, env, userUid, corsHeaders);
64
+ case 'DELETE': return streamProgress
65
+ ? handleDeleteUserWithProgress(env, userUid, corsHeaders)
66
+ : handleDeleteUser(env, userUid, corsHeaders);
1013
67
  default: return new Response('Method not allowed', {
1014
68
  status: 405,
1015
69
  headers: corsHeaders