@striae-org/striae 5.1.0 → 5.2.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 (40) hide show
  1. package/.env.example +22 -2
  2. package/app/components/actions/case-export/download-handlers.ts +18 -1
  3. package/app/components/actions/case-manage.ts +17 -1
  4. package/app/components/actions/generate-pdf.ts +9 -3
  5. package/app/components/actions/image-manage.ts +43 -11
  6. package/app/routes/striae/striae.tsx +1 -0
  7. package/app/types/file.ts +18 -2
  8. package/app/utils/api/image-api-client.ts +49 -1
  9. package/app/utils/data/permissions.ts +4 -2
  10. package/functions/api/image/[[path]].ts +2 -1
  11. package/package.json +4 -4
  12. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  13. package/scripts/deploy-config/modules/keys.sh +404 -0
  14. package/scripts/deploy-config/modules/prompt.sh +372 -0
  15. package/scripts/deploy-config/modules/scaffolding.sh +336 -0
  16. package/scripts/deploy-config/modules/validation.sh +365 -0
  17. package/scripts/deploy-config.sh +59 -1556
  18. package/scripts/deploy-worker-secrets.sh +101 -6
  19. package/worker-configuration.d.ts +9 -4
  20. package/workers/audit-worker/package.json +1 -1
  21. package/workers/audit-worker/src/audit-worker.example.ts +188 -6
  22. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  23. package/workers/data-worker/package.json +1 -1
  24. package/workers/data-worker/src/data-worker.example.ts +344 -32
  25. package/workers/data-worker/wrangler.jsonc.example +2 -4
  26. package/workers/image-worker/package.json +1 -1
  27. package/workers/image-worker/src/image-worker.example.ts +456 -20
  28. package/workers/image-worker/worker-configuration.d.ts +3 -2
  29. package/workers/image-worker/wrangler.jsonc.example +1 -1
  30. package/workers/keys-worker/package.json +1 -1
  31. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  32. package/workers/pdf-worker/package.json +1 -1
  33. package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
  34. package/workers/pdf-worker/wrangler.jsonc.example +1 -5
  35. package/workers/user-worker/package.json +17 -17
  36. package/workers/user-worker/src/encryption-utils.ts +244 -0
  37. package/workers/user-worker/src/user-worker.example.ts +333 -31
  38. package/workers/user-worker/wrangler.jsonc.example +1 -1
  39. package/wrangler.toml.example +1 -1
  40. package/scripts/encrypt-r2-backfill.mjs +0 -376
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
@@ -55,6 +54,17 @@ ACCOUNT_HASH=your_cloudflare_images_account_hash_here
55
54
  USER_WORKER_NAME=your_user_worker_name_here
56
55
  USER_WORKER_DOMAIN=your_user_worker_domain_here
57
56
  KV_STORE_ID=your_kv_store_id_here
57
+ USER_KV_ENCRYPTION_PRIVATE_KEY=your_user_kv_encryption_private_key_here
58
+ USER_KV_ENCRYPTION_KEY_ID=your_user_kv_encryption_key_id_here
59
+ USER_KV_ENCRYPTION_PUBLIC_KEY=your_user_kv_encryption_public_key_here
60
+ # Optional write toggle for USER_DB mutation endpoints.
61
+ # true (default): require USER_KV_ENCRYPTION_PUBLIC_KEY and USER_KV_ENCRYPTION_KEY_ID for encrypt-on-write.
62
+ # false: allow read-only deployments using private key material (legacy key or key registry) without write-path keys.
63
+ USER_KV_WRITE_ENDPOINTS_ENABLED=true
64
+ # Optional key registry for rotation-safe USER_DB reads.
65
+ # JSON shape: {"activeKeyId":"kid_current","keys":{"kid_current":"-----BEGIN PRIVATE KEY-----\\n...","kid_previous":"-----BEGIN PRIVATE KEY-----\\n..."}}
66
+ USER_KV_ENCRYPTION_KEYS_JSON='{"activeKeyId":"your_user_kv_active_encryption_key_id_here","keys":{"your_user_kv_active_encryption_key_id_here":"your_user_kv_encryption_private_key_here"}}'
67
+ USER_KV_ENCRYPTION_ACTIVE_KEY_ID=your_user_kv_active_encryption_key_id_here
58
68
 
59
69
  # ================================
60
70
  # DATA WORKER ENVIRONMENT VARIABLES
@@ -70,11 +80,18 @@ MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
70
80
  EXPORT_ENCRYPTION_PRIVATE_KEY=your_export_encryption_private_key_here
71
81
  EXPORT_ENCRYPTION_KEY_ID=your_export_encryption_key_id_here
72
82
  EXPORT_ENCRYPTION_PUBLIC_KEY=your_export_encryption_public_key_here
83
+ # Optional key registry for export decrypt compatibility.
84
+ # JSON shape: {"activeKeyId":"kid_current","keys":{"kid_current":"-----BEGIN PRIVATE KEY-----\\n...","kid_previous":"-----BEGIN PRIVATE KEY-----\\n..."}}
85
+ EXPORT_ENCRYPTION_KEYS_JSON='{"activeKeyId":"your_export_encryption_active_key_id_here","keys":{"your_export_encryption_active_key_id_here":"your_export_encryption_private_key_here"}}'
86
+ EXPORT_ENCRYPTION_ACTIVE_KEY_ID=your_export_encryption_active_key_id_here
73
87
  DATA_AT_REST_ENCRYPTION_ENABLED=true
