@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.
- package/.env.example +20 -1
- package/app/utils/data/permissions.ts +4 -2
- package/package.json +4 -4
- package/scripts/deploy-config/modules/env-utils.sh +322 -0
- package/scripts/deploy-config/modules/keys.sh +404 -0
- package/scripts/deploy-config/modules/prompt.sh +372 -0
- package/scripts/deploy-config/modules/scaffolding.sh +336 -0
- package/scripts/deploy-config/modules/validation.sh +365 -0
- package/scripts/deploy-config.sh +47 -1572
- package/scripts/deploy-worker-secrets.sh +100 -5
- package/worker-configuration.d.ts +6 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/src/audit-worker.example.ts +188 -6
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/src/data-worker.example.ts +344 -32
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +190 -5
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -5
- package/workers/user-worker/package.json +17 -17
- package/workers/user-worker/src/encryption-utils.ts +244 -0
- package/workers/user-worker/src/user-worker.example.ts +333 -31
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- 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
|
|
270
|
-
if (
|
|
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(
|
|
565
|
+
return new Response(JSON.stringify(userData), {
|
|
277
566
|
status: 200,
|
|
278
567
|
headers: corsHeaders
|
|
279
568
|
});
|
|
280
|
-
} catch {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
587
|
+
const existingUser = await readUserRecord(env, userUid);
|
|
296
588
|
|
|
297
589
|
let userData: UserData;
|
|
298
|
-
if (
|
|
590
|
+
if (existingUser !== null) {
|
|
299
591
|
// Update existing user, preserving cases
|
|
300
|
-
const existing: UserData = JSON.parse(value);
|
|
301
592
|
userData = {
|
|
302
|
-
...
|
|
593
|
+
...existingUser,
|
|
303
594
|
// Preserve all existing fields
|
|
304
|
-
email: email ||
|
|
305
|
-
firstName: firstName ||
|
|
306
|
-
lastName: lastName ||
|
|
307
|
-
company: company ||
|
|
308
|
-
badgeId: normalizedBadgeId !== undefined ? normalizedBadgeId : (
|
|
309
|
-
permitted: permitted !== undefined ? 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
|
|
625
|
+
await writeUserRecord(env, userUid, userData);
|
|
335
626
|
|
|
336
627
|
return new Response(JSON.stringify(userData), {
|
|
337
|
-
status:
|
|
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
|
|
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
|
|
463
|
-
const
|
|
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
|
|
608
|
-
if (!
|
|
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
|
|
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
|
|
651
|
-
if (!
|
|
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
|
|
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,
|
package/wrangler.toml.example
CHANGED