@striae-org/striae 5.0.0 → 5.1.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.
Files changed (33) hide show
  1. package/.env.example +7 -3
  2. package/app/components/actions/case-export/download-handlers.ts +23 -7
  3. package/app/components/actions/case-manage.ts +24 -9
  4. package/app/components/actions/generate-pdf.ts +52 -4
  5. package/app/components/actions/image-manage.ts +48 -48
  6. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  7. package/app/routes/striae/striae.tsx +16 -4
  8. package/app/types/file.ts +18 -2
  9. package/app/utils/api/image-api-client.ts +49 -1
  10. package/app/utils/data/operations/case-operations.ts +13 -1
  11. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  12. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  13. package/functions/api/image/[[path]].ts +2 -1
  14. package/package.json +2 -2
  15. package/scripts/deploy-config.sh +191 -20
  16. package/scripts/deploy-pages-secrets.sh +0 -6
  17. package/scripts/deploy-worker-secrets.sh +67 -6
  18. package/worker-configuration.d.ts +15 -7
  19. package/workers/audit-worker/package.json +1 -4
  20. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  21. package/workers/audit-worker/wrangler.jsonc.example +5 -0
  22. package/workers/data-worker/package.json +1 -4
  23. package/workers/data-worker/src/data-worker.example.ts +280 -2
  24. package/workers/data-worker/src/encryption-utils.ts +145 -1
  25. package/workers/data-worker/wrangler.jsonc.example +3 -1
  26. package/workers/image-worker/package.json +1 -4
  27. package/workers/image-worker/src/encryption-utils.ts +217 -0
  28. package/workers/image-worker/src/image-worker.example.ts +449 -129
  29. package/workers/image-worker/worker-configuration.d.ts +3 -2
  30. package/workers/image-worker/wrangler.jsonc.example +7 -0
  31. package/workers/keys-worker/package.json +1 -4
  32. package/workers/pdf-worker/package.json +1 -4
  33. package/workers/user-worker/package.json +1 -4
package/.env.example CHANGED
@@ -47,7 +47,6 @@ PAGES_CUSTOM_DOMAIN=your_custom_domain_here
47
47
  KEYS_WORKER_NAME=your_keys_worker_name_here
48
48
  KEYS_WORKER_DOMAIN=your_keys_worker_domain_here
49
49
  KEYS_AUTH=your_custom_keys_auth_token_here
50
- ACCOUNT_HASH=your_cloudflare_images_account_hash_here
51
50
 
52
51
  # ================================
53
52
  # USER WORKER ENVIRONMENT VARIABLES
@@ -61,6 +60,7 @@ KV_STORE_ID=your_kv_store_id_here
61
60
  # ================================
62
61
  DATA_WORKER_NAME=your_data_worker_name_here
63
62
  DATA_BUCKET_NAME=your_data_bucket_name_here
63
+ FILES_BUCKET_NAME=your_files_bucket_name_here
64
64
  DATA_WORKER_DOMAIN=your_data_worker_domain_here
65
65
  # Auto-generated by scripts/deploy-config.sh when placeholders are detected.
66
66
  MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
@@ -69,6 +69,9 @@ MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
69
69
  EXPORT_ENCRYPTION_PRIVATE_KEY=your_export_encryption_private_key_here
70
70
  EXPORT_ENCRYPTION_KEY_ID=your_export_encryption_key_id_here
71
71
  EXPORT_ENCRYPTION_PUBLIC_KEY=your_export_encryption_public_key_here
72
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY=your_data_at_rest_encryption_private_key_here
73
+ DATA_AT_REST_ENCRYPTION_KEY_ID=your_data_at_rest_encryption_key_id_here
74
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY=your_data_at_rest_encryption_public_key_here
72
75
 
73
76
 
74
77
  # ================================
@@ -83,8 +86,9 @@ AUDIT_WORKER_DOMAIN=your_audit_worker_domain_here
83
86
  # ================================
84
87
  IMAGES_WORKER_NAME=your_images_worker_name_here
85
88
  IMAGES_WORKER_DOMAIN=your_images_worker_domain_here