74
88
  DATA_AT_REST_ENCRYPTION_PRIVATE_KEY=your_data_at_rest_encryption_private_key_here
75
89
  DATA_AT_REST_ENCRYPTION_KEY_ID=your_data_at_rest_encryption_key_id_here
76
90
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY=your_data_at_rest_encryption_public_key_here
77
-
91
+ # Optional key registry for data/files/audit decryption compatibility.
92
+ # JSON shape: {"activeKeyId":"kid_current","keys":{"kid_current":"-----BEGIN PRIVATE KEY-----\\n...","kid_previous":"-----BEGIN PRIVATE KEY-----\\n..."}}
93
+ DATA_AT_REST_ENCRYPTION_KEYS_JSON='{"activeKeyId":"your_data_at_rest_active_encryption_key_id_here","keys":{"your_data_at_rest_active_encryption_key_id_here":"your_data_at_rest_encryption_private_key_here"}}'
94
+ DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID=your_data_at_rest_active_encryption_key_id_here
78
95
 
79
96
  # ================================
80
97
  # AUDIT WORKER ENVIRONMENT VARIABLES
@@ -88,6 +105,9 @@ AUDIT_WORKER_DOMAIN=your_audit_worker_domain_here
88
105
  # ================================
89
106
  IMAGES_WORKER_NAME=your_images_worker_name_here
90
107
  IMAGES_WORKER_DOMAIN=your_images_worker_domain_here
108
+ IMAGE_SIGNED_URL_SECRET=your_image_signed_url_secret_here
109
+ # Optional: defaults to 3600 and max is 86400.
110
+ IMAGE_SIGNED_URL_TTL_SECONDS=3600
91
111
 
92
112
  # ================================
93
113
  # PDF WORKER ENVIRONMENT VARIABLES
