@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
@@ -7,17 +7,31 @@ import {
7
7
  interface Env {
8
8
  IMAGES_API_TOKEN: string;
9
9
  STRIAE_FILES: R2Bucket;
10
- DATA_AT_REST_ENCRYPTION_PRIVATE_KEY: string;
10
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
11
11
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
12
12
  DATA_AT_REST_ENCRYPTION_KEY_ID: string;
13
+ DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
14
+ DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
15
+ IMAGE_SIGNED_URL_SECRET?: string;
16
+ IMAGE_SIGNED_URL_TTL_SECONDS?: string;
13
17
  }
14
18
 
19
+ interface KeyRegistryPayload {
20
+ activeKeyId?: unknown;
21
+ keys?: unknown;
22
+ }
23
+
24
+ interface PrivateKeyRegistry {
25
+ activeKeyId: string | null;
26
+ keys: Record<string, string>;
27
+ }
28
+
29
+ type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
30
+
15
31
  interface UploadResult {
16
32
  id: string;
17
33
  filename: string;
18
34
  uploaded: string;
19
- requireSignedURLs: boolean;
20
- variants: string[];
21
35
  }
22
36
 
23
37
  interface UploadResponse {
@@ -35,7 +49,29 @@ interface ErrorResponse {
35
49
  error: string;
36
50
  }
37
51
 
38
- type APIResponse = UploadResponse | SuccessResponse | ErrorResponse;
52
+ interface SignedUrlResult {
53
+ fileId: string;
54
+ url: string;
55
+ expiresAt: string;
56
+ expiresInSeconds: number;
57
+ }
58
+
59
+ interface SignedUrlResponse {
60
+ success: boolean;
61
+ result: SignedUrlResult;
62
+ }
63
+
64
+ type APIResponse = UploadResponse | SuccessResponse | ErrorResponse | SignedUrlResponse;
65
+
66
+ interface SignedAccessPayload {
67
+ fileId: string;
68
+ iat: number;
69
+ exp: number;
70
+ nonce: string;
71
+ }
72
+
73
+ const DEFAULT_SIGNED_URL_TTL_SECONDS = 3600;
74
+ const MAX_SIGNED_URL_TTL_SECONDS = 86400;
39
75
 
40
76
  const corsHeaders: Record<string, string> = {
41
77
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
@@ -67,11 +103,182 @@ function requireEncryptionUploadConfig(env: Env): void {
67
103
  }
68
104
 
69
105
  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');
106
+ const hasLegacyPrivateKey = typeof env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY === 'string' && env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
107
+ const hasRegistry = typeof env.DATA_AT_REST_ENCRYPTION_KEYS_JSON === 'string' && env.DATA_AT_REST_ENCRYPTION_KEYS_JSON.trim().length > 0;
108
+
109
+ if (!hasLegacyPrivateKey && !hasRegistry) {
110
+ throw new Error('Data-at-rest decryption registry is not configured for image retrieval');
72
111
  }
73
112
  }
74
113
 
114
+ function normalizePrivateKeyPem(rawValue: string): string {
115
+ return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
116
+ }
117
+
118
+ function getNonEmptyString(value: unknown): string | null {
119
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
120
+ }
121
+
122
+ function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
123
+ const keys: Record<string, string> = {};
124
+ const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
125
+ const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
126
+
127
+ if (registryJson) {
128
+ let parsedRegistry: unknown;
129
+ try {
130
+ parsedRegistry = JSON.parse(registryJson) as unknown;
131
+ } catch {
132
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
133
+ }
134
+
135
+ if (!parsedRegistry || typeof parsedRegistry !== 'object') {
136
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
137
+ }
138
+
139
+ const payload = parsedRegistry as KeyRegistryPayload;
140
+ if (!payload.keys || typeof payload.keys !== 'object') {
141
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must include a keys object');
142
+ }
143
+
144
+ for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
145
+ const normalizedKeyId = getNonEmptyString(keyId);
146
+ const normalizedPem = getNonEmptyString(pemValue);
147
+ if (!normalizedKeyId || !normalizedPem) {
148
+ continue;
149
+ }
150
+
151
+ keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
152
+ }
153
+
154
+ const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
155
+ const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
156
+
157
+ if (Object.keys(keys).length === 0) {
158
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
159
+ }
160
+
161
+ if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
162
+ throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
163
+ }
164
+
165
+ return {
166
+ activeKeyId: resolvedActiveKeyId ?? null,
167
+ keys
168
+ };
169
+ }
170
+
171
+ const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
172
+ const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
173
+ if (!legacyKeyId || !legacyPrivateKey) {
174
+ throw new Error('Data-at-rest decryption key registry is not configured');
175
+ }
176
+
177
+ keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
178
+
179
+ return {
180
+ activeKeyId: configuredActiveKeyId ?? legacyKeyId,
181
+ keys
182
+ };
183
+ }
184
+
185
+ function buildPrivateKeyCandidates(
186
+ recordKeyId: string,
187
+ registry: PrivateKeyRegistry
188
+ ): Array<{ keyId: string; privateKeyPem: string }> {
189
+ const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
190
+ const seen = new Set<string>();
191
+
192
+ const appendCandidate = (candidateKeyId: string | null): void => {
193
+ if (!candidateKeyId || seen.has(candidateKeyId)) {
194
+ return;
195
+ }
196
+
197
+ const privateKeyPem = registry.keys[candidateKeyId];
198
+ if (!privateKeyPem) {
199
+ return;
200
+ }
201
+
202
+ seen.add(candidateKeyId);
203
+ candidates.push({ keyId: candidateKeyId, privateKeyPem });
204
+ };
205
+
206
+ appendCandidate(getNonEmptyString(recordKeyId));
207
+ appendCandidate(registry.activeKeyId);
208
+
209
+ for (const keyId of Object.keys(registry.keys)) {
210
+ appendCandidate(keyId);
211
+ }
212
+
213
+ return candidates;
214
+ }
215
+
216
+ function logFileDecryptionTelemetry(input: {
217
+ recordKeyId: string;
218
+ selectedKeyId: string | null;
219
+ attemptCount: number;
220
+ outcome: DecryptionTelemetryOutcome;
221
+ reason?: string;
222
+ }): void {
223
+ const details = {
224
+ scope: 'file-at-rest',
225
+ recordKeyId: input.recordKeyId,
226
+ selectedKeyId: input.selectedKeyId,
227
+ attemptCount: input.attemptCount,
228
+ fallbackUsed: input.outcome === 'fallback-hit',
229
+ outcome: input.outcome,
230
+ reason: input.reason ?? null
231
+ };
232
+
233
+ if (input.outcome === 'all-failed') {
234
+ console.warn('Key registry decryption failed', details);
235
+ return;
236
+ }
237
+
238
+ console.info('Key registry decryption resolved', details);
239
+ }
240
+
241
+ async function decryptBinaryWithRegistry(
242
+ ciphertext: ArrayBuffer,
243
+ envelope: DataAtRestEnvelope,
244
+ env: Env
245
+ ): Promise<ArrayBuffer> {
246
+ const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
247
+ const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
248
+ const primaryKeyId = candidates[0]?.keyId ?? null;
249
+ let lastError: unknown;
250
+
251
+ for (let index = 0; index < candidates.length; index += 1) {
252
+ const candidate = candidates[index];
253
+ try {
254
+ const plaintext = await decryptBinaryFromStorage(ciphertext, envelope, candidate.privateKeyPem);
255
+ logFileDecryptionTelemetry({
256
+ recordKeyId: envelope.keyId,
257
+ selectedKeyId: candidate.keyId,
258
+ attemptCount: index + 1,
259
+ outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
260
+ });
261
+ return plaintext;
262
+ } catch (error) {
263
+ lastError = error;
264
+ }
265
+ }
266
+
267
+ logFileDecryptionTelemetry({
268
+ recordKeyId: envelope.keyId,
269
+ selectedKeyId: null,
270
+ attemptCount: candidates.length,
271
+ outcome: 'all-failed',
272
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
273
+ });
274
+
275
+ throw new Error(
276
+ `Failed to decrypt stored file after ${candidates.length} key attempt(s): ${
277
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
278
+ }`
279
+ );
280
+ }
281
+
75
282
  function parseFileId(pathname: string): string | null {
76
283
  const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
77
284
  if (!encodedFileId) {
@@ -92,6 +299,162 @@ function parseFileId(pathname: string): string | null {
92
299
  return decodedFileId;
93
300
  }
94
301
 
302
+ function parsePathSegments(pathname: string): string[] | null {
303
+ const normalized = pathname.startsWith('/') ? pathname.slice(1) : pathname;
304
+ if (!normalized) {
305
+ return [];
306
+ }
307
+
308
+ const rawSegments = normalized.split('/');
309
+ const decodedSegments: string[] = [];
310
+
311
+ for (const segment of rawSegments) {
312
+ if (!segment) {
313
+ return null;
314
+ }
315
+
316
+ let decoded = '';
317
+ try {
318
+ decoded = decodeURIComponent(segment);
319
+ } catch {
320
+ return null;
321
+ }
322
+
323
+ if (!decoded || decoded.includes('/')) {
324
+ return null;
325
+ }
326
+
327
+ decodedSegments.push(decoded);
328
+ }
329
+
330
+ return decodedSegments;
331
+ }
332
+
333
+ function base64UrlEncode(input: ArrayBuffer | Uint8Array): string {
334
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
335
+ let binary = '';
336
+ for (let i = 0; i < bytes.length; i += 1) {
337
+ binary += String.fromCharCode(bytes[i]);
338
+ }
339
+
340
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
341
+ }
342
+
343
+ function base64UrlDecode(input: string): Uint8Array | null {
344
+ if (!input || /[^A-Za-z0-9_-]/.test(input)) {
345
+ return null;
346
+ }
347
+
348
+ const paddingLength = (4 - (input.length % 4)) % 4;
349
+ const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(paddingLength);
350
+
351
+ try {
352
+ const binary = atob(padded);
353
+ const bytes = new Uint8Array(binary.length);
354
+ for (let i = 0; i < binary.length; i += 1) {
355
+ bytes[i] = binary.charCodeAt(i);
356
+ }
357
+
358
+ return bytes;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ function normalizeSignedUrlTtlSeconds(requestedTtlSeconds: unknown, env: Env): number {
365
+ const defaultFromEnv = Number.parseInt(env.IMAGE_SIGNED_URL_TTL_SECONDS ?? '', 10);
366
+ const fallbackTtl = Number.isFinite(defaultFromEnv) && defaultFromEnv > 0
367
+ ? defaultFromEnv
368
+ : DEFAULT_SIGNED_URL_TTL_SECONDS;
369
+ const requested = typeof requestedTtlSeconds === 'number' ? requestedTtlSeconds : fallbackTtl;
370
+ const normalized = Math.floor(requested);
371
+
372
+ if (!Number.isFinite(normalized) || normalized <= 0) {
373
+ return fallbackTtl;
374
+ }
375
+
376
+ return Math.min(normalized, MAX_SIGNED_URL_TTL_SECONDS);
377
+ }
378
+
379
+ function requireSignedUrlConfig(env: Env): void {
380
+ const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
381
+ if (resolvedSecret.length === 0) {
382
+ throw new Error('Signed URL configuration is missing');
383
+ }
384
+ }
385
+
386
+ async function getSignedUrlHmacKey(env: Env): Promise<CryptoKey> {
387
+ const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
388
+ const keyBytes = new TextEncoder().encode(resolvedSecret);
389
+ return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
390
+ }
391
+
392
+ async function signSignedAccessPayload(payload: SignedAccessPayload, env: Env): Promise<string> {
393
+ const payloadJson = JSON.stringify(payload);
394
+ const payloadBase64Url = base64UrlEncode(new TextEncoder().encode(payloadJson));
395
+ const hmacKey = await getSignedUrlHmacKey(env);
396
+ const signature = await crypto.subtle.sign('HMAC', hmacKey, new TextEncoder().encode(payloadBase64Url));
397
+ const signatureBase64Url = base64UrlEncode(signature);
398
+ return `${payloadBase64Url}.${signatureBase64Url}`;
399
+ }
400
+
401
+ async function verifySignedAccessToken(token: string, fileId: string, env: Env): Promise<boolean> {
402
+ const tokenParts = token.split('.');
403
+ if (tokenParts.length !== 2) {
404
+ return false;
405
+ }
406
+
407
+ const [payloadBase64Url, signatureBase64Url] = tokenParts;
408
+ if (!payloadBase64Url || !signatureBase64Url) {
409
+ return false;
410
+ }
411
+
412
+ const signatureBytes = base64UrlDecode(signatureBase64Url);
413
+ if (!signatureBytes) {
414
+ return false;
415
+ }
416
+
417
+ const signatureBuffer = new Uint8Array(signatureBytes).buffer;
418
+
419
+ const hmacKey = await getSignedUrlHmacKey(env);
420
+ const signatureValid = await crypto.subtle.verify(
421
+ 'HMAC',
422
+ hmacKey,
423
+ signatureBuffer,
424
+ new TextEncoder().encode(payloadBase64Url)
425
+ );
426
+ if (!signatureValid) {
427
+ return false;
428
+ }
429
+
430
+ const payloadBytes = base64UrlDecode(payloadBase64Url);
431
+ if (!payloadBytes) {
432
+ return false;
433
+ }
434
+
435
+ let payload: SignedAccessPayload;
436
+ try {
437
+ payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as SignedAccessPayload;
438
+ } catch {
439
+ return false;
440
+ }
441
+
442
+ if (payload.fileId !== fileId) {
443
+ return false;
444
+ }
445
+
446
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
447
+ if (!Number.isInteger(payload.exp) || payload.exp <= nowEpochSeconds) {
448
+ return false;
449
+ }
450
+
451
+ if (!Number.isInteger(payload.iat) || payload.iat > nowEpochSeconds + 300) {
452
+ return false;
453
+ }
454
+
455
+ return typeof payload.nonce === 'string' && payload.nonce.length > 0;
456
+ }
457
+
95
458
  function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
96
459
  const metadata = file.customMetadata;
97
460
  if (!metadata) {
@@ -174,9 +537,7 @@ async function handleImageUpload(request: Request, env: Env): Promise<Response>
174
537
  result: {
175
538
  id: fileId,
176
539
  filename,
177
- uploaded: uploadedAt,
178
- requireSignedURLs: false,
179
- variants: []
540
+ uploaded: uploadedAt
180
541
  }
181
542
  });
182
543
  }
@@ -200,18 +561,72 @@ async function handleImageDelete(request: Request, env: Env): Promise<Response>
200
561
  return createJsonResponse({ success: true });
201
562
  }
202
563
 
203
- async function handleImageServing(request: Request, env: Env): Promise<Response> {
564
+ async function handleSignedUrlMinting(request: Request, env: Env, fileId: string): Promise<Response> {
204
565
  if (!hasValidToken(request, env)) {
205
566
  return createJsonResponse({ error: 'Unauthorized' }, 403);
206
567
  }
207
568
 
208
- requireEncryptionRetrievalConfig(env);
569
+ requireSignedUrlConfig(env);
209
570
 
210
- const fileId = parseFileId(new URL(request.url).pathname);
211
- if (!fileId) {
212
- return createJsonResponse({ error: 'Image ID is required' }, 400);
571
+ const existing = await env.STRIAE_FILES.head(fileId);
572
+ if (!existing) {
573
+ return createJsonResponse({ error: 'File not found' }, 404);
574
+ }
575
+
576
+ let requestedExpiresInSeconds: number | undefined;
577
+ const contentType = request.headers.get('Content-Type') || '';
578
+ if (contentType.includes('application/json')) {
579
+ const requestBody = await request.json().catch(() => null) as { expiresInSeconds?: number } | null;
580
+ if (requestBody && typeof requestBody.expiresInSeconds === 'number') {
581
+ requestedExpiresInSeconds = requestBody.expiresInSeconds;
582
+ }
213
583
  }
214
584
 
585
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
586
+ const ttlSeconds = normalizeSignedUrlTtlSeconds(requestedExpiresInSeconds, env);
587
+ const payload: SignedAccessPayload = {
588
+ fileId,
589
+ iat: nowEpochSeconds,
590
+ exp: nowEpochSeconds + ttlSeconds,
591
+ nonce: crypto.randomUUID().replace(/-/g, '')
592
+ };
593
+
594
+ const signedToken = await signSignedAccessPayload(payload, env);
595
+ const signedPath = `/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
596
+ const signedUrl = new URL(signedPath, request.url).toString();
597
+
598
+ return createJsonResponse({
599
+ success: true,
600
+ result: {
601
+ fileId,
602
+ url: signedUrl,
603
+ expiresAt: new Date(payload.exp * 1000).toISOString(),
604
+ expiresInSeconds: ttlSeconds
605
+ }
606
+ });
607
+ }
608
+
609
+ async function handleImageServing(request: Request, env: Env, fileId: string): Promise<Response> {
610
+ const requestUrl = new URL(request.url);
611
+ const hasSignedToken = requestUrl.searchParams.has('st');
612
+ const signedToken = requestUrl.searchParams.get('st');
613
+ if (hasSignedToken) {
614
+ requireSignedUrlConfig(env);
615
+
616
+ if (!signedToken || signedToken.trim().length === 0) {
617
+ return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
618
+ }
619
+
620
+ const tokenValid = await verifySignedAccessToken(signedToken, fileId, env);
621
+ if (!tokenValid) {
622
+ return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
623
+ }
624
+ } else if (!hasValidToken(request, env)) {
625
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
626
+ }
627
+
628
+ requireEncryptionRetrievalConfig(env);
629
+
215
630
  const file = await env.STRIAE_FILES.get(fileId);
216
631
  if (!file) {
217
632
  return createJsonResponse({ error: 'File not found' }, 404);
@@ -223,10 +638,10 @@ async function handleImageServing(request: Request, env: Env): Promise<Response>
223
638
  }
224
639
 
225
640
  const encryptedData = await file.arrayBuffer();
226
- const plaintext = await decryptBinaryFromStorage(
641
+ const plaintext = await decryptBinaryWithRegistry(
227
642
  encryptedData,
228
643
  envelope,
229
- env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
644
+ env
230
645
  );
231
646
 
232
647
  const contentType = file.customMetadata?.contentType || 'application/octet-stream';
@@ -250,11 +665,32 @@ export default {
250
665
  }
251
666
 
252
667
  try {
668
+ const requestUrl = new URL(request.url);
669
+ const pathSegments = parsePathSegments(requestUrl.pathname);
670
+ if (!pathSegments) {
671
+ return createJsonResponse({ error: 'Invalid image path encoding' }, 400);
672
+ }
673
+
253
674
  switch (request.method) {
254
- case 'POST':
255
- return handleImageUpload(request, env);
256
- case 'GET':
257
- return handleImageServing(request, env);
675
+ case 'POST': {
676
+ if (pathSegments.length === 0) {
677
+ return handleImageUpload(request, env);
678
+ }
679
+
680
+ if (pathSegments.length === 2 && pathSegments[1] === 'signed-url') {
681
+ return handleSignedUrlMinting(request, env, pathSegments[0]);
682
+ }
683
+
684
+ return createJsonResponse({ error: 'Not found' }, 404);
685
+ }
686
+ case 'GET': {
687
+ const fileId = pathSegments.length === 1 ? pathSegments[0] : null;
688
+ if (!fileId) {
689
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
690
+ }
691
+
692
+ return handleImageServing(request, env, fileId);
693
+ }
258
694
  case 'DELETE':
259
695
  return handleImageDelete(request, env);
260
696
  default:
@@ -1,8 +1,9 @@
1
1
  /* eslint-disable */
2
- // Generated by Wrangler by running `wrangler types` (hash: 869ac3b4ce0f52ba3b2e0bc70c49089e)
3
- // Runtime types generated with workerd@1.20250823.0 2026-03-20 nodejs_compat
2
+ // Generated by Wrangler by running `wrangler types` (hash: 031f1b22e4c77b10fe83d4eace0f6b21)
3
+ // Runtime types generated with workerd@1.20250823.0 2026-03-24 nodejs_compat
4
4
  declare namespace Cloudflare {
5
5
  interface Env {
6
+ STRIAE_FILES: R2Bucket;
6
7
  }
7
8
  }
8
9
  interface Env extends Cloudflare.Env {}
@@ -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-24",
5
+ "compatibility_date": "2026-03-25",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.76.0"
12
+ "wrangler": "^4.77.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -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-24",
5
+ "compatibility_date": "2026-03-25",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "devDependencies": {
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
- "wrangler": "^4.76.0"
13
+ "wrangler": "^4.77.0"
14
14
  },
15
15
  "overrides": {
16
16
  "undici": "7.24.1",
@@ -1,7 +1,6 @@
1
1
  import type { PDFGenerationData, PDFGenerationRequest, ReportModule, ReportPdfOptions } from './report-types';
2
2
 
3
3
  interface Env {
4
- BROWSER: Fetcher;
5
4
  PDF_WORKER_AUTH: string;
6
5
  ACCOUNT_ID?: string;
7
6
  BROWSER_API_TOKEN?: string;
@@ -2,15 +2,11 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-24",
5
+ "compatibility_date": "2026-03-25",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
9
9
 
10
- "browser": {
11
- "binding": "BROWSER"
12
- },
13
-
14
10
  "observability": {
15
11
  "enabled": true
16
12
  },
@@ -1,18 +1,18 @@
1
1
  {
2
- "name": "user-worker",
3
- "version": "0.0.0",
4
- "private": true,
5
- "scripts": {
6
- "deploy": "wrangler deploy",
7
- "dev": "wrangler dev",
8
- "start": "wrangler dev"
9
- },
10
- "devDependencies": {
11
- "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.76.0"
13
- },
14
- "overrides": {
15
- "undici": "7.24.1",
16
- "yauzl": "3.2.1"
17
- }
18
- }
2
+ "name": "user-worker",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "deploy": "wrangler deploy",
7
+ "dev": "wrangler dev",
8
+ "start": "wrangler dev"
9
+ },
10
+ "devDependencies": {
11
+ "@cloudflare/puppeteer": "^1.0.6",
12
+ "wrangler": "^4.77.0"
13
+ },
14
+ "overrides": {
15
+ "undici": "7.24.1",
16
+ "yauzl": "3.2.1"
17
+ }
18
+ }