@striae-org/striae 4.3.4 → 5.1.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 (61) hide show
  1. package/.env.example +9 -2
  2. package/app/components/actions/case-export/download-handlers.ts +66 -11
  3. package/app/components/actions/case-import/confirmation-import.ts +50 -7
  4. package/app/components/actions/case-import/confirmation-package.ts +99 -22
  5. package/app/components/actions/case-import/orchestrator.ts +116 -13
  6. package/app/components/actions/case-import/validation.ts +171 -7
  7. package/app/components/actions/case-import/zip-processing.ts +224 -127
  8. package/app/components/actions/case-manage.ts +74 -15
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/actions/generate-pdf.ts +43 -1
  11. package/app/components/actions/image-manage.ts +13 -45
  12. package/app/components/navbar/navbar.module.css +0 -10
  13. package/app/components/navbar/navbar.tsx +0 -22
  14. package/app/components/sidebar/case-import/case-import.module.css +7 -131
  15. package/app/components/sidebar/case-import/case-import.tsx +7 -14
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
  19. package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
  20. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
  21. package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
  22. package/app/config-example/config.json +5 -0
  23. package/app/routes/auth/login.tsx +1 -1
  24. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  25. package/app/routes/striae/striae.tsx +15 -4
  26. package/app/utils/data/operations/case-operations.ts +13 -1
  27. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  28. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  29. package/app/utils/data/operations/signing-operations.ts +93 -0
  30. package/app/utils/data/operations/types.ts +6 -0
  31. package/app/utils/forensics/export-encryption.ts +316 -0
  32. package/app/utils/forensics/export-verification.ts +1 -409
  33. package/app/utils/forensics/index.ts +1 -0
  34. package/app/utils/ui/case-messages.ts +5 -2
  35. package/package.json +2 -2
  36. package/scripts/deploy-config.sh +244 -7
  37. package/scripts/deploy-pages-secrets.sh +0 -6
  38. package/scripts/deploy-worker-secrets.sh +66 -5
  39. package/scripts/encrypt-r2-backfill.mjs +376 -0
  40. package/worker-configuration.d.ts +13 -7
  41. package/workers/audit-worker/package.json +1 -4
  42. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  43. package/workers/audit-worker/wrangler.jsonc.example +6 -1
  44. package/workers/data-worker/package.json +1 -4
  45. package/workers/data-worker/src/data-worker.example.ts +409 -1
  46. package/workers/data-worker/src/encryption-utils.ts +269 -0
  47. package/workers/data-worker/worker-configuration.d.ts +1 -1
  48. package/workers/data-worker/wrangler.jsonc.example +6 -2
  49. package/workers/image-worker/package.json +1 -4
  50. package/workers/image-worker/src/encryption-utils.ts +217 -0
  51. package/workers/image-worker/src/image-worker.example.ts +196 -127
  52. package/workers/image-worker/wrangler.jsonc.example +8 -1
  53. package/workers/keys-worker/package.json +1 -4
  54. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  55. package/workers/pdf-worker/package.json +1 -4
  56. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  57. package/workers/user-worker/package.json +1 -4
  58. package/workers/user-worker/wrangler.jsonc.example +1 -1
  59. package/wrangler.toml.example +1 -1
  60. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  61. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
@@ -1,179 +1,248 @@
1
+ import {
2
+ decryptBinaryFromStorage,
3
+ encryptBinaryForStorage,
4
+ type DataAtRestEnvelope
5
+ } from './encryption-utils';
6
+
1
7
  interface Env {
2
- API_TOKEN: string;
3
- ACCOUNT_ID: string;
4
- HMAC_KEY: string;
8
+ IMAGES_API_TOKEN: string;
9
+ STRIAE_FILES: R2Bucket;
10
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY: string;
11
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
12
+ DATA_AT_REST_ENCRYPTION_KEY_ID: string;
13
+ }
14
+
15
+ interface UploadResult {
16
+ id: string;
17
+ filename: string;
18
+ uploaded: string;
19
+ requireSignedURLs: boolean;
20
+ variants: string[];
5
21
  }