@@ -965,7 +965,24 @@ 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 { blob, revoke } = await getImageUrl(user, fileData, caseNumber, 'Export Package');
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
+
969
986
  try {
970
987
  return blob;
971
988
  } finally {
@@ -685,7 +685,23 @@ const getVerificationPublicSigningKey = (preferredKeyId?: string): { keyId: stri
685
685
 
686
686
  const fetchImageAsBlob = async (user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> => {
687
687
  try {
688
- const { blob, revoke } = 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
+ });
698
+
699
+ if (!signedResponse.ok) {
700
+ throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
701
+ }
702
+
703
+ return await signedResponse.blob();
704
+ }
689
705
 
690
706
  try {
691
707
  return blob;
@@ -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;
@@ -44,6 +45,10 @@ const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<st
44
45
  return selectedImage;
45
46
  }
46
47
 
48
+ if (selectedImage.startsWith('/')) {
49
+ return new URL(selectedImage, window.location.origin).toString();
50
+ }
51
+
47
52
  if (selectedImage.startsWith('data:')) {
48
53
  return selectedImage;
49
54
  }
@@ -64,6 +69,7 @@ const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<st
64
69
  export const generatePDF = async ({
65
70
  user,
66
71
  selectedImage,
72
+ sourceImageId,
67
73
  selectedFilename,
68
74
  userCompany,
69
75
  userFirstName,
@@ -187,7 +193,7 @@ export const generatePDF = async ({
187
193
  processingTime,
188
194
  blob.size,
189
195
  [],
190
- selectedImage, // Source file ID
196
+ sourceImageId, // Source file ID
191
197
  selectedFilename // Source original filename
192
198
  );
193
199
  } catch (auditError) {
@@ -215,7 +221,7 @@ export const generatePDF = async ({
215
221
  processingTime,
216
222
  0, // No file size for failed generation
217
223
  [errorText || 'PDF generation failed'],
218
- selectedImage, // Source file ID
224
+ sourceImageId, // Source file ID
219
225
  selectedFilename // Source original filename
220
226
  );
221
227
  } catch (auditError) {
@@ -242,7 +248,7 @@ export const generatePDF = async ({
242
248
  processingTime,
243
249
  0, // No file size for failed generation
244
250
  [error instanceof Error ? error.message : 'Unknown error generating PDF'],
245
- selectedImage, // Source file ID
251
+ sourceImageId, // Source file ID
246
252
  selectedFilename // Source original filename
247
253
  );
248
254
  } catch (auditError) {
@@ -1,7 +1,7 @@
1
1
  import type { User } from 'firebase/auth';
2
- import { fetchImageApi, uploadImageApi } from '~/utils/api';
2
+ import { createSignedImageUrlApi, fetchImageApi, uploadImageApi } from '~/utils/api';
3
3
  import { canUploadFile, getCaseData, updateCaseData, deleteFileAnnotations } from '~/utils/data';
4
- import type { CaseData, FileData, ImageUploadResponse } from '~/types';
4
+ import type { CaseData, FileData, ImageAccessResult, ImageUploadResponse } from '~/types';
5
5
  import { auditService } from '~/services/audit';
6
6
 
7
7
  export interface DeleteFileResult {
@@ -255,20 +255,49 @@ export const deleteFile = async (
255
255
  }
256
256
  };
257
257
 
258
- export const getImageUrl = async (user: User, fileData: FileData, caseNumber: string, accessReason?: string): Promise<{ blob: Blob; url: string; revoke: () => void }> => {
258
+ export const getImageUrl = async (
259
+ user: User,
260
+ fileData: FileData,
261
+ caseNumber: string,
262
+ accessReason?: string
263
+ ): Promise<ImageAccessResult> => {
259
264
  const startTime = Date.now();
260
265
  const defaultAccessReason = accessReason || 'Image viewer access';
261
-
266
+
262
267
  try {
268
+ try {
269
+ const signedUrlResponse = await createSignedImageUrlApi(user, fileData.id);
270
+
271
+ await auditService.logFileAccess(
272
+ user,
273
+ fileData.originalFilename || fileData.id,
274
+ fileData.id,
275
+ 'signed-url',
276
+ caseNumber,
277
+ 'success',
278
+ Date.now() - startTime,
279
+ defaultAccessReason,
280
+ fileData.originalFilename
281
+ );
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.
291
+ }
292
+
263
293
  const workerResponse = await fetchImageApi(user, `/${encodeURIComponent(fileData.id)}`, {
264
294
  method: 'GET',
265
295
  headers: {
266
296
  'Accept': 'application/octet-stream,image/*'
267
297
  }
268
298
  });
269
-
299
+
270
300
  if (!workerResponse.ok) {
271
- // Log failed image access
272
301
  await auditService.logFileAccess(
273
302
  user,
274
303
  fileData.originalFilename || fileData.id,
@@ -285,8 +314,7 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
285
314
 
286
315
  const blob = await workerResponse.blob();
287
316
  const objectUrl = URL.createObjectURL(blob);
288
-
289
- // Log successful image access
317
+
290
318
  await auditService.logFileAccess(
291
319
  user,
292
320
  fileData.originalFilename || fileData.id,
@@ -298,10 +326,14 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
298
326
  defaultAccessReason,
299
327
  fileData.originalFilename
300
328
  );
301
-
302
- return { blob, url: objectUrl, revoke: () => URL.revokeObjectURL(objectUrl) };
329
+
330
+ return {
331
+ blob,
332
+ url: objectUrl,
333
+ revoke: () => URL.revokeObjectURL(objectUrl),
334
+ urlType: 'blob'
335
+ };
303
336
  } catch (error) {
304
- // Log any unexpected errors if not already logged
305
337
  if (!(error instanceof Error && error.message.includes('Failed to retrieve image'))) {
306
338
  await auditService.logFileAccess(
307
339
  user,
@@ -241,6 +241,7 @@ export const Striae = ({ user }: StriaePage) => {
241
241
  await generatePDF({
242
242
  user,
243
243
  selectedImage,
244
+ sourceImageId: imageId,
244
245
  selectedFilename,
245
246
  userCompany,
246
247
  userFirstName,
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,
@@ -42,8 +42,10 @@ export const getUserData = async (user: User): Promise<UserData | null> => {
42
42
  if (response.status === 404) {
43
43
  return null; // User not found
44
44
  }
45
-
46
- throw new Error('Failed to fetch user data');
45
+
46
+ const responseBody = await response.text().catch(() => '');
47
+ const detail = responseBody ? `: ${responseBody}` : '';
48
+ throw new Error(`Failed to fetch user data (${response.status} ${response.statusText})${detail}`);
47
49
  } catch (error) {
48
50
  console.error('Error fetching user data:', error);
49
51
  throw error;
@@ -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'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -54,7 +54,7 @@
54
54
  "workers/*/src/*.example.ts",
55
55
  "workers/*/src/*.example.js",
56
56
  "workers/*/src/*.ts",
57
- "workers/pdf-worker/scripts/*.js",
57
+ "workers/pdf-worker/scripts/*.js",
58
58
  "!workers/*/src/*worker.ts",
59
59
  "workers/pdf-worker/src/assets/generated-assets.example.ts",
60
60
  "workers/pdf-worker/src/formats/format-striae.ts",
@@ -67,7 +67,7 @@
67
67
  "vite.config.ts",
68
68
  "worker-configuration.d.ts",
69
69
  "wrangler.toml.example",
70
- "LICENSE"
70
+ "LICENSE"
71
71
  ],
72
72
  "sideEffects": false,
73
73
  "type": "module",
@@ -106,7 +106,7 @@
106
106
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
107
107
  "deploy-workers:keys": "cd workers/keys-worker && npm run deploy",
108
108
  "deploy-workers:pdf": "cd workers/pdf-worker && npm run deploy",
109
- "deploy-workers:user": "cd workers/user-worker && npm run deploy"
109
+ "deploy-workers:user": "cd workers/user-worker && npm run deploy"
110
110
  },
111
111
  "dependencies": {
112
112
  "@react-router/cloudflare": "^7.13.2",