@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.
Files changed (90) hide show
  1. package/LICENSE +1 -1
  2. package/app/components/actions/case-manage.ts +50 -17
  3. package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
  4. package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
  5. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  6. package/app/components/canvas/confirmation/confirmation.tsx +6 -2
  7. package/app/components/colors/colors.module.css +4 -3
  8. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  9. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  10. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  11. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  13. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  14. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  15. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  16. package/app/components/navbar/navbar.tsx +34 -9
  17. package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
  18. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  19. package/app/components/sidebar/cases/cases-modal.tsx +737 -116
  20. package/app/components/sidebar/cases/cases.module.css +43 -0
  21. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  22. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  23. package/app/components/sidebar/files/files-modal.module.css +285 -44
  24. package/app/components/sidebar/files/files-modal.tsx +482 -177
  25. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  26. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  27. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  28. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  29. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  30. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
  31. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  32. package/app/components/sidebar/notes/notes.module.css +262 -14
  33. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  34. package/app/components/sidebar/sidebar-container.tsx +2 -0
  35. package/app/components/sidebar/sidebar.tsx +15 -1
  36. package/app/{tailwind.css → global.css} +1 -3
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/hooks/useOverlayDismiss.ts +6 -4
  40. package/app/root.tsx +1 -1
  41. package/app/routes/striae/striae.tsx +7 -0
  42. package/app/services/audit/audit.service.ts +2 -2
  43. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  44. package/app/types/annotations.ts +48 -1
  45. package/app/types/audit.ts +1 -0
  46. package/app/utils/data/case-filters.ts +127 -0
  47. package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
  48. package/app/utils/data/data-operations.ts +17 -861
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/data/index.ts +11 -1
  51. package/app/utils/data/operations/batch-operations.ts +113 -0
  52. package/app/utils/data/operations/case-operations.ts +168 -0
  53. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  54. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  55. package/app/utils/data/operations/index.ts +7 -0
  56. package/app/utils/data/operations/signing-operations.ts +225 -0
  57. package/app/utils/data/operations/types.ts +42 -0
  58. package/app/utils/data/operations/validation-operations.ts +48 -0
  59. package/app/utils/forensics/export-verification.ts +40 -111
  60. package/functions/api/_shared/firebase-auth.ts +2 -7
  61. package/functions/api/image/[[path]].ts +23 -22
  62. package/functions/api/pdf/[[path]].ts +27 -8
  63. package/package.json +7 -13
  64. package/scripts/deploy-primershear-emails.sh +1 -1
  65. package/worker-configuration.d.ts +2 -2
  66. package/workers/audit-worker/package.json +1 -1
  67. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  68. package/workers/data-worker/package.json +1 -1
  69. package/workers/data-worker/wrangler.jsonc.example +1 -1
  70. package/workers/image-worker/package.json +1 -1
  71. package/workers/image-worker/src/image-worker.example.ts +16 -5
  72. package/workers/image-worker/wrangler.jsonc.example +1 -1
  73. package/workers/keys-worker/package.json +1 -1
  74. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  75. package/workers/pdf-worker/package.json +1 -1
  76. package/workers/pdf-worker/src/formats/format-striae.ts +84 -124
  77. package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
  78. package/workers/pdf-worker/src/report-layout.ts +227 -0
  79. package/workers/pdf-worker/src/report-types.ts +23 -3
  80. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  81. package/workers/user-worker/package.json +1 -1
  82. package/workers/user-worker/src/user-worker.example.ts +17 -0
  83. package/workers/user-worker/wrangler.jsonc.example +1 -1
  84. package/wrangler.toml.example +1 -1
  85. package/NOTICE +0 -13
  86. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  87. package/postcss.config.js +0 -6
  88. package/tailwind.config.ts +0 -22
  89. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  90. /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
- input.verificationPublicKeyPem
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 (async () => {
710
- const { auditTrailContent, auditSignatureContent } = input.bundledAuditFiles ?? {};
711
-
712
- if (!auditTrailContent && !auditSignatureContent) {
713
- return null;
714
- }
715
-
716
- if (!auditTrailContent || !auditSignatureContent) {
717
- return createVerificationResult(
718
- false,
719
- 'The archive ZIP contains incomplete bundled audit verification files.',
720
- 'case-zip'
721
- );
722
- }
723
-
724
- try {
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
- if (
801
- JSON.stringify(auditSignatureExport.signatureMetadata ?? null) !== JSON.stringify(metadata.signatureMetadata ?? null) ||
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
- const allowedProjectIds = new Set([configuredProjectId, FALLBACK_PROJECT_ID].filter(Boolean));
160
- if (allowedProjectIds.size === 0) {
155
+ if (configuredProjectId.length === 0) {
161
156
  return false;
162
157
  }
163
158
 
164
- if (typeof payload.aud !== 'string' || !allowedProjectIds.has(payload.aud)) {
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
- function extractProxyPath(url: URL): string | null {
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 null;
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.length > 0) {
50
- return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`;
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
- // Keep legacy behavior for non-encoded paths.
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
- const imageToken = typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
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 proxyPath = extractProxyPath(requestUrl);
95
- if (!proxyPath) {
96
- return textResponse('Not Found', 404);
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() as Record<string, unknown>;
107
- // Inject the server-resolved format, overriding any client-supplied value.
108
- if (payload.data && typeof payload.data === 'object') {
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
- upstreamBody = JSON.stringify(payload);
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);