@striae-org/striae 5.2.1 → 5.3.1
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 +2 -10
- package/README.md +5 -46
- package/app/components/actions/case-export/core-export.ts +5 -174
- package/app/components/actions/case-export/download-handlers.ts +84 -751
- package/app/components/actions/case-export/index.ts +6 -30
- package/app/components/actions/case-export/metadata-helpers.ts +0 -78
- package/app/components/actions/case-export/types-constants.ts +0 -43
- package/app/components/actions/case-import/confirmation-import.ts +75 -36
- package/app/components/actions/case-import/confirmation-package.ts +68 -1
- package/app/components/actions/case-import/index.ts +1 -1
- package/app/components/actions/case-import/orchestrator.ts +78 -53
- package/app/components/actions/case-import/zip-processing.ts +160 -330
- package/app/components/actions/generate-pdf.ts +3 -2
- package/app/components/audit/user-audit-viewer.tsx +0 -19
- package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
- package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
- package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
- package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.module.css +35 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +51 -3
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
- package/app/components/sidebar/case-import/index.ts +1 -4
- package/app/components/sidebar/notes/class-details-shared.ts +2 -2
- package/app/components/toast/toast.module.css +36 -0
- package/app/components/toast/toast.tsx +6 -2
- package/app/components/user/manage-profile.tsx +4 -3
- package/app/config-example/config.json +1 -2
- package/app/root.tsx +0 -7
- package/app/routes/_index.tsx +1 -1
- package/app/routes/auth/login.example.tsx +22 -103
- package/app/routes/auth/login.tsx +22 -103
- package/app/routes/auth/route.ts +1 -1
- package/app/routes/striae/striae.tsx +117 -59
- package/app/services/firebase/index.ts +0 -3
- package/app/types/case.ts +1 -0
- package/app/types/export.ts +2 -2
- package/app/types/import.ts +10 -0
- package/app/utils/auth/index.ts +0 -1
- package/app/utils/data/permissions.ts +3 -2
- package/package.json +9 -16
- package/public/_headers +0 -4
- package/public/_routes.json +0 -1
- package/worker-configuration.d.ts +20 -17
- package/workers/audit-worker/src/audit-worker.example.ts +9 -806
- package/workers/audit-worker/src/config.ts +7 -0
- package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
- package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
- package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
- package/workers/audit-worker/src/types.ts +56 -0
- package/workers/audit-worker/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/config.ts +11 -0
- package/workers/data-worker/src/data-worker.example.ts +21 -942
- package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
- package/workers/data-worker/src/handlers/signing.ts +174 -0
- package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
- package/workers/data-worker/src/registry/key-registry.ts +368 -0
- package/workers/data-worker/src/types.ts +46 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/worker-configuration.d.ts +2 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/auth.ts +30 -0
- package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
- package/workers/user-worker/src/config.ts +4 -0
- package/workers/user-worker/src/encryption-utils.ts +25 -0
- package/workers/user-worker/src/firebase/admin.ts +152 -0
- package/workers/user-worker/src/handlers/user-routes.ts +242 -0
- package/workers/user-worker/src/registry/user-kv.ts +172 -0
- package/workers/user-worker/src/storage/user-records.ts +34 -0
- package/workers/user-worker/src/types.ts +106 -0
- package/workers/user-worker/src/user-worker.example.ts +18 -964
- package/workers/user-worker/worker-configuration.d.ts +4 -2
- package/workers/user-worker/wrangler.jsonc.example +12 -1
- package/wrangler.toml.example +1 -1
- package/app/components/actions/case-export/data-processing.ts +0 -223
- package/app/components/sidebar/case-export/case-export.module.css +0 -418
- package/app/components/sidebar/case-export/case-export.tsx +0 -310
- package/app/types/exceljs-bare.d.ts +0 -9
- package/app/utils/auth/auth.ts +0 -11
- package/public/.well-known/security.txt +0 -6
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +0 -39
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/vendor/exceljs.LICENSE +0 -22
- package/public/vendor/exceljs.bare.min.js +0 -45
- package/scripts/deploy-all.sh +0 -166
- package/scripts/deploy-config/modules/env-utils.sh +0 -322
- package/scripts/deploy-config/modules/keys.sh +0 -404
- package/scripts/deploy-config/modules/prompt.sh +0 -372
- package/scripts/deploy-config/modules/scaffolding.sh +0 -344
- package/scripts/deploy-config/modules/validation.sh +0 -365
- package/scripts/deploy-config.sh +0 -236
- package/scripts/deploy-pages-secrets.sh +0 -231
- package/scripts/deploy-pages.sh +0 -34
- package/scripts/deploy-primershear-emails.sh +0 -167
- package/scripts/deploy-worker-secrets.sh +0 -374
- package/scripts/dev.cjs +0 -23
- package/scripts/install-workers.sh +0 -88
- package/scripts/run-eslint.cjs +0 -43
- package/scripts/update-compatibility-dates.cjs +0 -124
- package/scripts/update-markdown-versions.cjs +0 -43
- package/workers/keys-worker/package.json +0 -18
- package/workers/keys-worker/src/keys.example.ts +0 -67
- package/workers/keys-worker/src/keys.ts +0 -67
- package/workers/keys-worker/worker-configuration.d.ts +0 -7447
- package/workers/keys-worker/wrangler.jsonc.example +0 -15
|
@@ -2,40 +2,16 @@
|
|
|
2
2
|
// This maintains backward compatibility with existing imports
|
|
3
3
|
|
|
4
4
|
// Types and constants
|
|
5
|
-
export
|
|
6
|
-
export { CSV_HEADERS, formatDateForFilename } from './types-constants';
|
|
5
|
+
export { formatDateForFilename } from './types-constants';
|
|
7
6
|
|
|
8
|
-
// Metadata
|
|
9
|
-
export {
|
|
10
|
-
getUserExportMetadata,
|
|
11
|
-
addForensicDataWarning,
|
|
12
|
-
generateRandomPassword,
|
|
13
|
-
protectExcelWorksheet
|
|
14
|
-
} from './metadata-helpers';
|
|
15
|
-
|
|
16
|
-
// Data processing functions
|
|
17
|
-
export {
|
|
18
|
-
generateMetadataRows,
|
|
19
|
-
processFileDataForTabular,
|
|
20
|
-
generateCSVContent
|
|
21
|
-
} from './data-processing';
|
|
7
|
+
// Metadata helpers
|
|
8
|
+
export { getUserExportMetadata } from './metadata-helpers';
|
|
22
9
|
|
|
23
10
|
// Core export functions
|
|
24
|
-
export {
|
|
25
|
-
exportAllCases,
|
|
26
|
-
exportCaseData
|
|
27
|
-
} from './core-export';
|
|
11
|
+
export { exportCaseData } from './core-export';
|
|
28
12
|
|
|
29
13
|
// Download handlers
|
|
30
|
-
export {
|
|
31
|
-
downloadAllCasesAsJSON,
|
|
32
|
-
downloadAllCasesAsCSV,
|
|
33
|
-
downloadCaseAsJSON,
|
|
34
|
-
downloadCaseAsCSV,
|
|
35
|
-
downloadCaseAsZip
|
|
36
|
-
} from './download-handlers';
|
|
14
|
+
export { downloadCaseAsZip } from './download-handlers';
|
|
37
15
|
|
|
38
16
|
// Validation utilities
|
|
39
|
-
export {
|
|
40
|
-
validateCaseNumberForExport
|
|
41
|
-
} from './validation-utils';
|
|
17
|
+
export { validateCaseNumberForExport } from './validation-utils';
|
|
@@ -46,81 +46,3 @@ export function addForensicDataWarning(content: string, format: 'csv' | 'json'):
|
|
|
46
46
|
return warning + content;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
* Generate a secure random password for Excel protection
|
|
51
|
-
*/
|
|
52
|
-
export function generateRandomPassword(): string {
|
|
53
|
-
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
|
54
|
-
const length = 16;
|
|
55
|
-
let password = '';
|
|
56
|
-
|
|
57
|
-
// Ensure we have at least one of each type
|
|
58
|
-
password += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)]; // Uppercase
|
|
59
|
-
password += 'abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 26)]; // Lowercase
|
|
60
|
-
password += '0123456789'[Math.floor(Math.random() * 10)]; // Number
|
|
61
|
-
password += '!@#$%^&*'[Math.floor(Math.random() * 8)]; // Special char
|
|
62
|
-
|
|
63
|
-
// Fill remaining length with random characters
|
|
64
|
-
for (let i = password.length; i < length; i++) {
|
|
65
|
-
password += charset[Math.floor(Math.random() * charset.length)];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Shuffle the password to randomize character positions
|
|
69
|
-
return password.split('').sort(() => Math.random() - 0.5).join('');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
type WorksheetProtectionOptions = {
|
|
73
|
-
selectLockedCells: boolean;
|
|
74
|
-
selectUnlockedCells: boolean;
|
|
75
|
-
formatCells: boolean;
|
|
76
|
-
formatColumns: boolean;
|
|
77
|
-
formatRows: boolean;
|
|
78
|
-
insertColumns: boolean;
|
|
79
|
-
insertRows: boolean;
|
|
80
|
-
insertHyperlinks: boolean;
|
|
81
|
-
deleteColumns: boolean;
|
|
82
|
-
deleteRows: boolean;
|
|
83
|
-
sort: boolean;
|
|
84
|
-
autoFilter: boolean;
|
|
85
|
-
pivotTables: boolean;
|
|
86
|
-
objects: boolean;
|
|
87
|
-
scenarios: boolean;
|
|
88
|
-
spinCount: number;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
type ProtectableWorksheet = {
|
|
92
|
-
protect: (password: string, options: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Protect Excel worksheet from editing
|
|
97
|
-
*/
|
|
98
|
-
export async function protectExcelWorksheet(worksheet: ProtectableWorksheet, sheetPassword?: string): Promise<string> {
|
|
99
|
-
// Generate random password if none provided
|
|
100
|
-
const password = sheetPassword || generateRandomPassword();
|
|
101
|
-
|
|
102
|
-
const protectionOptions: WorksheetProtectionOptions = {
|
|
103
|
-
// Keep read-only defaults and prevent structural edits.
|
|
104
|
-
selectLockedCells: true,
|
|
105
|
-
selectUnlockedCells: true,
|
|
106
|
-
formatCells: false,
|
|
107
|
-
formatColumns: false,
|
|
108
|
-
formatRows: false,
|
|
109
|
-
insertColumns: false,
|
|
110
|
-
insertRows: false,
|
|
111
|
-
insertHyperlinks: false,
|
|
112
|
-
deleteColumns: false,
|
|
113
|
-
deleteRows: false,
|
|
114
|
-
sort: false,
|
|
115
|
-
autoFilter: false,
|
|
116
|
-
pivotTables: false,
|
|
117
|
-
objects: false,
|
|
118
|
-
scenarios: false,
|
|
119
|
-
spinCount: 100000
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
await Promise.resolve(worksheet.protect(password, protectionOptions as Record<string, unknown>));
|
|
123
|
-
|
|
124
|
-
// Return the password for inclusion in metadata
|
|
125
|
-
return password;
|
|
126
|
-
}
|
|
@@ -1,46 +1,3 @@
|
|
|
1
|
-
export type ExportFormat = 'json' | 'csv';
|
|
2
|
-
|
|
3
|
-
// Shared CSV headers for all tabular exports
|
|
4
|
-
export const CSV_HEADERS = [
|
|
5
|
-
'File ID',
|
|
6
|
-
'Original Filename',
|
|
7
|
-
'Upload Date',
|
|
8
|
-
'Has Annotations',
|
|
9
|
-
'Left Case',
|
|
10
|
-
'Right Case',
|
|
11
|
-
'Left Item',
|
|
12
|
-
'Right Item',
|
|
13
|
-
'Case Font Color',
|
|
14
|
-
'Class Type',
|
|
15
|
-
'Custom Class',
|
|
16
|
-
'Class Note',
|
|
17
|
-
'Index Type',
|
|
18
|
-
'Index Number',
|
|
19
|
-
'Index Color',
|
|
20
|
-
'Support Level',
|
|
21
|
-
'Has Subclass',
|
|
22
|
-
'Include Confirmation',
|
|
23
|
-
'Confirmation Status',
|
|
24
|
-
'Confirming Examiner Name',
|
|
25
|
-
'Confirming Examiner Badge ID',
|
|
26
|
-
'Confirming Examiner Email',
|
|
27
|
-
'Confirming Examiner Company',
|
|
28
|
-
'Confirmation ID',
|
|
29
|
-
'Confirmation Timestamp',
|
|
30
|
-
'Confirmation Date (ISO)',
|
|
31
|
-
'Total Box Annotations',
|
|
32
|
-
'Box ID',
|
|
33
|
-
'Box X',
|
|
34
|
-
'Box Y',
|
|
35
|
-
'Box Width',
|
|
36
|
-
'Box Height',
|
|
37
|
-
'Box Color',
|
|
38
|
-
'Box Label',
|
|
39
|
-
'Box Timestamp',
|
|
40
|
-
'Additional Notes',
|
|
41
|
-
'Last Updated'
|
|
42
|
-
];
|
|
43
|
-
|
|
44
1
|
/**
|
|
45
2
|
* Helper function to format timestamp for filename using user's local timezone
|
|
46
3
|
*/
|
|
@@ -39,6 +39,41 @@ function isEncryptionManifest(value: unknown): value is EncryptionManifest {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Validates that an encryption manifest is well-formed for a confirmation import.
|
|
44
|
+
* Confirmation packages must not contain encrypted images — this is a structural
|
|
45
|
+
* invariant. Fails closed with a clear message before decryptExportBatch is called.
|
|
46
|
+
*/
|
|
47
|
+
function validateConfirmationEncryptionManifest(manifest: EncryptionManifest): void {
|
|
48
|
+
if (
|
|
49
|
+
!manifest.encryptionVersion ||
|
|
50
|
+
!manifest.algorithm ||
|
|
51
|
+
!manifest.keyId ||
|
|
52
|
+
!manifest.wrappedKey ||
|
|
53
|
+
!manifest.dataIv
|
|
54
|
+
) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'Malformed encryption manifest: one or more required fields (encryptionVersion, algorithm, keyId, wrappedKey, dataIv) are missing.'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Confirmation packages must never carry image payloads. Reject any manifest
|
|
61
|
+
// that references encrypted images — this indicates a wrong package type or
|
|
62
|
+
// a tampered/malformed file.
|
|
63
|
+
const candidate = manifest as unknown as Record<string, unknown>;
|
|
64
|
+
const encryptedImages = candidate['encryptedImages'];
|
|
65
|
+
if (
|
|
66
|
+
encryptedImages !== undefined &&
|
|
67
|
+
(typeof encryptedImages !== 'object' ||
|
|
68
|
+
Object.keys(encryptedImages as object).length > 0)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'Invalid confirmation package: this manifest contains encrypted image references. ' +
|
|
72
|
+
'Confirmation packages must not include image data. The file may be a case export or may have been tampered with.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
/**
|
|
43
78
|
* Import confirmation data from JSON file
|
|
44
79
|
*/
|
|
@@ -75,31 +110,36 @@ export async function importConfirmationData(
|
|
|
75
110
|
const verificationPublicKeyPem = packageData.verificationPublicKeyPem;
|
|
76
111
|
const confirmationFileName = packageData.confirmationFileName;
|
|
77
112
|
|
|
78
|
-
//
|
|
79
|
-
if (packageData.
|
|
80
|
-
|
|
113
|
+
// All confirmation imports are encrypted — fail closed if manifest is missing
|
|
114
|
+
if (!packageData.encryptionManifest || !packageData.encryptedDataBase64) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'This confirmation package is not encrypted. Only encrypted confirmation packages exported from Striae can be imported.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
81
119
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
120
|
+
if (!isEncryptionManifest(packageData.encryptionManifest)) {
|
|
121
|
+
throw new Error('Invalid encryption manifest format.');
|
|
122
|
+
}
|
|
86
123
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
packageData.encryptionManifest,
|
|
90
|
-
packageData.encryptedDataBase64,
|
|
91
|
-
{} // No image hashes for confirmation-only exports
|
|
92
|
-
);
|
|
124
|
+
// Enforce confirmation-specific manifest shape before attempting decryption
|
|
125
|
+
validateConfirmationEncryptionManifest(packageData.encryptionManifest);
|
|
93
126
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
127
|
+
onProgress?.('Decrypting confirmation data', 15, 'Decrypting exported confirmation...');
|
|
128
|
+
try {
|
|
129
|
+
const decryptResult = await decryptExportBatch(
|
|
130
|
+
user,
|
|
131
|
+
packageData.encryptionManifest,
|
|
132
|
+
packageData.encryptedDataBase64,
|
|
133
|
+
{}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const decryptedJsonString = decryptResult.plaintext;
|
|
137
|
+
confirmationData = JSON.parse(decryptedJsonString) as ConfirmationImportData;
|
|
138
|
+
confirmationJsonContent = decryptedJsonString;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Failed to decrypt confirmation data: ${error instanceof Error ? error.message : 'Unknown decryption error'}`
|
|
142
|
+
);
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
confirmationDataForAudit = confirmationData;
|
|
@@ -297,15 +337,14 @@ export async function importConfirmationData(
|
|
|
297
337
|
|
|
298
338
|
// Audit log successful confirmation import
|
|
299
339
|
try {
|
|
300
|
-
await auditService.
|
|
340
|
+
await auditService.logConfirmationImport(
|
|
301
341
|
user,
|
|
302
|
-
`${result.caseNumber}-${currentImageId}`,
|
|
303
|
-
annotationData, // Previous state (without confirmation)
|
|
304
|
-
updatedAnnotationData, // New state (with confirmation)
|
|
305
342
|
result.caseNumber,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
343
|
+
displayFilename,
|
|
344
|
+
'success',
|
|
345
|
+
true,
|
|
346
|
+
confirmations.length,
|
|
347
|
+
[displayFilename]
|
|
309
348
|
);
|
|
310
349
|
} catch (auditError) {
|
|
311
350
|
console.error('Failed to log confirmation import audit:', auditError);
|
|
@@ -315,15 +354,15 @@ export async function importConfirmationData(
|
|
|
315
354
|
|
|
316
355
|
// Audit log failed confirmation import
|
|
317
356
|
try {
|
|
318
|
-
await auditService.
|
|
357
|
+
await auditService.logConfirmationImport(
|
|
319
358
|
user,
|
|
320
|
-
`${result.caseNumber}-${currentImageId}`,
|
|
321
|
-
annotationData, // Previous state
|
|
322
|
-
null, // Failed save
|
|
323
359
|
result.caseNumber,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
360
|
+
displayFilename,
|
|
361
|
+
'failure',
|
|
362
|
+
false,
|
|
363
|
+
0,
|
|
364
|
+
[],
|
|
365
|
+
[`Failed to update image ${displayFilename}: ${saveResponse.status}`]
|
|
327
366
|
);
|
|
328
367
|
} catch (auditError) {
|
|
329
368
|
console.error('Failed to log failed confirmation import audit:', auditError);
|
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import { type ConfirmationImportData, type ConfirmationImportPreview } from '~/types';
|
|
3
|
+
import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
|
|
4
|
+
import { decryptExportBatch } from '~/utils/data/operations/signing-operations';
|
|
5
|
+
|
|
6
|
+
function isEncryptionManifest(value: unknown): value is EncryptionManifest {
|
|
7
|
+
if (!value || typeof value !== 'object') {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const candidate = value as Partial<EncryptionManifest>;
|
|
11
|
+
return (
|
|
12
|
+
typeof candidate.encryptionVersion === 'string' &&
|
|
13
|
+
typeof candidate.algorithm === 'string' &&
|
|
14
|
+
typeof candidate.keyId === 'string' &&
|
|
15
|
+
typeof candidate.wrappedKey === 'string' &&
|
|
16
|
+
typeof candidate.dataIv === 'string' &&
|
|
17
|
+
Array.isArray(candidate.encryptedImages)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
2
20
|
|
|
3
21
|
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
4
22
|
const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
|
|
@@ -145,6 +163,55 @@ async function extractConfirmationPackageFromZip(file: File): Promise<Confirmati
|
|
|
145
163
|
};
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
export async function previewConfirmationImport(
|
|
167
|
+
file: File,
|
|
168
|
+
user: User
|
|
169
|
+
): Promise<ConfirmationImportPreview> {
|
|
170
|
+
const pkg = await extractConfirmationImportPackage(file);
|
|
171
|
+
|
|
172
|
+
if (!pkg.isEncrypted || !pkg.encryptedDataBase64) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
'Confirmation imports require an encrypted confirmation ZIP package exported from Striae.'
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!isEncryptionManifest(pkg.encryptionManifest)) {
|
|
179
|
+
throw new Error('Encrypted confirmation manifest is missing required fields.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let parsed: ConfirmationImportData;
|
|
183
|
+
try {
|
|
184
|
+
const decryptResult = await decryptExportBatch(
|
|
185
|
+
user,
|
|
186
|
+
pkg.encryptionManifest,
|
|
187
|
+
pkg.encryptedDataBase64,
|
|
188
|
+
{}
|
|
189
|
+
);
|
|
190
|
+
parsed = JSON.parse(decryptResult.plaintext) as ConfirmationImportData;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Failed to decrypt confirmation package for preview: ${
|
|
194
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
195
|
+
}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const meta = parsed.metadata;
|
|
200
|
+
if (!meta?.caseNumber) {
|
|
201
|
+
throw new Error('Decrypted confirmation data is missing required case number.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
caseNumber: meta.caseNumber,
|
|
206
|
+
exportedBy: meta.exportedBy ?? '',
|
|
207
|
+
exportedByName: meta.exportedByName ?? '',
|
|
208
|
+
exportedByCompany: meta.exportedByCompany ?? '',
|
|
209
|
+
exportedByBadgeId: meta.exportedByBadgeId,
|
|
210
|
+
exportDate: meta.exportDate ?? new Date().toISOString(),
|
|
211
|
+
totalConfirmations: meta.totalConfirmations ?? 0
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
148
215
|
export async function extractConfirmationImportPackage(file: File): Promise<ConfirmationImportPackage> {
|
|
149
216
|
const lowerName = file.name.toLowerCase();
|
|
150
217
|
|
|
@@ -34,7 +34,7 @@ export { importAnnotations } from './annotation-import';
|
|
|
34
34
|
|
|
35
35
|
// Confirmation import
|
|
36
36
|
export { importConfirmationData } from './confirmation-import';
|
|
37
|
-
export { extractConfirmationImportPackage } from './confirmation-package';
|
|
37
|
+
export { extractConfirmationImportPackage, previewConfirmationImport } from './confirmation-package';
|
|
38
38
|
|
|
39
39
|
// Main orchestrator
|
|
40
40
|
export { importCaseForReview } from './orchestrator';
|
|
@@ -207,7 +207,6 @@ export async function importCaseForReview(
|
|
|
207
207
|
// Step 1: Parse ZIP file
|
|
208
208
|
const {
|
|
209
209
|
caseData: initialCaseData,
|
|
210
|
-
imageFiles: initialImageFiles,
|
|
211
210
|
imageIdMapping,
|
|
212
211
|
isArchivedExport,
|
|
213
212
|
bundledAuditFiles,
|
|
@@ -217,71 +216,65 @@ export async function importCaseForReview(
|
|
|
217
216
|
encryptionManifest,
|
|
218
217
|
encryptedDataBase64,
|
|
219
218
|
encryptedImages,
|
|
220
|
-
|
|
221
|
-
} = await parseImportZip(zipFile
|
|
219
|
+
dataFileName
|
|
220
|
+
} = await parseImportZip(zipFile);
|
|
222
221
|
|
|
223
|
-
// Step 1.2:
|
|
222
|
+
// Step 1.2: Decrypt export — all imports are encrypted (fail closed if manifest is missing)
|
|
224
223
|
let caseData = initialCaseData;
|
|
225
224
|
let cleanedContent = initialCleanedContent || '';
|
|
226
|
-
let imageFiles =
|
|
225
|
+
let imageFiles: { [filename: string]: Blob } = {};
|
|
227
226
|
let resolvedBundledAuditFiles = bundledAuditFiles;
|
|
228
|
-
let decryptedImageBlobMap: { [filename: string]: Blob } | undefined;
|
|
229
227
|
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const decryptResult = await decryptExportBatch(
|
|
236
|
-
user,
|
|
237
|
-
encryptionManifest,
|
|
238
|
-
encryptedDataBase64,
|
|
239
|
-
encryptedImages ?? {}
|
|
240
|
-
);
|
|
228
|
+
if (!isEncryptionManifest(encryptionManifest) || !encryptedDataBase64) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'This case package is not encrypted. Only encrypted case packages exported from Striae can be imported.'
|
|
231
|
+
);
|
|
232
|
+
}
|
|
241
233
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
234
|
+
onProgress?.('Decrypting export', 11, 'Decrypting case data and images...');
|
|
235
|
+
try {
|
|
236
|
+
const decryptResult = await decryptExportBatch(
|
|
237
|
+
user,
|
|
238
|
+
encryptionManifest,
|
|
239
|
+
encryptedDataBase64,
|
|
240
|
+
encryptedImages ?? {}
|
|
241
|
+
);
|
|
246
242
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
243
|
+
cleanedContent = decryptResult.plaintext;
|
|
244
|
+
const parsedCaseData = JSON.parse(cleanedContent) as unknown;
|
|
245
|
+
caseData = parsedCaseData as CaseExportData;
|
|
250
246
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
auditTrailContent: decryptedAuditTrailBlob
|
|
255
|
-
? await decryptedAuditTrailBlob.text()
|
|
256
|
-
: resolvedBundledAuditFiles?.auditTrailContent,
|
|
257
|
-
auditSignatureContent: decryptedAuditSignatureBlob
|
|
258
|
-
? await decryptedAuditSignatureBlob.text()
|
|
259
|
-
: resolvedBundledAuditFiles?.auditSignatureContent
|
|
260
|
-
};
|
|
261
|
-
}
|
|
247
|
+
const decryptedFiles = decryptResult.decryptedImages;
|
|
248
|
+
const decryptedAuditTrailBlob = decryptedFiles['audit/case-audit-trail.json'];
|
|
249
|
+
const decryptedAuditSignatureBlob = decryptedFiles['audit/case-audit-signature.json'];
|
|
262
250
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
251
|
+
if (decryptedAuditTrailBlob || decryptedAuditSignatureBlob) {
|
|
252
|
+
resolvedBundledAuditFiles = {
|
|
253
|
+
...(resolvedBundledAuditFiles ?? {}),
|
|
254
|
+
auditTrailContent: decryptedAuditTrailBlob
|
|
255
|
+
? await decryptedAuditTrailBlob.text()
|
|
256
|
+
: resolvedBundledAuditFiles?.auditTrailContent,
|
|
257
|
+
auditSignatureContent: decryptedAuditSignatureBlob
|
|
258
|
+
? await decryptedAuditSignatureBlob.text()
|
|
259
|
+
: resolvedBundledAuditFiles?.auditSignatureContent
|
|
260
|
+
};
|
|
261
|
+
}
|
|
266
262
|
|
|
267
|
-
|
|
268
|
-
|
|
263
|
+
const decryptedImageBlobMap = Object.fromEntries(
|
|
264
|
+
Object.entries(decryptedFiles).filter(([filename]) => !filename.startsWith('audit/'))
|
|
265
|
+
);
|
|
266
|
+
imageFiles = decryptedImageBlobMap;
|
|
269
267
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
268
|
+
onProgress?.('Decryption successful', 13, `Decrypted case data and ${Object.keys(decryptedImageBlobMap).length} images`);
|
|
269
|
+
} catch (decryptError) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Failed to decrypt export: ${decryptError instanceof Error ? decryptError.message : 'Unknown error'}. ` +
|
|
272
|
+
'Ensure your Striae instance has export encryption configured.'
|
|
273
|
+
);
|
|
277
274
|
}
|
|
278
275
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
exporterUidValidationPassed = true;
|
|
282
|
-
} else {
|
|
283
|
-
exporterUidValidationPassed = true;
|
|
284
|
-
}
|
|
276
|
+
await validateCaseExporterUidForImport(caseData, user);
|
|
277
|
+
exporterUidValidationPassed = true;
|
|
285
278
|
|
|
286
279
|
// Now validate case number and format
|
|
287
280
|
if (!caseData.metadata?.caseNumber) {
|
|
@@ -292,6 +285,38 @@ export async function importCaseForReview(
|
|
|
292
285
|
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
293
286
|
}
|
|
294
287
|
|
|
288
|
+
// Validate that the data file name matches the decrypted case number — fail closed if it doesn't.
|
|
289
|
+
// Guards against corrupt archives and cases where parseImportZip returned a mismatched file.
|
|
290
|
+
if (dataFileName) {
|
|
291
|
+
const dataFileLeaf = dataFileName.split('/').filter(Boolean).pop()?.toLowerCase() ?? '';
|
|
292
|
+
const expectedDataFile = `${caseData.metadata.caseNumber.toLowerCase()}_data.json`;
|
|
293
|
+
if (dataFileLeaf !== expectedDataFile) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Data file name does not match case number. ` +
|
|
296
|
+
`Expected "${expectedDataFile}", found "${dataFileLeaf}". ` +
|
|
297
|
+
'The archive may be corrupt or tampered.'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Enforce designated reviewer before any writes occur.
|
|
303
|
+
// This mirrors previewCaseImport enforcement and cannot be bypassed by
|
|
304
|
+
// skipping preview or submitting a modified client request.
|
|
305
|
+
const designatedReviewerEmail = caseData.metadata.designatedReviewerEmail;
|
|
306
|
+
if (designatedReviewerEmail) {
|
|
307
|
+
if (!user.email) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
'Your account does not have an email address. This case export is restricted to a designated reviewer and cannot be imported.'
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (user.email.toLowerCase() !== designatedReviewerEmail.toLowerCase()) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`This case export is restricted to the designated reviewer (${designatedReviewerEmail}). ` +
|
|
315
|
+
'You are not authorized to import this case.'
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
295
320
|
const resolvedIsArchivedExport =
|
|
296
321
|
isArchivedExport ||
|
|
297
322
|
caseData.metadata.archived === true ||
|