@striae-org/striae 4.2.0 → 4.3.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.
- package/LICENSE +1 -1
- package/app/components/actions/case-manage.ts +50 -17
- package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
- package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +6 -2
- package/app/components/colors/colors.module.css +4 -3
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +737 -116
- package/app/components/sidebar/cases/cases.module.css +43 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +482 -177
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +262 -14
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +15 -1
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +7 -0
- package/app/services/audit/audit.service.ts +2 -2
- package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
- package/app/types/annotations.ts +48 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/file-filters.ts +201 -0
- 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/forensics/export-verification.ts +40 -111
- package/functions/api/_shared/firebase-auth.ts +2 -7
- package/functions/api/image/[[path]].ts +23 -22
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +7 -13
- package/scripts/deploy-primershear-emails.sh +1 -1
- package/worker-configuration.d.ts +2 -2
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- 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/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- 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 +84 -124
- package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +23 -3
- 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/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -52
- package/postcss.config.js +0 -6
- package/tailwind.config.ts +0 -22
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
|
@@ -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
|
+
};
|
|
@@ -674,6 +674,7 @@ export async function verifyCasePackageIntegrity(
|
|
|
674
674
|
input: CasePackageIntegrityInput
|
|
675
675
|
): Promise<CasePackageIntegrityResult> {
|
|
676
676
|
const manifestData = extractForensicManifestData(input.forensicManifest);
|
|
677
|
+
const verificationPublicKeyPem = input.verificationPublicKeyPem;
|
|
677
678
|
|
|
678
679
|
if (!manifestData) {
|
|
679
680
|
return {
|
|
@@ -694,9 +695,28 @@ export async function verifyCasePackageIntegrity(
|
|
|
694
695
|
};
|
|
695
696
|
}
|
|
696
697
|
|
|
698
|
+
if (!verificationPublicKeyPem) {
|
|
699
|
+
return {
|
|
700
|
+
isValid: false,
|
|
701
|
+
signatureResult: {
|
|
702
|
+
isValid: false,
|
|
703
|
+
error: 'Missing verification public key'
|
|
704
|
+
},
|
|
705
|
+
integrityResult: {
|
|
706
|
+
isValid: false,
|
|
707
|
+
dataValid: false,
|
|
708
|
+
imageValidation: {},
|
|
709
|
+
manifestValid: false,
|
|
710
|
+
errors: ['Missing verification public key'],
|
|
711
|
+
summary: 'Manifest validation failed'
|
|
712
|
+
},
|
|
713
|
+
bundledAuditVerification: null
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
697
717
|
const signatureResult = await verifyForensicManifestSignature(
|
|
698
718
|
input.forensicManifest,
|
|
699
|
-
|
|
719
|
+
verificationPublicKeyPem
|
|
700
720
|
);
|
|
701
721
|
|
|
702
722
|
const integrityResult = await validateCaseIntegritySecure(
|
|
@@ -706,117 +726,26 @@ export async function verifyCasePackageIntegrity(
|
|
|
706
726
|
);
|
|
707
727
|
|
|
708
728
|
const bundledAuditVerification = input.bundledAuditFiles
|
|
709
|
-
? await (
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const auditTrailExport = JSON.parse(auditTrailContent) as BundledAuditExportFile;
|
|
726
|
-
const auditSignatureExport = JSON.parse(auditSignatureContent) as {
|
|
727
|
-
signatureMetadata?: Partial<AuditExportSigningPayload>;
|
|
728
|
-
signature?: NonNullable<BundledAuditExportFile['metadata']>['signature'];
|
|
729
|
-
};
|
|
730
|
-
|
|
731
|
-
const metadata = auditTrailExport.metadata;
|
|
732
|
-
if (!metadata?.signature || typeof metadata.hash !== 'string') {
|
|
733
|
-
return createVerificationResult(
|
|
734
|
-
false,
|
|
735
|
-
'The bundled audit export is missing required hash or signature metadata.',
|
|
736
|
-
'case-zip'
|
|
737
|
-
);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const unsignedAuditExport = auditTrailExport.auditTrail !== undefined
|
|
741
|
-
? {
|
|
742
|
-
metadata: {
|
|
743
|
-
exportTimestamp: metadata.exportTimestamp,
|
|
744
|
-
exportVersion: metadata.exportVersion,
|
|
745
|
-
totalEntries: metadata.totalEntries,
|
|
746
|
-
application: metadata.application,
|
|
747
|
-
exportType: metadata.exportType,
|
|
748
|
-
scopeType: metadata.scopeType,
|
|
749
|
-
scopeIdentifier: metadata.scopeIdentifier,
|
|
750
|
-
},
|
|
751
|
-
auditTrail: auditTrailExport.auditTrail,
|
|
752
|
-
}
|
|
753
|
-
: {
|
|
754
|
-
metadata: {
|
|
755
|
-
exportTimestamp: metadata.exportTimestamp,
|
|
756
|
-
exportVersion: metadata.exportVersion,
|
|
757
|
-
totalEntries: metadata.totalEntries,
|
|
758
|
-
application: metadata.application,
|
|
759
|
-
exportType: metadata.exportType,
|
|
760
|
-
scopeType: metadata.scopeType,
|
|
761
|
-
scopeIdentifier: metadata.scopeIdentifier,
|
|
762
|
-
},
|
|
763
|
-
auditEntries: auditTrailExport.auditEntries,
|
|
764
|
-
};
|
|
765
|
-
|
|
766
|
-
const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
|
|
767
|
-
if (recalculatedHash.toUpperCase() !== metadata.hash.toUpperCase()) {
|
|
768
|
-
return createVerificationResult(
|
|
769
|
-
false,
|
|
770
|
-
'The bundled audit export failed integrity verification.',
|
|
771
|
-
'case-zip'
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
|
|
776
|
-
signatureVersion: metadata.signatureVersion,
|
|
777
|
-
exportFormat: 'json',
|
|
778
|
-
exportType: metadata.exportType,
|
|
779
|
-
scopeType: metadata.scopeType,
|
|
780
|
-
scopeIdentifier: metadata.scopeIdentifier,
|
|
781
|
-
generatedAt: metadata.exportTimestamp,
|
|
782
|
-
totalEntries: metadata.totalEntries,
|
|
783
|
-
hash: metadata.hash,
|
|
784
|
-
};
|
|
785
|
-
|
|
786
|
-
const signatureVerification = await verifyAuditExportSignature(
|
|
787
|
-
embeddedSignaturePayload,
|
|
788
|
-
metadata.signature,
|
|
789
|
-
input.verificationPublicKeyPem
|
|
790
|
-
);
|
|
791
|
-
|
|
792
|
-
if (!signatureVerification.isValid) {
|
|
793
|
-
return createVerificationResult(
|
|
794
|
-
false,
|
|
795
|
-
getSignatureFailureMessage(signatureVerification.error, 'export ZIP'),
|
|
796
|
-
'case-zip'
|
|
797
|
-
);
|
|
729
|
+
? await verifyBundledAuditExport(
|
|
730
|
+
{
|
|
731
|
+
file: (path: string) => {
|
|
732
|
+
const content = path === 'audit/case-audit-trail.json'
|
|
733
|
+
? input.bundledAuditFiles?.auditTrailContent
|
|
734
|
+
: path === 'audit/case-audit-signature.json'
|
|
735
|
+
? input.bundledAuditFiles?.auditSignatureContent
|
|
736
|
+
: undefined;
|
|
737
|
+
|
|
738
|
+
if (content === undefined) {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
async: async () => content,
|
|
744
|
+
};
|
|
798
745
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
JSON.stringify(auditSignatureExport.signature ?? null) !== JSON.stringify(metadata.signature ?? null)
|
|
803
|
-
) {
|
|
804
|
-
return createVerificationResult(
|
|
805
|
-
false,
|
|
806
|
-
'The bundled audit signature artifact does not match the signed audit export.',
|
|
807
|
-
'case-zip'
|
|
808
|
-
);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
return null;
|
|
812
|
-
} catch {
|
|
813
|
-
return createVerificationResult(
|
|
814
|
-
false,
|
|
815
|
-
'The bundled audit export could not be parsed for verification.',
|
|
816
|
-
'case-zip'
|
|
817
|
-
);
|
|
818
|
-
}
|
|
819
|
-
})()
|
|
746
|
+
},
|
|
747
|
+
verificationPublicKeyPem
|
|
748
|
+
)
|
|
820
749
|
: null;
|
|
821
750
|
|
|
822
751
|
return {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import firebaseConfig from '../../../app/config/firebase';
|
|
2
|
-
|
|
3
1
|
interface FirebaseJwtHeader {
|
|
4
2
|
alg?: string;
|
|
5
3
|
kid?: string;
|
|
@@ -31,8 +29,6 @@ const GOOGLE_SECURETOKEN_JWKS_URL =
|
|
|
31
29
|
'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
|
|
32
30
|
const DEFAULT_JWKS_CACHE_SECONDS = 300;
|
|
33
31
|
const CLOCK_SKEW_SECONDS = 300;
|
|
34
|
-
const FALLBACK_PROJECT_ID =
|
|
35
|
-
typeof firebaseConfig.projectId === 'string' ? firebaseConfig.projectId.trim() : '';
|
|
36
32
|
|
|
37
33
|
const textEncoder = new TextEncoder();
|
|
38
34
|
const textDecoder = new TextDecoder();
|
|
@@ -156,12 +152,11 @@ async function verifyTokenSignature(
|
|
|
156
152
|
|
|
157
153
|
function validateTokenClaims(payload: FirebaseJwtPayload, env: Env): boolean {
|
|
158
154
|
const configuredProjectId = typeof env.PROJECT_ID === 'string' ? env.PROJECT_ID.trim() : '';
|
|
159
|
-
|
|
160
|
-
if (allowedProjectIds.size === 0) {
|
|
155
|
+
if (configuredProjectId.length === 0) {
|
|
161
156
|
return false;
|
|
162
157
|
}
|
|
163
158
|
|
|
164
|
-
if (typeof payload.aud !== 'string' ||
|
|
159
|
+
if (typeof payload.aud !== 'string' || payload.aud !== configuredProjectId) {
|
|
165
160
|
return false;
|
|
166
161
|
}
|
|
167
162
|
|
|
@@ -30,44 +30,41 @@ function normalizeWorkerBaseUrl(workerDomain: string): string {
|
|
|
30
30
|
return `https://${trimmedDomain}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
type ProxyPathResult =
|
|
34
|
+
| { ok: true; path: string }
|
|
35
|
+
| { ok: false; reason: 'not-found' | 'bad-encoding' };
|
|
36
|
+
|
|
37
|
+
function extractProxyPath(url: URL): ProxyPathResult {
|
|
34
38
|
const routePrefix = '/api/image';
|
|
35
39
|
if (!url.pathname.startsWith(routePrefix)) {
|
|
36
|
-
return
|
|
40
|
+
return { ok: false, reason: 'not-found' };
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const remainder = url.pathname.slice(routePrefix.length);
|
|
40
44
|
if (remainder.length === 0) {
|
|
41
|
-
return '/';
|
|
45
|
+
return { ok: true, path: '/' };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const normalizedRemainder = remainder.startsWith('/') ? remainder : `/${remainder}`;
|
|
45
49
|
const encodedPath = normalizedRemainder.slice(1);
|
|
50
|
+
if (encodedPath.length === 0) {
|
|
51
|
+
return { ok: true, path: normalizedRemainder };
|
|
52
|
+
}
|
|
46
53
|
|
|
47
54
|
try {
|
|
48
55
|
const decodedPath = decodeURIComponent(encodedPath);
|
|
49
|
-
if (decodedPath.
|
|
50
|
-
return
|
|
56
|
+
if (decodedPath.includes('?') || decodedPath.includes('#')) {
|
|
57
|
+
return { ok: false, reason: 'bad-encoding' };
|
|
51
58
|
}
|
|
59
|
+
|
|
60
|
+
return { ok: true, path: decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}` };
|
|
52
61
|
} catch {
|
|
53
|
-
|
|
62
|
+
return { ok: false, reason: 'bad-encoding' };
|
|
54
63
|
}
|
|
55
|
-
|
|
56
|
-
return normalizedRemainder;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
function resolveImageWorkerToken(env: Env): string {
|
|
60
|
-
|
|
61
|
-
if (imageToken.length > 0) {
|
|
62
|
-
return imageToken;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const apiToken = typeof env.API_TOKEN === 'string' ? env.API_TOKEN.trim() : '';
|
|
66
|
-
if (apiToken.length > 0) {
|
|
67
|
-
return apiToken;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return '';
|
|
67
|
+
return typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
|
|
71
68
|
}
|
|
72
69
|
|
|
73
70
|
export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Response> => {
|
|
@@ -91,11 +88,15 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
|
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
const requestUrl = new URL(request.url);
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
return
|
|
91
|
+
const proxyPathResult = extractProxyPath(requestUrl);
|
|
92
|
+
if (!proxyPathResult.ok) {
|
|
93
|
+
return proxyPathResult.reason === 'bad-encoding'
|
|
94
|
+
? textResponse('Bad Request: malformed image path encoding', 400)
|
|
95
|
+
: textResponse('Not Found', 404);
|
|
97
96
|
}
|
|
98
97
|
|
|
98
|
+
const proxyPath = proxyPathResult.path;
|
|
99
|
+
|
|
99
100
|
const imageWorkerToken = resolveImageWorkerToken(env);
|
|
100
101
|
if (!env.IMAGES_WORKER_DOMAIN || !imageWorkerToken) {
|
|
101
102
|
return textResponse('Image service not configured', 502);
|
|
@@ -9,6 +9,10 @@ const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
|
|
|
9
9
|
const PRIMERSHEAR_FORMAT = 'primershear';
|
|
10
10
|
const DEFAULT_FORMAT = 'striae';
|
|
11
11
|
|
|
12
|
+
interface PdfProxyRequestBody {
|
|
13
|
+
data: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
function textResponse(message: string, status: number): Response {
|
|
13
17
|
return new Response(message, {
|
|
14
18
|
status,
|
|
@@ -48,6 +52,21 @@ function resolveReportFormat(email: string | null, primershearEmails: string): s
|
|
|
48
52
|
return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function parsePdfProxyRequestBody(payload: unknown): PdfProxyRequestBody | null {
|
|
56
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const record = payload as Record<string, unknown>;
|
|
61
|
+
if (!record.data || typeof record.data !== 'object' || Array.isArray(record.data)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
data: record.data as Record<string, unknown>
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
|
|
52
71
|
if (!SUPPORTED_METHODS.has(request.method)) {
|
|
53
72
|
return textResponse('Method not allowed', 405);
|
|
@@ -103,15 +122,15 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
|
|
|
103
122
|
|
|
104
123
|
let upstreamBody: BodyInit;
|
|
105
124
|
try {
|
|
106
|
-
const payload = await request.json()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
payload.reportFormat = reportFormat;
|
|
110
|
-
} else {
|
|
111
|
-
// Legacy flat payload shape
|
|
112
|
-
payload.reportFormat = reportFormat;
|
|
125
|
+
const payload = parsePdfProxyRequestBody(await request.json());
|
|
126
|
+
if (!payload) {
|
|
127
|
+
return textResponse('Invalid PDF request body', 400);
|
|
113
128
|
}
|
|
114
|
-
|
|
129
|
+
|
|
130
|
+
upstreamBody = JSON.stringify({
|
|
131
|
+
data: payload.data,
|
|
132
|
+
reportFormat
|
|
133
|
+
});
|
|
115
134
|
upstreamHeaders.set('Content-Type', 'application/json');
|
|
116
135
|
} catch {
|
|
117
136
|
return textResponse('Invalid request body', 400);
|