@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.
Files changed (117) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +5 -174
  4. package/app/components/actions/case-export/download-handlers.ts +84 -751
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +75 -36
  9. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  10. package/app/components/actions/case-import/index.ts +1 -1
  11. package/app/components/actions/case-import/orchestrator.ts +78 -53
  12. package/app/components/actions/case-import/zip-processing.ts +160 -330
  13. package/app/components/actions/generate-pdf.ts +3 -2
  14. package/app/components/audit/user-audit-viewer.tsx +0 -19
  15. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  16. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  17. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  18. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  19. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  20. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  21. package/app/components/navbar/navbar.tsx +1 -1
  22. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  23. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +51 -3
  24. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  25. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  26. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  27. package/app/components/sidebar/case-import/index.ts +1 -4
  28. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  29. package/app/components/toast/toast.module.css +36 -0
  30. package/app/components/toast/toast.tsx +6 -2
  31. package/app/components/user/manage-profile.tsx +4 -3
  32. package/app/config-example/config.json +1 -2
  33. package/app/root.tsx +0 -7
  34. package/app/routes/_index.tsx +1 -1
  35. package/app/routes/auth/login.example.tsx +22 -103
  36. package/app/routes/auth/login.tsx +22 -103
  37. package/app/routes/auth/route.ts +1 -1
  38. package/app/routes/striae/striae.tsx +117 -59
  39. package/app/services/firebase/index.ts +0 -3
  40. package/app/types/case.ts +1 -0
  41. package/app/types/export.ts +2 -2
  42. package/app/types/import.ts +10 -0
  43. package/app/utils/auth/index.ts +0 -1
  44. package/app/utils/data/permissions.ts +3 -2
  45. package/package.json +9 -16
  46. package/public/_headers +0 -4
  47. package/public/_routes.json +0 -1
  48. package/worker-configuration.d.ts +20 -17
  49. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  50. package/workers/audit-worker/src/config.ts +7 -0
  51. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  52. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  53. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  54. package/workers/audit-worker/src/types.ts +56 -0
  55. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  56. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  57. package/workers/data-worker/src/config.ts +11 -0
  58. package/workers/data-worker/src/data-worker.example.ts +21 -942
  59. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  60. package/workers/data-worker/src/handlers/signing.ts +174 -0
  61. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  62. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  63. package/workers/data-worker/src/types.ts +46 -0
  64. package/workers/data-worker/worker-configuration.d.ts +1 -1
  65. package/workers/data-worker/wrangler.jsonc.example +1 -1
  66. package/workers/image-worker/worker-configuration.d.ts +1 -1
  67. package/workers/image-worker/wrangler.jsonc.example +1 -1
  68. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  69. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  70. package/workers/user-worker/src/auth.ts +30 -0
  71. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  72. package/workers/user-worker/src/config.ts +4 -0
  73. package/workers/user-worker/src/encryption-utils.ts +25 -0
  74. package/workers/user-worker/src/firebase/admin.ts +152 -0
  75. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  76. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  77. package/workers/user-worker/src/storage/user-records.ts +34 -0
  78. package/workers/user-worker/src/types.ts +106 -0
  79. package/workers/user-worker/src/user-worker.example.ts +18 -964
  80. package/workers/user-worker/worker-configuration.d.ts +4 -2
  81. package/workers/user-worker/wrangler.jsonc.example +12 -1
  82. package/wrangler.toml.example +1 -1
  83. package/app/components/actions/case-export/data-processing.ts +0 -223
  84. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  85. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  86. package/app/types/exceljs-bare.d.ts +0 -9
  87. package/app/utils/auth/auth.ts +0 -11
  88. package/public/.well-known/security.txt +0 -6
  89. package/public/favicon.ico +0 -0
  90. package/public/icon-256.png +0 -0
  91. package/public/icon-512.png +0 -0
  92. package/public/manifest.json +0 -39
  93. package/public/shortcut.png +0 -0
  94. package/public/social-image.png +0 -0
  95. package/public/vendor/exceljs.LICENSE +0 -22
  96. package/public/vendor/exceljs.bare.min.js +0 -45
  97. package/scripts/deploy-all.sh +0 -166
  98. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  99. package/scripts/deploy-config/modules/keys.sh +0 -404
  100. package/scripts/deploy-config/modules/prompt.sh +0 -372
  101. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  102. package/scripts/deploy-config/modules/validation.sh +0 -365
  103. package/scripts/deploy-config.sh +0 -236
  104. package/scripts/deploy-pages-secrets.sh +0 -231
  105. package/scripts/deploy-pages.sh +0 -34
  106. package/scripts/deploy-primershear-emails.sh +0 -167
  107. package/scripts/deploy-worker-secrets.sh +0 -374
  108. package/scripts/dev.cjs +0 -23
  109. package/scripts/install-workers.sh +0 -88
  110. package/scripts/run-eslint.cjs +0 -43
  111. package/scripts/update-compatibility-dates.cjs +0 -124
  112. package/scripts/update-markdown-versions.cjs +0 -43
  113. package/workers/keys-worker/package.json +0 -18
  114. package/workers/keys-worker/src/keys.example.ts +0 -67
  115. package/workers/keys-worker/src/keys.ts +0 -67
  116. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  117. 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 type { ExportFormat } from './types-constants';
