@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,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
|
+
};
|