@striae-org/striae 5.5.2 → 6.0.0

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 (34) hide show
  1. package/app/components/actions/case-export/download-handlers.ts +130 -62
  2. package/app/components/actions/case-manage/archive-package-builder.ts +299 -0
  3. package/app/components/actions/case-manage/delete-helpers.ts +61 -0
  4. package/app/components/actions/case-manage/index.ts +2 -0
  5. package/app/components/actions/case-manage/operations.ts +714 -0
  6. package/app/components/actions/case-manage/types.ts +21 -0
  7. package/app/components/actions/case-manage/utils.ts +34 -0
  8. package/app/components/actions/case-manage.ts +1 -1079
  9. package/app/components/navbar/case-import/case-import.module.css +2 -2
  10. package/app/components/navbar/case-import/case-import.tsx +0 -8
  11. package/app/components/navbar/case-import/components/CasePreviewSection.tsx +1 -1
  12. package/app/components/navbar/case-modals/all-cases-modal.tsx +13 -1
  13. package/app/components/navbar/navbar.tsx +8 -5
  14. package/app/components/sidebar/cases/case-sidebar.tsx +3 -2
  15. package/app/routes/striae/striae.tsx +36 -11
  16. package/app/types/export.ts +1 -0
  17. package/app/utils/forensics/SHA256.ts +2 -2
  18. package/app/utils/forensics/audit-export-signature.ts +1 -1
  19. package/app/utils/forensics/confirmation-signature.ts +1 -1
  20. package/app/utils/forensics/signature-utils.ts +7 -2
  21. package/package.json +1 -1
  22. package/workers/audit-worker/package.json +1 -1
  23. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  24. package/workers/data-worker/package.json +1 -1
  25. package/workers/data-worker/src/signature-utils.ts +7 -2
  26. package/workers/data-worker/src/signing-payload-utils.ts +4 -4
  27. package/workers/data-worker/wrangler.jsonc.example +1 -1
  28. package/workers/image-worker/package.json +1 -1
  29. package/workers/image-worker/wrangler.jsonc.example +1 -1
  30. package/workers/pdf-worker/package.json +1 -1
  31. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  32. package/workers/user-worker/package.json +1 -1
  33. package/workers/user-worker/wrangler.jsonc.example +1 -1
  34. package/wrangler.toml.example +1 -1
@@ -8,13 +8,14 @@ import {
8
8
  getCurrentPublicSigningKeyDetails,
9
9
  getVerificationPublicKey,
10
10
  getCurrentEncryptionPublicKeyDetails,
11
- encryptExportDataWithAllImages
11
+ encryptExportDataWithAllImages,
12
12
  } from '~/utils/forensics';
13
13
  import { signForensicManifest } from '~/utils/data';
14
14
  import { formatDateForFilename } from './types-constants';
15
15
  import { addForensicDataWarning } from './metadata-helpers';
16
16
  import { exportCaseData } from './core-export';
17
17
  import { auditService } from '~/services/audit';
18
+ import { buildArchivePackage } from '~/components/actions/case-manage/archive-package-builder';
18
19
 
19
20
  /**
20
21
  * Generate export filename with embedded ID to prevent collisions
@@ -23,15 +24,15 @@ import { auditService } from '~/services/audit';
23
24
  */
24
25
  function generateExportFilename(originalFilename: string, id: string): string {
25
26
  const lastDotIndex = originalFilename.lastIndexOf('.');
26
-
27
+
27
28
  if (lastDotIndex === -1) {
28
29
  // No extension found
29
30
  return `${originalFilename}-${id}`;
30
31
  }
31
-
32
+
32
33
  const basename = originalFilename.substring(0, lastDotIndex);
33
34
  const extension = originalFilename.substring(lastDotIndex);
34
-
35
+
35
36
  return `${basename}-${id}${extension}`;
36
37
  }
37
38
 
