@striae-org/striae 3.0.4

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 (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. package/wrangler.toml.example +8 -0
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "PDF_WORKER_NAME",
3
+ "account_id": "ACCOUNT_ID",
4
+ "main": "src/pdf-worker.ts",
5
+ "compatibility_date": "2026-03-09",
6
+ "compatibility_flags": [
7
+ "nodejs_compat"
8
+ ],
9
+
10
+ "browser": {
11
+ "binding": "BROWSER"
12
+ },
13
+
14
+ "observability": {
15
+ "enabled": true
16
+ },
17
+
18
+ "routes": [
19
+ {
20
+ "pattern": "PDF_WORKER_DOMAIN",
21
+ "custom_domain": true
22
+ }
23
+ ],
24
+
25
+ "placement": { "mode": "smart" }
26
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "user-worker",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "deploy": "wrangler deploy",
7
+ "dev": "wrangler dev",
8
+ "start": "wrangler dev",
9
+ "test": "vitest"
10
+ },
11
+ "devDependencies": {
12
+ "@cloudflare/puppeteer": "^1.0.4",
13
+ "@cloudflare/vitest-pool-workers": "^0.12.9",
14
+ "vitest": "~3.2.0",
15
+ "wrangler": "^4.69.0"
16
+ }
17
+ }
@@ -0,0 +1,636 @@
1
+ interface Env {
2
+ USER_DB_AUTH: string;
3
+ USER_DB: KVNamespace;
4
+ R2_KEY_SECRET: string;
5
+ IMAGES_API_TOKEN: string;
6
+ PROJECT_ID: string;
7
+ FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
8
+ FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
9
+ }
10
+
11
+ interface UserData {
12
+ uid: string;
13
+ email: string;
14
+ firstName: string;
15
+ lastName: string;
16
+ company: string;
17
+ permitted: boolean;
18
+ cases: CaseItem[];
19
+ readOnlyCases?: ReadOnlyCaseItem[];
20
+ createdAt?: string;
21
+ updatedAt?: string;
22
+ }
23
+
24
+ interface CaseItem {
25
+ caseNumber: string;
26
+ caseName?: string;
27
+ [key: string]: any;
28
+ }
29
+
30
+ interface ReadOnlyCaseItem {
31
+ caseNumber: string;
32
+ caseName?: string;
33
+ [key: string]: any;
34
+ }
35
+
36
+ interface UserRequestData {
37
+ email?: string;
38
+ firstName?: string;
39
+ lastName?: string;
40
+ company?: string;
41
+ permitted?: boolean;
42
+ readOnlyCases?: ReadOnlyCaseItem[];
43
+ }
44
+
45
+ interface AddCasesRequest {
46
+ cases: CaseItem[];
47
+ }
48
+
49
+ interface DeleteCasesRequest {
50
+ casesToDelete: string[];
51
+ }
52
+
53
+ interface CaseData {
54
+ files?: Array<{ id: string; [key: string]: any }>;
55
+ [key: string]: any;
56
+ }
57
+
58
+ interface AccountDeletionProgressEvent {
59
+ event: 'start' | 'case-start' | 'case-complete' | 'complete' | 'error';
60
+ totalCases: number;
61
+ completedCases: number;
62
+ currentCaseNumber?: string;
63
+ success?: boolean;
64
+ message?: string;
65
+ }
66
+
67
+ interface GoogleOAuthTokenResponse {
68
+ access_token?: string;
69
+ error?: string;
70
+ error_description?: string;
71
+ }
72
+
73
+ interface FirebaseDeleteAccountErrorResponse {
74
+ error?: {
75
+ message?: string;
76
+ };
77
+ }
78
+
79
+ const corsHeaders: Record<string, string> = {
80
+ 'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
81
+ 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',
82
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Custom-Auth-Key',
83
+ 'Content-Type': 'application/json'
84
+ };
85
+
86
+ // Worker URLs - configure these for deployment
87
+ const DATA_WORKER_URL = 'DATA_WORKER_DOMAIN';
88
+
89
+ const IMAGE_WORKER_URL = 'IMAGES_WORKER_DOMAIN';
90
+
91
+ const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
92
+ const FIREBASE_IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1/projects';
93
+ const GOOGLE_IDENTITY_TOOLKIT_SCOPE = 'https://www.googleapis.com/auth/identitytoolkit';
94
+ const textEncoder = new TextEncoder();
95
+
96
+ async function authenticate(request: Request, env: Env): Promise<void> {
97
+ const authKey = request.headers.get('X-Custom-Auth-Key');
98
+ if (authKey !== env.USER_DB_AUTH) throw new Error('Unauthorized');
99
+ }
100
+
101
+ function base64UrlEncode(value: string | Uint8Array): string {
102
+ const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
103
+ let binary = '';
104
+
105
+ for (const byte of bytes) {
106
+ binary += String.fromCharCode(byte);
107
+ }
108
+
109
+ return btoa(binary)
110
+ .replace(/\+/g, '-')
111
+ .replace(/\//g, '_')
112
+ .replace(/=+$/g, '');
113
+ }
114
+
115
+ function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
116
+ const normalizedKey = privateKey
117
+ .trim()
118
+ .replace(/^['"]|['"]$/g, '')
119
+ .replace(/\\n/g, '\n');
120
+
121
+ const pemBody = normalizedKey
122
+ .replace('-----BEGIN PRIVATE KEY-----', '')
123
+ .replace('-----END PRIVATE KEY-----', '')
124
+ .replace(/\s+/g, '');
125
+
126
+ if (!pemBody) {
127
+ throw new Error('Firebase service account private key is invalid');
128
+ }
129
+
130
+ const binary = atob(pemBody);
131
+ const bytes = new Uint8Array(binary.length);
132
+
133
+ for (let index = 0; index < binary.length; index += 1) {
134
+ bytes[index] = binary.charCodeAt(index);
135
+ }
136
+
137
+ return bytes.buffer;
138
+ }
139
+
140
+ async function buildServiceAccountAssertion(env: Env): Promise<string> {
141
+ const issuedAt = Math.floor(Date.now() / 1000);
142
+ const header = {
143
+ alg: 'RS256',
144
+ typ: 'JWT'
145
+ };
146
+ const payload = {
147
+ iss: env.FIREBASE_SERVICE_ACCOUNT_EMAIL,
148
+ scope: GOOGLE_IDENTITY_TOOLKIT_SCOPE,
149
+ aud: GOOGLE_OAUTH_TOKEN_URL,
150
+ iat: issuedAt,
151
+ exp: issuedAt + 3600
152
+ };
153
+ const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
154
+
155
+ let signingKey: CryptoKey;
156
+
157
+ try {
158
+ signingKey = await crypto.subtle.importKey(
159
+ 'pkcs8',
160
+ parsePkcs8PrivateKey(env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY),
161
+ {
162
+ name: 'RSASSA-PKCS1-v1_5',
163
+ hash: 'SHA-256'
164
+ },
165
+ false,
166
+ ['sign']
167
+ );
168
+ } catch {
169
+ 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.');
170
+ }
171
+
172
+ const signature = await crypto.subtle.sign(
173
+ { name: 'RSASSA-PKCS1-v1_5' },
174
+ signingKey,
175
+ textEncoder.encode(unsignedToken)
176
+ );
177
+
178
+ return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`;
179
+ }
180
+
181
+ async function getGoogleAccessToken(env: Env): Promise<string> {
182
+ const assertion = await buildServiceAccountAssertion(env);
183
+ const body = new URLSearchParams({
184
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
185
+ assertion
186
+ });
187
+
188
+ const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
189
+ method: 'POST',
190
+ headers: {
191
+ 'Content-Type': 'application/x-www-form-urlencoded'
192
+ },
193
+ body
194
+ });
195
+
196
+ const tokenData = await tokenResponse.json().catch(() => ({})) as GoogleOAuthTokenResponse;
197
+ if (!tokenResponse.ok || !tokenData.access_token) {
198
+ const errorReason = tokenData.error_description || tokenData.error || `HTTP ${tokenResponse.status}`;
199
+ throw new Error(`Failed to authorize Firebase admin deletion: ${errorReason}`);
200
+ }
201
+
202
+ return tokenData.access_token;
203
+ }
204
+
205
+ async function deleteFirebaseAuthUser(env: Env, userUid: string): Promise<void> {
206
+ if (!env.PROJECT_ID || !env.FIREBASE_SERVICE_ACCOUNT_EMAIL || !env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY) {
207
+ throw new Error('Firebase Auth deletion is not configured in User Worker secrets');
208
+ }
209
+
210
+ const accessToken = await getGoogleAccessToken(env);
211
+ const deleteResponse = await fetch(
212
+ `${FIREBASE_IDENTITY_TOOLKIT_BASE_URL}/${encodeURIComponent(env.PROJECT_ID)}/accounts:delete`,
213
+ {
214
+ method: 'POST',
215
+ headers: {
216
+ 'Authorization': `Bearer ${accessToken}`,
217
+ 'Content-Type': 'application/json'
218
+ },
219
+ body: JSON.stringify({ localId: userUid })
220
+ }
221
+ );
222
+
223
+ if (deleteResponse.ok) {
224
+ return;
225
+ }
226
+
227
+ const deleteErrorPayload = await deleteResponse.json().catch(() => ({})) as FirebaseDeleteAccountErrorResponse;
228
+ const deleteErrorMessage = deleteErrorPayload.error?.message || '';
229
+
230
+ if (deleteErrorMessage.includes('USER_NOT_FOUND')) {
231
+ return;
232
+ }
233
+
234
+ throw new Error(deleteErrorMessage ? `Firebase Auth deletion failed: ${deleteErrorMessage}` : `Firebase Auth deletion failed with status ${deleteResponse.status}`);
235
+ }
236
+
237
+ async function handleGetUser(env: Env, userUid: string): Promise<Response> {
238
+ try {
239
+ const value = await env.USER_DB.get(userUid);
240
+ if (value === null) {
241
+ return new Response('User not found', {
242
+ status: 404,
243
+ headers: corsHeaders
244
+ });
245
+ }
246
+ return new Response(value, {
247
+ status: 200,
248
+ headers: corsHeaders
249
+ });
250
+ } catch (error) {
251
+ return new Response('Failed to get user data', {
252
+ status: 500,
253
+ headers: corsHeaders
254
+ });
255
+ }
256
+ }
257
+
258
+ async function handleAddUser(request: Request, env: Env, userUid: string): Promise<Response> {
259
+ try {
260
+ const requestData: UserRequestData = await request.json();
261
+ const { email, firstName, lastName, company, permitted } = requestData;
262
+
263
+ // Check for existing user
264
+ const value = await env.USER_DB.get(userUid);
265
+
266
+ let userData: UserData;
267
+ if (value !== null) {
268
+ // Update existing user, preserving cases
269
+ const existing: UserData = JSON.parse(value);
270
+ userData = {
271
+ ...existing,
272
+ // Preserve all existing fields
273
+ email: email || existing.email,
274
+ firstName: firstName || existing.firstName,
275
+ lastName: lastName || existing.lastName,
276
+ company: company || existing.company,
277
+ permitted: permitted !== undefined ? permitted : existing.permitted,
278
+ updatedAt: new Date().toISOString()
279
+ };
280
+ if (requestData.readOnlyCases !== undefined) {
281
+ userData.readOnlyCases = requestData.readOnlyCases;
282
+ }
283
+ } else {
284
+ // Create new user
285
+ userData = {
286
+ uid: userUid,
287
+ email: email || '',
288
+ firstName: firstName || '',
289
+ lastName: lastName || '',
290
+ company: company || '',
291
+ permitted: permitted !== undefined ? permitted : true,
292
+ cases: [],
293
+ createdAt: new Date().toISOString()
294
+ };
295
+ if (requestData.readOnlyCases !== undefined) {
296
+ userData.readOnlyCases = requestData.readOnlyCases;
297
+ }
298
+ }
299
+
300
+ // Store value in KV
301
+ await env.USER_DB.put(userUid, JSON.stringify(userData));
302
+
303
+ return new Response(JSON.stringify(userData), {
304
+ status: value !== null ? 200 : 201,
305
+ headers: corsHeaders
306
+ });
307
+ } catch (error) {
308
+ return new Response('Failed to save user data', {
309
+ status: 500,
310
+ headers: corsHeaders
311
+ });
312
+ }
313
+ }
314
+
315
+ // Function to delete a single case (similar to case-manage.ts deleteCase)
316
+ async function deleteSingleCase(env: Env, userUid: string, caseNumber: string): Promise<void> {
317
+ const dataApiKey = env.R2_KEY_SECRET;
318
+ const imageApiKey = env.IMAGES_API_TOKEN;
319
+
320
+ try {
321
+ // Get case data from data worker
322
+ const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/data.json`, {
323
+ headers: { 'X-Custom-Auth-Key': dataApiKey }
324
+ });
325
+
326
+ if (!caseResponse.ok) {
327
+ return;
328
+ }
329
+
330
+ const caseData: CaseData = await caseResponse.json();
331
+
332
+ // Delete all files associated with this case
333
+ if (caseData.files && caseData.files.length > 0) {
334
+ for (const file of caseData.files) {
335
+ try {
336
+ // Delete image file - correct endpoint
337
+ await fetch(`${IMAGE_WORKER_URL}/${encodeURIComponent(file.id)}`, {
338
+ method: 'DELETE',
339
+ headers: {
340
+ 'Authorization': `Bearer ${imageApiKey}`
341
+ }
342
+ });
343
+
344
+ // Delete notes file if exists
345
+ await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(file.id)}/data.json`, {
346
+ method: 'DELETE',
347
+ headers: { 'X-Custom-Auth-Key': dataApiKey }
348
+ });
349
+ } catch (fileError) {
350
+ // Continue with other files
351
+ }
352
+ }
353
+ }
354
+
355
+ // Delete the case data file
356
+ await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/data.json`, {
357
+ method: 'DELETE',
358
+ headers: { 'X-Custom-Auth-Key': dataApiKey }
359
+ });
360
+
361
+ } catch (error) {
362
+ // Continue with user deletion even if case deletion fails
363
+ }
364
+ }
365
+
366
+ async function executeUserDeletion(
367
+ env: Env,
368
+ userUid: string,
369
+ reportProgress?: (progress: AccountDeletionProgressEvent) => void
370
+ ): Promise<{ success: boolean; message: string; totalCases: number; completedCases: number }> {
371
+ const userData = await env.USER_DB.get(userUid);
372
+ if (userData === null) {
373
+ throw new Error('User not found');
374
+ }
375
+
376
+ const userObject: UserData = JSON.parse(userData);
377
+ const ownedCases = (userObject.cases || []).map((caseItem) => caseItem.caseNumber);
378
+ const readOnlyCases = (userObject.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
379
+ const allCaseNumbers = [...ownedCases, ...readOnlyCases];
380
+ const totalCases = allCaseNumbers.length;
381
+ let completedCases = 0;
382
+
383
+ await deleteFirebaseAuthUser(env, userUid);
384
+
385
+ reportProgress?.({
386
+ event: 'start',
387
+ totalCases,
388
+ completedCases
389
+ });
390
+
391
+ for (const caseNumber of allCaseNumbers) {
392
+ reportProgress?.({
393
+ event: 'case-start',
394
+ totalCases,
395
+ completedCases,
396
+ currentCaseNumber: caseNumber
397
+ });
398
+
399
+ await deleteSingleCase(env, userUid, caseNumber);
400
+ completedCases += 1;
401
+
402
+ reportProgress?.({
403
+ event: 'case-complete',
404
+ totalCases,
405
+ completedCases,
406
+ currentCaseNumber: caseNumber
407
+ });
408
+ }
409
+
410
+ // Delete the user account from the database
411
+ await env.USER_DB.delete(userUid);
412
+
413
+ return {
414
+ success: true,
415
+ message: 'Account successfully deleted',
416
+ totalCases,
417
+ completedCases
418
+ };
419
+ }
420
+
421
+ async function handleDeleteUser(env: Env, userUid: string): Promise<Response> {
422
+ try {
423
+ const result = await executeUserDeletion(env, userUid);
424
+
425
+ return new Response(JSON.stringify({
426
+ success: result.success,
427
+ message: result.message
428
+ }), {
429
+ status: 200,
430
+ headers: corsHeaders
431
+ });
432
+ } catch (error) {
433
+ console.error('Delete user error:', error);
434
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
435
+
436
+ if (errorMessage === 'User not found') {
437
+ return new Response('User not found', {
438
+ status: 404,
439
+ headers: corsHeaders
440
+ });
441
+ }
442
+
443
+ return new Response(JSON.stringify({
444
+ success: false,
445
+ message: 'Failed to delete user account'
446
+ }), {
447
+ status: 500,
448
+ headers: corsHeaders
449
+ });
450
+ }
451
+ }
452
+
453
+ function handleDeleteUserWithProgress(env: Env, userUid: string): Response {
454
+ const sseHeaders: Record<string, string> = {
455
+ ...corsHeaders,
456
+ 'Content-Type': 'text/event-stream',
457
+ 'Cache-Control': 'no-cache, no-transform',
458
+ 'Connection': 'keep-alive'
459
+ };
460
+
461
+ const encoder = new TextEncoder();
462
+
463
+ const stream = new ReadableStream<Uint8Array>({
464
+ async start(controller) {
465
+ const sendEvent = (payload: AccountDeletionProgressEvent) => {
466
+ controller.enqueue(encoder.encode(`event: ${payload.event}\ndata: ${JSON.stringify(payload)}\n\n`));
467
+ };
468
+
469
+ try {
470
+ const result = await executeUserDeletion(env, userUid, sendEvent);
471
+ sendEvent({
472
+ event: 'complete',
473
+ totalCases: result.totalCases,
474
+ completedCases: result.completedCases,
475
+ success: result.success,
476
+ message: result.message
477
+ });
478
+ } catch (error) {
479
+ const errorMessage = error instanceof Error ? error.message : 'Failed to delete user account';
480
+
481
+ sendEvent({
482
+ event: 'error',
483
+ totalCases: 0,
484
+ completedCases: 0,
485
+ success: false,
486
+ message: errorMessage
487
+ });
488
+ } finally {
489
+ controller.close();
490
+ }
491
+ }
492
+ });
493
+
494
+ return new Response(stream, {
495
+ status: 200,
496
+ headers: sseHeaders
497
+ });
498
+ }
499
+
500
+ async function handleAddCases(request: Request, env: Env, userUid: string): Promise<Response> {
501
+ try {
502
+ const { cases = [] }: AddCasesRequest = await request.json();
503
+
504
+ // Get current user data
505
+ const value = await env.USER_DB.get(userUid);
506
+ if (!value) {
507
+ return new Response('User not found', {
508
+ status: 404,
509
+ headers: corsHeaders
510
+ });
511
+ }
512
+
513
+ // Update cases
514
+ const userData: UserData = JSON.parse(value);
515
+ const existingCases = userData.cases || [];
516
+
517
+ // Filter out duplicates
518
+ const newCases = cases.filter(newCase =>
519
+ !existingCases.some(existingCase =>
520
+ existingCase.caseNumber === newCase.caseNumber
521
+ )
522
+ );
523
+
524
+ // Update user data
525
+ userData.cases = [...existingCases, ...newCases];
526
+ userData.updatedAt = new Date().toISOString();
527
+
528
+ // Save to KV
529
+ await env.USER_DB.put(userUid, JSON.stringify(userData));
530
+
531
+ return new Response(JSON.stringify(userData), {
532
+ status: 200,
533
+ headers: corsHeaders
534
+ });
535
+ } catch (error) {
536
+ return new Response('Failed to add cases', {
537
+ status: 500,
538
+ headers: corsHeaders
539
+ });
540
+ }
541
+ }
542
+
543
+ async function handleDeleteCases(request: Request, env: Env, userUid: string): Promise<Response> {
544
+ try {
545
+ const { casesToDelete }: DeleteCasesRequest = await request.json();
546
+
547
+ // Get current user data
548
+ const value = await env.USER_DB.get(userUid);
549
+ if (!value) {
550
+ return new Response('User not found', {
551
+ status: 404,
552
+ headers: corsHeaders
553
+ });
554
+ }
555
+
556
+ // Update user data
557
+ const userData: UserData = JSON.parse(value);
558
+ userData.cases = userData.cases.filter(c =>
559
+ !casesToDelete.includes(c.caseNumber)
560
+ );
561
+ userData.updatedAt = new Date().toISOString();
562
+
563
+ // Save to KV
564
+ await env.USER_DB.put(userUid, JSON.stringify(userData));
565
+
566
+ return new Response(JSON.stringify(userData), {
567
+ status: 200,
568
+ headers: corsHeaders
569
+ });
570
+ } catch (error) {
571
+ return new Response('Failed to delete cases', {
572
+ status: 500,
573
+ headers: corsHeaders
574
+ });
575
+ }
576
+ }
577
+
578
+ export default {
579
+ async fetch(request: Request, env: Env): Promise<Response> {
580
+ if (request.method === 'OPTIONS') {
581
+ return new Response(null, { headers: corsHeaders });
582
+ }
583
+
584
+ try {
585
+ await authenticate(request, env);
586
+
587
+ const url = new URL(request.url);
588
+ const parts = url.pathname.split('/');
589
+ const userUid = parts[1];
590
+ const isCasesEndpoint = parts[2] === 'cases';
591
+
592
+ if (!userUid) {
593
+ return new Response('Not Found', { status: 404 });
594
+ }
595
+
596
+ // Handle regular cases endpoint
597
+ if (isCasesEndpoint) {
598
+ switch (request.method) {
599
+ case 'PUT': return handleAddCases(request, env, userUid);
600
+ case 'DELETE': return handleDeleteCases(request, env, userUid);
601
+ default: return new Response('Method not allowed', {
602
+ status: 405,
603
+ headers: corsHeaders
604
+ });
605
+ }
606
+ }
607
+
608
+ // Handle user operations
609
+ const acceptsEventStream = request.headers.get('Accept')?.includes('text/event-stream') === true;
610
+ const streamProgress = url.searchParams.get('stream') === 'true' || acceptsEventStream;
611
+
612
+ switch (request.method) {
613
+ case 'GET': return handleGetUser(env, userUid);
614
+ case 'PUT': return handleAddUser(request, env, userUid);
615
+ case 'DELETE': return streamProgress ? handleDeleteUserWithProgress(env, userUid) : handleDeleteUser(env, userUid);
616
+ default: return new Response('Method not allowed', {
617
+ status: 405,
618
+ headers: corsHeaders
619
+ });
620
+ }
621
+ } catch (error) {
622
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
623
+ if (errorMessage === 'Unauthorized') {
624
+ return new Response('Forbidden', {
625
+ status: 403,
626
+ headers: corsHeaders
627
+ });
628
+ }
629
+
630
+ return new Response('Internal Server Error', {
631
+ status: 500,
632
+ headers: corsHeaders
633
+ });
634
+ }
635
+ }
636
+ };