6
- export { CSV_HEADERS, formatDateForFilename } from './types-constants';
5
+ export { formatDateForFilename } from './types-constants';
7
6
 
8
- // Metadata and protection helpers
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
- // Handle encrypted confirmation data
79
- if (packageData.isEncrypted && packageData.encryptionManifest && packageData.encryptedDataBase64) {
80
- onProgress?.('Decrypting confirmation data', 15, 'Decrypting exported confirmation...');
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
- try {
83
- if (!isEncryptionManifest(packageData.encryptionManifest)) {
84
- throw new Error('Invalid encryption manifest format.');
85
- }
120
+ if (!isEncryptionManifest(packageData.encryptionManifest)) {
121
+ throw new Error('Invalid encryption manifest format.');
122
+ }
86
123
 
87
- const decryptResult = await decryptExportBatch(
88
- user,
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
- // Parse decrypted plaintext as confirmation data
95
- const decryptedJsonString = decryptResult.plaintext;
96
- confirmationData = JSON.parse(decryptedJsonString) as ConfirmationImportData;
97
- confirmationJsonContent = decryptedJsonString;
98
- } catch (error) {
99
- throw new Error(
100
- `Failed to decrypt confirmation data: ${error instanceof Error ? error.message : 'Unknown decryption error'}`
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.logAnnotationEdit(
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
- 'confirmation-import',
307
- currentImageId,
308
- displayFilename
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.logAnnotationEdit(
357
+ await auditService.logConfirmationImport(
319
358
  user,
320
- `${result.caseNumber}-${currentImageId}`,
321
- annotationData, // Previous state
322
- null, // Failed save
323
359
  result.caseNumber,
324
- 'confirmation-import',
325
- currentImageId,
326
- displayFilename
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 { type ConfirmationImportData } from '~/types';
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
- isEncrypted
221
- } = await parseImportZip(zipFile, user);
219
+ dataFileName
220
+ } = await parseImportZip(zipFile);
222
221
 
223
- // Step 1.2: Handle decryption if export is encrypted
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 = initialImageFiles;
225
+ let imageFiles: { [filename: string]: Blob } = {};
227
226
  let resolvedBundledAuditFiles = bundledAuditFiles;
228
- let decryptedImageBlobMap: { [filename: string]: Blob } | undefined;
229
227
 
230
- if (isEncrypted && isEncryptionManifest(encryptionManifest) && encryptedDataBase64) {
231
- onProgress?.('Decrypting export', 11, 'Decrypting case data and images...');
232
-
233
- try {
234
- // Call decrypt endpoint on data-worker
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
- // Decrypted data is plaintext JSON
243
- cleanedContent = decryptResult.plaintext;
244
- const parsedCaseData = JSON.parse(cleanedContent) as unknown;
245
- caseData = parsedCaseData as CaseExportData;
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
- const decryptedFiles = decryptResult.decryptedImages;
248
- const decryptedAuditTrailBlob = decryptedFiles['audit/case-audit-trail.json'];
249
- const decryptedAuditSignatureBlob = decryptedFiles['audit/case-audit-signature.json'];
243
+ cleanedContent = decryptResult.plaintext;
244
+ const parsedCaseData = JSON.parse(cleanedContent) as unknown;
245
+ caseData = parsedCaseData as CaseExportData;
250
246
 
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
- }
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
- decryptedImageBlobMap = Object.fromEntries(
264
- Object.entries(decryptedFiles).filter(([filename]) => !filename.startsWith('audit/'))
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
- // Update imageFiles with decrypted images only
268
- imageFiles = { ...imageFiles, ...decryptedImageBlobMap };
263
+ const decryptedImageBlobMap = Object.fromEntries(
264
+ Object.entries(decryptedFiles).filter(([filename]) => !filename.startsWith('audit/'))
265
+ );
266
+ imageFiles = decryptedImageBlobMap;
269
267
 
270
- onProgress?.('Decryption successful', 13, `Decrypted case data and ${Object.keys(decryptedImageBlobMap).length} images`);
271
- } catch (decryptError) {
272
- throw new Error(
273
- `Failed to decrypt export: ${decryptError instanceof Error ? decryptError.message : 'Unknown error'}. ` +
274
- 'Ensure your Striae instance has export encryption configured.'
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
- if (isEncrypted) {
280
- await validateCaseExporterUidForImport(caseData, user);
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 ||