@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,562 @@
1
+ import { User } from 'firebase/auth';
2
+ import { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } from '~/types';
3
+ import paths from '~/config/config.json';
4
+ import { getUserApiKey } from './auth';
5
+
6
+ const USER_WORKER_URL = paths.user_worker_url;
7
+ const MAX_CASES_REVIEW = paths.max_cases_review;
8
+ const MAX_FILES_PER_CASE_REVIEW = paths.max_files_per_case_review;
9
+
10
+ export interface UserUsage {
11
+ currentCases: number;
12
+ currentFiles: number;
13
+ }
14
+
15
+ export interface UserSessionValidation {
16
+ valid: boolean;
17
+ reason?: string;
18
+ }
19
+
20
+ export interface PermissionResult {
21
+ allowed: boolean;
22
+ reason?: string;
23
+ }
24
+
25
+ export interface CaseMetadata {
26
+ caseNumber: string;
27
+ createdAt: string;
28
+ }
29
+
30
+ /**
31
+ * Get user data from KV store
32
+ */
33
+ export const getUserData = async (user: User): Promise<UserData | null> => {
34
+ try {
35
+ const apiKey = await getUserApiKey();
36
+ const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
37
+ method: 'GET',
38
+ headers: {
39
+ 'X-Custom-Auth-Key': apiKey
40
+ }
41
+ });
42
+
43
+ if (response.ok) {
44
+ return await response.json() as UserData;
45
+ }
46
+
47
+ if (response.status === 404) {
48
+ return null; // User not found
49
+ }
50
+
51
+ throw new Error('Failed to fetch user data');
52
+ } catch (error) {
53
+ console.error('Error fetching user data:', error);
54
+ throw error;
55
+ }
56
+ };
57
+
58
+ /**
59
+ * Get user limits based on their permission status
60
+ */
61
+ export const getUserLimits = (userData: UserData): UserLimits => {
62
+ if (userData.permitted) {
63
+ return {
64
+ maxCases: Infinity, // No limit for permitted users
65
+ maxFilesPerCase: Infinity // No limit for permitted users
66
+ };
67
+ } else {
68
+ return {
69
+ maxCases: MAX_CASES_REVIEW, // Use config value for review users
70
+ maxFilesPerCase: MAX_FILES_PER_CASE_REVIEW // Use config value for review users
71
+ };
72
+ }
73
+ };
74
+
75
+ /**
76
+ * Get current usage counts for a user
77
+ */
78
+ export const getUserUsage = async (user: User): Promise<UserUsage> => {
79
+ try {
80
+ const userData = await getUserData(user);
81
+ if (!userData) {
82
+ return { currentCases: 0, currentFiles: 0 };
83
+ }
84
+
85
+ const currentCases = userData.cases?.length || 0;
86
+
87
+ // If we need file count for a specific case, we'd need to fetch that from the data worker
88
+ // For now, we'll return 0 as we'll check this in the specific upload function
89
+ const currentFiles = 0;
90
+
91
+ return {
92
+ currentCases,
93
+ currentFiles
94
+ };
95
+ } catch (error) {
96
+ console.error('Error getting user usage:', error);
97
+ return { currentCases: 0, currentFiles: 0 };
98
+ }
99
+ };
100
+
101
+ /**
102
+ * Create a new user in the KV store
103
+ */
104
+ export const createUser = async (
105
+ user: User,
106
+ firstName: string,
107
+ lastName: string,
108
+ company: string,
109
+ permitted: boolean = false
110
+ ): Promise<UserData> => {
111
+ try {
112
+ const userData: UserData = {
113
+ uid: user.uid,
114
+ email: user.email,
115
+ firstName,
116
+ lastName,
117
+ company,
118
+ permitted,
119
+ cases: [],
120
+ readOnlyCases: [],
121
+ createdAt: new Date().toISOString()
122
+ };
123
+
124
+ const apiKey = await getUserApiKey();
125
+ const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
126
+ method: 'PUT',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'X-Custom-Auth-Key': apiKey
130
+ },
131
+ body: JSON.stringify(userData)
132
+ });
133
+
134
+ if (!response.ok) {
135
+ throw new Error(`Failed to create user data: ${response.status} ${response.statusText}`);
136
+ }
137
+
138
+ return userData;
139
+ } catch (error) {
140
+ console.error('Error creating user data:', error);
141
+ throw error;
142
+ }
143
+ };
144
+
145
+ /**
146
+ * Check if user can create a new case
147
+ */
148
+ export const canCreateCase = async (user: User): Promise<{ canCreate: boolean; reason?: string }> => {
149
+ try {
150
+ const userData = await getUserData(user);
151
+ if (!userData) {
152
+ return { canCreate: true }; // New users can create their first case
153
+ }
154
+
155
+ const limits = getUserLimits(userData);
156
+ const usage = await getUserUsage(user);
157
+
158
+ if (usage.currentCases >= limits.maxCases) {
159
+ return {
160
+ canCreate: false,
161
+ reason: `Read-Only Account: Case creation disabled`
162
+ };
163
+ }
164
+
165
+ return { canCreate: true };
166
+ } catch (error) {
167
+ console.error('Error checking case creation permission:', error);
168
+ return { canCreate: false, reason: 'Unable to verify permissions. Please try again.' };
169
+ }
170
+ };
171
+
172
+ /**
173
+ * Check if user can upload a file to a case
174
+ */
175
+ export const canUploadFile = async (user: User, currentFileCount: number): Promise<{ canUpload: boolean; reason?: string }> => {
176
+ try {
177
+ const userData = await getUserData(user);
178
+ if (!userData) {
179
+ return { canUpload: false, reason: 'User data not found.' };
180
+ }
181
+
182
+ const limits = getUserLimits(userData);
183
+
184
+ if (currentFileCount >= limits.maxFilesPerCase) {
185
+ return {
186
+ canUpload: false,
187
+ reason: `Read-Only Account: File uploads disabled`
188
+ };
189
+ }
190
+
191
+ return { canUpload: true };
192
+ } catch (error) {
193
+ console.error('Error checking file upload permission:', error);
194
+ return { canUpload: false, reason: 'Unable to verify permissions. Please try again.' };
195
+ }
196
+ };
197
+
198
+ /**
199
+ * Get a user-friendly description of their current limits
200
+ */
201
+ export const getLimitsDescription = async (user: User): Promise<string> => {
202
+ try {
203
+ const userData = await getUserData(user);
204
+ if (!userData) {
205
+ return `Account limits: ${MAX_CASES_REVIEW} case, ${MAX_FILES_PER_CASE_REVIEW} files per case`;
206
+ }
207
+
208
+ const limits = getUserLimits(userData);
209
+
210
+ if (userData.permitted) {
211
+ return '';
212
+ } else {
213
+ return `Read-Only Account: Case review only.`;
214
+ }
215
+ } catch (error) {
216
+ console.error('Error getting limits description:', error);
217
+ return 'Unable to determine account limits';
218
+ }
219
+ };
220
+
221
+ // ============================================================================
222
+ // ENHANCED CENTRALIZED FUNCTIONS
223
+ // ============================================================================
224
+
225
+ /**
226
+ * Validate user session with comprehensive checks
227
+ * Ensures user exists, has valid authentication, and passes basic security checks
228
+ */
229
+ export const validateUserSession = async (user: User): Promise<UserSessionValidation> => {
230
+ try {
231
+ // Basic user object validation
232
+ if (!user || !user.uid) {
233
+ return { valid: false, reason: 'Invalid user session: No user ID' };
234
+ }
235
+
236
+ if (!user.email) {
237
+ return { valid: false, reason: 'Invalid user session: No email address' };
238
+ }
239
+
240
+ // Check if user data exists in the system
241
+ const userData = await getUserData(user);
242
+ if (!userData) {
243
+ return { valid: false, reason: 'User not found in system database' };
244
+ }
245
+
246
+ // Verify email consistency
247
+ if (userData.email !== user.email) {
248
+ return { valid: false, reason: 'Email mismatch between session and database' };
249
+ }
250
+
251
+ return { valid: true };
252
+
253
+ } catch (error) {
254
+ console.error('Error validating user session:', error);
255
+ return { valid: false, reason: 'Session validation failed due to system error' };
256
+ }
257
+ };
258
+
259
+ /**
260
+ * Centralized user data update with built-in API key management and validation
261
+ * Handles all user data modifications through a single secure interface
262
+ */
263
+ export const updateUserData = async (user: User, updates: Partial<UserData>): Promise<UserData> => {
264
+ try {
265
+ // Validate user session first
266
+ const sessionValidation = await validateUserSession(user);
267
+ if (!sessionValidation.valid) {
268
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
269
+ }
270
+
271
+ // Get current user data
272
+ const currentUserData = await getUserData(user);
273
+ if (!currentUserData) {
274
+ throw new Error('Cannot update user data: User not found');
275
+ }
276
+
277
+ // Merge updates with current data
278
+ const updatedUserData = {
279
+ ...currentUserData,
280
+ ...updates,
281
+ updatedAt: new Date().toISOString()
282
+ };
283
+
284
+ // Perform the update with API key management
285
+ const apiKey = await getUserApiKey();
286
+ const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
287
+ method: 'PUT',
288
+ headers: {
289
+ 'Content-Type': 'application/json',
290
+ 'X-Custom-Auth-Key': apiKey
291
+ },
292
+ body: JSON.stringify(updatedUserData)
293
+ });
294
+
295
+ if (!response.ok) {
296
+ const errorText = await response.text();
297
+ throw new Error(`Failed to update user data: ${response.status} - ${errorText}`);
298
+ }
299
+
300
+ return await response.json() as UserData;
301
+
302
+ } catch (error) {
303
+ console.error('Error updating user data:', error);
304
+ throw error;
305
+ }
306
+ };
307
+
308
+ /**
309
+ * Get user's cases with centralized error handling and API key management
310
+ */
311
+ export const getUserCases = async (user: User): Promise<CaseMetadata[]> => {
312
+ try {
313
+ const userData = await getUserData(user);
314
+ if (!userData || !userData.cases) {
315
+ return [];
316
+ }
317
+
318
+ return userData.cases;
319
+
320
+ } catch (error) {
321
+ console.error('Error fetching user cases:', error);
322
+ return [];
323
+ }
324
+ };
325
+
326
+ /**
327
+ * Get user's read-only cases with centralized error handling
328
+ */
329
+ export const getUserReadOnlyCases = async (user: User): Promise<ReadOnlyCaseMetadata[]> => {
330
+ try {
331
+ const userData = await getUserData(user) as ExtendedUserData;
332
+ if (!userData || !userData.readOnlyCases) {
333
+ return [];
334
+ }
335
+
336
+ return userData.readOnlyCases;
337
+
338
+ } catch (error) {
339
+ console.error('Error fetching user read-only cases:', error);
340
+ return [];
341
+ }
342
+ };
343
+
344
+ /**
345
+ * Check if user has permitted status with caching and error handling
346
+ */
347
+ export const isUserPermitted = async (user: User): Promise<boolean> => {
348
+ try {
349
+ const userData = await getUserData(user);
350
+ return userData?.permitted || false;
351
+
352
+ } catch (error) {
353
+ console.error('Error checking user permitted status:', error);
354
+ return false; // Fail closed for security
355
+ }
356
+ };
357
+
358
+ /**
359
+ * Check if user can access a specific case (either owned or read-only)
360
+ */
361
+ export const canAccessCase = async (user: User, caseNumber: string): Promise<PermissionResult> => {
362
+ try {
363
+ // Validate inputs
364
+ if (!caseNumber || typeof caseNumber !== 'string') {
365
+ return { allowed: false, reason: 'Invalid case number provided' };
366
+ }
367
+
368
+ // Validate user session
369
+ const sessionValidation = await validateUserSession(user);
370
+ if (!sessionValidation.valid) {
371
+ return { allowed: false, reason: sessionValidation.reason };
372
+ }
373
+
374
+ const userData = await getUserData(user);
375
+ if (!userData) {
376
+ return { allowed: false, reason: 'User data not found' };
377
+ }
378
+
379
+ // Check owned cases
380
+ if (userData.cases && userData.cases.some(c => c.caseNumber === caseNumber)) {
381
+ return { allowed: true };
382
+ }
383
+
384
+ // Check read-only cases
385
+ const extendedUserData = userData as ExtendedUserData;
386
+ if (extendedUserData.readOnlyCases && extendedUserData.readOnlyCases.some(c => c.caseNumber === caseNumber)) {
387
+ return { allowed: true };
388
+ }
389
+
390
+ return { allowed: false, reason: 'Case not found in user access list' };
391
+
392
+ } catch (error) {
393
+ console.error('Error checking case access permission:', error);
394
+ return { allowed: false, reason: 'Permission check failed due to system error' };
395
+ }
396
+ };
397
+
398
+ /**
399
+ * Check if user can modify a specific case
400
+ * - Regular users (permitted=true) can modify their owned cases
401
+ * - Read-only users (permitted=false) can modify read-only cases for review
402
+ * - Nobody can modify cases marked as truly read-only in the case data itself
403
+ */
404
+ export const canModifyCase = async (user: User, caseNumber: string): Promise<PermissionResult> => {
405
+ try {
406
+ // Validate inputs
407
+ if (!caseNumber || typeof caseNumber !== 'string') {
408
+ return { allowed: false, reason: 'Invalid case number provided' };
409
+ }
410
+
411
+ const userData = await getUserData(user) as ExtendedUserData;
412
+ if (!userData) {
413
+ return { allowed: false, reason: 'User data not found' };
414
+ }
415
+
416
+ // Check if user owns the case (regular cases)
417
+ if (userData.cases && userData.cases.some(c => c.caseNumber === caseNumber)) {
418
+ // For owned cases, user must be permitted
419
+ if (!userData.permitted) {
420
+ return { allowed: false, reason: 'Read-Only Account: Cannot modify owned cases' };
421
+ }
422
+ return { allowed: true };
423
+ }
424
+
425
+ // Check if it's a read-only case that user can review
426
+ if (userData.readOnlyCases && userData.readOnlyCases.some(c => c.caseNumber === caseNumber)) {
427
+ // For read-only cases, both permitted and non-permitted users can modify for review
428
+ // The actual read-only restrictions should be enforced at the case data level, not user level
429
+ return { allowed: true };
430
+ }
431
+
432
+ return { allowed: false, reason: 'Case not found in user access list' };
433
+
434
+ } catch (error) {
435
+ console.error('Error checking case modification permission:', error);
436
+ return { allowed: false, reason: 'Permission check failed due to system error' };
437
+ }
438
+ };
439
+
440
+ /**
441
+ * Higher-order function for consistent error handling in user data operations
442
+ * Wraps operations with session validation and standardized error patterns
443
+ */
444
+ export const withUserDataOperation = <T>(
445
+ operation: (userData: UserData, user: User) => Promise<T>
446
+ ) => async (user: User): Promise<T> => {
447
+ try {
448
+ // Validate user session
449
+ const sessionValidation = await validateUserSession(user);
450
+ if (!sessionValidation.valid) {
451
+ throw new Error(`Operation failed: ${sessionValidation.reason}`);
452
+ }
453
+
454
+ // Get user data
455
+ const userData = await getUserData(user);
456
+ if (!userData) {
457
+ throw new Error('Operation failed: User data not found');
458
+ }
459
+
460
+ // Execute the operation
461
+ return await operation(userData, user);
462
+
463
+ } catch (error) {
464
+ console.error('User data operation failed:', error);
465
+ throw error;
466
+ }
467
+ };
468
+
469
+ /**
470
+ * Add a case to user's case list with validation and conflict checking
471
+ */
472
+ export const addUserCase = async (user: User, caseData: CaseMetadata): Promise<void> => {
473
+ try {
474
+ // Validate user session
475
+ const sessionValidation = await validateUserSession(user);
476
+ if (!sessionValidation.valid) {
477
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
478
+ }
479
+
480
+ // Get current user data to check for duplicates
481
+ const userData = await getUserData(user);
482
+ if (!userData) {
483
+ throw new Error('Cannot add case: User data not found');
484
+ }
485
+
486
+ // Check for duplicate case numbers
487
+ const existingCases = userData.cases || [];
488
+ const existingCase = existingCases.find(c => c.caseNumber === caseData.caseNumber);
489
+ if (existingCase) {
490
+ throw new Error(`Case ${caseData.caseNumber} already exists`);
491
+ }
492
+
493
+ // Use the dedicated /cases endpoint to add the case
494
+ const apiKey = await getUserApiKey();
495
+ const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}/cases`, {
496
+ method: 'PUT',
497
+ headers: {
498
+ 'Content-Type': 'application/json',
499
+ 'X-Custom-Auth-Key': apiKey
500
+ },
501
+ body: JSON.stringify({
502
+ cases: [caseData]
503
+ })
504
+ });
505
+
506
+ if (!response.ok) {
507
+ const errorText = await response.text();
508
+ throw new Error(`Failed to add case to user: ${response.status} - ${errorText}`);
509
+ }
510
+
511
+ } catch (error) {
512
+ console.error('Error adding case to user:', error);
513
+ throw error;
514
+ }
515
+ };
516
+
517
+ /**
518
+ * Remove a case from user's case list with validation
519
+ */
520
+ export const removeUserCase = async (user: User, caseNumber: string): Promise<void> => {
521
+ try {
522
+ // Validate user session
523
+ const sessionValidation = await validateUserSession(user);
524
+ if (!sessionValidation.valid) {
525
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
526
+ }
527
+
528
+ // Get current user data to check if case exists
529
+ const userData = await getUserData(user);
530
+ if (!userData || !userData.cases) {
531
+ throw new Error('Cannot remove case: No cases found');
532
+ }
533
+
534
+ // Check if the case exists
535
+ const existingCase = userData.cases.find(c => c.caseNumber === caseNumber);
536
+ if (!existingCase) {
537
+ throw new Error(`Case ${caseNumber} not found`);
538
+ }
539
+
540
+ // Use the dedicated /cases DELETE endpoint to remove the case
541
+ const apiKey = await getUserApiKey();
542
+ const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}/cases`, {
543
+ method: 'DELETE',
544
+ headers: {
545
+ 'Content-Type': 'application/json',
546
+ 'X-Custom-Auth-Key': apiKey
547
+ },
548
+ body: JSON.stringify({
549
+ casesToDelete: [caseNumber]
550
+ })
551
+ });
552
+
553
+ if (!response.ok) {
554
+ const errorText = await response.text();
555
+ throw new Error(`Failed to remove case from user: ${response.status} - ${errorText}`);
556
+ }
557
+
558
+ } catch (error) {
559
+ console.error('Error removing case from user:', error);
560
+ throw error;
561
+ }
562
+ };
@@ -0,0 +1,160 @@
1
+ import paths from '~/config/config.json';
2
+
3
+ export interface SignatureEnvelope {
4
+ algorithm: string;
5
+ keyId: string;
6
+ value: string;
7
+ }
8
+
9
+ export interface SignatureVerificationResult {
10
+ isValid: boolean;
11
+ keyId?: string;
12
+ error?: string;
13
+ }
14
+
15
+ export interface SignatureVerificationMessages {
16
+ unsupportedAlgorithmPrefix?: string;
17
+ missingKeyOrValueError?: string;
18
+ noVerificationKeyPrefix?: string;
19
+ invalidPublicKeyError?: string;
20
+ verificationFailedError?: string;
21
+ }
22
+
23
+ type ManifestSigningConfig = {
24
+ manifest_signing_public_keys?: Record<string, string>;
25
+ manifest_signing_public_key?: string;
26
+ manifest_signing_key_id?: string;
27
+ };
28
+
29
+ function normalizePemPublicKey(pem: string): string {
30
+ return pem.replace(/\\n/g, '\n').trim();
31
+ }
32
+
33
+ function publicKeyPemToArrayBuffer(publicKeyPem: string, invalidPublicKeyError: string): ArrayBuffer {
34
+ const normalized = normalizePemPublicKey(publicKeyPem);
35
+ const pemBody = normalized
36
+ .replace('-----BEGIN PUBLIC KEY-----', '')
37
+ .replace('-----END PUBLIC KEY-----', '')
38
+ .replace(/\s+/g, '');
39
+
40
+ if (!pemBody) {
41
+ throw new Error(invalidPublicKeyError);
42
+ }
43
+
44
+ const binary = atob(pemBody);
45
+ const bytes = new Uint8Array(binary.length);
46
+
47
+ for (let index = 0; index < binary.length; index += 1) {
48
+ bytes[index] = binary.charCodeAt(index);
49
+ }
50
+
51
+ return bytes.buffer;
52
+ }
53
+
54
+ function base64UrlToUint8Array(value: string): Uint8Array {
55
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
56
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
57
+ const decoded = atob(normalized + padding);
58
+ const bytes = new Uint8Array(decoded.length);
59
+
60
+ for (let i = 0; i < decoded.length; i += 1) {
61
+ bytes[i] = decoded.charCodeAt(i);
62
+ }
63
+
64
+ return bytes;
65
+ }
66
+
67
+ export function getVerificationPublicKey(keyId: string): string | null {
68
+ const config = paths as unknown as ManifestSigningConfig;
69
+ const keyMap = config.manifest_signing_public_keys;
70
+
71
+ if (keyMap && typeof keyMap === 'object') {
72
+ const mappedKey = keyMap[keyId];
73
+ if (typeof mappedKey === 'string' && mappedKey.trim().length > 0) {
74
+ return mappedKey;
75
+ }
76
+ }
77
+
78
+ if (
79
+ typeof config.manifest_signing_key_id === 'string' &&
80
+ config.manifest_signing_key_id === keyId &&
81
+ typeof config.manifest_signing_public_key === 'string' &&
82
+ config.manifest_signing_public_key.trim().length > 0
83
+ ) {
84
+ return config.manifest_signing_public_key;
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ export async function verifySignaturePayload(
91
+ payload: string,
92
+ signature: SignatureEnvelope,
93
+ expectedAlgorithm: string,
94
+ messages: SignatureVerificationMessages = {}
95
+ ): Promise<SignatureVerificationResult> {
96
+ if (signature.algorithm !== expectedAlgorithm) {
97
+ return {
98
+ isValid: false,
99
+ keyId: signature.keyId,
100
+ error: `${messages.unsupportedAlgorithmPrefix || 'Unsupported signature algorithm'}: ${signature.algorithm}`
101
+ };
102
+ }
103
+
104
+ if (!signature.keyId || !signature.value) {
105
+ return {
106
+ isValid: false,
107
+ error: messages.missingKeyOrValueError || 'Missing signature key ID or value'
108
+ };
109
+ }
110
+
111
+ const publicKeyPem = getVerificationPublicKey(signature.keyId);
112
+ if (!publicKeyPem) {
113
+ return {
114
+ isValid: false,
115
+ keyId: signature.keyId,
116
+ error: `${messages.noVerificationKeyPrefix || 'No verification key configured for key ID'}: ${signature.keyId}`
117
+ };
118
+ }
119
+
120
+ const verificationFailedError = messages.verificationFailedError || 'Signature verification failed';
121
+ const invalidPublicKeyError =
122
+ messages.invalidPublicKeyError ||
123
+ `${verificationFailedError}: invalid public key`;
124
+
125
+ try {
126
+ const key = await crypto.subtle.importKey(
127
+ 'spki',
128
+ publicKeyPemToArrayBuffer(publicKeyPem, invalidPublicKeyError),
129
+ {
130
+ name: 'RSASSA-PKCS1-v1_5',
131
+ hash: 'SHA-256'
132
+ },
133
+ false,
134
+ ['verify']
135
+ );
136
+
137
+ const signatureBytes = base64UrlToUint8Array(signature.value);
138
+ const signatureBuffer = new Uint8Array(signatureBytes.byteLength);
139
+ signatureBuffer.set(signatureBytes);
140
+
141
+ const verified = await crypto.subtle.verify(
142
+ { name: 'RSASSA-PKCS1-v1_5' },
143
+ key,
144
+ signatureBuffer,
145
+ new TextEncoder().encode(payload)
146
+ );
147
+
148
+ return {
149
+ isValid: verified,
150
+ keyId: signature.keyId,
151
+ error: verified ? undefined : verificationFailedError
152
+ };
153
+ } catch (error) {
154
+ return {
155
+ isValid: false,
156
+ keyId: signature.keyId,
157
+ error: error instanceof Error ? error.message : verificationFailedError
158
+ };
159
+ }
160
+ }