@striae-org/striae 5.1.1 → 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 (30) hide show
  1. package/.env.example +20 -1
  2. package/app/utils/data/permissions.ts +4 -2
  3. package/package.json +4 -4
  4. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  5. package/scripts/deploy-config/modules/keys.sh +404 -0
  6. package/scripts/deploy-config/modules/prompt.sh +372 -0
  7. package/scripts/deploy-config/modules/scaffolding.sh +336 -0
  8. package/scripts/deploy-config/modules/validation.sh +365 -0
  9. package/scripts/deploy-config.sh +47 -1572
  10. package/scripts/deploy-worker-secrets.sh +100 -5
  11. package/worker-configuration.d.ts +6 -3
  12. package/workers/audit-worker/package.json +1 -1
  13. package/workers/audit-worker/src/audit-worker.example.ts +188 -6
  14. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  15. package/workers/data-worker/package.json +1 -1
  16. package/workers/data-worker/src/data-worker.example.ts +344 -32
  17. package/workers/data-worker/wrangler.jsonc.example +1 -1
  18. package/workers/image-worker/package.json +1 -1
  19. package/workers/image-worker/src/image-worker.example.ts +190 -5
  20. package/workers/image-worker/wrangler.jsonc.example +1 -1
  21. package/workers/keys-worker/package.json +1 -1
  22. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  23. package/workers/pdf-worker/package.json +1 -1
  24. package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
  25. package/workers/pdf-worker/wrangler.jsonc.example +1 -5
  26. package/workers/user-worker/package.json +17 -17
  27. package/workers/user-worker/src/encryption-utils.ts +244 -0
  28. package/workers/user-worker/src/user-worker.example.ts +333 -31
  29. package/workers/user-worker/wrangler.jsonc.example +1 -1
  30. package/wrangler.toml.example +1 -1
