@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,871 @@
1
+ /**
2
+ * Centralized data worker operations for case and file management
3
+ * Provides consistent API key management, error handling, and validation
4
+ * for all interactions with the data worker microservice
5
+ */
6
+
7
+ import { User } from 'firebase/auth';
8
+ import { CaseData, AnnotationData, ConfirmationImportData } from '~/types';
9
+ import paths from '~/config/config.json';
10
+ import { getDataApiKey } from './auth';
11
+ import { validateUserSession, canAccessCase, canModifyCase } from './permissions';
12
+ import {
13
+ ForensicManifestData,
14
+ ForensicManifestSignature,
15
+ FORENSIC_MANIFEST_VERSION
16
+ } from './SHA256';
17
+ import { CONFIRMATION_SIGNATURE_VERSION } from './confirmation-signature';
18
+ import {
19
+ AUDIT_EXPORT_SIGNATURE_VERSION,
20
+ AuditExportSigningPayload,
21
+ isValidAuditExportSigningPayload
22
+ } from './audit-export-signature';
23
+
24
+ const DATA_WORKER_URL = paths.data_worker_url;
25
+
26
+ // ============================================================================
27
+ // INTERFACES AND TYPES
28
+ // ============================================================================
29
+
30
+ export interface DataAccessResult {
31
+ allowed: boolean;
32
+ reason?: string;
33
+ }
34
+
35
+ export interface FileUpdate {
36
+ fileId: string;
37
+ annotations: AnnotationData;
38
+ }
39
+
40
+ export interface BatchUpdateResult {
41
+ successful: string[];
42
+ failed: { fileId: string; error: string }[];
43
+ }
44
+
45
+ export interface DataOperationOptions {
46
+ includeTimestamp?: boolean;
47
+ retryCount?: number;
48
+ skipValidation?: boolean;
49
+ }
50
+
51
+ export interface ManifestSigningResponse {
52
+ manifestVersion: string;
53
+ signature: ForensicManifestSignature;
54
+ }
55
+
56
+ export interface ConfirmationSigningResponse {
57
+ signatureVersion: string;
58
+ signature: ForensicManifestSignature;
59
+ }
60
+
61
+ export interface AuditExportSigningResponse {
62
+ signatureVersion: string;
63
+ signature: ForensicManifestSignature;
64
+ }
65
+
66
+ // Higher-order function type for data operations
67
+ export type DataOperation<T> = (user: User, ...args: any[]) => Promise<T>;
68
+
69
+ // ============================================================================
70
+ // CORE CASE DATA OPERATIONS
71
+ // ============================================================================
72
+
73
+ /**
74
+ * Get case data from R2 storage with validation and error handling
75
+ * @param user - Authenticated user
76
+ * @param caseNumber - Case identifier
77
+ * @param options - Optional configuration for the operation
78
+ */
79
+ export const getCaseData = async (
80
+ user: User,
81
+ caseNumber: string,
82
+ options: DataOperationOptions = {}
83
+ ): Promise<CaseData | null> => {
84
+ try {
85
+ // Validate user session
86
+ const sessionValidation = await validateUserSession(user);
87
+ if (!sessionValidation.valid) {
88
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
89
+ }
90
+
91
+ // Validate case access - return null if access denied (normal case)
92
+ const accessCheck = await canAccessCase(user, caseNumber);
93
+ if (!accessCheck.allowed) {
94
+ return null; // Case doesn't exist or user doesn't have access
95
+ }
96
+
97
+ // Validate case number format
98
+ if (!caseNumber || typeof caseNumber !== 'string' || caseNumber.trim() === '') {
99
+ throw new Error('Invalid case number provided');
100
+ }
101
+
102
+ const apiKey = await getDataApiKey();
103
+ const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
104
+
105
+ const response = await fetch(url, {
106
+ method: 'GET',
107
+ headers: {
108
+ 'X-Custom-Auth-Key': apiKey
109
+ }
110
+ });
111
+
112
+ if (response.status === 404) {
113
+ return null; // Case not found
114
+ }
115
+
116
+ if (!response.ok) {
117
+ throw new Error(`Failed to fetch case data: ${response.status} ${response.statusText}`);
118
+ }
119
+
120
+ const caseData = await response.json() as CaseData;
121
+ return caseData;
122
+
123
+ } catch (error) {
124
+ console.error(`Error fetching case data for ${caseNumber}:`, error);
125
+ throw error;
126
+ }
127
+ };
128
+
129
+ /**
130
+ * Update case data in R2 storage with validation and timestamps
131
+ * @param user - Authenticated user
132
+ * @param caseNumber - Case identifier
133
+ * @param caseData - Case data to save
134
+ * @param options - Optional configuration
135
+ */
136
+ export const updateCaseData = async (
137
+ user: User,
138
+ caseNumber: string,
139
+ caseData: CaseData,
140
+ options: DataOperationOptions = {}
141
+ ): Promise<void> => {
142
+ try {
143
+ // Validate user session
144
+ const sessionValidation = await validateUserSession(user);
145
+ if (!sessionValidation.valid) {
146
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
147
+ }
148
+
149
+ // Check modification permissions
150
+ const modifyCheck = await canModifyCase(user, caseNumber);
151
+ if (!modifyCheck.allowed) {
152
+ throw new Error(`Modification denied: ${modifyCheck.reason}`);
153
+ }
154
+
155
+ // Validate inputs
156
+ if (!caseNumber || typeof caseNumber !== 'string') {
157
+ throw new Error('Invalid case number provided');
158
+ }
159
+
160
+ if (!caseData || typeof caseData !== 'object') {
161
+ throw new Error('Invalid case data provided');
162
+ }
163
+
164
+ const apiKey = await getDataApiKey();
165
+ const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
166
+
167
+ // Add timestamp if requested (default: true)
168
+ const dataToSave = options.includeTimestamp !== false ? {
169
+ ...caseData,
170
+ updatedAt: new Date().toISOString()
171
+ } : caseData;
172
+
173
+ const response = await fetch(url, {
174
+ method: 'PUT',
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ 'X-Custom-Auth-Key': apiKey
178
+ },
179
+ body: JSON.stringify(dataToSave)
180
+ });
181
+
182
+ if (!response.ok) {
183
+ throw new Error(`Failed to update case data: ${response.status} ${response.statusText}`);
184
+ }
185
+
186
+ } catch (error) {
187
+ console.error(`Error updating case data for ${caseNumber}:`, error);
188
+ throw error;
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Delete case data from R2 storage with validation
194
+ * @param user - Authenticated user
195
+ * @param caseNumber - Case identifier
196
+ */
197
+ export const deleteCaseData = async (
198
+ user: User,
199
+ caseNumber: string,
200
+ options: DataOperationOptions = {}
201
+ ): Promise<void> => {
202
+ try {
203
+ // Validate user session
204
+ const sessionValidation = await validateUserSession(user);
205
+ if (!sessionValidation.valid) {
206
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
207
+ }
208
+
209
+ // Check modification permissions if validation is not explicitly disabled
210
+ if (options.skipValidation !== true) {
211
+ const modifyCheck = await canModifyCase(user, caseNumber);
212
+ if (!modifyCheck.allowed) {
213
+ throw new Error(`Delete denied: ${modifyCheck.reason}`);
214
+ }
215
+ }
216
+
217
+ const apiKey = await getDataApiKey();
218
+ const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
219
+
220
+ const response = await fetch(url, {
221
+ method: 'DELETE',
222
+ headers: {
223
+ 'X-Custom-Auth-Key': apiKey
224
+ }
225
+ });
226
+
227
+ if (!response.ok && response.status !== 404) {
228
+ throw new Error(`Failed to delete case data: ${response.status} ${response.statusText}`);
229
+ }
230
+
231
+ } catch (error) {
232
+ console.error(`Error deleting case data for ${caseNumber}:`, error);
233
+ throw error;
234
+ }
235
+ };
236
+
237
+ // ============================================================================
238
+ // FILE ANNOTATION OPERATIONS
239
+ // ============================================================================
240
+
241
+ /**
242
+ * Get file annotation data from R2 storage
243
+ * @param user - Authenticated user
244
+ * @param caseNumber - Case identifier
245
+ * @param fileId - File identifier
246
+ */
247
+ export const getFileAnnotations = async (
248
+ user: User,
249
+ caseNumber: string,
250
+ fileId: string
251
+ ): Promise<AnnotationData | null> => {
252
+ try {
253
+ // Validate user session
254
+ const sessionValidation = await validateUserSession(user);
255
+ if (!sessionValidation.valid) {
256
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
257
+ }
258
+
259
+ // Check case access
260
+ const accessCheck = await canAccessCase(user, caseNumber);
261
+ if (!accessCheck.allowed) {
262
+ throw new Error(`Access denied: ${accessCheck.reason}`);
263
+ }
264
+
265
+ // Validate inputs
266
+ if (!fileId || typeof fileId !== 'string') {
267
+ throw new Error('Invalid file ID provided');
268
+ }
269
+
270
+ const apiKey = await getDataApiKey();
271
+ const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
272
+
273
+ const response = await fetch(url, {
274
+ method: 'GET',
275
+ headers: {
276
+ 'X-Custom-Auth-Key': apiKey
277
+ }
278
+ });
279
+
280
+ if (response.status === 404) {
281
+ return null; // No annotations found
282
+ }
283
+
284
+ if (!response.ok) {
285
+ throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
286
+ }
287
+
288
+ return await response.json() as AnnotationData;
289
+
290
+ } catch (error) {
291
+ console.error(`Error fetching annotations for ${caseNumber}/${fileId}:`, error);
292
+ return null; // Return null for graceful handling
293
+ }
294
+ };
295
+
296
+ /**
297
+ * Save file annotation data to R2 storage
298
+ * @param user - Authenticated user
299
+ * @param caseNumber - Case identifier
300
+ * @param fileId - File identifier
301
+ * @param annotationData - Annotation data to save
302
+ * @param options - Optional configuration
303
+ */
304
+ export const saveFileAnnotations = async (
305
+ user: User,
306
+ caseNumber: string,
307
+ fileId: string,
308
+ annotationData: AnnotationData,
309
+ options: DataOperationOptions = {}
310
+ ): Promise<void> => {
311
+ try {
312
+ // Validate user session
313
+ const sessionValidation = await validateUserSession(user);
314
+ if (!sessionValidation.valid) {
315
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
316
+ }
317
+
318
+ // Check modification permissions if validation is not explicitly disabled
319
+ if (options.skipValidation !== true) {
320
+ const modifyCheck = await canModifyCase(user, caseNumber);
321
+ if (!modifyCheck.allowed) {
322
+ throw new Error(`Modification denied: ${modifyCheck.reason}`);
323
+ }
324
+ }
325
+
326
+ // Validate inputs
327
+ if (!fileId || typeof fileId !== 'string') {
328
+ throw new Error('Invalid file ID provided');
329
+ }
330
+
331
+ if (!annotationData || typeof annotationData !== 'object') {
332
+ throw new Error('Invalid annotation data provided');
333
+ }
334
+
335
+ const apiKey = await getDataApiKey();
336
+ const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
337
+
338
+ // Enforce immutability once confirmation data exists on an image.
339
+ const existingResponse = await fetch(url, {
340
+ method: 'GET',
341
+ headers: {
342
+ 'X-Custom-Auth-Key': apiKey
343
+ }
344
+ });
345
+
346
+ if (existingResponse.ok) {
347
+ const existingAnnotations = await existingResponse.json() as AnnotationData;
348
+ if (existingAnnotations?.confirmationData) {
349
+ throw new Error('Cannot modify annotations for a confirmed image');
350
+ }
351
+ } else if (existingResponse.status !== 404) {
352
+ throw new Error(`Failed to verify existing annotations: ${existingResponse.status} ${existingResponse.statusText}`);
353
+ }
354
+
355
+ // Add timestamp to annotation data
356
+ const dataToSave = {
357
+ ...annotationData,
358
+ updatedAt: new Date().toISOString()
359
+ };
360
+
361
+ const response = await fetch(url, {
362
+ method: 'PUT',
363
+ headers: {
364
+ 'Content-Type': 'application/json',
365
+ 'X-Custom-Auth-Key': apiKey
366
+ },
367
+ body: JSON.stringify(dataToSave)
368
+ });
369
+
370
+ if (!response.ok) {
371
+ throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
372
+ }
373
+
374
+ } catch (error) {
375
+ console.error(`Error saving annotations for ${caseNumber}/${fileId}:`, error);
376
+ throw error;
377
+ }
378
+ };
379
+
380
+ /**
381
+ * Delete file annotation data from R2 storage
382
+ * @param user - Authenticated user
383
+ * @param caseNumber - Case identifier
384
+ * @param fileId - File identifier
385
+ * @param options - Additional options for the operation
386
+ */
387
+ export const deleteFileAnnotations = async (
388
+ user: User,
389
+ caseNumber: string,
390
+ fileId: string,
391
+ options: { skipValidation?: boolean } = {}
392
+ ): Promise<void> => {
393
+ try {
394
+ // Validate user session
395
+ const sessionValidation = await validateUserSession(user);
396
+ if (!sessionValidation.valid) {
397
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
398
+ }
399
+
400
+ // Check modification permissions if validation is not explicitly disabled
401
+ if (options.skipValidation !== true) {
402
+ const modifyCheck = await canModifyCase(user, caseNumber);
403
+ if (!modifyCheck.allowed) {
404
+ throw new Error(`Delete denied: ${modifyCheck.reason}`);
405
+ }
406
+ }
407
+
408
+ const apiKey = await getDataApiKey();
409
+ const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
410
+
411
+ const response = await fetch(url, {
412
+ method: 'DELETE',
413
+ headers: {
414
+ 'X-Custom-Auth-Key': apiKey
415
+ }
416
+ });
417
+
418
+ if (!response.ok && response.status !== 404) {
419
+ throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
420
+ }
421
+
422
+ } catch (error) {
423
+ console.error(`Error deleting annotations for ${caseNumber}/${fileId}:`, error);
424
+ throw error;
425
+ }
426
+ };
427
+
428
+ // ============================================================================
429
+ // BATCH OPERATIONS
430
+ // ============================================================================
431
+
432
+ /**
433
+ * Update multiple files with annotation data in a single operation
434
+ * @param user - Authenticated user
435
+ * @param caseNumber - Case identifier
436
+ * @param updates - Array of file updates to apply
437
+ */
438
+ export const batchUpdateFiles = async (
439
+ user: User,
440
+ caseNumber: string,
441
+ updates: FileUpdate[],
442
+ options: DataOperationOptions = {}
443
+ ): Promise<BatchUpdateResult> => {
444
+ const result: BatchUpdateResult = {
445
+ successful: [],
446
+ failed: []
447
+ };
448
+
449
+ try {
450
+ // Validate session and permissions once for the batch
451
+ const sessionValidation = await validateUserSession(user);
452
+ if (!sessionValidation.valid) {
453
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
454
+ }
455
+
456
+ // Check modification permissions
457
+ const modifyCheck = await canModifyCase(user, caseNumber);
458
+ if (!modifyCheck.allowed) {
459
+ throw new Error(`Batch update denied: ${modifyCheck.reason}`);
460
+ }
461
+
462
+ // Process each file update
463
+ for (const update of updates) {
464
+ try {
465
+ await saveFileAnnotations(user, caseNumber, update.fileId, update.annotations);
466
+ result.successful.push(update.fileId);
467
+ } catch (error) {
468
+ result.failed.push({
469
+ fileId: update.fileId,
470
+ error: error instanceof Error ? error.message : 'Unknown error'
471
+ });
472
+ }
473
+ }
474
+
475
+ return result;
476
+
477
+ } catch (error) {
478
+ // If validation fails, mark all as failed
479
+ for (const update of updates) {
480
+ result.failed.push({
481
+ fileId: update.fileId,
482
+ error: error instanceof Error ? error.message : 'Batch operation failed'
483
+ });
484
+ }
485
+ return result;
486
+ }
487
+ };
488
+
489
+ /**
490
+ * Duplicate case data from one case to another (for case renaming operations)
491
+ * @param user - Authenticated user
492
+ * @param fromCaseNumber - Source case number
493
+ * @param toCaseNumber - Destination case number
494
+ */
495
+ export const duplicateCaseData = async (
496
+ user: User,
497
+ fromCaseNumber: string,
498
+ toCaseNumber: string,
499
+ options: { skipDestinationCheck?: boolean } = {}
500
+ ): Promise<void> => {
501
+ try {
502
+ // For rename operations, we skip the destination check since the case doesn't exist yet
503
+ if (!options.skipDestinationCheck) {
504
+ // Check if user has permission to create/modify the destination case
505
+ const accessResult = await canModifyCase(user, toCaseNumber);
506
+ if (!accessResult.allowed) {
507
+ throw new Error(`User does not have permission to create or modify case ${toCaseNumber}: ${accessResult.reason || 'Access denied'}`);
508
+ }
509
+ }
510
+
511
+ // Get source case data
512
+ const sourceCaseData = await getCaseData(user, fromCaseNumber);
513
+ if (!sourceCaseData) {
514
+ throw new Error(`Source case ${fromCaseNumber} not found`);
515
+ }
516
+
517
+ // Update case number in the data
518
+ const newCaseData = {
519
+ ...sourceCaseData,
520
+ caseNumber: toCaseNumber,
521
+ updatedAt: new Date().toISOString()
522
+ };
523
+
524
+ // Save to new location
525
+ await updateCaseData(
526
+ user,
527
+ toCaseNumber,
528
+ newCaseData
529
+ );
530
+
531
+ // Copy file annotations if they exist
532
+ if (sourceCaseData.files && sourceCaseData.files.length > 0) {
533
+ const updates: FileUpdate[] = [];
534
+
535
+ for (const file of sourceCaseData.files) {
536
+ const annotations = await getFileAnnotations(user, fromCaseNumber, file.id);
537
+ if (annotations) {
538
+ updates.push({
539
+ fileId: file.id,
540
+ annotations
541
+ });
542
+ }
543
+ }
544
+
545
+ if (updates.length > 0) {
546
+ await batchUpdateFiles(
547
+ user,
548
+ toCaseNumber,
549
+ updates
550
+ );
551
+ }
552
+ }
553
+
554
+ } catch (error) {
555
+ console.error(`Error duplicating case data from ${fromCaseNumber} to ${toCaseNumber}:`, error);
556
+ throw error;
557
+ }
558
+ };
559
+
560
+ // ============================================================================
561
+ // VALIDATION AND UTILITY FUNCTIONS
562
+ // ============================================================================
563
+
564
+ /**
565
+ * Validate data access permissions for a user and case
566
+ * @param user - Authenticated user
567
+ * @param caseNumber - Case identifier
568
+ */
569
+ export const validateDataAccess = async (
570
+ user: User,
571
+ caseNumber: string
572
+ ): Promise<DataAccessResult> => {
573
+ try {
574
+ // Session validation
575
+ const sessionValidation = await validateUserSession(user);
576
+ if (!sessionValidation.valid) {
577
+ return { allowed: false, reason: sessionValidation.reason };
578
+ }
579
+
580
+ // Case access validation
581
+ const accessCheck = await canAccessCase(user, caseNumber);
582
+ if (!accessCheck.allowed) {
583
+ return { allowed: false, reason: accessCheck.reason };
584
+ }
585
+
586
+ return { allowed: true };
587
+
588
+ } catch (error) {
589
+ console.error('Error validating data access:', error);
590
+ return { allowed: false, reason: 'Access validation failed' };
591
+ }
592
+ };
593
+
594
+ /**
595
+ * Higher-order function for consistent data operation patterns
596
+ * Wraps operations with standard validation and error handling
597
+ * @param operation - The data operation to wrap
598
+ */
599
+ export const withDataOperation = <T>(
600
+ operation: DataOperation<T>
601
+ ) => async (user: User, ...args: any[]): Promise<T> => {
602
+ try {
603
+ // Standard session validation
604
+ const sessionValidation = await validateUserSession(user);
605
+ if (!sessionValidation.valid) {
606
+ throw new Error(`Operation failed: ${sessionValidation.reason}`);
607
+ }
608
+
609
+ // Execute the operation
610
+ return await operation(user, ...args);
611
+
612
+ } catch (error) {
613
+ console.error('Data operation failed:', error);
614
+ throw error;
615
+ }
616
+ };
617
+
618
+ /**
619
+ * Check if a case exists in storage
620
+ * @param user - Authenticated user
621
+ * @param caseNumber - Case identifier
622
+ */
623
+ export const caseExists = async (
624
+ user: User,
625
+ caseNumber: string
626
+ ): Promise<boolean> => {
627
+ try {
628
+ const caseData = await getCaseData(user, caseNumber);
629
+ return caseData !== null;
630
+ } catch (error) {
631
+ // If we get an access denied error, the case might exist but user can't access it
632
+ if (error instanceof Error && error.message.includes('Access denied')) {
633
+ return false; // For existence checking, treat access denied as "doesn't exist for this user"
634
+ }
635
+ console.error(`Error checking case existence for ${caseNumber}:`, error);
636
+ return false;
637
+ }
638
+ };
639
+
640
+ /**
641
+ * Check if a file has annotations
642
+ * @param user - Authenticated user
643
+ * @param caseNumber - Case identifier
644
+ * @param fileId - File identifier
645
+ */
646
+ export const fileHasAnnotations = async (
647
+ user: User,
648
+ caseNumber: string,
649
+ fileId: string
650
+ ): Promise<boolean> => {
651
+ try {
652
+ const annotations = await getFileAnnotations(user, caseNumber, fileId);
653
+ return annotations !== null;
654
+ } catch (error) {
655
+ console.error(`Error checking annotations for ${caseNumber}/${fileId}:`, error);
656
+ return false;
657
+ }
658
+ };
659
+
660
+ /**
661
+ * Request a server-side signature for a forensic manifest.
662
+ * The signature is generated by the data worker using a private key secret.
663
+ */
664
+ export const signForensicManifest = async (
665
+ user: User,
666
+ caseNumber: string,
667
+ manifest: ForensicManifestData
668
+ ): Promise<ManifestSigningResponse> => {
669
+ try {
670
+ const sessionValidation = await validateUserSession(user);
671
+ if (!sessionValidation.valid) {
672
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
673
+ }
674
+
675
+ const accessCheck = await canAccessCase(user, caseNumber);
676
+ if (!accessCheck.allowed) {
677
+ throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
678
+ }
679
+
680
+ const apiKey = await getDataApiKey();
681
+ const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-manifest`, {
682
+ method: 'POST',
683
+ headers: {
684
+ 'Content-Type': 'application/json',
685
+ 'X-Custom-Auth-Key': apiKey
686
+ },
687
+ body: JSON.stringify({
688
+ userId: user.uid,
689
+ caseNumber,
690
+ manifest
691
+ })
692
+ });
693
+
694
+ const responseData = await response.json().catch(() => null) as {
695
+ success?: boolean;
696
+ error?: string;
697
+ manifestVersion?: string;
698
+ signature?: ForensicManifestSignature;
699
+ } | null;
700
+
701
+ if (!response.ok) {
702
+ throw new Error(
703
+ responseData?.error ||
704
+ `Failed to sign forensic manifest: ${response.status} ${response.statusText}`
705
+ );
706
+ }
707
+
708
+ if (!responseData?.success || !responseData.signature || !responseData.manifestVersion) {
709
+ throw new Error('Invalid manifest signing response from data worker');
710
+ }
711
+
712
+ if (responseData.manifestVersion !== FORENSIC_MANIFEST_VERSION) {
713
+ throw new Error(
714
+ `Unexpected manifest version from signer: ${responseData.manifestVersion}`
715
+ );
716
+ }
717
+
718
+ return {
719
+ manifestVersion: responseData.manifestVersion,
720
+ signature: responseData.signature
721
+ };
722
+ } catch (error) {
723
+ console.error(`Error signing forensic manifest for ${caseNumber}:`, error);
724
+ throw error;
725
+ }
726
+ };
727
+
728
+ /**
729
+ * Request a server-side signature for confirmation export data.
730
+ * The signature is generated by the data worker using a private key secret.
731
+ */
732
+ export const signConfirmationData = async (
733
+ user: User,
734
+ caseNumber: string,
735
+ confirmationData: ConfirmationImportData
736
+ ): Promise<ConfirmationSigningResponse> => {
737
+ try {
738
+ const sessionValidation = await validateUserSession(user);
739
+ if (!sessionValidation.valid) {
740
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
741
+ }
742
+
743
+ const accessCheck = await canAccessCase(user, caseNumber);
744
+ if (!accessCheck.allowed) {
745
+ throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
746
+ }
747
+
748
+ const apiKey = await getDataApiKey();
749
+ const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-confirmation`, {
750
+ method: 'POST',
751
+ headers: {
752
+ 'Content-Type': 'application/json',
753
+ 'X-Custom-Auth-Key': apiKey
754
+ },
755
+ body: JSON.stringify({
756
+ userId: user.uid,
757
+ caseNumber,
758
+ confirmationData,
759
+ signatureVersion: CONFIRMATION_SIGNATURE_VERSION
760
+ })
761
+ });
762
+
763
+ const responseData = await response.json().catch(() => null) as {
764
+ success?: boolean;
765
+ error?: string;
766
+ signatureVersion?: string;
767
+ signature?: ForensicManifestSignature;
768
+ } | null;
769
+
770
+ if (!response.ok) {
771
+ throw new Error(
772
+ responseData?.error ||
773
+ `Failed to sign confirmation data: ${response.status} ${response.statusText}`
774
+ );
775
+ }
776
+
777
+ if (!responseData?.success || !responseData.signature || !responseData.signatureVersion) {
778
+ throw new Error('Invalid confirmation signing response from data worker');
779
+ }
780
+
781
+ if (responseData.signatureVersion !== CONFIRMATION_SIGNATURE_VERSION) {
782
+ throw new Error(
783
+ `Unexpected confirmation signature version from signer: ${responseData.signatureVersion}`
784
+ );
785
+ }
786
+
787
+ return {
788
+ signatureVersion: responseData.signatureVersion,
789
+ signature: responseData.signature
790
+ };
791
+ } catch (error) {
792
+ console.error(`Error signing confirmation data for ${caseNumber}:`, error);
793
+ throw error;
794
+ }
795
+ };
796
+
797
+ /**
798
+ * Request a server-side signature for audit export metadata.
799
+ * The signature is generated by the data worker using a private key secret.
800
+ */
801
+ export const signAuditExportData = async (
802
+ user: User,
803
+ auditExport: AuditExportSigningPayload,
804
+ options: { caseNumber?: string } = {}
805
+ ): Promise<AuditExportSigningResponse> => {
806
+ try {
807
+ const sessionValidation = await validateUserSession(user);
808
+ if (!sessionValidation.valid) {
809
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
810
+ }
811
+
812
+ if (!isValidAuditExportSigningPayload(auditExport)) {
813
+ throw new Error('Invalid audit export payload for signing');
814
+ }
815
+
816
+ const caseNumber = options.caseNumber;
817
+ if (caseNumber) {
818
+ const accessCheck = await canAccessCase(user, caseNumber);
819
+ if (!accessCheck.allowed) {
820
+ throw new Error(`Audit export signing denied: ${accessCheck.reason}`);
821
+ }
822
+ }
823
+
824
+ const apiKey = await getDataApiKey();
825
+ const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-audit-export`, {
826
+ method: 'POST',
827
+ headers: {
828
+ 'Content-Type': 'application/json',
829
+ 'X-Custom-Auth-Key': apiKey
830
+ },
831
+ body: JSON.stringify({
832
+ userId: user.uid,
833
+ caseNumber,
834
+ auditExport,
835
+ signatureVersion: AUDIT_EXPORT_SIGNATURE_VERSION
836
+ })
837
+ });
838
+
839
+ const responseData = await response.json().catch(() => null) as {
840
+ success?: boolean;
841
+ error?: string;
842
+ signatureVersion?: string;
843
+ signature?: ForensicManifestSignature;
844
+ } | null;
845
+
846
+ if (!response.ok) {
847
+ throw new Error(
848
+ responseData?.error ||
849
+ `Failed to sign audit export data: ${response.status} ${response.statusText}`
850
+ );
851
+ }
852
+
853
+ if (!responseData?.success || !responseData.signature || !responseData.signatureVersion) {
854
+ throw new Error('Invalid audit export signing response from data worker');
855
+ }
856
+
857
+ if (responseData.signatureVersion !== AUDIT_EXPORT_SIGNATURE_VERSION) {
858
+ throw new Error(
859
+ `Unexpected audit export signature version from signer: ${responseData.signatureVersion}`
860
+ );
861
+ }
862
+
863
+ return {
864
+ signatureVersion: responseData.signatureVersion,
865
+ signature: responseData.signature
866
+ };
867
+ } catch (error) {
868
+ console.error('Error signing audit export data:', error);
869
+ throw error;
870
+ }
871
+ };