@striae-org/striae 5.3.0 → 5.3.2

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 (31) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/case-export/core-export.ts +3 -0
  3. package/app/components/actions/case-export/download-handlers.ts +1 -1
  4. package/app/components/actions/case-import/confirmation-import.ts +62 -22
  5. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  6. package/app/components/actions/case-import/index.ts +1 -1
  7. package/app/components/actions/case-import/orchestrator.ts +78 -53
  8. package/app/components/actions/case-import/zip-processing.ts +157 -407
  9. package/app/components/actions/generate-pdf.ts +22 -0
  10. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  12. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  13. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  14. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -9
  15. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  16. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  17. package/app/components/sidebar/case-import/index.ts +1 -4
  18. package/app/routes/auth/login.tsx +22 -103
  19. package/app/routes/striae/striae.tsx +77 -13
  20. package/app/types/case.ts +1 -0
  21. package/app/types/export.ts +1 -0
  22. package/app/types/import.ts +10 -0
  23. package/functions/api/image/[[path]].ts +19 -3
  24. package/package.json +1 -1
  25. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  26. package/workers/data-worker/wrangler.jsonc.example +1 -1
  27. package/workers/image-worker/src/image-worker.example.ts +36 -2
  28. package/workers/image-worker/wrangler.jsonc.example +1 -1
  29. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  30. package/workers/user-worker/wrangler.jsonc.example +1 -1
  31. package/wrangler.toml.example +1 -1
package/.env.example CHANGED
@@ -111,6 +111,9 @@ IMAGES_WORKER_DOMAIN=your_images_worker_domain_here
111
111
  IMAGE_SIGNED_URL_SECRET=your_image_signed_url_secret_here
112
112
  # Optional: defaults to 3600 and max is 86400.
113
113
  IMAGE_SIGNED_URL_TTL_SECONDS=3600
114
+ # Optional: override the base URL used in signed URL responses to route delivery through the Pages proxy.
115
+ # Defaults to the image worker's own origin if unset (exposes worker domain to clients).
116
+ IMAGE_SIGNED_URL_BASE_URL=https://${PAGES_CUSTOM_DOMAIN}/api/image
114
117
 
115
118
  # ================================
116
119
  # PDF WORKER ENVIRONMENT VARIABLES
@@ -133,6 +133,9 @@ export async function exportCaseData(
133
133
  archiveReason: caseData.archiveReason,
134
134
  exportDate: new Date().toISOString(),
135
135
  ...userMetadata,
136
+ ...(options.designatedReviewerEmail?.trim()
137
+ ? { designatedReviewerEmail: options.designatedReviewerEmail.trim() }
138
+ : {}),
136
139
  striaeExportSchemaVersion: '1.0',
137
140
  totalFiles: files.length
138
141
  },
@@ -81,7 +81,7 @@ export async function downloadCaseAsZip(
81
81
  onProgress?.(10);
82
82
 
83
83
  // Get case data
84
- const exportData = await exportCaseData(user, caseNumber);
84
+ const exportData = await exportCaseData(user, caseNumber, options);
85
85
  onProgress?.(30);
86
86
 
87
87
  // Create ZIP
@@ -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;
@@ -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 ||