86
- API_TOKEN=your_cloudflare_images_api_token_here
87
- HMAC_KEY=your_cloudflare_images_hmac_key_here
89
+ IMAGE_SIGNED_URL_SECRET=your_image_signed_url_secret_here
90
+ # Optional: defaults to 3600 and max is 86400.
91
+ IMAGE_SIGNED_URL_TTL_SECONDS=3600
88
92
 
89
93
  # ================================
90
94
  # PDF WORKER ENVIRONMENT VARIABLES
@@ -965,13 +965,29 @@ For questions about this export, contact your Striae system administrator.
965
965
  */
966
966
  async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> {
967
967
  try {
968
- const imageUrl = await getImageUrl(user, fileData, caseNumber, 'Export Package');
969
- if (!imageUrl) return null;
970
-
971
- const response = await fetch(imageUrl);
972
- if (!response.ok) return null;
973
-
974
- return await response.blob();
968
+ const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Export Package');
969
+ const { blob, revoke, url } = imageAccess;
970
+
971
+ if (!blob) {
972
+ const signedResponse = await fetch(url, {
973
+ method: 'GET',
974
+ headers: {
975
+ 'Accept': 'application/octet-stream,image/*'
976
+ }
977
+ });
978
+
979
+ if (!signedResponse.ok) {
980
+ throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
981
+ }
982
+
983
+ return await signedResponse.blob();
984
+ }
985
+
986
+ try {
987
+ return blob;
988
+ } finally {
989
+ revoke();
990
+ }
975
991
  } catch (error) {
976
992
  console.error('Failed to fetch image blob:', error);
977
993
  return null;
@@ -12,6 +12,7 @@ import {
12
12
  duplicateCaseData,
13
13
  deleteFileAnnotations,
14
14
  signForensicManifest,
15
+ moveCaseConfirmationSummary,
15
16
  removeCaseConfirmationSummary
16
17
  } from '~/utils/data';
17
18
  import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail, type CaseExportData, type ValidationAuditEntry } from '~/types';
@@ -392,7 +393,10 @@ export const renameCase = async (
392
393
  // 4) Delete R2 case data with old case number
393
394
  await deleteCaseData(user, oldCaseNumber);
394
395
 
395
- // 5) Delete old case number in user's KV entry
396
+ // 5) Move confirmation summary metadata to the new case number
397
+ await moveCaseConfirmationSummary(user, oldCaseNumber, newCaseNumber);
398
+
399
+ // 6) Delete old case number in user's KV entry
396
400
  await removeUserCase(user, oldCaseNumber);
397
401
 
398
402
  // Log successful case rename under the original case number context
@@ -681,18 +685,29 @@ const getVerificationPublicSigningKey = (preferredKeyId?: string): { keyId: stri
681
685
 
682
686
  const fetchImageAsBlob = async (user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> => {
683
687
  try {
684
- const imageUrl = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
688
+ const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
689
+ const { blob, revoke, url } = imageAccess;
690
+
691
+ if (!blob) {
692
+ const signedResponse = await fetch(url, {
693
+ method: 'GET',
694
+ headers: {
695
+ 'Accept': 'application/octet-stream,image/*'
696
+ }
697
+ });
685
698
 
686
- if (!imageUrl) {
687
- return null;
688
- }
699
+ if (!signedResponse.ok) {
700
+ throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
701
+ }
689
702
 
690
- const response = await fetch(imageUrl);
691
- if (!response.ok) {
692
- return null;
703
+ return await signedResponse.blob();
693
704
  }
694
705
 
695
- return await response.blob();
706
+ try {
707
+ return blob;
708
+ } finally {
709
+ revoke();
710
+ }
696
711
  } catch (error) {
697
712
  console.error('Failed to fetch image for archive package:', error);
698
713
  return null;
@@ -6,6 +6,7 @@ import { fetchPdfApi } from '~/utils/api';
6
6
  interface GeneratePDFParams {
7
7
  user: User;
8
8
  selectedImage: string | undefined;
9
+ sourceImageId?: string;
9
10
  selectedFilename: string | undefined;
10
11
  userCompany: string;
11
12
  userFirstName: string;
@@ -21,9 +22,54 @@ interface GeneratePDFParams {
21
22
  setToastDuration?: (duration: number) => void;
22
23
  }
23
24
 
25
+ const CLEAR_IMAGE_SENTINEL = '/clear.jpg';
26
+
27
+ const blobToDataUrl = async (blob: Blob): Promise<string> => {
28
+ return await new Promise<string>((resolve, reject) => {
29
+ const reader = new FileReader();
30
+ reader.onloadend = () => {
31
+ if (typeof reader.result === 'string') {
32
+ resolve(reader.result);
33
+ return;
34
+ }
35
+
36
+ reject(new Error('Failed to read image blob as data URL'));
37
+ };
38
+ reader.onerror = () => reject(new Error('Failed to convert image for PDF rendering'));
39
+ reader.readAsDataURL(blob);
40
+ });
41
+ };
42
+
43
+ const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<string | undefined> => {
44
+ if (!selectedImage || selectedImage === CLEAR_IMAGE_SENTINEL) {
45
+ return selectedImage;
46
+ }
47
+
48
+ if (selectedImage.startsWith('/')) {
49
+ return new URL(selectedImage, window.location.origin).toString();
50
+ }
51
+
52
+ if (selectedImage.startsWith('data:')) {
53
+ return selectedImage;
54
+ }
55
+
56
+ if (selectedImage.startsWith('blob:')) {
57
+ const imageResponse = await fetch(selectedImage);
58
+ if (!imageResponse.ok) {
59
+ throw new Error('Failed to load selected image for PDF generation');
60
+ }
61
+
62
+ const imageBlob = await imageResponse.blob();
63
+ return await blobToDataUrl(imageBlob);
64
+ }
65
+
66
+ return selectedImage;
67
+ };
68
+
24
69
  export const generatePDF = async ({
25
70
  user,
26
71
  selectedImage,
72
+ sourceImageId,
27
73
  selectedFilename,
28
74
  userCompany,
29
75
  userFirstName,
@@ -61,8 +107,10 @@ export const generatePDF = async ({
61
107
  notesUpdatedFormatted = `${(updatedDate.getMonth() + 1).toString().padStart(2, '0')}/${updatedDate.getDate().toString().padStart(2, '0')}/${updatedDate.getFullYear()}`;
62
108
  }
63
109
 
110
+ const resolvedImageUrl = await resolvePdfImageUrl(selectedImage);
111
+
64
112
  const pdfData = {
65
- imageUrl: selectedImage,
113
+ imageUrl: resolvedImageUrl,
66
114
  filename: selectedFilename,
67
115
  userCompany: userCompany,
68
116
  firstName: userFirstName,
@@ -145,7 +193,7 @@ export const generatePDF = async ({
145
193
  processingTime,
146
194
  blob.size,
147
195
  [],
148
- selectedImage, // Source file ID
196
+ sourceImageId, // Source file ID
149
197
  selectedFilename // Source original filename
150
198
  );
151
199
  } catch (auditError) {
@@ -173,7 +221,7 @@ export const generatePDF = async ({
173
221
  processingTime,
174
222
  0, // No file size for failed generation
175
223
  [errorText || 'PDF generation failed'],
176
- selectedImage, // Source file ID
224
+ sourceImageId, // Source file ID
177
225
  selectedFilename // Source original filename
178
226
  );
179
227
  } catch (auditError) {
@@ -200,7 +248,7 @@ export const generatePDF = async ({
200
248
  processingTime,
201
249
  0, // No file size for failed generation
202
250
  [error instanceof Error ? error.message : 'Unknown error generating PDF'],
203
- selectedImage, // Source file ID
251
+ sourceImageId, // Source file ID
204
252
  selectedFilename // Source original filename
205
253
  );
206
254
  } catch (auditError) {
@@ -1,10 +1,7 @@
1
1
  import type { User } from 'firebase/auth';
2
- import {
3
- getAccountHash
4
- } from '~/utils/auth';
5
- import { fetchImageApi, uploadImageApi } from '~/utils/api';
2
+ import { createSignedImageUrlApi, fetchImageApi, uploadImageApi } from '~/utils/api';
6
3
  import { canUploadFile, getCaseData, updateCaseData, deleteFileAnnotations } from '~/utils/data';
7
- import type { CaseData, FileData, ImageUploadResponse } from '~/types';
4
+ import type { CaseData, FileData, ImageAccessResult, ImageUploadResponse } from '~/types';
8
5
  import { auditService } from '~/services/audit';
9
6
 
10
7
  export interface DeleteFileResult {
@@ -258,88 +255,91 @@ export const deleteFile = async (
258
255
  }
259
256
  };
260
257
 
261
- const DEFAULT_VARIANT = 'striae';
262
- interface ImageDeliveryConfig {
263
- accountHash: string;
264
- }
265
-
266
- const getImageConfig = async (): Promise<ImageDeliveryConfig> => {
267
- const accountHash = await getAccountHash();
268
- return { accountHash };
269
- };
270
-
271
-
272
- export const getImageUrl = async (user: User, fileData: FileData, caseNumber: string, accessReason?: string): Promise<string> => {
258
+ export const getImageUrl = async (
259
+ user: User,
260
+ fileData: FileData,
261
+ caseNumber: string,
262
+ accessReason?: string
263
+ ): Promise<ImageAccessResult> => {
273
264
  const startTime = Date.now();
274
265
  const defaultAccessReason = accessReason || 'Image viewer access';
275
-
266
+
276
267
  try {
277
- const { accountHash } = await getImageConfig();
278
- const imageDeliveryUrl = `https://imagedelivery.net/${accountHash}/${fileData.id}/${DEFAULT_VARIANT}`;
279
- const encodedImageDeliveryUrl = encodeURIComponent(imageDeliveryUrl);
268
+ try {
269
+ const signedUrlResponse = await createSignedImageUrlApi(user, fileData.id);
280
270
 
281
- const workerResponse = await fetchImageApi(user, `/${encodedImageDeliveryUrl}`, {
282
- method: 'GET',
283
- headers: {
284
- 'Accept': 'text/plain'
285
- }
286
- });
287
-
288
- if (!workerResponse.ok) {
289
- // Log failed image access
290
271
  await auditService.logFileAccess(
291
272
  user,
292
273
  fileData.originalFilename || fileData.id,
293
274
  fileData.id,
294
275
  'signed-url',
295
276
  caseNumber,
296
- 'failure',
277
+ 'success',
297
278
  Date.now() - startTime,
298
- 'Image URL generation failed',
279
+ defaultAccessReason,
299
280
  fileData.originalFilename
300
281
  );
301
- throw new Error('Failed to get signed image URL');
282
+
283
+ return {
284
+ url: signedUrlResponse.result.url,
285
+ revoke: () => {},
286
+ urlType: 'signed',
287
+ expiresAt: signedUrlResponse.result.expiresAt
288
+ };
289
+ } catch {
290
+ // Fallback to direct blob retrieval during migration.
302
291
  }
303
-
304
- const signedUrl = await workerResponse.text();
305
- if (!signedUrl.includes('sig=') || !signedUrl.includes('exp=')) {
306
- // Log invalid URL response
292
+
293
+ const workerResponse = await fetchImageApi(user, `/${encodeURIComponent(fileData.id)}`, {
294
+ method: 'GET',
295
+ headers: {
296
+ 'Accept': 'application/octet-stream,image/*'
297
+ }
298
+ });
299
+
300
+ if (!workerResponse.ok) {
307
301
  await auditService.logFileAccess(
308
302
  user,
309
303
  fileData.originalFilename || fileData.id,
310
304
  fileData.id,
311
- 'signed-url',
305
+ 'direct-url',
312
306
  caseNumber,
313
307
  'failure',
314
308
  Date.now() - startTime,
315
- 'Invalid signed URL returned',
309
+ 'Image retrieval failed',
316
310
  fileData.originalFilename
317
311
  );
318
- throw new Error('Invalid signed URL returned');
312
+ throw new Error('Failed to retrieve image');
319
313
  }
320
-
321
- // Log successful image access
314
+
315
+ const blob = await workerResponse.blob();
316
+ const objectUrl = URL.createObjectURL(blob);
317
+
322
318
  await auditService.logFileAccess(
323
319
  user,
324
320
  fileData.originalFilename || fileData.id,
325
321
  fileData.id,
326
- 'signed-url',
322
+ 'direct-url',
327
323
  caseNumber,
328
324
  'success',
329
325
  Date.now() - startTime,
330
326
  defaultAccessReason,
331
327
  fileData.originalFilename
332
328
  );
333
-
334
- return signedUrl;
329
+
330
+ return {
331
+ blob,
332
+ url: objectUrl,
333
+ revoke: () => URL.revokeObjectURL(objectUrl),
334
+ urlType: 'blob'
335
+ };
335
336
  } catch (error) {
336
- // Log any unexpected errors if not already logged
337
- if (!(error instanceof Error && error.message.includes('Failed to get signed image URL'))) {
337
+ if (!(error instanceof Error && error.message.includes('Failed to retrieve image'))) {
338
338
  await auditService.logFileAccess(
339
339
  user,
340
340
  fileData.originalFilename || fileData.id,
341
341
  fileData.id,
342
- 'signed-url',
342
+ 'direct-url',
343
343
  caseNumber,
344
344
  'failure',
345
345
  Date.now() - startTime,
@@ -26,6 +26,7 @@ interface UseStriaeResetHelpersProps {
26
26
  setShowNotes: Dispatch<SetStateAction<boolean>>;
27
27
  setIsAuditTrailOpen: Dispatch<SetStateAction<boolean>>;
28
28
  setIsRenameCaseModalOpen: Dispatch<SetStateAction<boolean>>;
29
+ onRevokeImage?: () => void;
29
30
  }
30
31
 
31
32
  export const useStriaeResetHelpers = ({
@@ -45,8 +46,10 @@ export const useStriaeResetHelpers = ({
45
46
  setShowNotes,
46
47
  setIsAuditTrailOpen,
47
48
  setIsRenameCaseModalOpen,
49
+ onRevokeImage,
48
50
  }: UseStriaeResetHelpersProps) => {
49
51
  const clearSelectedImageState = useCallback(() => {
52
+ onRevokeImage?.();
50
53
  setSelectedImage('/clear.jpg');
51
54
  setSelectedFilename(undefined);
52
55
  setImageId(undefined);
@@ -54,6 +57,7 @@ export const useStriaeResetHelpers = ({
54
57
  setError(undefined);
55
58
  setImageLoaded(false);
56
59
  }, [
60
+ onRevokeImage,
57
61
  setSelectedImage,
58
62
  setSelectedFilename,
59
63
  setImageId,
@@ -1,5 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef, useCallback } from 'react';
3
3
  import { SidebarContainer } from '~/components/sidebar/sidebar-container';
4
4
  import { Navbar } from '~/components/navbar/navbar';
5
5
  import { RenameCaseModal } from '~/components/navbar/case-modals/rename-case-modal';
@@ -47,6 +47,7 @@ export const Striae = ({ user }: StriaePage) => {
47
47
  const [imageId, setImageId] = useState<string>();
48
48
  const [error, setError] = useState<string>();
49
49
  const [imageLoaded, setImageLoaded] = useState(false);
50
+ const currentRevokeRef = useRef<(() => void) | null>(null);
50
51
 
51
52
  // User states
52
53
  const [userCompany, setUserCompany] = useState<string>('');
@@ -98,6 +99,11 @@ export const Striae = ({ user }: StriaePage) => {
98
99
  archiveReason?: string;
99
100
  }>({ archived: false });
100
101
 
102
+ const handleRevokeImage = useCallback(() => {
103
+ currentRevokeRef.current?.();
104
+ currentRevokeRef.current = null;
105
+ }, []);
106
+
101
107
  const {
102
108
  clearSelectedImageState,
103
109
  clearCaseContextState,
@@ -119,6 +125,7 @@ export const Striae = ({ user }: StriaePage) => {
119
125
  setShowNotes,
120
126
  setIsAuditTrailOpen,
121
127
  setIsRenameCaseModalOpen,
128
+ onRevokeImage: handleRevokeImage,
122
129
  });
123
130
 
124
131
 
@@ -234,6 +241,7 @@ export const Striae = ({ user }: StriaePage) => {
234
241
  await generatePDF({
235
242
  user,
236
243
  selectedImage,
244
+ sourceImageId: imageId,
237
245
  selectedFilename,
238
246
  userCompany,
239
247
  userFirstName,
@@ -574,6 +582,8 @@ export const Striae = ({ user }: StriaePage) => {
574
582
  useEffect(() => {
575
583
  // Cleanup function to clear image when component unmounts
576
584
  return () => {
585
+ currentRevokeRef.current?.();
586
+ currentRevokeRef.current = null;
577
587
  setSelectedImage(undefined);
578
588
  setSelectedFilename(undefined);
579
589
  setError(undefined);
@@ -645,14 +655,16 @@ export const Striae = ({ user }: StriaePage) => {
645
655
 
646
656
  try {
647
657
  setError(undefined);
658
+ currentRevokeRef.current?.();
659
+ currentRevokeRef.current = null;
648
660
  setSelectedImage(undefined);
649
661
  setSelectedFilename(undefined);
650
662
  setImageLoaded(false);
651
663
 
652
- const signedUrl = await getImageUrl(user, file, currentCase);
653
- if (!signedUrl) throw new Error('No URL returned');
664
+ const { url, revoke } = await getImageUrl(user, file, currentCase);
665
+ currentRevokeRef.current = revoke;
654
666
 
655
- setSelectedImage(signedUrl);
667
+ setSelectedImage(url);
656
668
  setSelectedFilename(file.originalFilename);
657
669
  setImageId(file.id);
658
670
  setImageLoaded(true);
package/app/types/file.ts CHANGED
@@ -12,8 +12,6 @@ export interface FileUploadResponse {
12
12
  id: string;
13
13
  filename: string;
14
14
  uploaded: string;
15
- requireSignedURLs: boolean;
16
- variants: string[];
17
15
  };
18
16
  errors: Array<{
19
17
  code: number;
@@ -27,4 +25,22 @@ export interface ImageUploadResponse {
27
25
  result: FileUploadResponse['result'];
28
26
  errors: FileUploadResponse['errors'];
29
27
  messages: FileUploadResponse['messages'];
28
+ }
29
+
30
+ export interface SignedImageUrlResponse {
31
+ success: boolean;
32
+ result: {
33
+ fileId: string;
34
+ url: string;
35
+ expiresAt: string;
36
+ expiresInSeconds: number;
37
+ };
38
+ }
39
+
40
+ export interface ImageAccessResult {
41
+ url: string;
42
+ revoke: () => void;
43
+ blob?: Blob;
44
+ urlType: 'signed' | 'blob';
45
+ expiresAt?: string;
30
46
  }
@@ -1,5 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
- import { type ImageUploadResponse } from '~/types';
2
+ import { type ImageUploadResponse, type SignedImageUrlResponse } from '~/types';
3
3
 
4
4
  const IMAGE_API_BASE = '/api/image';
5
5
 
@@ -93,6 +93,54 @@ function parseUploadResponse(payload: string): ImageUploadResponse {
93
93
  return parsed;
94
94
  }
95
95
 
96
+ function parseSignedUrlResponse(payload: string): SignedImageUrlResponse {
97
+ const parsed = JSON.parse(payload) as SignedImageUrlResponse;
98
+ if (!parsed.success || !parsed.result?.url || !parsed.result?.fileId || !parsed.result?.expiresAt) {
99
+ throw new Error('Signed URL response is invalid');
100
+ }
101
+
102
+ return parsed;
103
+ }
104
+
105
+ export async function createSignedImageUrlApi(
106
+ user: User,
107
+ fileId: string,
108
+ expiresInSeconds?: number
109
+ ): Promise<SignedImageUrlResponse> {
110
+ const response = await fetchImageApi(user, `/${encodeURIComponent(fileId)}/signed-url`, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ 'Accept': 'application/json'
115
+ },
116
+ body: JSON.stringify(
117
+ typeof expiresInSeconds === 'number'
118
+ ? { expiresInSeconds }
119
+ : {}
120
+ )
121
+ });
122
+
123
+ if (!response.ok) {
124
+ throw new Error(`Signed URL request failed with status ${response.status}`);
125
+ }
126
+
127
+ const parsed = parseSignedUrlResponse(await response.text());
128
+ const rawUrl = parsed.result.url;
129
+ let normalizedUrl = rawUrl;
130
+
131
+ if (rawUrl.startsWith('/')) {
132
+ normalizedUrl = new URL(rawUrl, window.location.origin).toString();
133
+ }
134
+
135
+ return {
136
+ ...parsed,
137
+ result: {
138
+ ...parsed.result,
139
+ url: normalizedUrl
140
+ }
141
+ };
142
+ }
143
+
96
144
  export async function uploadImageApi(
97
145
  user: User,
98
146
  file: File,
@@ -43,7 +43,19 @@ export const getCaseData = async (
43
43
  }
44
44
 
45
45
  if (!response.ok) {
46
- throw new Error(`Failed to fetch case data: ${response.status} ${response.statusText}`);
46
+ let errorDetails = '';
47
+
48
+ try {
49
+ const errorPayload = await response.json() as { error?: unknown };
50
+ if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
51
+ errorDetails = errorPayload.error.trim();
52
+ }
53
+ } catch {
54
+ // Ignore parse errors and fall back to status text only.
55
+ }
56
+
57
+ const baseMessage = `Failed to fetch case data: ${response.status} ${response.statusText}`;
58
+ throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
47
59
  }
48
60
 
49
61
  const caseData = await response.json() as CaseData;
@@ -86,7 +86,19 @@ export const getConfirmationSummaryDocument = async (
86
86
  });
87
87
 
88
88
  if (!response.ok) {
89
- throw new Error(`Failed to fetch confirmation summary: ${response.status} ${response.statusText}`);
89
+ let errorDetails = '';
90
+
91
+ try {
92
+ const errorPayload = await response.json() as { error?: unknown };
93
+ if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
94
+ errorDetails = errorPayload.error.trim();
95
+ }
96
+ } catch {
97
+ // Ignore parse errors and fall back to status text only.
98
+ }
99
+
100
+ const baseMessage = `Failed to fetch confirmation summary: ${response.status} ${response.statusText}`;
101
+ throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
90
102
  }
91
103
 
92
104
  const payload = await response.json().catch(() => null) as unknown;
@@ -299,3 +311,28 @@ export const removeCaseConfirmationSummary = async (
299
311
 
300
312
  await saveConfirmationSummaryDocument(user, summary);
301
313
  };
314
+
315
+ export const moveCaseConfirmationSummary = async (
316
+ user: User,
317
+ fromCaseNumber: string,
318
+ toCaseNumber: string
319
+ ): Promise<void> => {
320
+ if (fromCaseNumber === toCaseNumber) {
321
+ return;
322
+ }
323
+
324
+ const summary = await getConfirmationSummaryDocument(user);
325
+ const existingCaseSummary = summary.cases[fromCaseNumber];
326
+ if (!existingCaseSummary) {
327
+ return;
328
+ }
329
+
330
+ delete summary.cases[fromCaseNumber];
331
+ summary.cases[toCaseNumber] = {
332
+ ...existingCaseSummary,
333
+ updatedAt: getIsoNow(),
334
+ };
335
+ summary.updatedAt = getIsoNow();
336
+
337
+ await saveConfirmationSummaryDocument(user, summary);
338
+ };
@@ -42,7 +42,19 @@ export const getFileAnnotations = async (
42
42
  }
43
43
 
44
44
  if (!response.ok) {
45
- throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
45
+ let errorDetails = '';
46
+
47
+ try {
48
+ const errorPayload = await response.json() as { error?: unknown };
49
+ if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
50
+ errorDetails = errorPayload.error.trim();
51
+ }
52
+ } catch {
53
+ // Ignore parse errors and fall back to status text only.
54
+ }
55
+
56
+ const baseMessage = `Failed to fetch file annotations: ${response.status} ${response.statusText}`;
57
+ throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
46
58
  }
47
59
 
48
60
  return await response.json() as AnnotationData;
@@ -82,12 +82,13 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
82
82
  });
83
83
  }
84
84
 
85
+ const requestUrl = new URL(request.url);
86
+
85
87
  const identity = await verifyFirebaseIdentityFromRequest(request, env);
86
88
  if (!identity) {
87
89
  return textResponse('Unauthorized', 401);
88
90
  }
89
91
 
90
- const requestUrl = new URL(request.url);
91
92
  const proxyPathResult = extractProxyPath(requestUrl);
92
93
  if (!proxyPathResult.ok) {
93
94
  return proxyPathResult.reason === 'bad-encoding'