@striae-org/striae 5.4.1 → 5.4.3

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 (45) hide show
  1. package/app/components/actions/case-export/download-handlers.ts +1 -1
  2. package/app/components/actions/case-export/metadata-helpers.ts +2 -4
  3. package/app/components/actions/case-import/confirmation-import.ts +11 -0
  4. package/app/components/actions/case-import/orchestrator.ts +1 -0
  5. package/app/components/actions/case-import/storage-operations.ts +2 -0
  6. package/app/components/actions/case-import/zip-processing.ts +3 -3
  7. package/app/components/actions/confirm-export.ts +6 -4
  8. package/app/components/canvas/confirmation/confirmation.tsx +4 -18
  9. package/app/components/mobile-warning/mobile-warning.module.css +80 -0
  10. package/app/components/mobile-warning/mobile-warning.tsx +108 -0
  11. package/app/components/navbar/case-import/utils/file-validation.ts +1 -1
  12. package/app/config-example/config.json +2 -2
  13. package/app/root.tsx +2 -0
  14. package/app/services/audit/audit-file-type.ts +0 -1
  15. package/app/services/audit/audit.service.ts +1 -1
  16. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -6
  17. package/app/services/audit/index.ts +0 -1
  18. package/app/types/audit.ts +1 -1
  19. package/app/types/case.ts +1 -0
  20. package/app/types/import.ts +2 -0
  21. package/app/utils/data/permissions.ts +17 -15
  22. package/app/utils/forensics/audit-export-signature.ts +4 -4
  23. package/app/utils/forensics/confirmation-signature.ts +10 -0
  24. package/app/utils/forensics/export-verification.ts +3 -11
  25. package/package.json +2 -2
  26. package/worker-configuration.d.ts +4 -3
  27. package/workers/audit-worker/package.json +1 -1
  28. package/workers/audit-worker/src/audit-worker.example.ts +1 -1
  29. package/workers/audit-worker/src/handlers/audit-routes.ts +1 -30
  30. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  31. package/workers/data-worker/package.json +17 -17
  32. package/workers/data-worker/src/encryption-utils.ts +1 -1
  33. package/workers/data-worker/src/signing-payload-utils.ts +15 -4
  34. package/workers/data-worker/wrangler.jsonc.example +1 -1
  35. package/workers/image-worker/package.json +1 -1
  36. package/workers/image-worker/wrangler.jsonc.example +1 -1
  37. package/workers/pdf-worker/package.json +1 -1
  38. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  39. package/workers/user-worker/package.json +1 -1
  40. package/workers/user-worker/wrangler.jsonc.example +1 -1
  41. package/wrangler.toml.example +1 -1
  42. package/app/components/audit/viewer/use-audit-viewer-export.ts +0 -176
  43. package/app/services/audit/audit-export-csv.ts +0 -130
  44. package/app/services/audit/audit-export-report.ts +0 -205
  45. package/app/services/audit/audit-export.service.ts +0 -333