@@ -73,25 +74,99 @@ export async function downloadCaseAsZip(
73
74
  let manifestSigned = false;
74
75
  let publicKeyFileName: string | undefined;
75
76
  const protectForensicData = true;
76
-
77
+
77
78
  try {
78
79
  // Start audit workflow
79
80
  auditService.startWorkflow(caseNumber);
80
-
81
+
81
82
  onProgress?.(10);
82
-
83
+
83
84
  // Get case data
84
85
  const exportData = await exportCaseData(user, caseNumber, options);
85
86
  onProgress?.(30);
86
-
87
+
88
+ const archivePackageMode = options.archivePackageMode;
89
+
90
+ if (archivePackageMode) {
91
+ const archivedAt = exportData.metadata.archivedAt || new Date().toISOString();
92
+ const archivedByDisplay =
93
+ exportData.metadata.archivedByDisplay ||
94
+ exportData.metadata.archivedBy ||
95
+ exportData.metadata.exportedByName ||
96
+ exportData.metadata.exportedBy ||
97
+ 'Unknown';
98
+ const caseJsonContent = await generateJSONContent(
99
+ exportData,
100
+ options.includeUserInfo,
101
+ protectForensicData
102
+ );
103
+
104
+ const archivePackage = await buildArchivePackage({
105
+ user,
106
+ caseNumber,
107
+ caseJsonContent,
108
+ files: exportData.files,
109
+ auditConfig: {
110
+ startDate: exportData.metadata.caseCreatedDate,
111
+ endDate: archivedAt,
112
+ },
113
+ readmeConfig: {
114
+ archivedAt,
115
+ archivedByDisplay,
116
+ archiveReason: exportData.metadata.archiveReason,
117
+ },
118
+ });
119
+
120
+ manifestSignatureKeyId = archivePackage.manifestSignatureKeyId;
121
+ manifestSigned = true;
122
+ onProgress?.(95);
123
+
124
+ const url = URL.createObjectURL(archivePackage.zipBlob);
125
+ const exportFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}-encrypted.zip`;
126
+
127
+ const linkElement = document.createElement('a');
128
+ linkElement.href = url;
129
+ linkElement.setAttribute('download', exportFileName);
130
+ linkElement.setAttribute('title', 'Encrypted Striae case package');
131
+ linkElement.click();
132
+
133
+ URL.revokeObjectURL(url);
134
+ onProgress?.(100);
135
+
136
+ const endTime = Date.now();
137
+ await auditService.logCaseExport(
138
+ user,
139
+ caseNumber,
140
+ exportFileName,
141
+ 'success',
142
+ [],
143
+ {
144
+ processingTimeMs: endTime - startTime,
145
+ fileSizeBytes: archivePackage.zipBlob.size,
146
+ validationStepsCompleted: exportData.files?.length || 0,
147
+ validationStepsFailed: 0,
148
+ },
149
+ 'zip',
150
+ protectForensicData,
151
+ {
152
+ present: true,
153
+ valid: true,
154
+ keyId: manifestSignatureKeyId,
155
+ }
156
+ );
157
+
158
+ auditService.endWorkflow();
159
+ return;
160
+ }
161
+
87
162
  // Create ZIP
88
163
  const JSZip = (await import('jszip')).default;
89
164
  const zip = new JSZip();
90
-
165
+
91
166
  const jsonContent = await generateJSONContent(exportData, options.includeUserInfo, protectForensicData);
92
167
  zip.file(`${caseNumber}_data.json`, jsonContent);
93
168
  onProgress?.(50);
94
-
169
+
95
170
  // Add images and collect them for manifest generation
96
171
  const imageFolder = zip.folder('images');
97
172
  const imageFiles: { [filename: string]: Blob } = {};
@@ -101,7 +176,6 @@ export async function downloadCaseAsZip(
101
176
  try {
102
177
  const imageBlob = await fetchImageAsBlob(user, file.fileData, caseNumber);
103
178
  if (imageBlob) {
104
- // Generate export filename with embedded ID to prevent collisions
105
179
  const exportFilename = generateExportFilename(file.fileData.originalFilename, file.fileData.id);
106
180
  imageFolder.file(exportFilename, imageBlob);
107
181
  imageFiles[exportFilename] = imageBlob;
@@ -112,9 +186,7 @@ export async function downloadCaseAsZip(
112
186
  onProgress?.(50 + (i / exportData.files.length) * 30);
113
187
  }
114
188
  }
115
-
116
- // CRITICAL: Get the content that will be used for hash calculation.
117
- // This must match the exported package content before encryption.
189
+
118
190
  const contentForHash = await generateJSONContent(exportData, options.includeUserInfo, false);
119
191
 
120
192
  const forensicManifest = await generateForensicManifestSecure(contentForHash, imageFiles);
@@ -128,7 +200,7 @@ export async function downloadCaseAsZip(
128
200
  const signedForensicManifest = {
129
201
  ...forensicManifest,
130
202
  manifestVersion: signingResult.manifestVersion,
131
- signature: signingResult.signature
203
+ signature: signingResult.signature,
132
204
  };
133
205
 
134
206
  zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
@@ -143,24 +215,30 @@ export async function downloadCaseAsZip(
143
215
  }
144
216
 
145
217
  try {
146
- const imagesToEncrypt = Object.entries(imageFiles).map(([filename, blob]) => ({
147
- filename,
148
- blob
149
- }));
218
+ const filesToEncrypt = [
219
+ ...Object.entries(imageFiles).map(([filename, blob]) => ({
220
+ filename,
221
+ blob,
222
+ })),
223
+ ];
150
224
 
151
225
  const encryptionResult = await encryptExportDataWithAllImages(
152
226
  contentForHash,
153
- imagesToEncrypt,
227
+ filesToEncrypt,
154
228
  encKeyDetails.publicKeyPem,
155
229
  encKeyDetails.keyId
156
230
  );
157
231
 
158
232
  zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
159
233
 
160
- if (imageFolder && encryptionResult.encryptedImages.length > 0) {
161
- for (let i = 0; i < imagesToEncrypt.length; i++) {
162
- const originalFilename = imagesToEncrypt[i].filename;
163
- imageFolder.file(originalFilename, encryptionResult.encryptedImages[i]);
234
+ if (encryptionResult.encryptedImages.length > 0) {
235
+ for (let i = 0; i < filesToEncrypt.length; i++) {
236
+ const originalFilename = filesToEncrypt[i].filename;
237
+ const encryptedContent = encryptionResult.encryptedImages[i];
238
+
239
+ if (imageFolder) {
240
+ imageFolder.file(originalFilename, encryptedContent);
241
+ }
164
242
  }
165
243
  }
166
244
 
@@ -213,28 +291,27 @@ For questions about this export, contact your Striae system administrator.
213
291
  );
214
292
  zip.file('README.txt', readme);
215
293
  onProgress?.(85);
216
-
217
- const zipBlob = await zip.generateAsync({
294
+
295
+ const zipBlob = await zip.generateAsync({
218
296
  type: 'blob',
219
297
  compression: 'DEFLATE',
220
- compressionOptions: { level: 6 }
298
+ compressionOptions: { level: 6 },
221
299
  });
222
300
  onProgress?.(95);
223
301
 
224
302
  const url = URL.createObjectURL(zipBlob);
225
303
  const exportFileName = `striae-case-${caseNumber}-encrypted-package-${formatDateForFilename(new Date())}.zip`;
226
-
304
+
227
305
  const linkElement = document.createElement('a');
228
306
  linkElement.href = url;
229
307
  linkElement.setAttribute('download', exportFileName);
230
308
  linkElement.setAttribute('title', 'Encrypted Striae case package');
231
-
309
+
232
310
  linkElement.click();
233
-
311
+
234
312
  URL.revokeObjectURL(url);
235
313
  onProgress?.(100);
236
-
237
- // Log successful export audit event (standard case)
314
+
238
315
  const endTime = Date.now();
239
316
  await auditService.logCaseExport(
240
317
  user,
@@ -246,24 +323,21 @@ For questions about this export, contact your Striae system administrator.
246
323
  processingTimeMs: endTime - startTime,
247
324
  fileSizeBytes: zipBlob.size,
248
325
  validationStepsCompleted: exportData.files?.length || 0,
249
- validationStepsFailed: 0
326
+ validationStepsFailed: 0,
250
327
  },
251
328
  'zip',
252
329
  protectForensicData,
253
330
  {
254
331
  present: true,
255
332
  valid: true,
256
- keyId: manifestSignatureKeyId
333
+ keyId: manifestSignatureKeyId,
257
334
  }
258
335
  );
259
-
260
- // End audit workflow
336
+
261
337
  auditService.endWorkflow();
262
-
263
338
  } catch (error) {
264
339
  console.error('ZIP export failed:', error);
265
-
266
- // Log failed export audit event
340
+
267
341
  const endTime = Date.now();
268
342
  await auditService.logCaseExport(
269
343
  user,
@@ -275,20 +349,19 @@ For questions about this export, contact your Striae system administrator.
275
349
  processingTimeMs: endTime - startTime,
276
350
  fileSizeBytes: 0,
277
351
  validationStepsCompleted: 0,
278
- validationStepsFailed: 1
352
+ validationStepsFailed: 1,
279
353
  },
280
354
  'zip',
281
355
  protectForensicData,
282
356
  {
283
357
  present: manifestSigned,
284
358
  valid: manifestSigned,
285
- keyId: manifestSignatureKeyId
359
+ keyId: manifestSignatureKeyId,
286
360
  }
287
361
  );
288
-
289
- // End audit workflow
362
+
290
363
  auditService.endWorkflow();
291
-
364
+
292
365
  throw new Error('Failed to export encrypted case package');
293
366
  }
294
367
  }
@@ -305,8 +378,8 @@ async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: stri
305
378
  const signedResponse = await fetch(url, {
306
379
  method: 'GET',
307
380
  headers: {
308
- 'Accept': 'application/octet-stream,image/*'
309
- }
381
+ Accept: 'application/octet-stream,image/*',
382
+ },
310
383
  });
311
384
 
312
385
  if (!signedResponse.ok) {
@@ -394,13 +467,12 @@ https://striae.app`;
394
467
  * Generate JSON content for case export with forensic protection options
395
468
  */
396
469
  async function generateJSONContent(
397
- exportData: CaseExportData,
398
- includeUserInfo: boolean = true,
470
+ exportData: CaseExportData,
471
+ includeUserInfo: boolean = true,
399
472
  protectForensicData: boolean = true
400
473
  ): Promise<string> {
401
474
  const jsonData = { ...exportData };
402
-
403
- // Remove sensitive user info if not included
475
+
404
476
  if (!includeUserInfo) {
405
477
  if (jsonData.metadata.exportedBy) {
406
478
  jsonData.metadata.exportedBy = '[User Info Excluded]';
@@ -418,28 +490,24 @@ async function generateJSONContent(
418
490
  jsonData.metadata.exportedByBadgeId = '[User Info Excluded]';
419
491
  }
420
492
  }
421
-
493
+
422
494
  const jsonString = JSON.stringify(jsonData, null, 2);
423
-
424
- // Calculate hash for integrity verification
425
495
  const hash = await calculateSHA256Secure(jsonString);
426
-
427
- // Add hash to metadata
496
+
428
497
  const finalJsonData = {
429
498
  ...jsonData,
430
499
  metadata: {
431
500
  ...jsonData.metadata,
432
501
  hash: hash.toUpperCase(),
433
- integrityNote: 'Verify by recalculating SHA256 of this entire JSON content'
434
- }
502
+ integrityNote: 'Verify by recalculating SHA256 of this entire JSON content',
503
+ },
435
504
  };
436
-
505
+
437
506
  const finalJsonString = JSON.stringify(finalJsonData, null, 2);
438
-
439
- // Add forensic protection warning if enabled
507
+
440
508
  if (protectForensicData) {
441
509
  return addForensicDataWarning(finalJsonString);
442
510
  }
443
-
511
+
444
512
  return finalJsonString;
445
- }
513
+ }
@@ -0,0 +1,299 @@
1
+ import type { User } from 'firebase/auth';
2
+ import { type AuditTrail, type CaseExportData, type ValidationAuditEntry } from '~/types';
3
+ import { signForensicManifest } from '~/utils/data';
4
+ import {
5
+ calculateSHA256Secure,
6
+ createPublicSigningKeyFileName,
7
+ encryptExportDataWithAllImages,
8
+ generateForensicManifestSecure,
9
+ getCurrentEncryptionPublicKeyDetails,
10
+ getCurrentPublicSigningKeyDetails,
11
+ getVerificationPublicKey,
12
+ } from '~/utils/forensics';
13
+ import { signAuditExport } from '~/services/audit/audit-export-signing';
14
+ import { generateAuditSummary, sortAuditEntriesNewestFirst } from '~/services/audit/audit-query-helpers';
15
+ import { auditService } from '~/services/audit';
16
+ import { getImageUrl } from '../image-manage';
17
+
18
+ export interface ArchiveBundleAuditConfig {
19
+ startDate: string;
20
+ endDate: string;
21
+ additionalEntries?: ValidationAuditEntry[];
22
+ }
23
+
24
+ export interface ArchiveBundleReadmeConfig {
25
+ archivedAt: string;
26
+ archivedByDisplay: string;
27
+ archiveReason?: string;
28
+ }
29
+
30
+ export interface BuildArchivePackageInput {
31
+ user: User;
32
+ caseNumber: string;
33
+ caseJsonContent: string;
34
+ files: CaseExportData['files'];
35
+ auditConfig: ArchiveBundleAuditConfig;
36
+ readmeConfig: ArchiveBundleReadmeConfig;
37
+ }
38
+
39
+ export interface BuildArchivePackageResult {
40
+ zipBlob: Blob;
41
+ publicKeyFileName: string;
42
+ manifestSignatureKeyId: string;
43
+ }
44
+
45
+ function generateArchiveImageFilename(originalFilename: string, id: string): string {
46
+ const lastDotIndex = originalFilename.lastIndexOf('.');
47
+
48
+ if (lastDotIndex === -1) {
49
+ return `${originalFilename}-${id}`;
50
+ }
51
+
52
+ const basename = originalFilename.substring(0, lastDotIndex);
53
+ const extension = originalFilename.substring(lastDotIndex);
54
+
55
+ return `${basename}-${id}${extension}`;
56
+ }
57
+
58
+ function getVerificationPublicSigningKey(preferredKeyId?: string): { keyId: string | null; publicKeyPem: string } {
59
+ const preferredKey = preferredKeyId ? getVerificationPublicKey(preferredKeyId) : null;
60
+ const currentDetails = getCurrentPublicSigningKeyDetails();
61
+ const resolvedPem = preferredKey ?? currentDetails.publicKeyPem;
62
+ const resolvedKeyId = preferredKey ? preferredKeyId ?? null : currentDetails.keyId;
63
+
64
+ if (!resolvedPem || resolvedPem.trim().length === 0) {
65
+ throw new Error('No public signing key is configured for archive packaging.');
66
+ }
67
+
68
+ return {
69
+ keyId: resolvedKeyId,
70
+ publicKeyPem: resolvedPem.endsWith('\n') ? resolvedPem : `${resolvedPem}\n`,
71
+ };
72
+ }
73
+
74
+ async function fetchImageAsBlob(user: User, fileData: CaseExportData['files'][number]['fileData'], caseNumber: string): Promise<Blob | null> {
75
+ try {
76
+ const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
77
+ const { blob, revoke, url } = imageAccess;
78
+
79
+ if (!blob) {
80
+ const signedResponse = await fetch(url, {
81
+ method: 'GET',
82
+ headers: {
83
+ Accept: 'application/octet-stream,image/*',
84
+ },
85
+ });
86
+
87
+ if (!signedResponse.ok) {
88
+ throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
89
+ }
90
+
91
+ return await signedResponse.blob();
92
+ }
93
+
94
+ try {
95
+ return blob;
96
+ } finally {
97
+ revoke();
98
+ }
99
+ } catch (error) {
100
+ console.error('Failed to fetch image for archive package:', error);
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export async function buildArchivePackage(input: BuildArchivePackageInput): Promise<BuildArchivePackageResult> {
106
+ const { user, caseNumber, caseJsonContent, files, auditConfig, readmeConfig } = input;
107
+
108
+ const JSZip = (await import('jszip')).default;
109
+ const zip = new JSZip();
110
+ zip.file(`${caseNumber}_data.json`, caseJsonContent);
111
+
112
+ const imageFolder = zip.folder('images');
113
+ const imageBlobs: Record<string, Blob> = {};
114
+ if (imageFolder && files) {
115
+ for (const fileEntry of files) {
116
+ const imageBlob = await fetchImageAsBlob(user, fileEntry.fileData, caseNumber);
117
+ if (!imageBlob) {
118
+ continue;
119
+ }
120
+
121
+ const exportFileName = generateArchiveImageFilename(
122
+ fileEntry.fileData.originalFilename,
123
+ fileEntry.fileData.id
124
+ );
125
+ imageFolder.file(exportFileName, imageBlob);
126
+ imageBlobs[exportFileName] = imageBlob;
127
+ }
128
+ }
129
+
130
+ const forensicManifest = await generateForensicManifestSecure(caseJsonContent, imageBlobs);
131
+ const manifestSigningResponse = await signForensicManifest(user, caseNumber, forensicManifest);
132
+
133
+ const signingKey = getVerificationPublicSigningKey(manifestSigningResponse.signature.keyId);
134
+ const publicKeyFileName = createPublicSigningKeyFileName(signingKey.keyId);
135
+ zip.file(publicKeyFileName, signingKey.publicKeyPem);
136
+
137
+ zip.file(
138
+ 'FORENSIC_MANIFEST.json',
139
+ JSON.stringify(
140
+ {
141
+ ...forensicManifest,
142
+ manifestVersion: manifestSigningResponse.manifestVersion,
143
+ signature: manifestSigningResponse.signature,
144
+ },
145
+ null,
146
+ 2
147
+ )
148
+ );
149
+
150
+ const auditEntries = await auditService.getAuditEntriesForUser(user.uid, {
151
+ caseNumber,
152
+ startDate: auditConfig.startDate,
153
+ endDate: auditConfig.endDate,
154
+ });
155
+
156
+ const auditEntriesWithExtras = sortAuditEntriesNewestFirst([
157
+ ...auditEntries,
158
+ ...(auditConfig.additionalEntries ?? []),
159
+ ]);
160
+
161
+ const auditTrail: AuditTrail = {
162
+ caseNumber,
163
+ workflowId: `${caseNumber}-archive-${Date.now()}`,
164
+ entries: auditEntriesWithExtras,
165
+ summary: generateAuditSummary(auditEntriesWithExtras),
166
+ };
167
+
168
+ const auditTrailPayload = {
169
+ metadata: {
170
+ exportTimestamp: new Date().toISOString(),
171
+ exportVersion: '1.0',
172
+ totalEntries: auditTrail.summary.totalEvents,
173
+ application: 'Striae',
174
+ exportType: 'trail' as const,
175
+ scopeType: 'case' as const,
176
+ scopeIdentifier: caseNumber,
177
+ },
178
+ auditTrail,
179
+ };
180
+
181
+ const auditTrailRawContent = JSON.stringify(auditTrailPayload, null, 2);
182
+ const auditTrailHash = await calculateSHA256Secure(auditTrailRawContent);
183
+ const signedAuditExportPayload = await signAuditExport(
184
+ {
185
+ exportFormat: 'json',
186
+ exportType: 'trail',
187
+ generatedAt: auditTrailPayload.metadata.exportTimestamp,
188
+ totalEntries: auditTrail.summary.totalEvents,
189
+ hash: auditTrailHash.toUpperCase(),
190
+ },
191
+ {
192
+ user,
193
+ scopeType: 'case',
194
+ scopeIdentifier: caseNumber,
195
+ caseNumber,
196
+ }
197
+ );
198
+
199
+ const signedAuditTrail = {
200
+ metadata: {
201
+ ...auditTrailPayload.metadata,
202
+ hash: auditTrailHash.toUpperCase(),
203
+ signatureVersion: signedAuditExportPayload.signatureMetadata.signatureVersion,
204
+ signatureMetadata: signedAuditExportPayload.signatureMetadata,
205
+ signature: signedAuditExportPayload.signature,
206
+ },
207
+ auditTrail,
208
+ };
209
+
210
+ const auditTrailJson = JSON.stringify(signedAuditTrail, null, 2);
211
+ const auditSignatureJson = JSON.stringify(signedAuditExportPayload, null, 2);
212
+ zip.file('audit/case-audit-trail.json', auditTrailJson);
213
+ zip.file('audit/case-audit-signature.json', auditSignatureJson);
214
+
215
+ const encryptionKeyDetails = getCurrentEncryptionPublicKeyDetails();
216
+
217
+ if (!encryptionKeyDetails.publicKeyPem || !encryptionKeyDetails.keyId) {
218
+ throw new Error(
219
+ 'Archive encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
220
+ 'Please contact your administrator to set up export encryption.'
221
+ );
222
+ }
223
+
224
+ const filesToEncrypt: Array<{ filename: string; blob: Blob }> = [
225
+ ...Object.entries(imageBlobs).map(([filename, blob]) => ({
226
+ filename,
227
+ blob,
228
+ })),
229
+ {
230
+ filename: 'audit/case-audit-trail.json',
231
+ blob: new Blob([auditTrailJson], { type: 'application/json' }),
232
+ },
233
+ {
234
+ filename: 'audit/case-audit-signature.json',
235
+ blob: new Blob([auditSignatureJson], { type: 'application/json' }),
236
+ },
237
+ ];
238
+
239
+ const encryptionResult = await encryptExportDataWithAllImages(
240
+ caseJsonContent,
241
+ filesToEncrypt,
242
+ encryptionKeyDetails.publicKeyPem,
243
+ encryptionKeyDetails.keyId
244
+ );
245
+
246
+ zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
247
+
248
+ for (let index = 0; index < filesToEncrypt.length; index += 1) {
249
+ const originalFilename = filesToEncrypt[index].filename;
250
+ const encryptedContent = encryptionResult.encryptedImages[index];
251
+
252
+ if (originalFilename.startsWith('audit/')) {
253
+ zip.file(originalFilename, encryptedContent);
254
+ continue;
255
+ }
256
+
257
+ if (imageFolder) {
258
+ imageFolder.file(originalFilename, encryptedContent);
259
+ }
260
+ }
261
+
262
+ zip.file('ENCRYPTION_MANIFEST.json', JSON.stringify(encryptionResult.encryptionManifest, null, 2));
263
+
264
+ zip.file(
265
+ 'README.txt',
266
+ [
267
+ 'Striae Archived Case Package',
268
+ '===========================',
269
+ '',
270
+ `Case Number: ${caseNumber}`,
271
+ `Archived At: ${readmeConfig.archivedAt}`,
272
+ `Archived By: ${readmeConfig.archivedByDisplay}`,
273
+ `Archive Reason: ${readmeConfig.archiveReason?.trim() || 'Not provided'}`,
274
+ '',
275
+ 'Package Contents',
276
+ '- Case data JSON export with all image references',
277
+ '- images/ folder with exported image files (encrypted)',
278
+ '- Full case audit trail export and signed audit metadata',
279
+ '- Forensic manifest with server-side signature',
280
+ '- ENCRYPTION_MANIFEST.json with encryption metadata and encrypted image hashes',
281
+ `- ${publicKeyFileName} for verification`,
282
+ '',
283
+ 'This package is intended for read-only review and verification workflows.',
284
+ 'This package is encrypted. Only Striae can decrypt and re-import it.',
285
+ ].join('\n')
286
+ );
287
+
288
+ const zipBlob = await zip.generateAsync({
289
+ type: 'blob',
290
+ compression: 'DEFLATE',
291
+ compressionOptions: { level: 6 },
292
+ });
293
+
294
+ return {
295
+ zipBlob,
296
+ publicKeyFileName,
297
+ manifestSignatureKeyId: manifestSigningResponse.signature.keyId,
298
+ };
299
+ }