@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.
- package/.env.example +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- 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
|
+
}
|