@@ -438,7 +438,7 @@ async function generateJSONContent(
438
438
 
439
439
  // Add forensic protection warning if enabled
440
440
  if (protectForensicData) {
441
- return addForensicDataWarning(finalJsonString, 'json');
441
+ return addForensicDataWarning(finalJsonString);
442
442
  }
443
443
 
444
444
  return finalJsonString;
@@ -32,10 +32,8 @@ export async function getUserExportMetadata(user: User) {
32
32
  /**
33
33
  * Add data protection warning to content
34
34
  */
35
- export function addForensicDataWarning(content: string, format: 'csv' | 'json'): string {
36
- const warning = format === 'csv'
37
- ? `"CASE DATA WARNING: This file contains evidence data for forensic examination. Any modification may compromise the integrity of the evidence. Handle according to your organization's chain of custody procedures."\n\n`
38
- : `/* CASE DATA WARNING
35
+ export function addForensicDataWarning(content: string): string {
36
+ const warning = `/* CASE DATA WARNING
39
37
  * This file contains evidence data for forensic examination.
40
38
  * Any modification may compromise the integrity of the evidence.
41
39
  * Handle according to your organization's chain of custody procedures.
@@ -185,6 +185,17 @@ export async function importConfirmationData(
185
185
  throw new Error('You cannot import confirmation data that you exported yourself.');
186
186
  }
187
187
 
188
+ // Validate that this confirmation package was intended for the current user.
189
+ // originalCaseOwnerUid is embedded at export time and covered by the package signature.
190
+ if (
191
+ confirmationData.metadata.originalCaseOwnerUid &&
192
+ confirmationData.metadata.originalCaseOwnerUid !== user.uid
193
+ ) {
194
+ throw new Error(
195
+ 'This confirmation package was not exported for your case. It can only be imported by the original case owner.'
196
+ );
197
+ }
198
+
188
199
  onProgress?.('Validating case', 50, 'Checking case exists...');
189
200
 
190
201
  // Check if case exists in user's regular cases
@@ -512,6 +512,7 @@ export async function importCaseForReview(
512
512
  importedAt: new Date().toISOString(),
513
513
  originalExportDate: caseData.metadata.exportDate,
514
514
  originalExportedBy: caseData.metadata.exportedBy || 'Unknown',
515
+ originalExportedByUid: caseData.metadata.exportedByUid,
515
516
  sourceHash: parsedForensicManifest?.manifestHash,
516
517
  sourceManifestVersion: parsedForensicManifest?.manifestVersion,
517
518
  sourceSignatureKeyId: parsedForensicManifest?.signature?.keyId,
@@ -118,6 +118,8 @@ export async function storeCaseDataInR2(
118
118
  ...(bundledAuditTrail && { bundledAuditTrail }),
119
119
  // Add original image ID mapping for confirmation linking
120
120
  originalImageIds: originalImageIds,
121
+ // Store original case owner UID so confirmation exports can embed the intended recipient
122
+ ...(caseData.metadata.exportedByUid && { originalCaseOwnerUid: caseData.metadata.exportedByUid }),
121
123
  // Add forensic manifest timestamp if available for confirmation exports
122
124
  ...(forensicManifest?.createdAt && { forensicManifestCreatedAt: forensicManifest.createdAt }),
123
125
  // Store full forensic manifest metadata for chain-of-custody validation
@@ -281,13 +281,13 @@ export async function parseImportZip(zipFile: File): Promise<{
281
281
  const zip = await JSZip.loadAsync(zipFile);
282
282
  const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
283
283
 
284
- // Find the main data file (JSON or CSV)
284
+ // Find the main data file (JSON)
285
285
  const dataFiles = Object.keys(zip.files).filter(name =>
286
- name.endsWith('_data.json') || name.endsWith('_data.csv')
286
+ name.endsWith('_data.json')
287
287
  );
288
288
 
289
289
  if (dataFiles.length === 0) {
290
- throw new Error('No valid data file found in ZIP archive');
290
+ throw new Error('No valid JSON data file found in ZIP archive');
291
291
  }
292
292
 
293
293
  if (dataFiles.length > 1) {
@@ -153,7 +153,7 @@ export async function getCaseConfirmations(
153
153
  export async function getCaseDataWithManifest(
154
154
  user: User,
155
155
  caseNumber: string
156
- ): Promise<{ confirmations: CaseConfirmations | null; forensicManifestCreatedAt?: string }> {
156
+ ): Promise<{ confirmations: CaseConfirmations | null; forensicManifestCreatedAt?: string; originalCaseOwnerUid?: string }> {
157
157
  try {
158
158
  const caseData = await getCaseData(user, caseNumber) as CaseDataWithConfirmations & { forensicManifestCreatedAt?: string };
159
159
  if (!caseData) {
@@ -163,7 +163,8 @@ export async function getCaseDataWithManifest(
163
163
 
164
164
  return {
165
165
  confirmations: caseData.confirmations || null,
166
- forensicManifestCreatedAt: caseData.forensicManifestCreatedAt
166
+ forensicManifestCreatedAt: caseData.forensicManifestCreatedAt,
167
+ originalCaseOwnerUid: caseData.originalCaseOwnerUid
167
168
  };
168
169
 
169
170
  } catch (error) {
@@ -206,7 +207,7 @@ export async function exportConfirmationData(
206
207
  auditService.startWorkflow(caseNumber);
207
208
 
208
209
  // Get all confirmation data and forensic manifest info for the case
209
- const { confirmations: caseConfirmations, forensicManifestCreatedAt } = await getCaseDataWithManifest(user, caseNumber);
210
+ const { confirmations: caseConfirmations, forensicManifestCreatedAt, originalCaseOwnerUid } = await getCaseDataWithManifest(user, caseNumber);
210
211
 
211
212
  if (!caseConfirmations || Object.keys(caseConfirmations).length === 0) {
212
213
  throw new Error('No confirmation data found for this case');
@@ -256,7 +257,8 @@ export async function exportConfirmationData(
256
257
  ...userMetadata,
257
258
  totalConfirmations: Object.keys(caseConfirmations).length,
258
259
  version: '2.0',
259
- ...(originalExportCreatedAt && { originalExportCreatedAt })
260
+ ...(originalExportCreatedAt && { originalExportCreatedAt }),
261
+ ...(originalCaseOwnerUid && { originalCaseOwnerUid })
260
262
  },
261
263
  confirmations: caseConfirmations
262
264
  };
@@ -72,11 +72,6 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
72
72
  if (!isOpen) return null;
73
73
 
74
74
  const handleConfirm = async () => {
75
- if (!badgeId.trim()) {
76
- setError('Badge/ID is required');
77
- return;
78
- }
79
-
80
75
  setIsConfirming(true);
81
76
  setError('');
82
77
 
@@ -134,19 +129,10 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
134
129
  </div>
135
130
 
136
131
  <div className={styles.field}>
137
- <label className={styles.label} htmlFor="badgeId">Badge/ID: *</label>
138
- <input
139
- id="badgeId"
140
- type="text"
141
- className={styles.input}
142
- value={badgeId}
143
- onChange={(e) => {
144
- setBadgeId(e.target.value);
145
- if (error) setError('');
146
- }}
147
- placeholder="Enter your badge or ID number"
148
- disabled={isConfirming || hasExistingConfirmation}
149
- />
132
+ <span className={styles.label}>Badge/ID:</span>
133
+ <div className={styles.readOnlyValue}>
134
+ {badgeId || 'Not set'}
135
+ </div>
150
136
  </div>
151
137
 
152
138
  <div className={styles.field}>
@@ -0,0 +1,80 @@
1
+ @layer layout {
2
+ .overlay {
3
+ display: none;
4
+ position: fixed;
5
+ inset: 0;
6
+ z-index: 10000;
7
+ background: var(--background);
8
+ align-items: center;
9
+ justify-content: center;
10
+ padding: var(--spaceXL);
11
+ }
12
+
13
+ .backdrop {
14
+ appearance: none;
15
+ position: absolute;
16
+ inset: 0;
17
+ background: transparent;
18
+ border: none;
19
+ cursor: default;
20
+ }
21
+
22
+ .content {
23
+ display: flex;
24
+ flex-direction: column;
25
+ align-items: center;
26
+ text-align: center;
27
+ max-width: 420px;
28
+ gap: var(--spaceL);
29
+ }
30
+
31
+ .icon {
32
+ color: var(--primary);
33
+ }
34
+
35
+ .title {
36
+ font-size: var(--fontSizeH4);
37
+ font-weight: var(--fontWeightBold);
38
+ color: var(--textTitle);
39
+ line-height: var(--lineHeightTitle);
40
+ margin: 0;
41
+ }
42
+
43
+ .message {
44
+ font-size: var(--fontSizeBodyS);
45
+ color: var(--textBody);
46
+ line-height: var(--lineHeightBody);
47
+ margin: 0;
48
+ }
49
+
50
+ .dismissButton {
51
+ appearance: none;
52
+ background-color: var(--primary);
53
+ border: 1px solid var(--primary);
54
+ border-radius: var(--radiusM);
55
+ color: var(--white);
56
+ cursor: pointer;
57
+ font-size: var(--fontSizeBodyS);
58
+ font-weight: var(--fontWeightMedium);
59
+ padding: var(--spaceM) var(--spaceXL);
60
+ transition:
61
+ background-color var(--durationS) var(--bezierFastoutSlowin),
62
+ border-color var(--durationS) var(--bezierFastoutSlowin);
63
+ }
64
+
65
+ .dismissButton:hover {
66
+ background-color: color-mix(in lab, var(--primary) 82%, var(--black));
67
+ border-color: color-mix(in lab, var(--primary) 82%, var(--black));
68
+ }
69
+
70
+ .dismissButton:focus-visible {
71
+ outline: 3px solid color-mix(in lab, var(--white) 65%, var(--primary));
72
+ outline-offset: 3px;
73
+ }
74
+
75
+ @media (max-width: 1024px) {
76
+ .overlay {
77
+ display: flex;
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,108 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import styles from './mobile-warning.module.css';
3
+
4
+ const DISMISSED_KEY = 'striae-mobile-warning-dismissed';
5
+
6
+ const isBrowser =
7
+ typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
8
+
9
+ export function MobileWarning() {
10
+ const [dismissed, setDismissed] = useState<boolean>(() => {
11
+ if (!isBrowser) {
12
+ // Match previous server behavior: render null during SSR.
13
+ return true;
14
+ }
15
+
16
+ try {
17
+ return window.sessionStorage.getItem(DISMISSED_KEY) === '1';
18
+ } catch {
19
+ return false;
20
+ }
21
+ });
22
+
23
+ const buttonRef = useRef<HTMLButtonElement>(null);
24
+
25
+ useEffect(() => {
26
+ if (!dismissed && buttonRef.current) {
27
+ buttonRef.current.focus();
28
+ }
29
+ }, [dismissed]);
30
+
31
+ const handleDismiss = useCallback(() => {
32
+ if (isBrowser) {
33
+ try {
34
+ window.sessionStorage.setItem(DISMISSED_KEY, '1');
35
+ } catch {
36
+ // Ignore storage errors and still dismiss for this session.
37
+ }
38
+ }
39
+ setDismissed(true);
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ if (dismissed) return;
44
+ const onKeyDown = (e: KeyboardEvent) => {
45
+ if (e.key === 'Escape') {
46
+ handleDismiss();
47
+ }
48
+ };
49
+ document.addEventListener('keydown', onKeyDown);
50
+ return () => document.removeEventListener('keydown', onKeyDown);
51
+ }, [dismissed, handleDismiss]);
52
+
53
+ if (dismissed) {
54
+ return null;
55
+ }
56
+
57
+ return (
58
+ <div
59
+ className={styles.overlay}
60
+ role="dialog"
61
+ aria-modal="true"
62
+ aria-labelledby="mobile-warning-title"
63
+ aria-describedby="mobile-warning-message"
64
+ >
65
+ {/* Backdrop dismiss button covers area outside dialog content */}
66
+ <button
67
+ type="button"
68
+ className={styles.backdrop}
69
+ onClick={handleDismiss}
70
+ aria-label="Dismiss"
71
+ tabIndex={-1}
72
+ />
73
+ <div className={styles.content}>
74
+ <div className={styles.icon}>
75
+ <svg
76
+ width="48"
77
+ height="48"
78
+ viewBox="0 0 24 24"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ strokeWidth="1.5"
82
+ strokeLinecap="round"
83
+ strokeLinejoin="round"
84
+ >
85
+ <rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
86
+ <line x1="12" y1="18" x2="12.01" y2="18" />
87
+ </svg>
88
+ </div>
89
+ <h2 className={styles.title} id="mobile-warning-title">
90
+ Desktop Experience Only
91
+ </h2>
92
+ <p className={styles.message} id="mobile-warning-message">
93
+ Striae is designed for desktop browsers and is not optimized for
94
+ mobile devices or tablets. For the best experience, please use a
95
+ desktop computer.
96
+ </p>
97
+ <button
98
+ ref={buttonRef}
99
+ type="button"
100
+ className={styles.dismissButton}
101
+ onClick={handleDismiss}
102
+ >
103
+ Continue Anyway
104
+ </button>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -1,4 +1,4 @@
1
- const CASE_EXPORT_DATA_FILE_REGEX = /_data\.(json|csv)$/i;
1
+ const CASE_EXPORT_DATA_FILE_REGEX = /_data\.json$/i;
2
2
  const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
3
3
  const FORENSIC_MANIFEST_FILE_NAME = 'forensic_manifest.json';
4
4
  const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
@@ -10,6 +10,6 @@
10
10
  "export_encryption_public_keys": {
11
11
  "EXPORT_ENCRYPTION_KEY_ID": "EXPORT_ENCRYPTION_PUBLIC_KEY"
12
12
  },
13
- "max_cases_review": 0,
14
- "max_files_per_case_review": 0
13
+ "max_cases_demo": 0,
14
+ "max_files_per_case_demo": 0
15
15
  }
package/app/root.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  themeStyles
14
14
  } from '~/components/theme-provider/theme-provider';