@@ -1,3 +1,11 @@
1
+ import {
2
+ decryptJsonFromUserKv,
3
+ encryptJsonForUserKv,
4
+ tryParseEncryptedRecord,
5
+ type UserKvEncryptedRecord,
6
+ validateEncryptedRecord
7
+ } from './encryption-utils';
8
+
1
9
  interface Env {
2
10
  USER_DB_AUTH: string;
3
11
  USER_DB: KVNamespace;
@@ -8,8 +16,25 @@ interface Env {
8
16
  PROJECT_ID: string;
9
17
  FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
10
18
  FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
19
+ USER_KV_ENCRYPTION_PRIVATE_KEY: string;
20
+ USER_KV_ENCRYPTION_PUBLIC_KEY: string;
21
+ USER_KV_ENCRYPTION_KEY_ID: string;
22
+ USER_KV_ENCRYPTION_KEYS_JSON?: string;
23
+ USER_KV_ENCRYPTION_ACTIVE_KEY_ID?: string;
24
+ }
25
+
26
+ interface KeyRegistryPayload {
27
+ activeKeyId?: unknown;
28
+ keys?: unknown;
29
+ }
30
+
31
+ interface PrivateKeyRegistry {
32
+ activeKeyId: string | null;
33
+ keys: Record<string, string>;
11
34
  }
12
35
 
36
+ type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
37
+
13
38
  interface UserData {
14
39
  uid: string;
15
40
  email: string;
@@ -24,6 +49,23 @@ interface UserData {
24
49
  updatedAt?: string;
25
50
  }
26
51
 
52
+ function isLegacyUserData(value: unknown): value is UserData {
53
+ if (!value || typeof value !== 'object') {
54
+ return false;
55
+ }
56
+
57
+ const candidate = value as Partial<UserData>;
58
+ return (
59
+ typeof candidate.uid === 'string' &&
60
+ typeof candidate.email === 'string' &&
61
+ typeof candidate.firstName === 'string' &&
62
+ typeof candidate.lastName === 'string' &&
63
+ typeof candidate.company === 'string' &&
64
+ typeof candidate.permitted === 'boolean' &&
65
+ Array.isArray(candidate.cases)
66
+ );
67
+ }
68
+
27
69
  interface CaseItem {
28
70
  caseNumber: string;
29
71
  caseName?: string;
@@ -128,6 +170,253 @@ function resolveImageWorkerBaseUrl(env: Env): string {
128
170
  return normalizeWorkerBaseUrl(DEFAULT_IMAGE_WORKER_BASE_URL);
129
171
  }
130
172
 
173
+ function requireUserKvReadConfig(env: Env): void {
174
+ const hasLegacyPrivateKey = typeof env.USER_KV_ENCRYPTION_PRIVATE_KEY === 'string' && env.USER_KV_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
175
+ const hasRegistryPrivateKeys = typeof env.USER_KV_ENCRYPTION_KEYS_JSON === 'string' && env.USER_KV_ENCRYPTION_KEYS_JSON.trim().length > 0;
176
+
177
+ if (!hasLegacyPrivateKey && !hasRegistryPrivateKeys) {
178
+ throw new Error('User KV encryption is not fully configured');
179
+ }
180
+ }
181
+
182
+ function requireUserKvWriteConfig(env: Env): void {
183
+ const hasLegacyPrivateKey = typeof env.USER_KV_ENCRYPTION_PRIVATE_KEY === 'string' && env.USER_KV_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
184
+ const hasRegistryPrivateKeys = typeof env.USER_KV_ENCRYPTION_KEYS_JSON === 'string' && env.USER_KV_ENCRYPTION_KEYS_JSON.trim().length > 0;
185
+
186
+ if (
187
+ !env.USER_KV_ENCRYPTION_PUBLIC_KEY ||
188
+ !env.USER_KV_ENCRYPTION_KEY_ID ||
189
+ (!hasLegacyPrivateKey && !hasRegistryPrivateKeys)
190
+ ) {
191
+ throw new Error('User KV encryption is not fully configured');
192
+ }
193
+ }
194
+
195
+ function normalizePrivateKeyPem(rawValue: string): string {
196
+ return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
197
+ }
198
+
199
+ function getNonEmptyString(value: unknown): string | null {
200
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
201
+ }
202
+
203
+ function parseUserKvPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
204
+ const keys: Record<string, string> = {};
205
+ const configuredActiveKeyId = getNonEmptyString(env.USER_KV_ENCRYPTION_ACTIVE_KEY_ID);
206
+
207
+ if (getNonEmptyString(env.USER_KV_ENCRYPTION_KEYS_JSON)) {
208
+ let parsedRegistry: unknown;
209
+ try {
210
+ parsedRegistry = JSON.parse(env.USER_KV_ENCRYPTION_KEYS_JSON as string) as unknown;
211
+ } catch {
212
+ throw new Error('USER_KV_ENCRYPTION_KEYS_JSON is not valid JSON');
213
+ }
214
+
215
+ if (!parsedRegistry || typeof parsedRegistry !== 'object') {
216
+ throw new Error('USER_KV_ENCRYPTION_KEYS_JSON must be an object');
217
+ }
218
+
219
+ const payload = parsedRegistry as KeyRegistryPayload;
220
+ if (!payload.keys || typeof payload.keys !== 'object') {
221
+ throw new Error('USER_KV_ENCRYPTION_KEYS_JSON must include a keys object');
222
+ }
223
+
224
+ for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
225
+ const normalizedKeyId = getNonEmptyString(keyId);
226
+ const normalizedPem = getNonEmptyString(pemValue);
227
+ if (!normalizedKeyId || !normalizedPem) {
228
+ continue;
229
+ }
230
+
231
+ keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
232
+ }
233
+
234
+ const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
235
+ const activeKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
236
+
237
+ if (Object.keys(keys).length === 0) {
238
+ throw new Error('USER_KV_ENCRYPTION_KEYS_JSON does not contain any usable keys');
239
+ }
240
+
241
+ if (activeKeyId && !keys[activeKeyId]) {
242
+ throw new Error('USER_KV active key ID is not present in USER_KV_ENCRYPTION_KEYS_JSON');
243
+ }
244
+
245
+ return {
246
+ activeKeyId: activeKeyId ?? null,
247
+ keys
248
+ };
249
+ }
250
+
251
+ const legacyKeyId = getNonEmptyString(env.USER_KV_ENCRYPTION_KEY_ID);
252
+ const legacyPrivateKey = getNonEmptyString(env.USER_KV_ENCRYPTION_PRIVATE_KEY);
253
+ if (!legacyKeyId || !legacyPrivateKey) {
254
+ throw new Error('User KV encryption private key registry is not configured');
255
+ }
256
+
257
+ keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
258
+
259
+ return {
260
+ activeKeyId: configuredActiveKeyId ?? legacyKeyId,
261
+ keys
262
+ };
263
+ }
264
+
265
+ function buildPrivateKeyCandidates(
266
+ recordKeyId: string,
267
+ registry: PrivateKeyRegistry
268
+ ): Array<{ keyId: string; privateKeyPem: string }> {
269
+ const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
270
+ const seen = new Set<string>();
271
+
272
+ const appendCandidate = (candidateKeyId: string | null): void => {
273
+ if (!candidateKeyId || seen.has(candidateKeyId)) {
274
+ return;
275
+ }
276
+
277
+ const privateKeyPem = registry.keys[candidateKeyId];
278
+ if (!privateKeyPem) {
279
+ return;
280
+ }
281
+
282
+ seen.add(candidateKeyId);
283
+ candidates.push({ keyId: candidateKeyId, privateKeyPem });
284
+ };
285
+
286
+ appendCandidate(getNonEmptyString(recordKeyId));
287
+ appendCandidate(registry.activeKeyId);
288
+
289
+ for (const keyId of Object.keys(registry.keys)) {
290
+ appendCandidate(keyId);
291
+ }
292
+
293
+ return candidates;
294
+ }
295
+
296
+ function logUserKvDecryptionTelemetry(input: {
297
+ recordKeyId: string;
298
+ selectedKeyId: string | null;
299
+ attemptCount: number;
300
+ outcome: DecryptionTelemetryOutcome;
301
+ reason?: string;
302
+ }): void {
303
+ const details = {
304
+ scope: 'user-kv',
305
+ recordKeyId: input.recordKeyId,
306
+ selectedKeyId: input.selectedKeyId,
307
+ attemptCount: input.attemptCount,
308
+ fallbackUsed: input.outcome === 'fallback-hit',
309
+ outcome: input.outcome,
310
+ reason: input.reason ?? null
311
+ };
312
+
313
+ if (input.outcome === 'all-failed') {
314
+ console.warn('Key registry decryption failed', details);
315
+ return;
316
+ }
317
+
318
+ console.info('Key registry decryption resolved', details);
319
+ }
320
+
321
+ async function decryptUserKvRecord(
322
+ encryptedRecord: UserKvEncryptedRecord,
323
+ registry: PrivateKeyRegistry
324
+ ): Promise<string> {
325
+ const candidates = buildPrivateKeyCandidates(encryptedRecord.keyId, registry);
326
+ const primaryKeyId = candidates[0]?.keyId ?? null;
327
+ let lastError: unknown;
328
+
329
+ for (let index = 0; index < candidates.length; index += 1) {
330
+ const candidate = candidates[index];
331
+ try {
332
+ const decryptedJson = await decryptJsonFromUserKv(encryptedRecord, candidate.privateKeyPem);
333
+ logUserKvDecryptionTelemetry({
334
+ recordKeyId: encryptedRecord.keyId,
335
+ selectedKeyId: candidate.keyId,
336
+ attemptCount: index + 1,
337
+ outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
338
+ });
339
+ return decryptedJson;
340
+ } catch (error) {
341
+ lastError = error;
342
+ }
343
+ }
344
+
345
+ logUserKvDecryptionTelemetry({
346
+ recordKeyId: encryptedRecord.keyId,
347
+ selectedKeyId: null,
348
+ attemptCount: candidates.length,
349
+ outcome: 'all-failed',
350
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
351
+ });
352
+
353
+ throw new Error(
354
+ `Failed to decrypt user KV record after ${candidates.length} key attempt(s): ${
355
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
356
+ }`
357
+ );
358
+ }
359
+
360
+ async function readUserRecord(env: Env, userUid: string): Promise<UserData | null> {
361
+ const storedValue = await env.USER_DB.get(userUid);
362
+ if (storedValue === null) {
363
+ return null;
364
+ }
365
+
366
+ const encryptedRecord = tryParseEncryptedRecord(storedValue);
367
+ if (encryptedRecord) {
368
+ validateEncryptedRecord(encryptedRecord);
369
+ const keyRegistry = parseUserKvPrivateKeyRegistry(env);
370
+ const decryptedJson = await decryptUserKvRecord(encryptedRecord, keyRegistry);
371
+ return JSON.parse(decryptedJson) as UserData;
372
+ }
373
+
374
+ // Legacy support: accept existing plaintext records and opportunistically
375
+ // rewrite them as encrypted records during the first successful read.
376
+ let parsedLegacyRecord: unknown;
377
+ try {
378
+ parsedLegacyRecord = JSON.parse(storedValue) as unknown;
379
+ } catch {
380
+ throw new Error('User KV record is not encrypted');
381
+ }
382
+
383
+ if (!isLegacyUserData(parsedLegacyRecord)) {
384
+ throw new Error('User KV record is not encrypted');
385
+ }
386
+
387
+ const legacyUserData = parsedLegacyRecord;
388
+
389
+ if (legacyUserData.uid !== userUid) {
390
+ throw new Error('User KV record UID mismatch');
391
+ }
392
+
393
+ try {
394
+ await writeUserRecord(env, userUid, legacyUserData);
395
+ console.info('Migrated plaintext USER_DB record to encrypted format', {
396
+ scope: 'user-kv',
397
+ uid: userUid
398
+ });
399
+ } catch (error) {
400
+ console.warn('Failed to migrate plaintext USER_DB record during read', {
401
+ scope: 'user-kv',
402
+ uid: userUid,
403
+ reason: error instanceof Error ? error.message : 'unknown migration error'
404
+ });
405
+ }
406
+
407
+ return legacyUserData;
408
+ }
409
+
410
+ async function writeUserRecord(env: Env, userUid: string, userData: UserData): Promise<void> {
411
+ const encryptedPayload = await encryptJsonForUserKv(
412
+ JSON.stringify(userData),
413
+ env.USER_KV_ENCRYPTION_PUBLIC_KEY,
414
+ env.USER_KV_ENCRYPTION_KEY_ID
415
+ );
416
+
417
+ await env.USER_DB.put(userUid, encryptedPayload);
418
+ }
419
+
131
420
  function base64UrlEncode(value: string | Uint8Array): string {
132
421
  const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
133
422
  let binary = '';
@@ -266,21 +555,24 @@ async function deleteFirebaseAuthUser(env: Env, userUid: string): Promise<void>
266
555
 
267
556
  async function handleGetUser(env: Env, userUid: string): Promise<Response> {
268
557
  try {
269
- const value = await env.USER_DB.get(userUid);
270
- if (value === null) {
558
+ const userData = await readUserRecord(env, userUid);
559
+ if (userData === null) {
271
560
  return new Response('User not found', {
272
561
  status: 404,
273
562
  headers: corsHeaders
274
563
  });
275
564
  }
276
- return new Response(value, {
565
+ return new Response(JSON.stringify(userData), {
277
566
  status: 200,
278
567
  headers: corsHeaders
279
568
  });
280
- } catch {
281
- return new Response('Failed to get user data', {
282
- status: 500,
283
- headers: corsHeaders
569
+ } catch (error) {
570
+ const errorMessage = error instanceof Error ? error.message : 'Unknown user data read error';
571
+ console.error('Failed to get user data:', { uid: userUid, reason: errorMessage });
572
+
573
+ return new Response('Failed to get user data', {
574
+ status: 500,
575
+ headers: corsHeaders
284
576
  });
285
577
  }
286
578
  }
@@ -292,21 +584,20 @@ async function handleAddUser(request: Request, env: Env, userUid: string): Promi
292
584
  const normalizedBadgeId = typeof badgeId === 'string' ? badgeId.trim() : undefined;
293
585
 
294
586
  // Check for existing user
295
- const value = await env.USER_DB.get(userUid);
587
+ const existingUser = await readUserRecord(env, userUid);
296
588
 
297
589
  let userData: UserData;
298
- if (value !== null) {
590
+ if (existingUser !== null) {
299
591
  // Update existing user, preserving cases
300
- const existing: UserData = JSON.parse(value);
301
592
  userData = {
302
- ...existing,
593
+ ...existingUser,
303
594
  // Preserve all existing fields
304
- email: email || existing.email,
305
- firstName: firstName || existing.firstName,
306
- lastName: lastName || existing.lastName,
307
- company: company || existing.company,
308
- badgeId: normalizedBadgeId !== undefined ? normalizedBadgeId : (existing.badgeId ?? ''),
309
- permitted: permitted !== undefined ? permitted : existing.permitted,
595
+ email: email || existingUser.email,
596
+ firstName: firstName || existingUser.firstName,
597
+ lastName: lastName || existingUser.lastName,
598
+ company: company || existingUser.company,
599
+ badgeId: normalizedBadgeId !== undefined ? normalizedBadgeId : (existingUser.badgeId ?? ''),
600
+ permitted: permitted !== undefined ? permitted : existingUser.permitted,
310
601
  updatedAt: new Date().toISOString()
311
602
  };
312
603
  if (requestData.readOnlyCases !== undefined) {
@@ -331,10 +622,10 @@ async function handleAddUser(request: Request, env: Env, userUid: string): Promi
331
622
  }
332
623
 
333
624
  // Store value in KV
334
- await env.USER_DB.put(userUid, JSON.stringify(userData));
625
+ await writeUserRecord(env, userUid, userData);
335
626
 
336
627
  return new Response(JSON.stringify(userData), {
337
- status: value !== null ? 200 : 201,
628
+ status: existingUser !== null ? 200 : 201,
338
629
  headers: corsHeaders
339
630
  });
340
631
  } catch {
@@ -454,14 +745,13 @@ async function executeUserDeletion(
454
745
  userUid: string,
455
746
  reportProgress?: (progress: AccountDeletionProgressEvent) => void
456
747
  ): Promise<{ success: boolean; message: string; totalCases: number; completedCases: number }> {
457
- const userData = await env.USER_DB.get(userUid);
748
+ const userData = await readUserRecord(env, userUid);
458
749
  if (userData === null) {
459
750
  throw new Error('User not found');
460
751
  }
461
752
 
462
- const userObject: UserData = JSON.parse(userData);
463
- const ownedCases = (userObject.cases || []).map((caseItem) => caseItem.caseNumber);
464
- const readOnlyCases = (userObject.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
753
+ const ownedCases = (userData.cases || []).map((caseItem) => caseItem.caseNumber);
754
+ const readOnlyCases = (userData.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
465
755
  const allCaseNumbers = Array.from(new Set([...ownedCases, ...readOnlyCases]));
466
756
  const totalCases = allCaseNumbers.length;
467
757
  let completedCases = 0;
@@ -604,8 +894,8 @@ async function handleAddCases(request: Request, env: Env, userUid: string): Prom
604
894
  const { cases = [] }: AddCasesRequest = await request.json();
605
895
 
606
896
  // Get current user data
607
- const value = await env.USER_DB.get(userUid);
608
- if (!value) {
897
+ const userData = await readUserRecord(env, userUid);
898
+ if (!userData) {
609
899
  return new Response('User not found', {
610
900
  status: 404,
611
901
  headers: corsHeaders
@@ -613,7 +903,6 @@ async function handleAddCases(request: Request, env: Env, userUid: string): Prom
613
903
  }
614
904
 
615
905
  // Update cases
616
- const userData: UserData = JSON.parse(value);
617
906
  const existingCases = userData.cases || [];
618
907
 
619
908
  // Filter out duplicates
@@ -628,7 +917,7 @@ async function handleAddCases(request: Request, env: Env, userUid: string): Prom
628
917
  userData.updatedAt = new Date().toISOString();
629
918
 
630
919
  // Save to KV
631
- await env.USER_DB.put(userUid, JSON.stringify(userData));
920
+ await writeUserRecord(env, userUid, userData);
632
921
 
633
922
  return new Response(JSON.stringify(userData), {
634
923
  status: 200,
@@ -647,8 +936,8 @@ async function handleDeleteCases(request: Request, env: Env, userUid: string): P
647
936
  const { casesToDelete }: DeleteCasesRequest = await request.json();
648
937
 
649
938
  // Get current user data
650
- const value = await env.USER_DB.get(userUid);
651
- if (!value) {
939
+ const userData = await readUserRecord(env, userUid);
940
+ if (!userData) {
652
941
  return new Response('User not found', {
653
942
  status: 404,
654
943
  headers: corsHeaders
@@ -656,14 +945,13 @@ async function handleDeleteCases(request: Request, env: Env, userUid: string): P
656
945
  }
657
946
 
658
947
  // Update user data
659
- const userData: UserData = JSON.parse(value);
660
948
  userData.cases = userData.cases.filter(c =>
661
949
  !casesToDelete.includes(c.caseNumber)
662
950
  );
663
951
  userData.updatedAt = new Date().toISOString();
664
952
 
665
953
  // Save to KV
666
- await env.USER_DB.put(userUid, JSON.stringify(userData));
954
+ await writeUserRecord(env, userUid, userData);
667
955
 
668
956
  return new Response(JSON.stringify(userData), {
669
957
  status: 200,
@@ -685,6 +973,13 @@ export default {
685
973
 
686
974
  try {
687
975
  await authenticate(request, env);
976
+
977
+ // DELETE can mutate user KV data (for example /:uid/cases), so non-GET methods require write config.
978
+ if (request.method === 'GET') {
979
+ requireUserKvReadConfig(env);
980
+ } else {
981
+ requireUserKvWriteConfig(env);
982
+ }
688
983
 
689
984
  const url = new URL(request.url);
690
985
  const parts = url.pathname.split('/');
@@ -728,6 +1023,13 @@ export default {
728
1023
  headers: corsHeaders
729
1024
  });
730
1025
  }
1026
+
1027
+ if (errorMessage === 'User KV encryption is not fully configured') {
1028
+ return new Response(errorMessage, {
1029
+ status: 500,
1030
+ headers: corsHeaders
1031
+ });
1032
+ }
731
1033
 
732
1034
  return new Response('Internal Server Error', {
733
1035
  status: 500,
@@ -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-24",
5
+ "compatibility_date": "2026-03-25",
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-24"
3
+ compatibility_date = "2026-03-25"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6