@striae-org/striae 4.1.0 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/LICENSE +1 -1
- package/app/components/actions/case-export/core-export.ts +14 -8
- package/app/components/actions/case-export/data-processing.ts +1 -0
- package/app/components/actions/case-export/download-handlers.ts +7 -0
- package/app/components/actions/case-export/metadata-helpers.ts +2 -1
- package/app/components/actions/case-import/confirmation-import.ts +12 -2
- package/app/components/actions/case-import/orchestrator.ts +78 -32
- package/app/components/actions/case-import/storage-operations.ts +97 -8
- package/app/components/actions/case-import/zip-processing.ts +159 -86
- package/app/components/actions/case-manage.ts +463 -8
- package/app/components/actions/confirm-export.ts +9 -2
- package/app/components/actions/image-manage.ts +77 -44
- package/app/components/audit/user-audit-viewer.tsx +19 -8
- package/app/components/audit/user-audit.module.css +21 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
- package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
- package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
- package/app/components/canvas/canvas.module.css +64 -54
- package/app/components/canvas/canvas.tsx +14 -16
- package/app/components/canvas/confirmation/confirmation.module.css +1 -0
- package/app/components/canvas/confirmation/confirmation.tsx +12 -14
- package/app/components/colors/colors.module.css +4 -3
- package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
- package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
- package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
- package/app/components/navbar/navbar.module.css +447 -0
- package/app/components/navbar/navbar.tsx +402 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
- package/app/components/sidebar/case-export/case-export.module.css +1 -0
- package/app/components/sidebar/case-export/case-export.tsx +8 -46
- package/app/components/sidebar/case-import/case-import.module.css +23 -0
- package/app/components/sidebar/case-import/case-import.tsx +64 -16
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +68 -588
- package/app/components/sidebar/cases/cases-modal.module.css +1 -0
- package/app/components/sidebar/cases/cases-modal.tsx +82 -43
- package/app/components/sidebar/cases/cases.module.css +82 -21
- package/app/components/sidebar/files/files-modal.module.css +1 -0
- package/app/components/sidebar/files/files-modal.tsx +49 -52
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
- package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
- package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
- package/app/components/sidebar/notes/notes.module.css +170 -1
- package/app/components/sidebar/sidebar-container.tsx +16 -28
- package/app/components/sidebar/sidebar.module.css +5 -69
- package/app/components/sidebar/sidebar.tsx +27 -125
- package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
- package/app/components/user/inactivity-warning.module.css +1 -0
- package/app/components/user/inactivity-warning.tsx +15 -2
- package/app/components/user/manage-profile.tsx +23 -10
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useOverlayDismiss.ts +54 -4
- package/app/root.tsx +1 -1
- package/app/routes/auth/login.tsx +785 -774
- package/app/routes/striae/striae.module.css +10 -3
- package/app/routes/striae/striae.tsx +475 -30
- package/app/services/audit/audit.service.ts +173 -27
- package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
- package/app/services/audit/builders/index.ts +1 -0
- package/app/types/audit.ts +4 -1
- package/app/types/case.ts +29 -0
- package/app/types/import.ts +3 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/index.ts +11 -1
- package/app/utils/data/operations/batch-operations.ts +113 -0
- package/app/utils/data/operations/case-operations.ts +168 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
- package/app/utils/data/operations/file-annotation-operations.ts +196 -0
- package/app/utils/data/operations/index.ts +7 -0
- package/app/utils/data/operations/signing-operations.ts +225 -0
- package/app/utils/data/operations/types.ts +42 -0
- package/app/utils/data/operations/validation-operations.ts +48 -0
- package/app/utils/data/permissions.ts +16 -1
- package/app/utils/forensics/audit-export-signature.ts +5 -1
- package/app/utils/forensics/confirmation-signature.ts +3 -0
- package/app/utils/forensics/export-verification.ts +426 -22
- package/functions/api/_shared/firebase-auth.ts +2 -7
- package/functions/api/image/[[path]].ts +20 -23
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +7 -12
- package/scripts/deploy-primershear-emails.sh +2 -1
- package/worker-configuration.d.ts +3 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/worker-configuration.d.ts +7448 -11323
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +16 -5
- package/workers/image-worker/worker-configuration.d.ts +7447 -11322
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
- package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
- package/workers/pdf-worker/src/report-types.ts +3 -3
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/src/user-worker.example.ts +17 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -11323
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -53
- package/postcss.config.js +0 -6
- package/public/.well-known/keybase.txt +0 -56
- package/tailwind.config.ts +0 -22
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { AnnotationData } from '~/types';
|
|
3
|
+
|
|
4
|
+
import { fetchDataApi } from '../../api';
|
|
5
|
+
import { canAccessCase, canModifyCase, validateUserSession } from '../permissions';
|
|
6
|
+
import { removeFileConfirmationSummary, upsertFileConfirmationSummary } from './confirmation-summary-operations';
|
|
7
|
+
import type { DataOperationOptions } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get file annotation data from R2 storage.
|
|
11
|
+
*/
|
|
12
|
+
export const getFileAnnotations = async (
|
|
13
|
+
user: User,
|
|
14
|
+
caseNumber: string,
|
|
15
|
+
fileId: string
|
|
16
|
+
): Promise<AnnotationData | null> => {
|
|
17
|
+
try {
|
|
18
|
+
const sessionValidation = await validateUserSession(user);
|
|
19
|
+
if (!sessionValidation.valid) {
|
|
20
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
24
|
+
if (!accessCheck.allowed) {
|
|
25
|
+
throw new Error(`Access denied: ${accessCheck.reason}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!fileId || typeof fileId !== 'string') {
|
|
29
|
+
throw new Error('Invalid file ID provided');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const response = await fetchDataApi(
|
|
33
|
+
user,
|
|
34
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
35
|
+
{
|
|
36
|
+
method: 'GET'
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (response.status === 404) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return await response.json() as AnnotationData;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(`Error fetching annotations for ${caseNumber}/${fileId}:`, error);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Save file annotation data to R2 storage.
|
|
57
|
+
*/
|
|
58
|
+
export const saveFileAnnotations = async (
|
|
59
|
+
user: User,
|
|
60
|
+
caseNumber: string,
|
|
61
|
+
fileId: string,
|
|
62
|
+
annotationData: AnnotationData,
|
|
63
|
+
options: DataOperationOptions = {}
|
|
64
|
+
): Promise<void> => {
|
|
65
|
+
try {
|
|
66
|
+
const sessionValidation = await validateUserSession(user);
|
|
67
|
+
if (!sessionValidation.valid) {
|
|
68
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.skipValidation !== true) {
|
|
72
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
73
|
+
if (!modifyCheck.allowed) {
|
|
74
|
+
throw new Error(`Modification denied: ${modifyCheck.reason}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fileId || typeof fileId !== 'string') {
|
|
79
|
+
throw new Error('Invalid file ID provided');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!annotationData || typeof annotationData !== 'object') {
|
|
83
|
+
throw new Error('Invalid annotation data provided');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Enforce immutability once confirmation data exists on an image.
|
|
87
|
+
const existingResponse = await fetchDataApi(
|
|
88
|
+
user,
|
|
89
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
90
|
+
{
|
|
91
|
+
method: 'GET'
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (existingResponse.ok) {
|
|
96
|
+
const existingAnnotations = await existingResponse.json() as AnnotationData;
|
|
97
|
+
if (existingAnnotations?.confirmationData) {
|
|
98
|
+
throw new Error('Cannot modify annotations for a confirmed image');
|
|
99
|
+
}
|
|
100
|
+
} else if (existingResponse.status !== 404) {
|
|
101
|
+
throw new Error(`Failed to verify existing annotations: ${existingResponse.status} ${existingResponse.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dataToSave = {
|
|
105
|
+
...annotationData,
|
|
106
|
+
updatedAt: new Date().toISOString()
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const response = await fetchDataApi(
|
|
110
|
+
user,
|
|
111
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
112
|
+
{
|
|
113
|
+
method: 'PUT',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json'
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify(dataToSave)
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await upsertFileConfirmationSummary(user, caseNumber, fileId, dataToSave);
|
|
127
|
+
} catch (summaryError) {
|
|
128
|
+
console.warn(`Failed to update confirmation summary for ${caseNumber}/${fileId}:`, summaryError);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`Error saving annotations for ${caseNumber}/${fileId}:`, error);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delete file annotation data from R2 storage.
|
|
138
|
+
*/
|
|
139
|
+
export const deleteFileAnnotations = async (
|
|
140
|
+
user: User,
|
|
141
|
+
caseNumber: string,
|
|
142
|
+
fileId: string,
|
|
143
|
+
options: { skipValidation?: boolean } = {}
|
|
144
|
+
): Promise<void> => {
|
|
145
|
+
try {
|
|
146
|
+
const sessionValidation = await validateUserSession(user);
|
|
147
|
+
if (!sessionValidation.valid) {
|
|
148
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (options.skipValidation !== true) {
|
|
152
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
153
|
+
if (!modifyCheck.allowed) {
|
|
154
|
+
throw new Error(`Delete denied: ${modifyCheck.reason}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const response = await fetchDataApi(
|
|
159
|
+
user,
|
|
160
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
161
|
+
{
|
|
162
|
+
method: 'DELETE'
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!response.ok && response.status !== 404) {
|
|
167
|
+
throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await removeFileConfirmationSummary(user, caseNumber, fileId);
|
|
172
|
+
} catch (summaryError) {
|
|
173
|
+
console.warn(`Failed to update confirmation summary after delete for ${caseNumber}/${fileId}:`, summaryError);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`Error deleting annotations for ${caseNumber}/${fileId}:`, error);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if a file has annotations.
|
|
183
|
+
*/
|
|
184
|
+
export const fileHasAnnotations = async (
|
|
185
|
+
user: User,
|
|
186
|
+
caseNumber: string,
|
|
187
|
+
fileId: string
|
|
188
|
+
): Promise<boolean> => {
|
|
189
|
+
try {
|
|
190
|
+
const annotations = await getFileAnnotations(user, caseNumber, fileId);
|
|
191
|
+
return annotations !== null;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error(`Error checking annotations for ${caseNumber}/${fileId}:`, error);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export * from './confirmation-summary-operations';
|
|
3
|
+
export * from './case-operations';
|
|
4
|
+
export * from './file-annotation-operations';
|
|
5
|
+
export * from './batch-operations';
|
|
6
|
+
export * from './validation-operations';
|
|
7
|
+
export * from './signing-operations';
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { ConfirmationImportData } from '~/types';
|
|
3
|
+
|
|
4
|
+
import { fetchDataApi } from '../../api';
|
|
5
|
+
import {
|
|
6
|
+
AUDIT_EXPORT_SIGNATURE_VERSION,
|
|
7
|
+
type AuditExportSigningPayload,
|
|
8
|
+
isValidAuditExportSigningPayload
|
|
9
|
+
} from '../../forensics/audit-export-signature';
|
|
10
|
+
import { CONFIRMATION_SIGNATURE_VERSION } from '../../forensics/confirmation-signature';
|
|
11
|
+
import {
|
|
12
|
+
type ForensicManifestData,
|
|
13
|
+
type ForensicManifestSignature,
|
|
14
|
+
FORENSIC_MANIFEST_VERSION
|
|
15
|
+
} from '../../forensics/SHA256';
|
|
16
|
+
import { canAccessCase, validateUserSession } from '../permissions';
|
|
17
|
+
import type {
|
|
18
|
+
AuditExportSigningResponse,
|
|
19
|
+
ConfirmationSigningResponse,
|
|
20
|
+
ManifestSigningResponse
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Request a server-side signature for a forensic manifest.
|
|
25
|
+
*/
|
|
26
|
+
export const signForensicManifest = async (
|
|
27
|
+
user: User,
|
|
28
|
+
caseNumber: string,
|
|
29
|
+
manifest: ForensicManifestData
|
|
30
|
+
): Promise<ManifestSigningResponse> => {
|
|
31
|
+
try {
|
|
32
|
+
const sessionValidation = await validateUserSession(user);
|
|
33
|
+
if (!sessionValidation.valid) {
|
|
34
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
38
|
+
if (!accessCheck.allowed) {
|
|
39
|
+
throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-manifest', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json'
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
userId: user.uid,
|
|
49
|
+
caseNumber,
|
|
50
|
+
manifest
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const responseData = await response.json().catch(() => null) as {
|
|
55
|
+
success?: boolean;
|
|
56
|
+
error?: string;
|
|
57
|
+
manifestVersion?: string;
|
|
58
|
+
signature?: ForensicManifestSignature;
|
|
59
|
+
} | null;
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
responseData?.error ||
|
|
64
|
+
`Failed to sign forensic manifest: ${response.status} ${response.statusText}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!responseData?.success || !responseData.signature || !responseData.manifestVersion) {
|
|
69
|
+
throw new Error('Invalid manifest signing response from data worker');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (responseData.manifestVersion !== FORENSIC_MANIFEST_VERSION) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Unexpected manifest version from signer: ${responseData.manifestVersion}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
manifestVersion: responseData.manifestVersion,
|
|
80
|
+
signature: responseData.signature
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`Error signing forensic manifest for ${caseNumber}:`, error);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Request a server-side signature for confirmation export data.
|
|
90
|
+
*/
|
|
91
|
+
export const signConfirmationData = async (
|
|
92
|
+
user: User,
|
|
93
|
+
caseNumber: string,
|
|
94
|
+
confirmationData: ConfirmationImportData
|
|
95
|
+
): Promise<ConfirmationSigningResponse> => {
|
|
96
|
+
try {
|
|
97
|
+
const sessionValidation = await validateUserSession(user);
|
|
98
|
+
if (!sessionValidation.valid) {
|
|
99
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
103
|
+
if (!accessCheck.allowed) {
|
|
104
|
+
throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-confirmation', {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/json'
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
userId: user.uid,
|
|
114
|
+
caseNumber,
|
|
115
|
+
confirmationData,
|
|
116
|
+
signatureVersion: CONFIRMATION_SIGNATURE_VERSION
|
|
117
|
+
})
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const responseData = await response.json().catch(() => null) as {
|
|
121
|
+
success?: boolean;
|
|
122
|
+
error?: string;
|
|
123
|
+
signatureVersion?: string;
|
|
124
|
+
signature?: ForensicManifestSignature;
|
|
125
|
+
} | null;
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
responseData?.error ||
|
|
130
|
+
`Failed to sign confirmation data: ${response.status} ${response.statusText}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!responseData?.success || !responseData.signature || !responseData.signatureVersion) {
|
|
135
|
+
throw new Error('Invalid confirmation signing response from data worker');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (responseData.signatureVersion !== CONFIRMATION_SIGNATURE_VERSION) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Unexpected confirmation signature version from signer: ${responseData.signatureVersion}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
signatureVersion: responseData.signatureVersion,
|
|
146
|
+
signature: responseData.signature
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(`Error signing confirmation data for ${caseNumber}:`, error);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Request a server-side signature for audit export metadata.
|
|
156
|
+
*/
|
|
157
|
+
export const signAuditExportData = async (
|
|
158
|
+
user: User,
|
|
159
|
+
auditExport: AuditExportSigningPayload,
|
|
160
|
+
options: { caseNumber?: string } = {}
|
|
161
|
+
): Promise<AuditExportSigningResponse> => {
|
|
162
|
+
try {
|
|
163
|
+
const sessionValidation = await validateUserSession(user);
|
|
164
|
+
if (!sessionValidation.valid) {
|
|
165
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!isValidAuditExportSigningPayload(auditExport)) {
|
|
169
|
+
throw new Error('Invalid audit export payload for signing');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const caseNumber = options.caseNumber;
|
|
173
|
+
if (caseNumber) {
|
|
174
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
175
|
+
if (!accessCheck.allowed) {
|
|
176
|
+
throw new Error(`Audit export signing denied: ${accessCheck.reason}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-audit-export', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json'
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
userId: user.uid,
|
|
187
|
+
caseNumber,
|
|
188
|
+
auditExport,
|
|
189
|
+
signatureVersion: AUDIT_EXPORT_SIGNATURE_VERSION
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const responseData = await response.json().catch(() => null) as {
|
|
194
|
+
success?: boolean;
|
|
195
|
+
error?: string;
|
|
196
|
+
signatureVersion?: string;
|
|
197
|
+
signature?: ForensicManifestSignature;
|
|
198
|
+
} | null;
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
responseData?.error ||
|
|
203
|
+
`Failed to sign audit export data: ${response.status} ${response.statusText}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!responseData?.success || !responseData.signature || !responseData.signatureVersion) {
|
|
208
|
+
throw new Error('Invalid audit export signing response from data worker');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (responseData.signatureVersion !== AUDIT_EXPORT_SIGNATURE_VERSION) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Unexpected audit export signature version from signer: ${responseData.signatureVersion}`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
signatureVersion: responseData.signatureVersion,
|
|
219
|
+
signature: responseData.signature
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('Error signing audit export data:', error);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { ForensicManifestSignature } from '~/utils/forensics/SHA256';
|
|
3
|
+
|
|
4
|
+
import type { AnnotationData } from '~/types';
|
|
5
|
+
|
|
6
|
+
export interface DataAccessResult {
|
|
7
|
+
allowed: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FileUpdate {
|
|
12
|
+
fileId: string;
|
|
13
|
+
annotations: AnnotationData;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BatchUpdateResult {
|
|
17
|
+
successful: string[];
|
|
18
|
+
failed: { fileId: string; error: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DataOperationOptions {
|
|
22
|
+
includeTimestamp?: boolean;
|
|
23
|
+
retryCount?: number;
|
|
24
|
+
skipValidation?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ManifestSigningResponse {
|
|
28
|
+
manifestVersion: string;
|
|
29
|
+
signature: ForensicManifestSignature;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConfirmationSigningResponse {
|
|
33
|
+
signatureVersion: string;
|
|
34
|
+
signature: ForensicManifestSignature;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuditExportSigningResponse {
|
|
38
|
+
signatureVersion: string;
|
|
39
|
+
signature: ForensicManifestSignature;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type DataOperation<T> = (user: User, ...args: unknown[]) => Promise<T>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
import { canAccessCase, validateUserSession } from '../permissions';
|
|
4
|
+
import type { DataAccessResult, DataOperation } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate data access permissions for a user and case.
|
|
8
|
+
*/
|
|
9
|
+
export const validateDataAccess = async (
|
|
10
|
+
user: User,
|
|
11
|
+
caseNumber: string
|
|
12
|
+
): Promise<DataAccessResult> => {
|
|
13
|
+
try {
|
|
14
|
+
const sessionValidation = await validateUserSession(user);
|
|
15
|
+
if (!sessionValidation.valid) {
|
|
16
|
+
return { allowed: false, reason: sessionValidation.reason };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
20
|
+
if (!accessCheck.allowed) {
|
|
21
|
+
return { allowed: false, reason: accessCheck.reason };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { allowed: true };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('Error validating data access:', error);
|
|
27
|
+
return { allowed: false, reason: 'Access validation failed' };
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Higher-order function for consistent data operation patterns.
|
|
33
|
+
*/
|
|
34
|
+
export const withDataOperation = <T>(
|
|
35
|
+
operation: DataOperation<T>
|
|
36
|
+
) => async (user: User, ...args: unknown[]): Promise<T> => {
|
|
37
|
+
try {
|
|
38
|
+
const sessionValidation = await validateUserSession(user);
|
|
39
|
+
if (!sessionValidation.valid) {
|
|
40
|
+
throw new Error(`Operation failed: ${sessionValidation.reason}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return await operation(user, ...args);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Data operation failed:', error);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import type { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } from '~/types';
|
|
3
3
|
import paths from '~/config/config.json';
|
|
4
|
-
import { fetchUserApi } from '../api';
|
|
4
|
+
import { fetchDataApi, fetchUserApi } from '../api';
|
|
5
5
|
|
|
6
6
|
const MAX_CASES_REVIEW = paths.max_cases_review;
|
|
7
7
|
const MAX_FILES_PER_CASE_REVIEW = paths.max_files_per_case_review;
|
|
@@ -403,6 +403,21 @@ export const canModifyCase = async (user: User, caseNumber: string): Promise<Per
|
|
|
403
403
|
return { allowed: false, reason: 'User data not found' };
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
const archiveCheckResponse = await fetchDataApi(
|
|
407
|
+
user,
|
|
408
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
409
|
+
{
|
|
410
|
+
method: 'GET'
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
if (archiveCheckResponse.ok) {
|
|
415
|
+
const caseData = await archiveCheckResponse.json() as { archived?: boolean };
|
|
416
|
+
if (caseData.archived) {
|
|
417
|
+
return { allowed: false, reason: 'Archived cases are immutable and read-only' };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
406
421
|
// Check if user owns the case (regular cases)
|
|
407
422
|
if (userData.cases && userData.cases.some(c => c.caseNumber === caseNumber)) {
|
|
408
423
|
// For owned cases, user must be permitted
|
|
@@ -83,7 +83,8 @@ export function createAuditExportSigningPayload(payload: AuditExportSigningPaylo
|
|
|
83
83
|
|
|
84
84
|
export async function verifyAuditExportSignature(
|
|
85
85
|
payload: Partial<AuditExportSigningPayload>,
|
|
86
|
-
signature?: ForensicManifestSignature
|
|
86
|
+
signature?: ForensicManifestSignature,
|
|
87
|
+
verificationPublicKeyPem?: string
|
|
87
88
|
): Promise<ManifestSignatureVerificationResult> {
|
|
88
89
|
if (!signature) {
|
|
89
90
|
return {
|
|
@@ -112,6 +113,9 @@ export async function verifyAuditExportSignature(
|
|
|
112
113
|
noVerificationKeyPrefix: 'No verification key configured for key ID',
|
|
113
114
|
invalidPublicKeyError: 'Audit export signature verification failed: invalid public key',
|
|
114
115
|
verificationFailedError: 'Audit export signature verification failed'
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
verificationPublicKeyPem
|
|
115
119
|
}
|
|
116
120
|
);
|
|
117
121
|
}
|
|
@@ -134,6 +134,9 @@ export function createConfirmationSigningPayload(
|
|
|
134
134
|
exportedByUid: confirmationData.metadata.exportedByUid,
|
|
135
135
|
exportedByName: confirmationData.metadata.exportedByName,
|
|
136
136
|
exportedByCompany: confirmationData.metadata.exportedByCompany,
|
|
137
|
+
...(confirmationData.metadata.exportedByBadgeId
|
|
138
|
+
? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
|
|
139
|
+
: {}),
|
|
137
140
|
totalConfirmations: confirmationData.metadata.totalConfirmations,
|
|
138
141
|
version: confirmationData.metadata.version,
|
|
139
142
|
hash: confirmationData.metadata.hash.toUpperCase(),
|