15
15
  import { AuthProvider } from '~/components/auth/auth-provider';
16
+ import { MobileWarning } from '~/components/mobile-warning/mobile-warning';
16
17
  import { auth } from '~/services/firebase';
17
18
  import styles from '~/styles/root.module.css';
18
19
  import './global.css';
@@ -68,6 +69,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
68
69
  </head>
69
70
  <body className="flex flex-col h-screen w-full overflow-x-hidden">
70
71
  <ThemeProvider theme={theme} className="">
72
+ <MobileWarning />
71
73
  <main>
72
74
  {children}
73
75
  </main>
@@ -4,7 +4,6 @@ export const getAuditFileTypeFromMime = (mimeType: string): AuditFileType => {
4
4
  if (mimeType.startsWith('image/')) return 'image-file';
5
5
  if (mimeType === 'application/pdf') return 'pdf-document';
6
6
  if (mimeType === 'application/json') return 'json-data';
7
- if (mimeType === 'text/csv') return 'csv-export';
8
7
  return 'unknown';
9
8
  };
10
9
 
@@ -208,7 +208,7 @@ class AuditService {
208
208
  result: AuditResult,
209
209
  errors: string[] = [],
210
210
  performanceMetrics?: PerformanceMetrics,
211
- exportFormat?: 'json' | 'csv' | 'xlsx' | 'zip',
211
+ exportFormat?: 'json' | 'zip',
212
212
  protectionEnabled?: boolean,
213
213
  signatureDetails?: {
214
214
  present?: boolean;
@@ -20,21 +20,18 @@ interface BuildCaseExportAuditParamsInput {
20
20
  result: AuditResult;
21
21
  errors?: string[];
22
22
  performanceMetrics?: PerformanceMetrics;
23
- exportFormat?: 'json' | 'csv' | 'xlsx' | 'zip';
23
+ exportFormat?: 'json' | 'zip';
24
24
  signatureDetails?: SignatureDetailsInput;
25
25
  }
26
26
 
27
27
  const resolveCaseExportFileType = (
28
28
  fileName: string,
29
- exportFormat?: 'json' | 'csv' | 'xlsx' | 'zip'
29
+ exportFormat?: 'json' | 'zip'
30
30
  ): AuditFileType => {
31
31
  if (exportFormat) {
32
32
  switch (exportFormat) {
33
33
  case 'json':
34
34
  return 'json-data';
35
- case 'csv':
36
- case 'xlsx':
37
- return 'csv-export';
38
35
  case 'zip':
39
36
  default:
40
37
  return 'case-package';
@@ -42,7 +39,6 @@ const resolveCaseExportFileType = (
42
39
  }
43
40
 
44
41
  if (fileName.includes('.json')) return 'json-data';
45
- if (fileName.includes('.csv') || fileName.includes('.xlsx')) return 'csv-export';
46
42
  return 'case-package';
47
43
  };
48
44
 
@@ -1,2 +1 @@
1
1
  export { auditService } from './audit.service';
2
- export { auditExportService } from './audit-export.service';
@@ -25,7 +25,7 @@ export type AuditResult = 'success' | 'failure' | 'warning' | 'blocked' | 'pendi
25
25
 
26
26
  export type AuditFileType =
27
27
  | 'case-package' | 'confirmation-data' | 'image-file' | 'pdf-document'
28
- | 'json-data' | 'csv-export' | 'log-file' | 'unknown';
28
+ | 'json-data' | 'log-file' | 'unknown';
29
29
 
30
30
  /**
31
31
  * Core audit entry structure for all validation events
package/app/types/case.ts CHANGED
@@ -115,6 +115,7 @@ export interface CaseDataWithConfirmations {
115
115
  archiveReason?: string;
116
116
  importedAt?: string;
117
117
  originalImageIds?: { [originalId: string]: string };
118
+ originalCaseOwnerUid?: string;
118
119
  confirmations?: CaseConfirmations;
119
120
  bundledAuditTrail?: BundledAuditTrailData;
120
121
  }
@@ -21,6 +21,7 @@ export interface ReadOnlyCaseMetadata {
21
21
  importedAt: string;
22
22
  originalExportDate: string;
23
23
  originalExportedBy: string;
24
+ originalExportedByUid?: string;
24
25
  sourceHash?: string;
25
26
  sourceManifestVersion?: string;
26
27
  sourceSignatureKeyId?: string;
@@ -63,6 +64,7 @@ export interface ConfirmationImportData {
63
64
  value: string;
64
65
  };
65
66
  originalExportCreatedAt?: string;
67
+ originalCaseOwnerUid?: string;
66
68
  };
67
69
  confirmations: {
68
70
  [originalImageId: string]: Array<{
@@ -3,8 +3,8 @@ import type { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } fro
3
3
  import paths from '~/config/config.json';
4
4
  import { fetchDataApi, fetchUserApi } from '../api';
5
5
 
6
- const MAX_CASES_REVIEW = paths.max_cases_review;
7
- const MAX_FILES_PER_CASE_REVIEW = paths.max_files_per_case_review;
6
+ const MAX_CASES_DEMO = paths.max_cases_demo;
7
+ const MAX_FILES_PER_CASE_DEMO = paths.max_files_per_case_demo;
8
8
 
9
9
  export interface UserUsage {
10
10
  currentCases: number;
@@ -63,8 +63,8 @@ export const getUserLimits = (userData: UserData): UserLimits => {
63
63
  };
64
64
  } else {
65
65
  return {
66
- maxCases: MAX_CASES_REVIEW, // Use config value for review users
67
- maxFilesPerCase: MAX_FILES_PER_CASE_REVIEW // Use config value for review users
66
+ maxCases: MAX_CASES_DEMO, // Use config value for demo users
67
+ maxFilesPerCase: MAX_FILES_PER_CASE_DEMO // Use config value for demo users
68
68
  };
69
69
  }
70
70
  };
@@ -155,7 +155,7 @@ export const canCreateCase = async (user: User): Promise<{ canCreate: boolean; r
155
155
  if (usage.currentCases >= limits.maxCases) {
156
156
  return {
157
157
  canCreate: false,
158
- reason: `Read-Only Account: Case creation disabled`
158
+ reason: `Demo account only: Maximum of ${limits.maxCases} case${limits.maxCases === 1 ? '' : 's'} reached`
159
159
  };
160
160
  }
161
161
 
@@ -181,7 +181,7 @@ export const canUploadFile = async (user: User, currentFileCount: number): Promi
181
181
  if (currentFileCount >= limits.maxFilesPerCase) {
182
182
  return {
183
183
  canUpload: false,
184
- reason: `Read-Only Account: File uploads disabled`
184
+ reason: `Demo account only: Maximum of ${limits.maxFilesPerCase} file${limits.maxFilesPerCase === 1 ? '' : 's'} per case reached`
185
185
  };
186
186
  }
187
187
 
@@ -199,13 +199,13 @@ export const getLimitsDescription = async (user: User): Promise<string> => {
199
199
  try {
200
200
  const userData = await getUserData(user);
201
201
  if (!userData) {
202
- return `Account limits: ${MAX_CASES_REVIEW} case, ${MAX_FILES_PER_CASE_REVIEW} files per case`;
202
+ return `Account limits: ${MAX_CASES_DEMO} case${MAX_CASES_DEMO === 1 ? '' : 's'}, ${MAX_FILES_PER_CASE_DEMO} file${MAX_FILES_PER_CASE_DEMO === 1 ? '' : 's'} per case`;
203
203
  }
204
204
 
205
205
  if (userData.permitted) {
206
206
  return '';
207
207
  } else {
208
- return `Read-Only Account: Case review only.`;
208
+ return `Demo account only: ${MAX_CASES_DEMO} case${MAX_CASES_DEMO === 1 ? '' : 's'}, ${MAX_FILES_PER_CASE_DEMO} file${MAX_FILES_PER_CASE_DEMO === 1 ? '' : 's'} per case`;
209
209
  }
210
210
  } catch (error) {
211
211
  console.error('Error getting limits description:', error);
@@ -391,8 +391,9 @@ export const canAccessCase = async (user: User, caseNumber: string): Promise<Per
391
391
  /**
392
392
  * Check if user can modify a specific case
393
393
  * - Regular users (permitted=true) can modify their owned cases
394
- * - Read-only users (permitted=false) can modify read-only cases for review
395
- * - Nobody can modify cases marked as truly read-only in the case data itself
394
+ * - Demo users (permitted=false) can modify their owned cases
395
+ * - Both permitted and demo users can modify read-only cases for review
396
+ * - Nobody can modify cases marked as archived in the case data itself
396
397
  */
397
398
  export const canModifyCase = async (user: User, caseNumber: string): Promise<PermissionResult> => {
398
399
  try {
@@ -419,20 +420,21 @@ export const canModifyCase = async (user: User, caseNumber: string): Promise<Per
419
420
  if (caseData.archived) {
420
421
  return { allowed: false, reason: 'Archived cases are immutable and read-only' };
421
422
  }
423
+ } else if (archiveCheckResponse.status !== 404) {
424
+ // Fail closed: if archive status can't be verified (worker error/timeout),
425
+ // block modification rather than risk mutating an archived case
426
+ return { allowed: false, reason: 'Unable to verify case archive status' };
422
427
  }
423
428
 
424
429
  // Check if user owns the case (regular cases)
425
430
  if (userData.cases && userData.cases.some(c => c.caseNumber === caseNumber)) {
426
- // For owned cases, user must be permitted
427
- if (!userData.permitted) {
428
- return { allowed: false, reason: 'Read-Only Account: Cannot modify owned cases' };
429
- }
431
+ // Both permitted and demo users can modify their owned cases
430
432
  return { allowed: true };
431
433
  }
432
434
 
433
435
  // Check if it's a read-only case that user can review
434
436
  if (userData.readOnlyCases && userData.readOnlyCases.some(c => c.caseNumber === caseNumber)) {
435
- // For read-only cases, both permitted and non-permitted users can modify for review
437
+ // For read-only cases, both permitted and demo users can modify for review
436
438
  // The actual read-only restrictions should be enforced at the case data level, not user level
437
439
  return { allowed: true };
438
440
  }
@@ -9,8 +9,8 @@ export const AUDIT_EXPORT_SIGNATURE_VERSION = '1.0';
9
9
 
10
10
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/i;
11
11
 
12
- export type AuditExportFormat = 'csv' | 'json' | 'txt';
13
- export type AuditExportType = 'entries' | 'trail' | 'report';
12
+ export type AuditExportFormat = 'json';
13
+ export type AuditExportType = 'trail';
14
14
  export type AuditExportScopeType = 'case' | 'user';
15
15
 
16
16
  export interface AuditExportSigningPayload {
@@ -35,11 +35,11 @@ export function isValidAuditExportSigningPayload(
35
35
  return false;
36
36
  }
37
37
 
38
- if (payload.exportFormat !== 'csv' && payload.exportFormat !== 'json' && payload.exportFormat !== 'txt') {
38
+ if (payload.exportFormat !== 'json') {
39
39
  return false;
40
40
  }
41
41
 
42
- if (payload.exportType !== 'entries' && payload.exportType !== 'trail' && payload.exportType !== 'report') {
42
+ if (payload.exportType !== 'trail') {
43
43
  return false;
44
44
  }
45
45
 
@@ -71,6 +71,13 @@ function isValidConfirmationData(candidate: Partial<ConfirmationImportData>): ca
71
71
  return false;
72
72
  }
73
73
 
74
+ if (
75
+ typeof metadata.originalCaseOwnerUid !== 'undefined' &&
76
+ (typeof metadata.originalCaseOwnerUid !== 'string' || metadata.originalCaseOwnerUid.trim().length === 0)
77
+ ) {
78
+ return false;
79
+ }
80
+
74
81
  const confirmations = candidate.confirmations as Record<string, unknown>;
75
82
  for (const [imageId, confirmationList] of Object.entries(confirmations)) {
76
83
  if (!imageId || !Array.isArray(confirmationList)) {
@@ -146,6 +153,9 @@ export function createConfirmationSigningPayload(
146
153
  hash: confirmationData.metadata.hash.toUpperCase(),
147
154
  ...(confirmationData.metadata.originalExportCreatedAt
148
155
  ? { originalExportCreatedAt: confirmationData.metadata.originalExportCreatedAt }
156
+ : {}),
157
+ ...(confirmationData.metadata.originalCaseOwnerUid
158
+ ? { originalCaseOwnerUid: confirmationData.metadata.originalCaseOwnerUid }
149
159
  : {})
150
160
  },
151
161
  confirmations: normalizeConfirmations(confirmationData.confirmations)
@@ -23,7 +23,7 @@ interface BundledAuditExportFile {
23
23
  exportVersion?: string;
24
24
  totalEntries?: number;
25
25
  application?: string;
26
- exportType?: 'entries' | 'trail' | 'report';
26
+ exportType?: 'trail';
27
27
  scopeType?: 'case' | 'user';
28
28
  scopeIdentifier?: string;
29
29
  hash?: string;
@@ -164,7 +164,7 @@ async function verifyBundledAuditExport(
164
164
  const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
165
165
  signatureVersion: metadata.signatureVersion,
166
166
  exportFormat: 'json',
167
- exportType: metadata.exportType,
167
+ exportType: 'trail',
168
168
  scopeType: metadata.scopeType,
169
169
  scopeIdentifier: metadata.scopeIdentifier,
170
170
  generatedAt: metadata.exportTimestamp,
@@ -209,23 +209,15 @@ async function verifyBundledAuditExport(
209
209
 
210
210
  /**
211
211
  * Remove forensic warning from content for hash validation.
212
- * Supports the warning formats added to JSON and CSV case exports.
212
+ * Supports the warning format added to JSON case exports.
213
213
  */
214
214
  export function removeForensicWarning(content: string): string {
215
215
  const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
216
- const csvForensicWarningRegex = /^"CASE DATA WARNING: This file contains evidence data for forensic examination\. Any modification may compromise the integrity of the evidence\. Handle according to your organization's chain of custody procedures\."(?:\r?\n){2}/;
217
216
 
218
217
  let cleaned = content;
219
218
 
220
219
  if (jsonForensicWarningRegex.test(content)) {
221
220
  cleaned = content.replace(jsonForensicWarningRegex, '');
222
- } else if (csvForensicWarningRegex.test(content)) {
223
- cleaned = content.replace(csvForensicWarningRegex, '');
224
- } else if (content.startsWith('"CASE DATA WARNING:')) {
225
- const match = content.match(/^"[^"]*"(?:\r?\n)+/);
226
- if (match) {
227
- cleaned = content.substring(match[0].length);
228
- }
229
221
  }
230
222
 
231
223
  return cleaned.replace(/^\s+/, '');