6
22
 
7
- interface CloudflareImagesResponse {
23
+ interface UploadResponse {
24
+ success: boolean;
25
+ errors: Array<{ code: number; message: string }>;
26
+ messages: string[];
27
+ result: UploadResult;
28
+ }
29
+
30
+ interface SuccessResponse {
8
31
  success: boolean;
9
- errors?: Array<{
10
- code: number;
11
- message: string;
12
- }>;
13
- messages?: string[];
14
- result?: {
15
- id: string;
16
- filename: string;
17
- uploaded: string;
18
- requireSignedURLs: boolean;
19
- variants: string[];
20
- };
21
32
  }
22
33
 
23
34
  interface ErrorResponse {
24
35
  error: string;
25
36
  }
26
37
 
27
- type APIResponse = CloudflareImagesResponse | ErrorResponse | string;
38
+ type APIResponse = UploadResponse | SuccessResponse | ErrorResponse;
28
39
 
29
- const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
30
-
31
- /**
32
- * CORS headers to allow requests from the Striae app
33
- */
34
40
  const corsHeaders: Record<string, string> = {
35
41
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
36
42
  'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
37
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key',
38
- 'Content-Type': 'application/json'
43
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key'
39
44
  };
40
45
 
41
- const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
42
- typeof data === 'string' ? data : JSON.stringify(data),
43
- { status, headers: corsHeaders }
46
+ const createJsonResponse = (data: APIResponse, status: number = 200): Response => new Response(
47
+ JSON.stringify(data),
48
+ {
49
+ status,
50
+ headers: {
51
+ ...corsHeaders,
52
+ 'Content-Type': 'application/json'
53
+ }
54
+ }
44
55
  );
45
56
 
46
- const hasValidToken = (request: Request, env: Env): boolean => {
47
- const authHeader = request.headers.get("Authorization");
48
- const expectedToken = `Bearer ${env.API_TOKEN}`;
57
+ function hasValidToken(request: Request, env: Env): boolean {
58
+ const authHeader = request.headers.get('Authorization');
59
+ const expectedToken = `Bearer ${env.IMAGES_API_TOKEN}`;
49
60
  return authHeader === expectedToken;
50
- };
61
+ }
51
62
 
52
- /**
53
- * Handle image upload requests
54
- */
55
- async function handleImageUpload(request: Request, env: Env): Promise<Response> {
56
- if (!hasValidToken(request, env)) {
57
- return createResponse({ error: 'Unauthorized' }, 403);
63
+ function requireEncryptionUploadConfig(env: Env): void {
64
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
65
+ throw new Error('Data-at-rest encryption is not configured for image uploads');
58
66
  }
67
+ }
59
68
 
60
- const formData = await request.formData();
61
- const endpoint = `${API_BASE}/${env.ACCOUNT_ID}/images/v1`;
69
+ function requireEncryptionRetrievalConfig(env: Env): void {
70
+ if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
71
+ throw new Error('Data-at-rest decryption is not configured for image retrieval');
72
+ }
73
+ }
62
74
 
63
- // Add requireSignedURLs to form data
64
- formData.append('requireSignedURLs', 'true');
75
+ function parseFileId(pathname: string): string | null {
76
+ const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
77
+ if (!encodedFileId) {
78
+ return null;
79
+ }
65
80
 
66
- const response = await fetch(endpoint, {
67
- method: 'POST',
68
- headers: {
69
- 'Authorization': `Bearer ${env.API_TOKEN}`,
70
- },
71
- body: formData
72
- });
73
-
74
- const data: CloudflareImagesResponse = await response.json();
75
- return createResponse(data, response.status);
81
+ let decodedFileId = '';
82
+ try {
83
+ decodedFileId = decodeURIComponent(encodedFileId);
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ if (!decodedFileId || decodedFileId.includes('/')) {
89
+ return null;
90
+ }
91
+
92
+ return decodedFileId;
76
93
  }
77
94
 
78
- /**
79
- * Handle image delete requests
80
- */
81
- async function handleImageDelete(request: Request, env: Env): Promise<Response> {
82
- if (!hasValidToken(request, env)) {
83
- return createResponse({ error: 'Unauthorized' }, 403);
95
+ function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
96
+ const metadata = file.customMetadata;
97
+ if (!metadata) {
98
+ return null;
84
99
  }
85
100
 
86
- const url = new URL(request.url);
87
- const imageId = url.pathname.split('/').pop();
88
-
89
- if (!imageId) {
90
- return createResponse({ error: 'Image ID is required' }, 400);
101
+ const { algorithm, encryptionVersion, keyId, dataIv, wrappedKey } = metadata;
102
+ if (
103
+ typeof algorithm !== 'string' ||
104
+ typeof encryptionVersion !== 'string' ||
105
+ typeof keyId !== 'string' ||
106
+ typeof dataIv !== 'string' ||
107
+ typeof wrappedKey !== 'string'
108
+ ) {
109
+ return null;
91
110
  }
92
-
93
- const endpoint = `${API_BASE}/${env.ACCOUNT_ID}/images/v1/${imageId}`;
94
- const response = await fetch(endpoint, {
95
- method: 'DELETE',
96
- headers: {
97
- 'Authorization': `Bearer ${env.API_TOKEN}`,
98
- }
99
- });
100
-
101
- const data: CloudflareImagesResponse = await response.json();
102
- return createResponse(data, response.status);
111
+
112
+ return {
113
+ algorithm,
114
+ encryptionVersion,
115
+ keyId,
116
+ dataIv,
117
+ wrappedKey
118
+ };
103
119
  }
104
120
 
105
- /**
106
- * Handle Signed URL generation
107
- */
108
- const EXPIRATION = 60 * 60; // 1 hour
109
-
110
- const bufferToHex = (buffer: ArrayBuffer): string =>
111
- [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
112
-
113
- async function generateSignedUrl(url: URL, env: Env): Promise<Response> {
114
- const encoder = new TextEncoder();
115
- const secretKeyData = encoder.encode(env.HMAC_KEY);
116
- const key = await crypto.subtle.importKey(
117
- 'raw',
118
- secretKeyData,
119
- { name: 'HMAC', hash: 'SHA-256' },
120
- false,
121
- ['sign']
122
- );
121
+ function deriveFileKind(contentType: string): string {
122
+ if (contentType.startsWith('image/')) {
123
+ return 'image';
124
+ }
125
+
126
+ return 'file';
127
+ }
128
+
129
+ async function handleImageUpload(request: Request, env: Env): Promise<Response> {
130
+ if (!hasValidToken(request, env)) {
131
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
132
+ }
133
+
134
+ requireEncryptionUploadConfig(env);
135
+
136
+ const formData = await request.formData();
137
+ const fileValue = formData.get('file');
138
+ if (!(fileValue instanceof Blob)) {
139
+ return createJsonResponse({ error: 'Missing file upload payload' }, 400);
140
+ }
123
141
 
124
- // Add expiration
125
- const expiry = Math.floor(Date.now() / 1000) + EXPIRATION;
126
- url.searchParams.set('exp', expiry.toString());
142
+ const fileBlob = fileValue;
143
+ const uploadedAt = new Date().toISOString();
144
+ const filename = fileValue instanceof File && fileValue.name ? fileValue.name : 'upload.bin';
145
+ const contentType = fileBlob.type || 'application/octet-stream';
146
+ const fileId = crypto.randomUUID().replace(/-/g, '');
147
+ const plaintextBytes = await fileBlob.arrayBuffer();
127
148
 
128
- const stringToSign = url.pathname + '?' + url.searchParams.toString();
129
- const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign));
130
- const sig = bufferToHex(mac);
149
+ const encryptedPayload = await encryptBinaryForStorage(
150
+ plaintextBytes,
151
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
152
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
153
+ );
131
154
 
132
- // Add signature
133
- url.searchParams.set('sig', sig);
155
+ await env.STRIAE_FILES.put(fileId, encryptedPayload.ciphertext, {
156
+ customMetadata: {
157
+ algorithm: encryptedPayload.envelope.algorithm,
158
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
159
+ keyId: encryptedPayload.envelope.keyId,
160
+ dataIv: encryptedPayload.envelope.dataIv,
161
+ wrappedKey: encryptedPayload.envelope.wrappedKey,
162
+ contentType,
163
+ originalFilename: filename,
164
+ byteLength: String(fileBlob.size),
165
+ createdAt: uploadedAt,
166
+ fileKind: deriveFileKind(contentType)
167
+ }
168
+ });
134
169
 
135
- // Return the modified URL with signature and expiration
136
- return new Response(url.toString(), {
137
- headers: corsHeaders
170
+ return createJsonResponse({
171
+ success: true,
172
+ errors: [],
173
+ messages: [],
174
+ result: {
175
+ id: fileId,
176
+ filename,
177
+ uploaded: uploadedAt,
178
+ requireSignedURLs: false,
179
+ variants: []
180
+ }
138
181
  });
139
182
  }
140
183
 
141
- async function handleImageServing(request: Request, env: Env): Promise<Response> {
184
+ async function handleImageDelete(request: Request, env: Env): Promise<Response> {
142
185
  if (!hasValidToken(request, env)) {
143
- return createResponse({ error: 'Unauthorized' }, 403);
186
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
187
+ }
188
+
189
+ const fileId = parseFileId(new URL(request.url).pathname);
190
+ if (!fileId) {
191
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
144
192
  }
145
193
 
146
- const url = new URL(request.url);
147
- const encodedPath = url.pathname.slice(1);
148
- if (!encodedPath) {
149
- return createResponse({ error: 'Image delivery URL is required' }, 400);
194
+ const existing = await env.STRIAE_FILES.head(fileId);
195
+ if (!existing) {
196
+ return createJsonResponse({ error: 'File not found' }, 404);
150
197
  }
151
198
 
152
- let decodedPath: string;
199
+ await env.STRIAE_FILES.delete(fileId);
200
+ return createJsonResponse({ success: true });
201
+ }
153
202
 
154
- try {
155
- decodedPath = decodeURIComponent(encodedPath);
156
- } catch {
157
- return createResponse({ error: 'Image delivery URL must be URL-encoded' }, 400);
203
+ async function handleImageServing(request: Request, env: Env): Promise<Response> {
204
+ if (!hasValidToken(request, env)) {
205
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
158
206
  }
159
207
 
160
- let imageDeliveryURL: URL;
161
- try {
162
- imageDeliveryURL = new URL(decodedPath);
163
- } catch {
164
- return createResponse({ error: 'Image delivery URL is invalid' }, 400);
208
+ requireEncryptionRetrievalConfig(env);
209
+
210
+ const fileId = parseFileId(new URL(request.url).pathname);
211
+ if (!fileId) {
212
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
165
213
  }
166
214
 
167
- if (imageDeliveryURL.protocol !== 'https:' || imageDeliveryURL.hostname !== 'imagedelivery.net') {
168
- return createResponse({ error: 'Image delivery URL must target imagedelivery.net over HTTPS' }, 400);
215
+ const file = await env.STRIAE_FILES.get(fileId);
216
+ if (!file) {
217
+ return createJsonResponse({ error: 'File not found' }, 404);
169
218
  }
170
-
171
- return generateSignedUrl(imageDeliveryURL, env);
219
+
220
+ const envelope = extractEnvelope(file);
221
+ if (!envelope) {
222
+ return createJsonResponse({ error: 'Missing data-at-rest envelope metadata' }, 500);
223
+ }
224
+
225
+ const encryptedData = await file.arrayBuffer();
226
+ const plaintext = await decryptBinaryFromStorage(
227
+ encryptedData,
228
+ envelope,
229
+ env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
230
+ );
231
+
232
+ const contentType = file.customMetadata?.contentType || 'application/octet-stream';
233
+ const filename = file.customMetadata?.originalFilename || fileId;
234
+
235
+ return new Response(plaintext, {
236
+ status: 200,
237
+ headers: {
238
+ ...corsHeaders,
239
+ 'Cache-Control': 'no-store',
240
+ 'Content-Type': contentType,
241
+ 'Content-Disposition': `inline; filename="${filename.replace(/"/g, '')}"`
242
+ }
243
+ });
172
244
  }
173
245
 
174
- /**
175
- * Main worker functions
176
- */
177
246
  export default {
178
247
  async fetch(request: Request, env: Env): Promise<Response> {
179
248
  if (request.method === 'OPTIONS') {
@@ -189,11 +258,11 @@ export default {
189
258
  case 'DELETE':
190
259
  return handleImageDelete(request, env);
191
260
  default:
192
- return createResponse({ error: 'Method not allowed' }, 405);
261
+ return createJsonResponse({ error: 'Method not allowed' }, 405);
193
262
  }
194
263
  } catch (error) {
195
264
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
196
- return createResponse({ error: errorMessage }, 500);
265
+ return createJsonResponse({ error: errorMessage }, 500);
197
266
  }
198
267
  }
199
268
  };
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -11,5 +11,12 @@
11
11
  "enabled": true
12
12
  },
13
13
 
14
+ "r2_buckets": [
15
+ {
16
+ "binding": "STRIAE_FILES",
17
+ "bucket_name": "FILES_BUCKET_NAME"
18
+ }
19
+ ],
20
+
14
21
  "placement": { "mode": "smart" }
15
22
  }
@@ -5,13 +5,10 @@
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
7
7
  "dev": "wrangler dev",
8
- "start": "wrangler dev",
9
- "test": "vitest"
8
+ "start": "wrangler dev"
10
9
  },
11
10
  "devDependencies": {
12
11
  "@cloudflare/puppeteer": "^1.0.6",
13
- "@cloudflare/vitest-pool-workers": "^0.13.0",
14
- "vitest": "~4.1.0",
15
12
  "wrangler": "^4.76.0"
16
13
  },
17
14
  "overrides": {
@@ -2,7 +2,7 @@
2
2
  "name": "KEYS_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/keys.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -6,13 +6,10 @@
6
6
  "generate:assets": "node scripts/generate-assets.js",
7
7
  "deploy": "wrangler deploy",
8
8
  "dev": "wrangler dev",
9
- "start": "wrangler dev",
10
- "test": "vitest"
9
+ "start": "wrangler dev"
11
10
  },
12
11
  "devDependencies": {
13
12
  "@cloudflare/puppeteer": "^1.0.6",
14
- "@cloudflare/vitest-pool-workers": "^0.13.0",
15
- "vitest": "~4.1.0",
16
13
  "wrangler": "^4.76.0"
17
14
  },
18
15
  "overrides": {
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -5,13 +5,10 @@
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
7
7
  "dev": "wrangler dev",
8
- "start": "wrangler dev",
9
- "test": "vitest"
8
+ "start": "wrangler dev"
10
9
  },
11
10
  "devDependencies": {
12
11
  "@cloudflare/puppeteer": "^1.0.6",
13
- "@cloudflare/vitest-pool-workers": "^0.13.0",
14
- "vitest": "~4.1.0",
15
12
  "wrangler": "^4.76.0"
16
13
  },
17
14
  "overrides": {
@@ -2,7 +2,7 @@
2
2
  "name": "USER_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-03-23"
3
+ compatibility_date = "2026-03-24"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6